This is an OAuth 2.1-only authorization server. It deliberately does not
implement the OAuth 2.0 behaviors that 2.1 removes. The server core is the
grant machinery under src/protocol/.
This server targets draft-ietf-oauth-v2-1-16, "The OAuth 2.1
Authorization Framework".
This is an Internet-Draft (work in progress), not a published RFC. It may be updated, replaced, or obsoleted by other documents at any time, and citing it here does not imply endorsement. The version above is the one this implementation was reviewed against.
The draft consolidates RFC 6749 (OAuth 2.0), RFC 6750 (Bearer Token Usage), RFC 8252 (OAuth 2.0 for Native Apps), RFC 9700 (Security Best Current Practice), and the Browser-Based Apps draft.
For a protocol implementer or an integrator: the exact deltas from OAuth 2.0 and the section citation behind each, the endpoints, the five grants and their parameters, PKCE, redirect matching, and the error codes.
The 2.1 draft consolidates the OAuth 2.0 core with the security best current practice in RFC 9700. This server enforces every one of these:
| Delta | Behavior here | Reference |
|---|---|---|
| PKCE is mandatory for every authorization-code flow | A request without code_challenge is rejected; only S256 is accepted. |
draft-ietf-oauth-v2-1-16, RFC 9700 §2.1.1 |
The plain PKCE method is removed |
code_challenge_method=plain is rejected. |
RFC 7636 §4.3, RFC 9700 §2.1.1 |
| The implicit grant is removed | Only response_type=code exists; there is no token response. |
draft-ietf-oauth-v2-1-16, RFC 9700 |
| The resource-owner-password grant is removed | There is no grant_type=password code path. |
draft-ietf-oauth-v2-1-16 |
| Bearer credentials in the URI query are rejected | Access tokens are presented in the Authorization header. |
RFC 6750 §2 |
| Redirect URIs are matched exactly | No prefix or wildcard match; one loopback exception. | RFC 9700 §2.1, RFC 3986 §6.2.1 |
| Public-client refresh tokens rotate | Rotation on every use, with tombstone reuse detection. | RFC 9700 §2.2.2, §4.14 |
| Authorization codes are single-use and short-lived | A code is consumed atomically and capped at 60 seconds. | RFC 9700 §4.5.3 |
state is supported and echoed |
The value is returned on the success and error redirects. | RFC 6749 §4.1.2.1 |
| No open redirect | The redirect URI must be registered, attached, and enabled. | RFC 9700 §4.1.3 |
An internal audit raised the seven findings below against the draft cited
above. All seven are resolved in the current implementation; there are no
open gaps. Findings 1, 4, and 7 are also recorded under [Unreleased] Fixed
in CHANGELOG.md. Each entry names the draft section, the
previous behavior, and the current behavior.
Token endpoint accepts a missing or empty redirect_uri (draft-16
§4.1.3, §10.2): the back-compat mode must allow redirect_uri, not
require it, but the implementation used to reject a token request with an
empty redirect_uri (invalid_request), which could break pure-2.1
clients. Resolved: the token endpoint accepts a missing or empty
redirect_uri and skips the binding check; a supplied value must still
match exactly or redemption fails invalid_grant.
Authorization error redirects carry iss (draft-16 §4.1.2.1,
§7.15.1): iss is required on the authorization response, including
errors, but the error redirect (error, error_description, state)
used to omit it while the success redirect included it. Resolved:
every error redirect issued after client_id and redirect_uri are
validated now carries the percent-encoded iss alongside error,
error_description, and the echoed state.
Missing response_type redirects once the client is validated
(draft-16 §4.1.1, §4.1.2.1): the absent-response_type case used to
return a direct error page even after client_id and redirect_uri were
validated, where the draft requires delivery via redirect. Resolved:
the absent-response_type case now redirects (invalid_request with
error_description + state) once client_id and redirect_uri are
validated; before that it still renders the direct 400 page.
Rotated refresh-token scope splits retained from access scope
(draft-16 §4.3.3, §4.3.1): the new refresh token's scope must stay
identical to the presented one while a narrower scope may be requested,
but rotation used to narrow the replacement refresh token itself.
Resolved: the replacement refresh token keeps the presented scope
re-checked against the current enabled catalog, client registration,
and user entitlements (revoked entries drop silently), while an explicit
narrower scope narrows only the access token and the response scope.
Pinned TLS 1.2 minimum (draft-16 §1.5, §7.14): TLS 1.2 or above is
required. Resolved: the server pins the minimum explicitly in
src/protocol/mtls/verifier.rs (TLS_PROTOCOL_VERSIONS: exactly TLS 1.2
ServerConfig::builder_with_protocol_versions
instead of rustls defaults), covered by the tls_version_* tests in
src/routes.rs (pinned set is exactly 1.2/1.3; version-pinned clients
negotiate the expected version).Consent success redirect uses 303 (draft-16 §7.5.4, SHOULD): the
consent success redirect used 302 where the draft prefers 303.
Resolved: POST /consent success (approval and denial) now returns
303 See Other with Location, so browsers continue with GET;
cookies, CSRF, and CSP handling are unchanged.
Private-use URI schemes require reverse-DNS . notation (draft-16
§2.3.1, SHOULD): there was no reverse-DNS . check for private-use URI
schemes. Resolved: a private-use scheme must contain a .
(com.example.app:/ is accepted, myapp:/ is rejected with
invalid_redirect_uri on DCR and invalid input on the CLI); https
and the loopback http exception are unchanged.
All endpoints are on the TLS public listener. The plaintext health listener
mounts only /health/live and /health/ready.
| Method | Path | Reference | Status |
|---|---|---|---|
| GET | /.well-known/oauth-authorization-server |
RFC 8414 | Wired |
| GET | /authorize |
RFC 6749 §4.1.2.1, RFC 9700 §4.1.3 | Wired |
| POST | /par |
RFC 9126 §2.1 | Wired |
| POST | /token |
RFC 6749 §4.1.3, RFC 8628 §3.4, RFC 8693 §2 | Wired |
| POST | /device_authorization |
RFC 8628 §3.1 | Wired |
| GET, POST | /device |
RFC 8628 §3.3 | Wired |
| GET, POST | /login |
RFC 9700 §4.7.1 | Wired |
| GET, POST | /logout |
server-specific | Wired |
| GET, POST | /consent |
RFC 9700 §4.7.1 | Wired |
| POST | /introspect |
RFC 7662 §2.1 | Wired |
| POST | /revoke |
RFC 7009 §2.1 | Wired |
| POST | /register |
RFC 7591 §2 | Wired |
| GET, PUT, DELETE | /register/{client_id} |
RFC 7592 §2 | Wired |
GET /logout and POST /logout are a server-specific session endpoint, not a
feature defined by OAuth 2.1 or OIDC. GET /logout is read-only: it renders
a confirm page (live session) or a neutral signed-out page (anonymous) and
never destroys the session or writes cookies. Only POST /logout, carrying
the session-bound CSRF form_token, destroys the authorization-server
session and expires the session cookie (__Host-session_id, or plain
session_id when cookie_secure = false; the clearing cookie mirrors the
session cookie's name and its Path/HttpOnly/SameSite/Secure; no Domain is ever
emitted, so the cookie stays host-only in both variants). In production the
session reader honors only __Host-session_id: a plain session_id cookie
is never read, so a cookie planted by a sibling subdomain cannot fixate a
victim's login onto an attacker presession. The development variant reads plain
session_id and may additionally fall back to __Host-session_id; POST
/logout clears every name the reader honors. GET /logout takes no query
parameters and issues no redirect at all, so a forced navigation
cannot cause an open redirect; only POST /logout may redirect, and only to a
post_logout URI that was registered for the client and frozen in session
meta when the authorization flow was bound. It redirects there only when the
owning client is still active and still registers that exact URI (exact-string
match, with the RFC 8252 loopback exception); otherwise it renders a neutral
signed-out page. A POST /logout with a live session but no bound redirect
target renders the neutral signed-out page instead of redirecting.
This is what lets a client end the browser SSO session so a different user can
sign in. There is no post_logout_redirect_uri parameter, no id_token_hint,
and no end_session_endpoint in metadata.
The POST /token handler parses the form once, authenticates the client
through the shared client_auth dispatch, verifies the fresh sender proof,
dispatches on grant_type to the matching grant, and shapes the RFC 6749 §5.1
JSON response; an unknown grant_type is unsupported_grant_type. The DCR
routes map onto protocol::dcr::register_handler and
protocol::dcr::manage::{read,update,delete}_handler (RFC 7591 / RFC 7592).
Dynamic registration redirect policy (dcr_redirect_policy): catalog attaches
only catalog-registered URIs; catalog_loopback additionally upserts RFC 8252
loopback URIs; open additionally upserts any absolute https URI with a
non-empty host and no userinfo, fragment, or whitespace. Loopback URIs are
always allowed. Anything else is invalid_redirect_uri.
The advertised set is authorization_code, refresh_token,
client_credentials, urn:ietf:params:oauth:grant-type:device_code, and
urn:ietf:params:oauth:grant-type:token-exchange. DCR may register only
authorization_code, refresh_token, and device_code; client_credentials
is never registered for a dynamically created client.
Reference: RFC 6749 §4.1.3, RFC 9700 §4.5.3, RFC 7636, RFC 9449 §6.
Parameters: code, code_verifier, client_id, optional redirect_uri,
client_secret or client_assertion_type with client_assertion, and a DPoP
proof when the code is DPoP-bound.
Behavior: the code is consumed exactly once. redirect_uri is accept-but-ignore
(draft-16 §4.1.3, §10.2): a missing or empty value skips the binding check; a
supplied value must still match exactly or redemption fails invalid_grant.
The client, PKCE verifier, and sender proof are all checked before minting. The
token is minted
under the code's grant chain. A refresh token is issued only if the client is
registered for the refresh grant.
Reference: RFC 6749 §6, RFC 9700 §2.2.2 and §4.14, RFC 9449 §5.
Parameters: client_id, refresh_token, optional requested_scope (which may
narrow but never widen), and a freshly verified sender proof. The replacement
refresh token keeps the presented scope re-checked against the current
enabled catalog and the client's current registration and the user's current
entitlements (fresh Postgres reads), so a revoked scope or entitlement drops
from the retained refresh scope on refresh; an explicit narrower scope
(§4.3.1) narrows only the access token and the response scope, and the next
rotation from the retained refresh token can still use the full scope.
Behavior: rotation on every use. The presented token is tombstoned with a
rotated_to member. Presenting a rotated token again is reuse: the whole grant
family is revoked and the request fails with invalid_grant. A lost reply does
not double-rotate, because the rotation is keyed by a deterministic request id
and a retry returns the same tokens. If the refresh token's non-empty subject
resolves to a disabled user, the request fails invalid_grant before minting or
rotating.
Reference: RFC 6749 §4.4, RFC 9449 §6, RFC 8705 §3.
Parameters: client_id (confidential clients only), optional requested_scope,
and a sender proof when the client is sender-constrained.
Behavior: no user and no subject. The granted scope is the requested scope intersected with the client's registered scope. No refresh token is issued.
Reference: RFC 8628 §3.1 through §3.5.
Authorization: POST /device_authorization returns a device code, a user code,
a verification URI, an expiry, and a polling interval. The user completes
GET /device and POST /device and approves or denies.
Polling: grant_type=urn:ietf:params:oauth:grant-type:device_code with
device_code. Before approval the response is authorization_pending; polling
faster than the interval returns slow_down; denial returns access_denied;
expiry returns expired_token. On approval the token is minted with the scope
recomputed against the approving user's entitlements.
Reference: RFC 8693 §2 through §4.1.
Parameters: subject_token, subject_token_type (an access-token type),
optional actor_token and actor_token_type, optional
requested_token_type, optional requested_scope, and a sender proof that
matches the subject token's confirmation, not merely the caller's.
Behavior: disabled by default and enabled with allow_token_exchange. The
exchange must be on the explicit (caller, subject, scopes) allowlist. The
granted scope is the subject's scope intersected with the caller's registered
scope, the subject's entitlements, the enabled catalog, and the allowlist, and
it can only narrow. Chained exchange is rejected.
Reference: RFC 7636, RFC 9700 §2.1.1.
S256 is the only accepted code_challenge_method. plain is never
advertised and never accepted.ALPHA / DIGIT / - . _ ~.Reference: RFC 3986 §6.2.1, RFC 8252 §7.3, RFC 9700 §2.1.
http://127.0.0.1 or http://[::1] matches the same scheme, host, and path
on any port, and the request must carry no userinfo, query, or fragment.localhost is never treated as a loopback host.com.example.app:/cb is accepted, myapp:/cb is rejected.
https and the loopback http exception are unaffected.Before a redirect URI is validated, the server must not redirect anywhere. It
returns an HTML page with no Location header for: absent or unknown
client_id, a JAR/PAR resolution failure, a client_id binding mismatch, and a
missing or unregistered redirect_uri. The page carries the error's HTTP
status: client errors render 400, a fail-closed Postgres/PII outage
(temporarily_unavailable) renders 503 with Retry-After: 1, and a masked
internal failure renders 500.
After the redirect URI is validated, errors are delivered on the redirect as
302 with error, error_description, the echoed state, and the RFC 9207
iss. This covers
a missing or unsupported response_type (invalid_request when absent,
unsupported_response_type otherwise), invalid_scope, the PKCE failures,
and a post_logout catalog outage (temporarily_unavailable/server_error).
Errors use the RFC 6749 §5.2 body {error, error_description}.
| Code | HTTP | Typical causes |
|---|---|---|
invalid_request |
400, or 302 on the /authorize error redirect once client_id and redirect_uri are validated |
Missing or duplicated parameter; PAR misuse; JAR misuse. |
invalid_client |
401 with WWW-Authenticate for Basic or Bearer, else 400 |
Bad client authentication; ambiguous mTLS certificate. |
invalid_grant |
400 | Unknown, consumed, or foreign code; rotation reuse; PKCE mismatch; proof against the wrong grant. |
invalid_scope |
400 | Unknown, disabled, over-registered, or non-entitled scope; refresh widening. |
unsupported_grant_type |
400 | A grant_type outside the five. |
unsupported_response_type |
302 error redirect | A response_type other than code. |
unauthorized_client |
400 | A client that may not use the requested grant. |
invalid_request_object |
400 | Malformed, unsigned, mis-bound, or conflicting JAR request object. |
invalid_dpop_proof |
400 | Missing or invalid DPoP proof; replayed jti. |
invalid_redirect_uri |
400 | DCR redirect URI not in the catalog or otherwise ineligible. |
invalid_client_metadata |
400 | DCR metadata that violates the rules. |
access_denied |
400 | Resource-owner denial; RFC 7592 token mismatch. |
authorization_pending |
400 | Device flow before approval. |
slow_down |
400 | Device polling faster than the interval. |
expired_token |
400 | Device code or code expired. |
temporarily_unavailable |
503 with Retry-After: 1 |
Postgres or Redis is unavailable on a security path. |
server_error |
500 | A masked internal failure. |
The server never fails open on a security path: a dependency outage yields
503, never a silent success.
Every sender-constrained grant requires a freshly verified proof of possession on every use.
| Client class | Binding |
|---|---|
| Public | DPoP mandatory. A missing proof is a hard invalid_grant. |
| Confidential | mTLS and/or DPoP; the confirmation reports both when both are proven. |
private_key_jwt is client authentication only. It produces no confirmation and
never satisfies sender constraint. For token exchange, the proof must match the
subject token's confirmation. See SECURITY.md for the full matrix
and the confirmation propagation rule. It accepts RS256, PS256, ES256, and
EdDSA only (none and HMAC are rejected); exp may lie at most 300 s past
now plus 30 s clock skew, and the single-use jti marker lives for the
assertion's remaining validity, clamped to [1 s, 360 s].
GET /.well-known/oauth-authorization-server returns the RFC 8414 document.
The only state-dependent member is scopes_supported, read from the enabled
scopes table on every request. The catalog is mutated out of process by the
CLI, so a per-request read makes a change visible immediately on every replica
with no restart. When Postgres is unavailable the endpoint returns 503 rather
than advertising an unknown list.
The document carries no jwks_uri: the server signs no artifact of its own.
code_challenge_methods_supported advertises only S256.
authorization_response_iss_parameter_supported is true, so the authorization
response carries iss (RFC 9207). The document advertises
token_endpoint_auth_signing_alg_values_supported = [ES256, EdDSA, RS256,
PS256]; dpop_signing_alg_values_supported stays [ES256, EdDSA, RS256].
Reference: RFC 9101 §4.1 and §5.2, RFC 9126 §2 and §3.
/authorize, a confidential client must use either a PAR-issued
request_uri or a signed request object. A public client may use the query
string.iss equal to client_id,
must target the issuer as aud, and must use RS256, PS256, ES256, or
EdDSA (none and everything else are rejected before key selection).
Conflicts between the object and the query string are rejected.request_uri is a one-time, client-bound PAR handle or an exactly
pre-registered URI. It is fetched only with the SSRF guard: https only, no
redirects, global IPs only, size and time capped. The connection is pinned to
the addresses validated before the fetch, so a rebinding hostname cannot steer
it to a blocked address.request_uri is fetched over outbound
HTTPS, which the shipped cluster network policy default-denies. It is
available only when the deployment adds a scoped egress allowance for the
specific host; see DEPLOYMENT.md.scope keeps the prior grant on both new tokens
after re-checking against the current enabled catalog, client registration,
and user entitlements (fresh Postgres reads), with revoked entries dropped
silently from the retained scope (possibly to empty); an explicit scope
must be a subset of the prior grant and all three current sets, and narrows
only the access token and the response scope — the replacement refresh
token retains the full re-checked scope (draft-16 §4.3.1 + §4.3.3), so a
later rotation with an omitted scope restores the full retained scope.iat freshness window, or with a replayed jti, is
rejected.WAITAOF; the server then requires instance-wide
appendfsync always, or fails closed.Confirm the advertised capabilities:
curl -s https://auth.example.com/.well-known/oauth-authorization-server | jq .
A code flow starts with GET /authorize?response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&code_challenge=...&code_challenge_method=S256.
Introspect a token: POST /introspect with token and client authentication.
A token whose subject is a disabled user returns {"active": false} like an
unknown token.
Revoke a token: POST /revoke with token and an optional token_type_hint.
If every security endpoint returns 503, check /health/ready and the
Postgres and Redis connections.
src/protocol/authorize.rs for authorization validation and the error matrix.src/protocol/code.rs for code issuance and the success redirect.src/protocol/token/ for the grants and TokenService.src/protocol/par.rs, src/protocol/device.rs, src/protocol/introspect.rs,
src/protocol/revoke.rs for the other endpoints.src/protocol/dcr.rs, src/protocol/dcr/manage.rs for registration.src/protocol/metadata.rs for the RFC 8414 document.src/crypto/pkce.rs for PKCE.src/protocol/redirect_repo.rs for redirect matching.src/jose/ for DPoP, JAR, and private_key_jwt.src/error.rs for the error-to-HTTP mapping.