Log users in
How createRelyingParty and the route helpers sign people in, what they guarantee, and what your application must store.
On this page
Signing a person in is two things: a protocol (discovery, PKCE, a pushed authorization request, client authentication, token checks) and a session (cookies, storage, what a login means in your app). The SDK owns the first and leaves the second to you.
| Layer | Function | Subpath | What it does |
|---|---|---|---|
| Protocol | createRelyingParty | @tdacorp/identity-sdk/rp | Discovery, PKCE, PAR, private_key_jwt, callback checks, code exchange, ID token verification, refresh, end-session address, logout token verification. Never sees a cookie. |
| Routes | createAuthRoutes | @tdacorp/identity-sdk/rp | Four Request to Response handlers over a relying party: login, callback, logout, backchannelLogout. |
| Next.js | createNextAuthHandlers | @tdacorp/identity-sdk/rp/next | The same handlers, plus a GET and POST that dispatch on the last path segment. |
Mount the routes#
Configure the relying party once, as in the Quickstart. Then mount the handlers in your framework.
Next.js#
Use createNextAuthHandlers in app/api/auth/[action]/route.ts and export its GET and POST. The full file is in the Quickstart. To mount one route file per handler instead, export the individual handlers: export const GET = handlers.login.
Any runtime that speaks Request and Response#
createAuthRoutes returns plain functions of Request, so a Vercel function, a Cloudflare worker or a Node server can call them from its own router.
import { createAuthRoutes } from '@tdacorp/identity-sdk/rp'import { rp } from './relying-party'const routes = createAuthRoutes({ rp, appOrigin: 'https://app.example.com', cookieSecret: process.env.APP_COOKIE_SECRET ?? '', onLogin: (login) => new Response(null, { status: 303, headers: { location: login.redirectTo } }),})// Any runtime that speaks Request and Response can mount the four handlers.export function handleAuth(request: Request): Promise<Response> { switch (new URL(request.url).pathname) { case '/auth/login': return routes.login(request) case '/auth/callback': return routes.callback(request) case '/auth/logout': return routes.logout(request) case '/auth/backchannel-logout': return routes.backchannelLogout(request) default: return Promise.resolve(new Response('Not found', { status: 404 })) }}Hono#
Hono hands you the standard request as c.req.raw, so the handler above mounts with one line. This is a sketch: Hono is not a dependency of the samples on this site, so it is not type-checked here.
app.all('/auth/*', (c) => handleAuth(c.req.raw))Options#
| Option | Default | Meaning |
|---|---|---|
rp | required | The relying party. |
appOrigin | required | The origin your app is served from, a bare origin such as https://app.example.com. |
cookieSecret | required | Encrypts the transaction cookie. At least 32 bytes. A rotation map is accepted. |
scope | openid profile email | Scopes to request. Must include openid. |
authorization | none | Extra request members: maxAge, acrValues, prompt, tenant, resource, loginHint, uiLocales. |
getTenant | none | (request) => tenantId or undefined, run on the login route: picks the organization for this one sign-in, and wins over authorization.tenant. Return the canonical lowercase tenant id your app stored when the organization installed it, never a value copied unchecked from the query. undefined falls back to authorization.tenant. A result that is not a canonical tenant id refuses the login with invalid_tenant (400) and starts nothing. |
defaultReturnTo | / | Where to go when there is no valid returnTo. |
onLogin | required | Creates your session and returns the response. |
onError | 400 JSON with the code | Turns a failed callback into a response. |
logout | route answers 404 | Configures sign-out. |
backchannelLogout | route answers 404 | Configures back-channel logout. |
maxConcurrentLogins | 5 | Logins in flight at once, 1 to 10. |
transactionTtlSeconds | 600 | How long a person has to come back. |
secureCookies | true | false is accepted only for an http loopback appOrigin, for local development. |
What the helpers guarantee#
- Every absolute address comes from
appOriginand the registered redirect URI. TheHostheader andX-Forwarded-*are never read. - The PKCE verifier,
state,nonceandreturnToare sealed (encrypted and integrity protected) in anHttpOnly,SameSite=Lax,Secure,Path=/cookie with a__Host-prefix. There is one cookie per login in flight, so two tabs do not overwrite each other, and the cookie is expired on every callback outcome. returnTomust be a path on your own app. A protocol-relative address, a backslash, a control character or ajavascript:style value becomes the default.- Logout accepts
POSTonly unless you setallowGet, and refuses a cross-originOriginorSec-Fetch-Site: cross-sitewith 403. - The callback runs its checks in a fixed order and sends nothing to the token endpoint until they have all passed: the stored transaction is valid and belongs to this issuer and client, none of
code,state,issorerroris duplicated, the transaction has not expired,issis present and equals the issuer the flow started at,statematches, and only then is anerrorparameter reported and thecoderead. - The ID token must have
typJWT, an allowed algorithm, akid, and the rightiss,aud,azp,exp,iat,nonceandsub. If verification fails, no tokens are returned, and the refresh token the exchange produced is revoked.
What you must persist#
PKCE, PAR and the client assertion are automatic. These are not.
- The refresh token, on your server. It rotates on every refresh. Save the new one before you discard the old one. A sealed cookie holds at most 4096 bytes, which is too small for tokens, so keep them in your own store keyed by your session.
- The DPoP key that goes with it. Identity binds a refresh token to the thumbprint of the DPoP key that was used. If your key changes, every refresh token bound to the old one stops working. Generate one key, store it as a secret and load it at start-up: see DPoP.
- A way to map
sidto your sessions, if you use back-channel logout.
// Sketch, not type-checked: your database, not part of the SDK.await db.refreshTokens.upsert({ sessionId, refreshToken: login.tokens.refreshToken })Refreshing is one call. Serialize refreshes per token: a refresh token that is presented a second time after identity's short retry window is treated as stolen. Identity revokes the token family and ends the session, so the person has to sign in again. See Refresh tokens.
import { OAuthEndpointError } from '@tdacorp/identity-sdk/rp'import { rp } from './relying-party'// Returns the new token set. Persist tokens.refreshToken before you discard the old one.export async function refreshSession(refreshToken: string) { try { return await rp.refresh(refreshToken) } catch (error) { if (error instanceof OAuthEndpointError) { // The SDK already retried a 429 a bounded number of times and never retries a 5xx: what to do now is your decision. if (error.reason === 'rate_limited') throw new Error(`retry in ${String(error.retryAfterSeconds ?? 60)} s`, { cause: error }) // The refresh family is over (revoked, expired or reused): sign the person in again. if (error.oauthError === 'invalid_grant') return null } throw error }}Call an API with the access token#
An access token from a client that requires DPoP is bound to your key (token_type is DPoP). A resource server needs a proof next to it, and the proof carries ath, the hash of that token.
import { rp } from './relying-party'// The access token from a sign-in is bound to your DPoP key, so a resource server needs a proof next to it.export async function getThings(accessToken: string): Promise<Response> { const url = 'https://api.example.com/things' const proof = await rp.dpopProof({ method: 'GET', url, accessToken }) // carries ath, the hash of the token return fetch(url, { headers: { authorization: `DPoP ${accessToken}`, dpop: proof } })}Step up when an API asks#
An API you call may answer 401 with insufficient_user_authentication and the acr_values and max_age it wants (RFC 9470), for example because the person's organization enforces single sign-on. Pass that challenge, as the object or the raw WWW-Authenticate header value, and the organization id to rp.stepUpUrl(challenge, tenant). It returns { url, transaction } like startAuthorization: store the transaction sealed and redirect the browser to url. It asks for the challenge's max_age and for its acr_values that are aal1, aal2 or aal3. An organization that enforces single sign-on sends the person to its own connection. The token endpoint can give the same answer (status 400, with acr_values and max_age) on a code exchange, a refresh or a token exchange. Single sign-on enforcement is behind the sso_enforcement flag, which defaults OFF.
Sign out#
POST to the logout route. With logout.getIdTokenHint returning the stored ID token, the SDK redirects to identity's end-session address with the hint and your postLogoutRedirectUri, and identity can send the person back to your app. Without a hint the SDK sends no return address, so identity cannot send the person back. postLogoutRedirectUri must be one of the post-logout addresses registered for the client; an unregistered address is not followed. Your own cookies are cleared first, even if identity is unreachable.
Errors and limits#
- A failed token, PAR or revoke call throws
OAuthEndpointErrorwithreason(rate_limited,server_error,oauth_error,malformed_response,transport),status,oauthErrorand, for a 429,retryAfterSeconds. The SDK retries a 429 a bounded number of times (see Retrying a 429) and never a 5xx. The other retry it makes is the DPoP nonce challenge. - The error codes are listed in Error codes, and the client side of handling them is in Error handling.
- The callback handles
response_mode=queryonly. The SDK asks forqueryexplicitly, although the server's default for awebclient isform_post. - There is no
rolesclaim. See Scopes and consent. - The relying party verifies
EdDSAID tokens by default. If your client is registered forES256, passidTokenAlgorithms: ['ES256']. - Public clients and
tls_client_authclients are not supported by this module.