Skip to content
TDACorpIDENTITYSign in

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.

LayerFunctionSubpathWhat it does
ProtocolcreateRelyingParty@tdacorp/identity-sdk/rpDiscovery, PKCE, PAR, private_key_jwt, callback checks, code exchange, ID token verification, refresh, end-session address, logout token verification. Never sees a cookie.
RoutescreateAuthRoutes@tdacorp/identity-sdk/rpFour Request to Response handlers over a relying party: login, callback, logout, backchannelLogout.
Next.jscreateNextAuthHandlers@tdacorp/identity-sdk/rp/nextThe 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#

OptionDefaultMeaning
rprequiredThe relying party.
appOriginrequiredThe origin your app is served from, a bare origin such as https://app.example.com.
cookieSecretrequiredEncrypts the transaction cookie. At least 32 bytes. A rotation map is accepted.
scopeopenid profile emailScopes to request. Must include openid.
authorizationnoneExtra request members: maxAge, acrValues, prompt, tenant, resource, loginHint, uiLocales.
getTenantnone(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.
onLoginrequiredCreates your session and returns the response.
onError400 JSON with the codeTurns a failed callback into a response.
logoutroute answers 404Configures sign-out.
backchannelLogoutroute answers 404Configures back-channel logout.
maxConcurrentLogins5Logins in flight at once, 1 to 10.
transactionTtlSeconds600How long a person has to come back.
secureCookiestruefalse is accepted only for an http loopback appOrigin, for local development.

What the helpers guarantee#

  • Every absolute address comes from appOrigin and the registered redirect URI. The Host header and X-Forwarded-* are never read.
  • The PKCE verifier, state, nonce and returnTo are sealed (encrypted and integrity protected) in an HttpOnly, 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.
  • returnTo must be a path on your own app. A protocol-relative address, a backslash, a control character or a javascript: style value becomes the default.
  • Logout accepts POST only unless you set allowGet, and refuses a cross-origin Origin or Sec-Fetch-Site: cross-site with 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, iss or error is duplicated, the transaction has not expired, iss is present and equals the issuer the flow started at, state matches, and only then is an error parameter reported and the code read.
  • The ID token must have typ JWT, an allowed algorithm, a kid, and the right iss, aud, azp, exp, iat, nonce and sub. 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.

  1. 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.
  2. 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.
  3. A way to map sid to 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 OAuthEndpointError with reason (rate_limited, server_error, oauth_error, malformed_response, transport), status, oauthError and, 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=query only. The SDK asks for query explicitly, although the server's default for a web client is form_post.
  • There is no roles claim. See Scopes and consent.
  • The relying party verifies EdDSA ID tokens by default. If your client is registered for ES256, pass idTokenAlgorithms: ['ES256'].
  • Public clients and tls_client_auth clients are not supported by this module.

Verified against commit 5d85077