Authorization flow
This page explains how auth runs OpenID Connect inside: what it stores at each
step of an authorization, how codes become tokens, and how refresh tokens,
revocation, userinfo and logout work. Read it before changing anything under
apps/auth-service/src/oidc. For the protocol as apps see it, read the
OpenID Connect reference instead.
| Module | Holds |
|---|---|
oidc/mod.rs | OidcApplication, its routes, the discovery document, client authentication and opaque tokens |
oidc/authorize/ | Authorization requests, pushed requests, the sign-in requirements and the consent decision |
oidc/inline.rs | Completing a pushed request from a passkey sign-in on an app’s own page |
oidc/policy.rs | ClientPolicy, the check of the client’s current registration |
oidc/token.rs | The token, revocation and userinfo endpoints |
oidc/logout.rs | RP-initiated logout |
web/auth/ | The sign-in, consent and logout pages the flow sends the browser to |
app ──/oauth/par──▶ pending request (60 s) ──request_uri──┐
browser ──/oauth/authorize──▶ pending request (10 min) ◀──┘ claimed once
│ session falls short? ──▶ /login ─┐
│ consent needed? ──▶ /authorize (consent page)
▼ │ /oauth/authorize/resume
code (2 min) ──redirect──▶ app ◀───────┘
app ──/oauth/token──▶ access + ID token (5 min), refresh family
Opaque tokens
Pending requests, codes and refresh tokens are opaque tokens, written
<prefix>_<id>.<secret> (OpaqueToken). The prefix, par_v1, pac_v1 or
prt_v1, binds a token to one purpose, so a code never passes as a refresh
token. The ID names the row. Only an HMAC of the secret is stored, under the
current token digest key, with that key’s fingerprint; a lookup accepts the
digest under every configured key, so a change of key breaks nothing
(token digest keys).
Pending authorizations
/oauth/authorize (OidcApplication::authorize) takes a request by query or
form post. It finds the client, which must be enabled, and matches
redirect_uri exactly against its registration. Until then an error answers as
OAuth error JSON; from then on, every error goes back to the client’s redirect
URI, always with state and iss (ClientRedirect).
AuthorizationParameters::validate checks the rest the same way for browser and
pushed requests: response_type=code, an S256 PKCE challenge, scopes and
audience the client allows, prompt, max_age, acr_values, and an
id_token_hint that auth signed for this client, which names the only account
that may answer. The request is stored in authorization_requests for ten
minutes, with its first known acr_values entry as its target acr. The
browser carries its opaque token, the transaction, from then on.
A client registered to push its requests is refused here.
Pushed requests
/oauth/par (push_authorization) authenticates the client as the token
endpoint does, validates the parameters the same way, and stores the request
for 60 seconds, answering request_uri with the token after
urn:ietf:params:oauth:request_uri:. Errors are JSON, since no browser is
involved.
The browser then brings request_uri and client_id to /oauth/authorize,
which ignores every other parameter. Claiming it (claim) works once, only for
that client: it gives the request a new secret and ten more minutes, and records
when it was claimed as when it started, so prompt=login and max_age count
from there.
Sign-in and consent
continue_authorization decides where the browser goes. Every step that reads a
pending request first checks it against the client’s registration as it is now
(ClientPolicy), so a client disabled, suspended or changed meanwhile leads the
browser nowhere it no longer allows.
- Does the session meet the request?
session_satisfieschecks the hinted account, a sign-in after the request started forprompt=loginormax_age=0, and the targetacrand a positivemax_ageas aStepUpRequirement, the rule resources apply. The session’sacrandauth_timecome fromSessionAuthentication. - If not, the browser goes to
/login?transaction=…, with the targetacr, so the page offers only the methods that reach it. Once signed in, the sign-in page continues the authorization in-process, or a provider’s callback resumes it at/oauth/authorize/resume. Withprompt=none, the client getslogin_requiredinstead. A passkey that signed in for this request but still falls short, as a recovered passkey in probation does, ends it withunmet_authentication_requirements, so the browser does not loop. - A recovery that can be undone. A sign-in made for the request, to an account with a recovery that can still be undone, first asks on the sign-in page whether it was the owner’s.
- Consent. A trusted client, or one the account already consented to for
these scopes and audience, skips the consent page unless
prompt=consentasks for it. Otherwise the browser goes to/authorize?transaction=…, or the client getsconsent_requiredforprompt=none.
decide (authorize/consent.rs) consumes the pending request, whether the
consent page posted the decision or consent was implicit. An approval locks the
account and the session first, so a consent never commits after its session
ended, then share-locks the client (ClientPolicy::share) and checks its policy
again. An explicit approval saves the consent, per account, client, audience and
scopes, and records ConsentGranted. A denial redirects with access_denied.
Codes
An approval issues an authorization code (issue_code), stored in
authorization_codes for two minutes. It is bound to the client, the redirect
URI, the PKCE challenge, the nonce, the scopes and audience, and the session, and
it copies the session’s SessionAuthentication as it is then.
On an app’s own page
A trusted client may complete a pushed request with a passkey sign-in on its own
page, from an approved related origin (passkey sign-in on your own
pages). The ceremony claims the request
(claim_inline) and, once the passkey signed in, consumes it and issues its code
(complete_inline), which goes back to the page as JSON with state and iss.
The client must still be trusted and the origin approved. A request that needs
auth’s own pages answers interaction_required instead, and the app sends the
browser to auth: one with prompt=consent or prompt=none, a sign-in that falls
short of it, or an account with a recovery that can still be undone.
The token endpoint
/oauth/token (token.rs) first authenticates the client
(authenticate_client). A public client sends its ID alone. A confidential one
sends HTTP Basic credentials, compared in constant time with the digests of
every active secret, so the previous secrets work during a rotation’s grace
period.
Exchanging a code
exchange_code runs in one transaction:
- It locks the code’s account, then the code (
lock_code), and checks that the form redeems it: the same client, the sameredirect_uri, and acode_verifiermatching its PKCE challenge. A failed check expires the code and recordsCodeRedemptionFailed, so a code is tried once. - It consumes the code. A code redeemed before is a replay: the refresh
families issued from it are revoked and
CodeReplayedis recorded, before the request is rejected. The browser session stays signed in, since a leaked code exposes the client’s callback, not the session cookie. - It locks the session, which must still be active, share-locks the client and checks its policy, and, for an untrusted client, reads the consent again, so a consent withdrawn since the code was issued fails the exchange.
- It signs the tokens, with the claims the scopes release, and records
GrantIssued.
Tokens are signed with the current key set (keys). The access token
(at+jwt) is for the request’s audience; an ID token is issued when the scopes
include openid, with the request’s nonce. Both last five minutes
(ACCESS_TOKEN_SECONDS) and report the code’s authentication, not the
session’s current one. sourceorigin.auth.tokens.issued{grant_type, token_type} counts
them once the transaction commits.
Refresh families
A refresh token is issued only when the client allows refresh and the scopes
include offline_access. The exchange creates a refresh family
(refresh_token_families): one grant, tied to its session, to the code it came
from and to the code’s authentication. Its tokens expire with the session.
redeem_refresh (crates/database/src/repositories/tokens) locks the token’s
account, session, family and client, in that order, and keeps the locks until
the new tokens commit, so a refresh waits for a logout or a disablement and then
fails on it. What it does depends on the client’s kind:
- A public client’s token rotates: it is consumed, and the response carries
its replacement in the same family. Presenting a consumed token is a replay:
the family is revoked and
GrantRevokedrecordsrefresh_replay, so neither the thief nor the client refreshes again. - A confidential client keeps its token, which is useless without the client’s secret, so its refresh writes nothing.
A family whose client no longer allows refresh, its audience or its scopes is
revoked on its next use (policy_rejected). A refresh may narrow the scopes,
never add any. The new tokens keep reporting the family’s original
authentication (OIDC Core §12.2).
A family ends with its session: revoking a session revokes its families in the same transaction (sessions), and removing an app’s access, deleting a client or disabling an account revokes theirs.
Revocation and userinfo
/oauth/revoke (RFC 7009) authenticates the client, revokes the family of the
refresh token it sends if that token is its own, and records GrantRevoked
(client_revoked). It answers 200 whatever the token, so it reveals nothing.
Access tokens cannot be revoked; they expire within five minutes.
/oidc/userinfo verifies a bearer access token against the current key set and
issuer, requires the openid scope, and answers the profile claims the token
itself carries, released by its scopes. It reads nothing from the database.
Logout
RP-initiated logout takes two requests:
/oidc/logout(start_logout) validates the request: anid_token_hintauth signed, which may be expired and signed by a retired key, aclient_idmatching the hint’s audience, and apost_logout_redirect_uriregistered exactly for that client. It then sends the browser to auth’s/logoutpage with the same parameters.- The page asks the user to confirm. Its form validates the parameters again and
signs the browser out (
end_session), recordingRelyingPartyLogoutwith the client that asked, orLogoutwhen none is named. The browser goes on to the redirect URI withstate, or to sign-in.
Signing out revokes the session, which tells every app that received tokens from it (back-channel logout).
Discovery
OidcApplication::metadata builds the discovery document once at startup, from
the ALL lists of the enums the endpoints parse into, so nothing is advertised
that is not handled. It is served at /.well-known/openid-configuration and
/.well-known/oauth-authorization-server, and JWKS at /.well-known/jwks.json
from the current key set; all three are cacheable for an hour.