OIDC + OAuth 2.1
Every tenant is a full OpenID Connect provider. Discovery, JWKS, authorization, token, and userinfo are all served from the tenant’s own host, signed by the tenant’s own key.
Discovery
Section titled “Discovery”GET https://acme.oauth.work/.well-known/openid-configuration{ "issuer": "https://acme.oauth.work", "authorization_endpoint": "https://acme.oauth.work/authorize", "token_endpoint": "https://acme.oauth.work/token", "userinfo_endpoint": "https://acme.oauth.work/userinfo", "jwks_uri": "https://acme.oauth.work/.well-known/jwks.json", "end_session_endpoint": "https://acme.oauth.work/logout", "registration_endpoint": "https://acme.oauth.work/register", "revocation_endpoint": "https://acme.oauth.work/revoke", "introspection_endpoint": "https://acme.oauth.work/introspect", "pushed_authorization_request_endpoint": "https://acme.oauth.work/par", "id_token_signing_alg_values_supported": ["EdDSA"], "code_challenge_methods_supported": ["S256"], "scopes_supported": ["openid", "profile", "work_credential", "offline_access"]}Discovery sends CORS headers, so a browser relying party can fetch it directly. See scopes, claims, and discovery for the full metadata.
Authorization code + PKCE
Section titled “Authorization code + PKCE”OAuth 2.1 makes PKCE mandatory, and only S256 is accepted — no plain, and no implicit or hybrid
flow.
GET /authorize ?response_type=code &client_id=client_7f3a… &redirect_uri=https://app.acme.com/callback &scope=openid%20profile%20offline_access &state=<opaque> &nonce=<opaque> &code_challenge=<S256(verifier)> &code_challenge_method=S256Useful additional parameters:
| Parameter | Effect |
|---|---|
organization | Broker the login to a specific org’s SSO connection. |
login_hint | An email address. A verified domain routes it to that org’s connection. |
prompt=login | Force re-authentication even if a session exists. |
max_age | Require the existing session to be no older than this many seconds. |
resource | Bind the token’s audience (RFC 8707). Repeatable. |
authorization_details | Fine-grained permissions. |
request_uri | A handle from PAR; replaces all of the above. |
The user authenticates on the tenant’s hosted screens — see login methods — and consents. The response comes back to your redirect URI:
302 https://app.acme.com/callback?code=<code>&state=…&iss=https://acme.oauth.workCheck both. state must match what you sent, and iss (RFC 9207) must be the issuer you started
at — that is what prevents a response from another authorization server being replayed at your
client.
Authorization codes live 60 seconds, are bound to the PKCE challenge, and are single-use:
redemption reads and deletes atomically in a Durable Object, so a replay always fails closed with
invalid_grant.
Token exchange
Section titled “Token exchange”POST /tokenContent-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=<code>&client_id=client_7f3a…&redirect_uri=https://app.acme.com/callback&code_verifier=<verifier>{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600, "scope": "openid profile offline_access", "id_token": "eyJ…", "refresh_token": "rt_…"}A confidential client must also authenticate here — see
client authentication. client_id and redirect_uri must match the
values bound at /authorize, or the exchange fails.
Verifying the ID token
Section titled “Verifying the ID token”import { createRemoteJWKSet, jwtVerify } from 'jose'
const jwks = createRemoteJWKSet(new URL('https://acme.oauth.work/.well-known/jwks.json'))
const { payload } = await jwtVerify(idToken, jwks, { issuer: 'https://acme.oauth.work', audience: 'client_7f3a…',})
if (payload.nonce !== expectedNonce) throw new Error('nonce mismatch')Check nonce against the value you sent — it binds the ID token to your authorization request.
Resolve the JWKS from the token’s own iss rather than a hardcoded URL: tenant and platform tokens
are signed by different keys, and rotation publishes new ones alongside old. Use a client
that selects by kid.
See scopes, claims, and discovery for what the token carries —
including amr and auth_time, which tell you how and when the user actually authenticated.
Userinfo
Section titled “Userinfo”GET /userinfoAuthorization: Bearer eyJ…Requires a token with openid. If the token is DPoP-bound, a matching proof is required
too.
Logout
Section titled “Logout”/logout is the end_session_endpoint. It destroys the platform session and clears the cookie:
GET /logout ?id_token_hint=<the id_token you received> &post_logout_redirect_uri=https://app.acme.com/signed-out &state=<opaque>A post-logout redirect is honoured only when all of these hold:
id_token_hintis present and verifies against the issuer that signed it.- The URI is registered in that client’s
post_logout_redirect_uris— not its authorizationredirect_uris, which are a separate list. - The URI is https.
Otherwise the endpoint simply confirms the logout without redirecting. That is deliberate: an unvalidated post-logout redirect is an open redirect.
Register post-logout URIs at registration
time via post_logout_redirect_uris.
Logging out ends one session. To end all of a user’s sessions, see sessions.
- Login methods — what the hosted screens offer
- Token lifecycle — refresh rotation, introspection, revocation
- DPoP — sender-constrained tokens
- OIDC federation — brokering to a customer’s own identity provider