Skip to content

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.

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.

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=S256

Useful additional parameters:

ParameterEffect
organizationBroker the login to a specific org’s SSO connection.
login_hintAn email address. A verified domain routes it to that org’s connection.
prompt=loginForce re-authentication even if a session exists.
max_ageRequire the existing session to be no older than this many seconds.
resourceBind the token’s audience (RFC 8707). Repeatable.
authorization_detailsFine-grained permissions.
request_uriA 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.work

Check 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.

POST /token
Content-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.

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.

GET /userinfo
Authorization: Bearer eyJ…

Requires a token with openid. If the token is DPoP-bound, a matching proof is required too.

/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_hint is present and verifies against the issuer that signed it.
  • The URI is registered in that client’s post_logout_redirect_urisnot its authorization redirect_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.