OAuth 2.0 and OpenID Connect profile#
This is the profile the server implements. It describes behavior verified in
internal/oauth, internal/application and internal/httpapi/protocol.go, and
exercised by tests/e2e/protocol_test.go, flows_test.go, tokens_test.go and
bff_test.go. It is not a certification claim: no OpenID or FAPI conformance suite
has been run (interoperability.md).
I below is the issuer: <server.public_origin>/realms/<realm>.
Endpoints#
| Endpoint | Path | Notes |
|---|---|---|
| Discovery | GET I/.well-known/openid-configuration | Generated from implemented features; Cache-Control: public, max-age=300 |
| RFC 8414 metadata | GET /.well-known/oauth-authorization-server/realms/<realm> | Same document |
| JWKS | GET I/jwks | Pending, active and retiring public keys; application/jwk-set+json; max-age=300 |
| Authorization | GET or POST I/oauth/authorize | POST requires application/x-www-form-urlencoded |
| Token | POST I/oauth/token | Form body only; parameters in the query string are rejected |
| Introspection (RFC 7662) | POST I/oauth/introspect | Confidential clients registered as introspectors |
| Revocation (RFC 7009) | POST I/oauth/revoke | |
| Device authorization (RFC 8628) | POST I/oauth/device_authorization | |
| Pushed authorization requests (RFC 9126) | POST I/oauth/par | |
| UserInfo | GET or POST I/oidc/userinfo | Bearer token in Authorization header only (DPoP scheme for DPoP-bound tokens) |
| Dynamic client registration (RFC 7591, 7592) | POST I/oauth/register; GET, PUT, DELETE I/oauth/register/{client_id} | Only when the realm enables it (Dynamic client registration) |
| RP-initiated logout | GET or POST I/oidc/logout | Extension parameter upstream_logout (Logout) |
| Return from an upstream logout | GET I/oidc/logout/upstream-return | Redirect target registered at upstream providers (Upstream logout) |
| Check-session iframe (OIDC Session Management 1.0) | GET I/oidc/check-session | Only when the realm turns it on (Session management); 404 otherwise |
With mutual TLS deployed (mutual-tls.md), the token,
introspection, revocation, UserInfo, PAR and device authorization endpoints
are also reachable on server.mtls_origin and listed in discovery as
mtls_endpoint_aliases.
When ProudAuth acts as a client of an upstream identity provider, the upstream
redirects to GET or POST /api/v1/realms/<realm>/federation/<connection alias>/callback
(the redirect URI to register at the upstream). It is not part of the
authorization-server metadata; see
headless-authentication.md.
Issuers and endpoint URLs come only from server.public_origin; Host and
X-Forwarded-* headers never influence them (TOK-022; tested by
TestDiscoveryAccuracy). A realm's verified custom domain
(installation.md) can serve
these endpoints too, but discovery there still names the public origin's
issuer and endpoints, and tokens, DPoP proofs (htu) and client assertions
(aud) keep using public-origin URLs.
Clients#
client_type | Authentication | Allowed grants |
|---|---|---|
confidential | client_secret_basic (default), client_secret_post, private_key_jwt, tls_client_auth, self_signed_tls_client_auth | authorization code, refresh, client credentials, token exchange, device code |
public_browser | none | authorization code, refresh |
native | none | authorization code, refresh, device code |
device | none by default | device code, refresh |
machine | client_secret_basic (default), client_secret_post, private_key_jwt, tls_client_auth, self_signed_tls_client_auth; none only when jwt-bearer is its only grant | client credentials, token exchange, jwt-bearer |
Exactly one authentication method per request, and it must be the client's registered method (
client_secret_basicandclient_secret_postare interchangeable). Failures return401 invalid_clientwithout detail.Client secrets are
pa_cs_+ 256 random bits, shown once, stored as SHA-256 digests, at most three active per client, optionally with expiry for rotation.private_key_jwt(MCH-010, APP-009): RS256, PS256, ES256 or EdDSA (no HMAC;fapi2clients PS256, ES256 or EdDSA only); signed by one of the client's registered keys (kidandalgmust match where the key names them);iss=sub=client_id;audhas one or two values and every value must be this server's endpoint URL or the issuer, so an assertion also addressed to another party is refused;exprequired, at most 5 minutes ahead (30 s tolerance);jtirequired and single-use (TestPrivateKeyJWT).passwordandimplicitare rejected at registration and at the token endpoint.client_credentialsrequires a linked service principal.jwt-bearer(urn:ietf:params:oauth:grant-type:jwt-bearer, RFC 7523) is workload federation for machine clients with a service principal: an assertion from an issuer the client trusts yields a service token. A machine client whose only grant isjwt-bearermay use authentication methodnone(Workload federation).Mutual-TLS client authentication (
tls_client_auth,self_signed_tls_client_auth, RFC 8705) and certificate-bound tokens: Mutual TLS.Security profiles:
standard;third_party(consent required, not first-party);privileged(first-party, notpublic_browser);high_assurance(requiresprivate_key_jwtand forces PAR);fapi2(the FAPI 2.0 rule set, FAPI 2.0 security profile).Optional per-client protocol settings, stored together in
clients.protocol_options(migration0050) and shown in the client view only when set: front-channel logout (Logout), response signing and encryption (response-security.md), mutual-TLS client authentication and certificate binding, and the display URLs of dynamically registered clients. Configuration documents and the configuration export carry them with the client (TestConfigClientProtocolOptions).Per-client
token_policy:access_token_ttl_seconds(cap),refresh_idle_seconds,refresh_absolute_seconds,require_dpop,require_par,require_mfa,max_session_age_seconds(an SSO session whose authentication is older than this is not reused for the client;prompt=nonethen returnslogin_required).Consent (APP-013):
consent_modeispreauthorized(default for first-party clients) orrequired(default otherwise).consent_terms_version(at most 64 characters) names the client's current terms; a user's consent covers the client only for the version it was given for, so changing it asks users again at their next authorization (existing grants stay valid). Administrators can consent on behalf of the realm or one organization withPOST /clients/{client}/consent-preauthorizations(see headless-authentication.md).Pairwise subjects (
subject_type: pairwise, OIDC-010):sector_identifieris a host name; when omitted it is the host all redirect URIs share, and it is required when they span several hosts. Every redirect URI must be the sector host or one of its subdomains (422otherwise), so a client cannot register into another application's sector. Clients in the same sector receive the same pairwisesubfor a user; clients in different sectors never do (TestPairwiseSectors).Environments (APP-015,
TestEnvironmentSeparation):environmentisdevelopment,stagingorproduction(default). Production clients cannot use loopback redirect URIs orhttporigins (see below). A client registered in a project defaults to the project's environment and must match it (422). The environment is fixed:PUT /clients/{client}with another value fails with422onenvironment, so a development client, its secrets and its redirect URIs are never promoted in place; register a new client instead. Token exchange refuses subject tokens issued to a client of another environment (Token exchange).Claim mappings (APP-011,
TestClaimMappings):id_token_claimsandaccess_token_claimsmap claim names to values, at most 20 claims per map and claim names of at most 64 characters. A value is either a static JSON value (anything but an object), released as is, or an attribute mapping:json{"department": {"$attr": "profile.department", "transform": "upper", "default": "none"}, "tier": "gold"}$attrnames exactly one approved attribute:user.given_name,user.family_name,user.display_name,user.locale,user.zoneinfo, orprofile.<name>, a realm profile attribute (profile.attributes) markedclaimable: true. A realm profile schema that marks an attribute bothclaimableanduser_editableis refused (422, fieldprofile.attributes[<i>].claimable), and an attribute stored with both flags by an older version is never released. Attribute values (user.*andprofile.*) are released only when the grant includes theprofilescope: for ID tokens, the scopes of the authorization request; for access tokens, the token's scopes or, failing that, its grant's. Static values are released regardless.transformislowerorupper;defaultis a string used when the attribute is absent (foruser.*, also when it is empty), the profile attribute is not claimable, or theprofilescope is missing; without a default no claim is emitted. Mapping objects take no other members, and there is no way to compute, concatenate or reach other data. String values are cut to 256 characters before the transform; numeric and boolean profile values pass unchanged. Whether a profile attribute is claimable is checked at issuance, not at registration. Mappings read the user of user tokens; service-principal tokens have no attributes, so only a mapping'sdefaultcan apply, and exchanged tokens carry no grant, so they release attributes only when their own scopes includeprofile. Theuser.*attributes (names, locale, time zone) are edited by users themselves throughPATCH /me: never authorize on them.Mappings may not name reserved claims (
422, "reserved claim <name> cannot be mapped"; tokens.md), and neither map may name an OIDC standard user claim (OIDC-007):name,given_name,family_name,middle_name,nickname,preferred_username,profile,picture,website,email,email_verified,gender,birthdate,zoneinfo,locale,phone_number,phone_number_verified,addressandupdated_at. These come only from the user record and are released by scope, and resource servers may read them as user data. Registration and update reject such a mapping, static value or attribute mapping alike, with422onid_token_claimsoraccess_token_claims. Issuance skips these names, and reserved ones, in mappings stored by older versions, in access tokens as in ID tokens (ID tokens also skip any claim already released by scope). Attribute mappings can still release name and locale values under other claim names regardless of the granted scopes, so review them like a scope.
Redirect URIs and origins#
- Exact string match against registered URIs (APP-004). Registration rejects
wildcards, backslashes, whitespace, fragments, userinfo, relative URIs, path
traversal and encoded separators (
%2e,%2f,%5c). redirect_urisandpost_logout_redirect_urismust not point at the identity provider itself (APP-004): a URI whose host equals the host ofserver.public_originis refused at registration and update (422on the field, "must not point at the identity provider itself"), because the identity provider's own endpoints redirect further (authorization, logout, federation) and would form open-redirect chains. The comparison is on the host name, whatever the scheme and port, and also covers the realm's verified custom domains. Loopback hosts (localhost,*.localhostand loopback addresses) are exempt for every client type, so native clients keep their loopback redirects. Other realms' custom domains are not checked; do not register URIs on them either.httpsis required, except:httploopback IPs fornativeanddeviceclients (RFC 8252 §7.3, any port at request time when the registered URI ishttp://127.0.0.1/...orhttp://[::1]/...);http://localhostand other loopback hosts for non-production clients; reverse-domain private-use schemes (containing a dot) fornativeclients. Production clients may not redirect to loopback overhttpseither.- Browser origins (
allowed_origins) are registered separately,httpsonly (except non-production loopback), no paths or wildcards. CORS responses reflect only registered origins of active clients; credentials are allowed only on/api/v1/realms/{realm}/auth/....
Authorization endpoint#
| Parameter | Rule |
|---|---|
response_type | code only |
response_mode | absent or query; query.jwt or jwt for a JWT-secured response (JARM, ADV-002, response-security.md). Others are refused with an error redirect in query mode |
code_challenge, code_challenge_method | Required: S256 and a 43-character base64url value. plain is rejected. |
redirect_uri | Required; exact match (see above) |
scope | Space-separated, at most 50; every scope must exist and be allowed for the client (see Scopes and resources) |
resource | Repeatable, at most 5 per request; registered audiences only |
state, nonce | Up to 1024 and 512 characters |
prompt | none, login, consent, select_account, create; none cannot be combined |
max_age | Non-negative integer |
acr_values | Local values only (below) |
login_hint, ui_locales | Hints passed to the login UI; never authority |
organization | Organization ID or slug to bind; not disclosed if unknown |
request_uri | A PAR reference |
intent=register | Product extension, same as prompt=create |
claims | OIDC claims request (JSON, at most 4096 bytes; members id_token and userinfo only; standard claim names plus acr, auth_time, amr). Unknown members or claims return invalid_request. See Claims parameter. |
request | Rejected (request_not_supported) |
- Single-valued parameters must not repeat (
invalid_request). - Errors detected before the client and redirect URI are validated are returned as
JSON (
400) and never redirected. Later errors redirect witherror,error_description,stateandiss. - Successful responses carry
code,stateandiss(RFC 9207). Withresponse_mode=query.jwt(orjwt), successful and error redirects carry one signedresponseparameter instead. - Without a usable SSO session the browser is redirected to the login UI with
transaction_idandrealm; see headless-authentication.md.
Sessions, prompt and assurance#
- An IdP session cookie (
pa_sid) lets the server complete a request silently when user, membership, client consent and assurance requirements are met (SES-014). prompt=loginignores the existing session.max_agereuses the session only if its authentication time is within the limit; the originalauth_timeis kept (OIDC-004, AUT-017).prompt=nonereturnslogin_required,consent_required,account_selection_requiredorinteraction_requiredinstead of showing UI.prompt=consentforces the consent step even when consent was recorded.prompt=select_accountwith a reusable SSO session stops the transaction with a requiredselect_accountaction showing the current account (display name and masked email). The login UI answers withPOST .../{id}/account:{"continue": true}keeps the signed-in account;{"continue": false}drops the session's identity and returns toidentify(OIDC-005).acr_valuesacceptsurn:proudauth:acr:single_factor,:federated,:recovery,:mfa,:phishing_resistant. Values containingmfaorphishing_resistantmake MFA mandatory for the transaction. Requested scopes with a step-up policy add theiracrand lowermax_age(see Scope step-up). These are local assurance profiles, not external certifications (AUT-015). Guest sessions (REG-010, realm policyregistration.allow_guests) carryacrurn:proudauth:acr:anonymousandamr["anon"], which rank below every other profile.
Claims parameter#
The OIDC claims request (OIDC-008) is validated at the authorization endpoint
(and at PAR and headless start) and stored with the authorization grant. It only
narrows release; it never releases a claim that the granted scopes do not
already authorize, so it cannot override protected or unconsented claims.
id_tokenmember: the ID token carries only the listed user claims (plusemail_verifiedwhenemailis listed andphone_number_verifiedwhenphone_numberis listed), still limited by the granted scopes. Without anid_tokenmember, ID-token claims follow the scopes.userinfomember: UserInfo is narrowed the same way;subis always returned. Without auserinfomember, UserInfo follows the scopes.acrrequests (in either member, essential or not) add their values to the transaction's assurance requirement. An essentialacrrequest for a value the server does not support fails withunmet_authentication_requirements; one that the existing session cannot meet is not satisfied silently (withprompt=noneno code is issued).value,valuesandessentialare accepted for every claim, but onlyacrvalues change behavior; other claims are released from the user record or not at all.
Tests: TestClaimsRequestAndStaticClaims (tokens_test.go) and
TestBrowserFlowCSRFSSOAndLogout subtest "OIDC-005 select_account and OIDC-008
claims" (flows_test.go).
Scopes and resources#
- OIDC scopes:
openid,profile,email,phone,address,offline_access.profile/email/phone/addressrequireopenid; OIDC scopes require a user subject;offline_accessrequires the client'sallow_offline_access. - Resource scopes belong to one API resource; a client may request them only if it
has a resource grant (
PUT /clients/{client}/resource-grants/{resource}) listing the scope. Requesting a scope never grants it (APP-007).PUTreplaces the grant (allowed_scopes,subject_types,exchange_policy).GET /clients/{client}/resource-grants/{resource}(realm.clients.read) returns one grant with anETagderived from its content (W/"g<hex>");PUTaccepts it as an optionalIf-Matchand answers412 precondition_failedwhen the grant changed meanwhile, or whenIf-Matchis sent for a grant that does not exist (API-010, DB-010). - If no
resourceis given, the resources of the requested scopes are used, then the client'sdefault_resource_id.openidadds the UserInfo resource. - Default grants (APP-007). A resource scope declared with
is_default: trueis requested on the client's behalf when a request names its resource (withresource, or through the client'sdefault_resource_id) but none of that resource's scopes, for examplescope=openid&resource=https://orders.example. Only defaults the client's resource grant lists are added; from then on they are requested scopes like any other: they are shown for consent and the user may decline them, they raise the transaction's assurance per Scope step-up, and at every issuance the organization's scope ceiling and the scope'srequired_permissionsapply. A default scope is therefore never issued where requesting it would not have been; it is left out silently instead of failing the request. A request that names any scope of a resource gets exactly what it names for that resource and no defaults, and protocol (OIDC) scopes are never added by default (openidmust be requested). Defaults are resolved at authorization (authorization endpoint, PAR, headless start, device authorization) and for each client-credentials andjwt-bearerrequest; the authorization's grant then records them, so a refresh can narrow them but never adds defaults declared later. Token exchange keeps its own default, the exchange ceiling (Token exchange). A resource without default scopes, or whose defaults are all left out, is issued a token without scopes, as before (TestDefaultScopeGrants). - Each access token has exactly one audience. At the token endpoint, at most one
resourcemay be named; without one, the first resource of the grant is used. A client obtains tokens for another resource of the same grant by refreshing withresource=<audience>(RFC 8707 style).
Scope step-up#
A resource scope can demand stronger or fresher authentication (AUT-010,
migration 0028, TestScopeStepUp in tokens_test.go). It is declared with the
scope (POST /resources/{resource}/scopes, or in a resource's scopes):
| Member | Values |
|---|---|
step_up_acr | urn:proudauth:acr:mfa or urn:proudauth:acr:phishing_resistant; anything else is 422 |
max_auth_age_seconds | 0 (no limit) or 60–86400 seconds |
A scope with sensitivity: privileged and no step_up_acr requires
urn:proudauth:acr:mfa.
- At authorization (authorization endpoint, PAR, headless start, device
flow): the strongest
acrthe requested scopes require is added to the transaction'sacr_values, andmax_ageis lowered to the shortestmax_auth_age_seconds. The usual rules then apply (see Sessions, prompt and assurance): MFA becomes mandatory, an SSO session that is not recent enough is not reused, a remembered device cannot skip the MFA prompt, and withprompt=nonethe request fails instead of completing at lower assurance. A user without a second factor is asked to enroll one. - At issuance (authorization code, device code and refresh): a scope is left
out of the access token when the grant's
acrranks below the scope's requirement or itsauth_timeis older than the scope'smax_auth_age_seconds; the response'sscopelists what was issued, and other scopes are unaffected. A refresh after the age limit therefore keeps the ordinary scopes and drops the sensitive one. To regain it, the client sends a new authorization request; the existing session is stepped up, never downgraded. - If the realm cannot produce the required assurance (for example
phishing_resistantwhile passkeys are not enabled), the transaction completes with MFA and the scope is then left out of the token. - Token exchange applies it to the subject token's own
acrandauth_time: an explicitly requested scope that falls short isinvalid_scope, and the default ceiling drops such scopes (Token exchange). API keys and personal access tokens never carry step-up scopes, since they hold no authentication event.
Token endpoint#
| Grant | Behavior |
|---|---|
authorization_code | Requires code, code_verifier (43–128 characters), redirect_uri (exact). The code is single-use and bound to client, redirect, PKCE challenge and transaction; valid 60 s. Presenting a used code again revokes the grant and everything issued from it and fails with invalid_grant. A binding failure also revokes the grant. Returns access token, refresh token (if the client has the refresh grant), ID token (if openid). |
refresh_token | Rotation with reuse detection (ADR 0011); fapi2 clients keep the same refresh token (FAPI 2.0). scope may only narrow; scopes whose step-up policy the grant no longer meets are left out (Scope step-up). Live re-checks of user, client, session, organization and membership. |
client_credentials | Confidential client with a service principal; resource must resolve to exactly one business resource (account and UserInfo audiences are refused); organization must be allowed for the service principal; no refresh token. |
urn:ietf:params:oauth:grant-type:device_code | RFC 8628 polling: authorization_pending, slow_down (interval +5 s when polled faster than the interval, initial 5 s), access_denied, expired_token; single redemption; client-bound. |
urn:ietf:params:oauth:grant-type:token-exchange | See below. |
urn:ietf:params:oauth:grant-type:jwt-bearer | Workload federation (RFC 7523 §2.1): see below. |
- Per-client budget
security.rate_limits.token_per_clientpertoken_window; excess returns429 slow_downwithRetry-After. Tokens bound to an organization also count against that organization's budget (security.rate_limits.organization_per_window, TEN-018), so one tenant cannot use up issuance for the others. - Responses:
access_token,token_type,expires_in,scope, optionalrefresh_token,id_token,issued_token_type.token_typeisDPoPfor a token bound to a DPoP key (cnf.jkt) andBearerfor every other token, certificate-bound tokens (cnf.x5t#S256, RFC 8705 §3) included; this holds for every grant, token exchange included. All responses areCache-Control: no-store. - Errors use RFC 6749 JSON (
error,error_description);invalid_clientaddsWWW-Authenticate: Basic. - Administrative audiences (
I/admin) require that the session holds MFA evidence and is not recovery-restricted.
Token exchange (RFC 8693)#
- Confidential clients only.
subject_token_typemust beurn:ietf:params:oauth:token-type:access_token;requested_token_type, if given, the same.actor_tokenis rejected: the authenticated client is the actor. - The subject token must be a JWT access token of this realm (
user_accessorexchange_access); support tokens, service tokens, opaque tokens and ID tokens are refused. - Its signed
grant_idmust identify a live, unexpired grant of this user and realm. Exchanges retain that originating link, so revoking the grant stops further exchanges and makes its already exchanged descendants inactive online. - Exactly one target (
resourceoraudience), which must be an active business resource for which the client's resource grant lists the subject token's audience inexchange_policy.accept_from_audiences(TEST-038). - Scope is limited to the grant's allowed scopes intersected with
exchange_policy.max_scopes(a requested scope beyond that isinvalid_scope: "scope X exceeds the exchange ceiling"). - The organization's scope policy applies as at every other issuance: scopes
outside the subject token's organization
allowed_scopes, and scopes whoserequired_permissionsthe subject does not hold there, are refused when requested (invalid_scope) and left out of the default ceiling (OIDC scopes are not subject to the organization ceiling). When nothing remains, the request fails withinvalid_scope("no scope is available for this exchange"); a token without scopes is never issued. - When the client the subject token was issued to has
consent_mode: requiredand is not a machine client, the exchanged token carries only scopes the user's live consent for that client covers: a requested scope outside it isinvalid_scope("a requested scope is outside the user's consent for the originating client"), and the default ceiling is narrowed to it (TOK-008). In practice this is the first hop, from the user's own client; a token from an earlier exchange names the machine client that exchanged it, and is bounded by the exchange policies and grants of each hop instead. - The new token has
token_kind: exchange_access, the subject'ssub,org_idandsid, anactclaim naming the client's service principal (or client ID) with nested prior actors, at mosttokens.exchange_max_chain_depth(2) levels (a deeper chain isinvalid_grant, "delegation chain too deep"), and an expiry no later than the subject token's. No refresh token. When the client's service principal is an agent (MCH-017),actalso carriesagent: true; the token itself has no top-levelagentclaim, because its subject is the user. - Each exchange records the audit event
token.exchanged(AUD-005): the actor is the client's service principal (service_principal, an agent's included), or the client when it has none; the subject is the user;client_idandmetadata.client_refname the exchanging client andmetadata.subject_client_idthe client the subject token was issued to.metadata.actor_chainkeeps the new token's wholeactchain, outermost first, including the actors of the subject token's ownactclaim (sub,iss,agent: truefor agents; at most 16 entries, withactor_chain_truncatedbeyond), withchain_depth,from_audience,to_audienceand, for an agent,agent: true. Decisions about an exchanged token record the same chain (policy decisions). - Service and support tokens are never exchanged as subjects (
invalid_grant, "this subject token cannot be exchanged"), so a service cannot turn its own credential into user authority. This is the on-behalf-of model for services and agents (MCH-015): see machine-clients.md. - The subject token must have been issued to a registered client of the same
environment as the presenting client (APP-015); otherwise
invalid_grant("the subject token was issued to a client of another environment"). - Scopes with a step-up policy are checked against the subject token's
acrandauth_time(Scope step-up). A scope named inscopethat falls short fails the request withinvalid_scope. Withoutscope, the default (the ceiling) leaves such scopes out and issues the rest. - Live checks: subject session, user and membership must be active.
Workload federation (RFC 7523)#
MCH-011..MCH-013, ADV-004, TestWorkloadFederation. A machine client with a
service principal and the urn:ietf:params:oauth:grant-type:jwt-bearer grant
sends assertion=<JWT>: a token from an external workload identity issuer
(GitHub Actions, Kubernetes, SPIFFE, or any issuer of signed JWTs) that matches
one of the client's workload trusts (issuer, keys, exact audience, subject,
claim constraints, lifetime, single-use jti). The result is the same
service_access token as for client_credentials, never longer-lived than
the assertion. A refused assertion is 400 invalid_grant ("the assertion was
not accepted"); the reason is audited as workload.assertion.rejected. The
assertion is never accepted as client_assertion (client authentication), and
never yields a user token. Trusts, profiles and verification:
workload-federation.md.
PAR (RFC 9126)#
- Authenticated clients push the authorization parameters to
I/oauth/parand receiverequest_uri(urn:ietf:params:oauth:request_uri:+ random) valid 60 s, single use, bound to the pushing client.request_uriinside a pushed request is rejected. The same validation as the authorization endpoint applies at push time. - Clients with
token_policy.require_par(always forhigh_assuranceandfapi2) must use it; an authorization request without it isinvalid_request("this client must use pushed authorization requests"). - The headless API also accepts
request_uri.
DPoP (RFC 9449)#
- A
DPoPheader at the token endpoint is verified:typ: dpop+jwt, embedded public JWK, asymmetric algorithm, exact case-sensitivehtmandhtumatching the token endpoint (query and fragment ignored),iatno more than 30 s ahead or 2 minutes old,jtiunique per key (replay cache 5 minutes). Multiple proof headers, duplicate claims and case-shadowed required claims are refused (TestDPoPBoundTokens). - The access token gets
cnf.jktandtoken_type: DPoP(from every grant that accepts a proof, token exchange included); clients withrequire_dpopmust send a proof. - The IdP's own APIs (account, organization and administration) and UserInfo
enforce the binding (TOK-019): a DPoP-bound token is accepted only with the
DPoPscheme and a fresh proof for that request whosehtuis the public-origin URL (401otherwise; UserInfo answers401withWWW-Authenticate: Bearer error="invalid_token"), and a token that is not bound is refused with theDPoPscheme. Device-code and token-exchange grants bind the token they issue to the proof of their own request. - Limits: DPoP nonces are not supported; refresh tokens are not bound to the
key. The Go resource SDK checks proofs through
SenderVerifier; token-only methods in all SDKs reject bound tokens. Node.js, Dart and PHP have no request-level proof verifier (resource profile). Introspection returnscnf(also for opaque tokens) andtoken_type: DPoPfor a DPoP-bound token (Bearerotherwise, also for certificate-bound tokens), so a resource server must verify the proof itself.
Mutual TLS (RFC 8705)#
TOK-019, MCH-014, migration 0056, TestMutualTLS (tests/e2e/mtls_test.go).
Clients can authenticate with a TLS client certificate, and access tokens can
be bound to it. Deployment (how certificates reach the server):
mutual-tls.md.
tls_client_auth(confidential and machine clients): the certificate must chain to an active client trust anchor of the realm and match exactly one of the client'stls_client_auth_subject_dn(compared with the subject in Go's RFC 4514 form),tls_client_auth_san_dns,_san_uri,_san_email(exact, case-sensitive) or_san_ip. No secret is issued.self_signed_tls_client_auth: the certificate must equal the firstx5ccertificate of one of the client's registered public keys, or match itsx5t#S256, and be within its validity period.- Certificate authentication applies only when the request carries no other
client credential, and needs
client_idin the form. Without a fitting certificate:401 invalid_client. tls_client_certificate_bound_access_tokens: true: every access token the client receives carriescnf: {"x5t#S256": "<base64url SHA-256 of the certificate>"}, from any grant (code, refresh, client credentials, device, token exchange,jwt-bearer), and opaque tokens keep it too. The token response names ittoken_type: Bearer: it is presented with theBearerscheme, and the TLS connection proves the binding. A token request without a certificate is400 invalid_request("this client's tokens are bound to its TLS client certificate; use the mutual-TLS endpoint"); a request with both a DPoP proof and a certificate binding is400 invalid_request("use one sender constraint: DPoP or a TLS client certificate"). A client that requires DPoP and certificate binding at once can therefore get no token; registration does not refuse the combination.- The IdP's own APIs and UserInfo accept a certificate-bound token only on a
connection presenting the same certificate (
401, "this token is bound to a TLS client certificate; present it with the same certificate"). Introspection returnscnf; resource servers must compare the thumbprint with the certificate of their own connection; GoSenderVerifierperforms this check, while ordinary SDK token-only paths reject bound tokens. - DPoP proofs and
private_key_jwtassertions must still name the public token endpoint (htu,aud), not the mutual-TLS alias.
FAPI 2.0 security profile (ADV-001)#
TestFAPI2Profile (tests/e2e/fapi_test.go). A client registered with
security_profile: fapi2 follows a separate rule set modelled on the FAPI 2.0
Security Profile. This is not certified by the OpenID Foundation, and no
conformance suite has been run (interoperability.md).
| FAPI 2.0 requirement | ProudAuth behavior for fapi2 clients |
|---|---|
| Confidential clients only | client_type: confidential (422 on security_profile otherwise) |
Client authentication with private_key_jwt or mutual TLS | private_key_jwt, tls_client_auth or self_signed_tls_client_auth (422). Client assertions must be signed with PS256, ES256 or EdDSA; an RS256 assertion is refused at the token endpoint (401 invalid_client) |
| Authorization code flow only | Grants authorization_code and refresh_token only (422); default both |
| Sender-constrained access tokens | token_policy.require_dpop or tls_client_certificate_bound_access_tokens is required (422) |
| Pushed authorization requests | token_policy.require_par is forced on; requests without PAR are refused. request_uri lives 60 s (as for every client) |
PKCE with S256 | Required for every client |
iss in authorization responses (RFC 9207) | Always sent |
Exact redirect URI matching, https | Exact matching for every client; fapi2 redirect URIs must be https (422 on redirect_uris) |
Signing algorithms PS256, ES256 or EdDSA | The realm's signing algorithm (keys.signing_algorithm) must be one of them when the client is registered or updated (422); the default RS256 is not |
| Short-lived authorization codes | At most 60 seconds, whatever tokens.authorization_code_ttl says |
| Access token lifetime | At most 10 minutes |
| Refresh tokens | Not rotated: the same refresh token stays valid until the family's idle or absolute expiry, the session ends or it is revoked; using it again is not treated as reuse (these clients are confidential and their tokens sender-constrained) |
| TLS 1.2 or later | The mutual-TLS listener requires TLS 1.2; the public listener and any proxy in front of it are the operator's to configure (Go's defaults allow TLS 1.2 and 1.3) |
| Message signing (optional part) | JARM is available (response-security.md); signed request objects (JAR) are not supported, the request parameter is refused (request_not_supported) |
Organization switches: POST /me/organization-grants issues an
authorization code outside a pushed authorization request, so it is refused
for fapi2 clients (and for every client whose token_policy.require_par
is set, high_assurance included): 403 ("this client must use pushed
authorization requests: switch organizations with a new pushed
authorization request naming the organization"). Such a client switches by
pushing a new authorization request with the organization parameter,
where PAR, the 60-second code lifetime and the other rules above apply.
Introspection#
- The caller authenticates as a confidential client and must be registered as an
introspector for the token's audience
(
POST /resources/{resource}/introspectors). For any other token the response is{"active": false}with no further detail (TEST-037). - Live checks as listed in revocation-exposure.md.
- Active responses include
iss,sub,aud,client_id,scope,exp,iat,jti,token_kind,token_type, and as applicableorg_id,sid,auth_time,acr,act,cnf, currentauthz_version, andagent: truefor JWT and opaque tokens of an agent service principal (MCH-017; not for its API keys); support tokens addact,support_modeandimpersonation_grant_id; API keys and personal tokens returntoken_kindapi_key/personal_accessandsubject_type. - Every introspection of a support token is audited (
support.token.introspected).
Revocation#
- Any authenticated client (public clients authenticate with
client_idonly). The response is200whether or not the token existed or belonged to the caller. - Refresh token (
pa_rt_): revokes the grant, its refresh families and opaque tokens, only if it belongs to the caller. Linked user/exchanged JWTs are then inactive at online consumers; unrelated grants and the SSO session survive. - JWT access token: records the JTI as revoked if the token's
client_idis the caller; the IdP's APIs and introspection then reject it. Offline verifiers do not. - Opaque access token: revoked if it belongs to the caller.
token_type_hintis ignored; the token prefix or format decides.
UserInfo#
Accepts only a user_access JWT whose audience is I/userinfo and whose scope
contains openid (business-audience tokens are rejected even if they contain
openid, OIDC-006). The session (if any), user, client and linked authorization
grant must be active; the grant must be unexpired and match the realm, user and
client. Returns
sub (public or pairwise) and claims for the token's scopes: profile → name,
given_name, family_name, locale, zoneinfo, preferred_username,
updated_at; email → email, email_verified; phone → phone_number,
phone_number_verified; address → address from the user profile. When the
most recent active authorization of the token's session for that client carried
a claims request with a userinfo member, the response is narrowed to those
claims (see Claims parameter). The response is unsigned JSON,
unless the client registered userinfo_signed_response_alg or
userinfo_encrypted_response_alg (OIDC-013): then it is application/jwt,
signed (with iss and aud added) and/or encrypted
(response-security.md).
A sender-constrained token must come with its DPoP proof or client certificate
(DPoP, Mutual TLS).
Logout#
- RP-initiated (
I/oidc/logout): parametersid_token_hint,client_id,post_logout_redirect_uri,state. The hint's signature and issuer are verified (expiry is not). AGETwithoutid_token_hintnever ends the browser session (prevents silent logout CSRF); usePOSTor include the hint. With a session cookie, the cookie's session is ended unless the hint names a different session; without a cookie, the hint'ssidsession is ended.post_logout_redirect_urimust be registered for the client (named byclient_idor the hint); otherwise400 invalid_request("post_logout_redirect_uri is not registered", or "post_logout_redirect_uri requires client_id or id_token_hint"). It is checked before anything is revoked, so a bad redirect leaves the session intact. Response:302to the registered URI (withstate), or200 {"session_ended": bool, "upstream_logout": {...}}without one; when clients registered front-channel logout, the page or JSON described below instead. Clients with encrypted ID tokens send the decrypted, signed ID token asid_token_hint. upstream_logoutin every response describes the upstream provider session (FED-013):{"provider": "<connection alias>", "status": ...}withstatusnot_federated(the session was not federated; noprovider),not_requested(only the local session ended; the provider's session may remain),redirect,unsupported(the connection has no end-session endpoint) orunavailable(it could not be determined). The provider is the connection of the session's last federated factor.- Back-channel logout (OIDC Back-Channel Logout 1.0): when a session is revoked
for any reason, clients with
backchannel_logout_urithat have a client session for it receive a POST withlogout_token: a JWT withtyp: logout+jwt,aud= client ID,sub,sid,jti,iat,exp(+2 min) and thehttp://schemas.openid.net/event/backchannel-logoutevent. Delivery goes through the egress policy, up to 8 attempts with backoff; a 2xx response marks it delivered. - Session management (check-session iframe and
session_state) is available per realm, off by default (below).
Session management (OIDC-014)#
OpenID Connect Session Management 1.0, opt-in per realm,
TestCheckSessionIframe (tests/e2e/checksession_test.go). The extension is
claimed only for realms that turn it on; the session APIs (/me/sessions,
the session-event stream, back-channel logout) do not claim it.
- Turning it on: realm policy
sessions.check_session_iframe: true(PUT /settings; defaultfalse). Discovery then listscheck_session_iframe=I/oidc/check-session(when the logout endpoint is published), and every authorization response that carries a code includessession_state: sign-in completions, silentprompt=nonecompletions and organization switches (POST /me/organization-grants). With JARM the parameter is inside the response JWT. session_stateishex(SHA-256(client_id + " " + RP origin + " " + opbs + " " + salt)) + "." + salt, where the RP origin is the origin of the request'sredirect_uriand the salt is fresh for every response.- OP browser state (
opbs) is a keyed digest of the session ID under the deployment's lookup key; it is never the session cookie or secret. It is held in the cookiepa_opbson path/realms/<realm>/, readable by scripts (notHttpOnly),SameSite=None; Secureon deployments with secure cookies (withsessions.cookie_insecure_dev, notSecureandSameSitepersessions.cookie_same_site). It is set on browser sign-in completion (including SAML sign-ins) and silent authorization, and cleared by RP-initiated logout. - The iframe (
GET I/oidc/check-session) has no visible content. ItsContent-Security-Policyallows only its own inline script (by hash),connect-src 'self'andframe-ancestors *(it must be embeddable by any RP); it is cacheable for an hour. It answers apostMessageof"<client_id> <session_state>"with"unchanged","changed"or"error", posted only to the sender's origin. The sender's origin must be one of the client'sallowed_originsor the origin of one of its redirect URIs, and the client must be active; the iframe checks this withGET I/oidc/check-session/origin?client_id=&origin=(answers{"allowed": bool}, cached per page; at most 300 checks per minute per client address, then429). Other origins get"error". Because the state is recomputed with the message's origin, a value copied to another origin never reads"unchanged".
Browser and protocol limitations. Plan for them before relying on the iframe:
- Browsers that block or partition third-party cookies (Safari Intelligent
Tracking Prevention, Firefox Total Cookie Protection, Chrome with
third-party cookies restricted or in private windows) hide
pa_opbsfrom the iframe embedded in the RP's page, so every check reportschanged. The RP then falls back toprompt=nonere-authentication, which in such browsers may itself need a top-level navigation. - Sessions ended on the server (administrator revocation, idle or absolute timeout, risk or emergency actions, revocations from shared signals) are not visible to the iframe: the cookie only changes when the browser reaches the provider. For prompt revocation use back-channel logout, front-channel logout, token introspection or the session-event stream.
- Rotating the root key that the lookup key derives from changes
opbs, so every RP seeschangedonce. - The script is tested by recomputing its algorithm in Go; it has not been run in real browsers (interoperability.md).
Front-channel logout (SES-018)#
OpenID Connect Front-Channel Logout 1.0, TestFrontChannelLogout
(tests/e2e/frontchannel_test.go). A client registers:
| Option | Rules |
|---|---|
frontchannel_logout_uri | Must have the scheme, host and port of one of the client's registered redirect URIs (compared as written; default ports are not normalized) and pass the redirect URI rules: https, or http only for localhost and loopback hosts of non-production clients |
frontchannel_logout_session_required | true adds iss and sid to the URI's query. Requires frontchannel_logout_uri (422 otherwise) |
When the logout endpoint ends a session, the active clients that took part in
it (their client session is active) and registered a URI are listed, in
client_id order:
- A browser gets
200text/htmlinstead of the302: a "Signed out" page with one hidden iframe per URI and, when there is a post-logout redirect,<meta http-equiv="refresh" content="2;url=...">and a "Continue" link; without one, just the "signed out" text. ItsContent-Security-Policyisdefault-src 'none'; frame-src <the frame origins>; frame-ancestors 'none'; base-uri 'none'; form-action 'none': the page runs no script and frames only those origins. When the redirect goes to an upstream provider's end-session endpoint (upstream_logout=true), the page continues there. - A caller that sends
Accept: application/json(and nottext/html), such as an external login UI, gets200 {"session_ended", "upstream_logout", "frontchannel_logout_uris", "redirect_to"}and loads the URIs in frames itself before continuing toredirect_to.
Delivery is never confirmed. Browsers that block third-party cookies often
keep the client from seeing its own session inside the frame, so the
notification may do nothing. Back-channel logout remains the reliable
mechanism; use front-channel logout only in addition. Without front-channel
clients the endpoint behaves as before (302 or JSON). A GET without
id_token_hint ends no session and so loads no frames. Frames are produced
only by the logout endpoint, not when a session is revoked elsewhere (an
administrator, a password reset, expiry).
Upstream logout (FED-013)#
Ending the local session never ends the user's session at an upstream
identity provider. A client can ask for both with the extension parameter
upstream_logout=true (query on GET, form on POST; TestFederationAdapters):
- ProudAuth ends the local session as usual, then redirects (
302) the browser to the connection's end-session endpoint:config.end_session_endpoint, or the one in the provider's discovery document (not discovered for multi-tenant connections, which need it configured). The request carriesclient_id(ProudAuth's client at the provider),post_logout_redirect_uri=I/oidc/logout/upstream-returnand a sealedstatevalid for 15 minutes; noid_token_hint, because upstream tokens are never stored. Register that return URI at the provider. - The provider sends the browser to
I/oidc/logout/upstream-return?state=..., which redirects to the client'spost_logout_redirect_uriwith itsstateandupstream_logout=returned, or answers{"session_ended": true, "upstream_logout": {"status": "returned"}}when the client gave none. A forged or expiredstateis400 invalid_request, and so is astateused a second time ("state already used"): each return is accepted once.returnedmeans the browser came back, not that the provider ended its session. - When no upstream redirect is possible, the client's redirect carries
upstream_logout=unsupportedorunavailableinstead.
session.logout audit events of federated sessions record
upstream_provider and upstream_logout_requested.
Upstream identity providers#
ProudAuth is also a client of upstream providers (FED-*,
internal/federation); connections are realm data managed at
/api/v1/realms/{realm}/identity-providers and, for organization-scoped
connections, under /organizations/{org}/identity-providers. The sign-in
flow is in headless-authentication.md.
Each connection names an adapter, which fixes the protocol and what the
provider's assertions mean (FED-002, TestFederationAdapters):
adapter | Protocol | Defaults and rules | |
|---|---|---|---|
oidc (default) | OpenID Connect | issuer required; scopes openid email profile | email_verified from the provider is believed |
github | OAuth 2.0 with the GitHub API | Issuer https://github.com, GitHub's endpoints and API; scopes read:user user:email; the subject is the numeric account ID | Only the primary, verified address from the API |
google | OpenID Connect | Issuer fixed to https://accounts.google.com; with config.allowed_tenants (Workspace domains) the tenant claim is hd, so consumer accounts are refused; scopes openid email profile | email_verified is believed |
microsoft | OpenID Connect (Entra ID work and school accounts) | Issuer https://login.microsoftonline.com/{tenant}/v2.0 with multi_tenant_issuer and tenant claim tid always set; config.allowed_tenants (tenant IDs) required; endpoints default to the organizations authorize and token endpoints and the common JWKS. Subjects are pairwise per application | Never treated as verified (the claim is a mutable attribute), so trusted_verified_email linking is refused (422) and just-in-time accounts keep the address unverified |
An unknown adapter is 422 on adapter (the message lists the registered
ones). Operators who build their own binary can add adapters with
federation.RegisterAdapter (name, protocol, whether email assertions are
trusted for linking, defaults and checks, identity resolution); an adapter
whose email assertions are not trusted never yields a verified email. The
generic oidc adapter has signed users in through a real provider, Keycloak
26.8.0 (interoperability.md);
the google, microsoft and github adapters only through test doubles
(interoperability.md).
The connection's kind (oidc, or oauth2_social for adapters that are not
OpenID Connect) follows the adapter, also when PUT /identity-providers/{id}
changes it (FED-016). While identities are linked to a connection, changing
its issuer or changing the adapter to one of another protocol is refused with
409 (field issuer or adapter, "cannot change while identities are
linked; use a reviewed migration").
Just-in-time accounts (REG-009) never take over an address: an upstream
email that the provider does not mark verified and that another account has
already verified is not copied to the new account, which then gets a
generated username (<alias>-<login or subject>). A generated username
that is taken gets a random suffix instead of failing the sign-in (after
three taken suffixes the sign-in stops with account_conflict).
Discovery document#
Published members (values reflect configuration):
issuer, authorization_endpoint, token_endpoint, jwks_uri,
userinfo_endpoint, introspection_endpoint, revocation_endpoint,
end_session_endpoint, device_authorization_endpoint,
pushed_authorization_request_endpoint, require_pushed_authorization_requests: false, response_types_supported: ["code"], response_modes_supported: ["query", "query.jwt", "jwt"], grant_types_supported (authorization code, refresh, client
credentials, device code, token exchange, jwt-bearer; a grant type the realm has disabled
with an emergency control is left out, OIDC-015, while
device_authorization_endpoint stays published; jwt-bearer has no
emergency control), subject_types_supported: ["public", "pairwise"], id_token_signing_alg_values_supported (algorithms of published
keys), userinfo_signing_alg_values_supported ("none" and the algorithms of
published keys), authorization_signing_alg_values_supported, the
id_token_, userinfo_ and authorization_ encryption_alg_values_supported
and encryption_enc_values_supported lists
(response-security.md), token, introspection and
revocation endpoint auth methods (with tls_client_auth and
self_signed_tls_client_auth when client certificates can reach the server),
token_endpoint_auth_signing_alg_values_supported,
code_challenge_methods_supported: ["S256"], scopes_supported (OIDC scopes only;
resource scopes are not listed), claims_supported, claims_parameter_supported: true, request_parameter_supported: false, request_uri_parameter_supported
(true when PAR is enabled), require_request_uri_registration: false,
prompt_values_supported, acr_values_supported,
authorization_response_iss_parameter_supported: true,
tls_client_certificate_bound_access_tokens (true when server.mtls_listen
or server.client_cert_header is set), mtls_endpoint_aliases (with
server.mtls_origin: token, introspection, revocation, UserInfo, PAR and
device authorization endpoints on that origin; mutual-tls.md),
frontchannel_logout_supported: true, frontchannel_logout_session_supported: true, backchannel_logout_supported: true,
backchannel_logout_session_supported: true, dpop_signing_alg_values_supported,
and check_session_iframe only for realms with
sessions.check_session_iframe (Session management).
No service_documentation member is published.
registration_endpoint (I/oauth/register) is published only while the realm
enables dynamic client registration (Dynamic client registration).
Dynamic client registration (RFC 7591, 7592)#
APP-016, APP-017, ADV-003. Off by default; realm policy
client_registration enables it. POST I/oauth/register registers a
third-party client that asks for consent, authorized by an initial access
token an administrator issued (pa_iat_, bound to the non-privileged scopes
the client may receive) or, with open registration, by nothing (the client
is then held for an administrator's approval). The response carries a
registration access token (pa_rat_) for GET, PUT and DELETE on
I/oauth/register/{client_id}. jwks_uri and software statements are refused,
and no client metadata URL is ever fetched. Budgets: 20 registrations per hour
per network, 500 per realm. Details:
dynamic-client-registration.md.
Not supported#
Implicit and hybrid flows; resource-owner password grant; response_mode
fragment, form_post, fragment.jwt and form_post.jwt; JAR (request
objects); response encryption with RSA1_5, RSA-OAEP (SHA-1) or CBC-HMAC
content encryption; client jwks_uri; software statements in dynamic
registration; CIBA; JWT bearer assertions that represent users (RFC 7523
jwt-bearer issues service tokens only); DPoP nonces. OIDC Session
Management is opt-in per realm and limited by browsers
(Session management). For the P3 items that
are deliberately not offered (push approval, CIBA, UMA, verifiable
credentials, JAR) and the reasons, see
compatibility.md.