Skip to content
TDACorpIDENTITYSign in

Relying-party module

The framework-agnostic client that does discovery, PKCE, the pushed request, client authentication, callback checks and token handling for a login.

On this page

The relying-party module is the protocol half of a login. Pushed authorization requests, PKCE and signed client authentication are mandatory at identity, so there is no "redirect and read a code" client to write. This module does it all and nothing else: it never touches a cookie, a session or a router. Its only I/O is an injectable fetch and an injectable clock, and it uses Web Crypto, so it runs in Node, an edge runtime or a worker.

The module is imported from the SDK's rp subpath. For the routes that wrap it, see Next.js adapter and Log users in.

Create it#

import { createRelyingParty } from '@tdacorp/identity-sdk/rp'const rp = createRelyingParty({  issuer: 'https://identity.tdacorp.in',  clientId,  redirectUri: 'https://app.example.com/api/auth/callback',  key: { kid: 'k1', privateKey: privateJwk },  dpop,})
OptionDefaultMeaning
issuerrequiredThe issuer, compared byte for byte against discovery and every iss. https, or http for a loopback host only.
clientIdrequiredYour client identifier.
redirectUrirequiredA registered redirect address. A call may name another registered one.
keyrequired{ kid, privateKey, lifetimeSeconds? }. An Ed25519 or P-256 private key as a JWK or CryptoKey. Assertion lifetime defaults to 60 seconds, at most 300.
dpopnoneA DPoP key pair. Required when the client is registered to require DPoP.
idTokenAlgorithms['EdDSA', 'ES256']The ID token algorithms you accept. Only EdDSA and ES256 are ever allowed: anything else, none, HS256 and RS256 included, is refused when the module is created. Whatever you list is further limited to what the issuer's discovery document advertises, and if discovery advertises none of them the module refuses to start the flow.
clockToleranceSeconds60Tolerance for exp, iat and auth_time, a whole number from 0 to 300. A value outside that range is refused when the module is created, not clamped.
retry3 attemptsBounded retry of an HTTP 429 from the issuer. See Retrying a 429.
requestTimeoutMs, discoveryTtlMs, jwksTtlMs, jwksCooldownMsSee belowNetwork limits and cache lifetimes. Discovery is cached five minutes, the key set ten minutes, and an unknown kid refetches at most every 30 seconds.
fetch, clockglobal fetch, system clockInjectable, for tests.

Clients that authenticate with none or with client certificates are not supported by this module.

Methods#

MethodWhat it does
discover()Returns the cached provider metadata. Throws DiscoveryError if the document is unavailable, malformed, names another issuer, or has an endpoint off the issuer's origin.
startAuthorization(input)Pushes the request and returns { url, transaction }. Redirect the browser to url; store the transaction, sealed.
handleCallback(response, transaction)Checks the redirect back, exchanges the code, verifies the ID token and returns { tokens, idToken }.
refresh(refreshToken, { scope?, resource?, expectedSub? })Rotates a refresh token and returns a new TokenSet. When expectedSub is given, the response must carry an ID token (grant openid) whose sub equals it, or the call throws IdTokenError with reason sub. If the refreshed ID token is refused, the rotated refresh token is revoked on a best-effort basis, since you never receive it.
stepUpUrl(challenge, tenant, input?)Answers a resource server's RFC 9470 insufficient_user_authentication challenge. challenge is the parsed { acrValues, maxAge } or the raw WWW-Authenticate header value; tenant is the organization id. It starts an authorization that asks for the challenge's max_age and for those acr_values that are aal1, aal2 or aal3 (the only values the server accepts), and returns { url, transaction } like startAuthorization. input may set scope (default openid), redirectUri and resource.
revoke(token)Calls the revocation endpoint. Throws ConfigurationError if none is published.
endSessionUrl({ idTokenHint, postLogoutRedirectUri, state })Builds the end-session address.
verifyLogoutToken(token, { claimJti? })Verifies a back-channel logout token. See Back-channel logout.
verifyIdToken(token, { nonce, maxAge?, acrValues? })Verifies a stored ID token outside the callback. A nonce of null skips the nonce check.
dpopProof({ method, url, accessToken, nonce? })A proof for a request to an API, with ath. Throws if the module has no DPoP key.
dpopThumbprint()The jkt your tokens' cnf will carry, or null.

startAuthorization input#

FieldMeaning
scopeRequired. Must include openid. Add offline_access for a refresh token.
redirectUriAnother registered address for this call.
maxAge, acrValues, promptFreshness and assurance. See acr and amr values.
tenantAn organization id to sign in to.
resourceThe API the access token is for. It becomes the token's aud.
loginHintAn identify-field prefill.
uiLocalesThe languages the sign-in pages should prefer, best first, sent as ui_locales.
responseModequery (the default here) or form_post. handleCallback takes a query.
transactionTtlSecondsHow long the person has to come back. Default 600, at most 3600.

What handleCallback checks#

In order, and nothing is sent to the token endpoint until all pass: the stored transaction is valid and belongs to this issuer and client; no parameter is repeated; the transaction has not expired; iss is present and equals the issuer the flow started at; state matches; then any error is reported; and last the code is read. After the exchange it verifies the ID token as described in ID token. If that fails the tokens are not returned and the refresh token is revoked.

The transaction holds the PKCE verifier, the nonce and the redirect address. Keep it sealed: encrypted and integrity-protected, or on your server under an opaque session id. See Sealed sessions.

Results#

TokenSet: accessToken, tokenType (Bearer or DPoP), expiresAt, scope (array), refreshToken and idToken (each string | null).

VerifiedIdToken: claims, sub, sid, acr, amr, authTime and expiresAt. For a client registered with a pairwise subject type, sub is the per-client value, which differs from the sub in the access token (VerifiedAccessToken.subject). Key your users on one of them, normally this one, and never on both; see Pairwise subjects.

DPoP helpers#

generateDpopKeyPair(alg?) (default ES256), exportDpopKeyPair, importDpopKeyPair, dpopThumbprint and signDpopProof are exported. Generate one key, export it, keep it in your secret store and import it at start-up: a refresh token is bound to its thumbprint. See DPoP.

Errors#

All errors extend RpError and carry a reason.

Classreason values
ConfigurationErrorinvalid_options, invalid_transaction, alg_unsupported, endpoint_unavailable, invalid_end_session_request, invalid_key, insecure_url
DiscoveryErrorunavailable, malformed, issuer_mismatch, endpoint_origin_mismatch, insecure_endpoint
AuthorizationResponseErrorinvalid_transaction, transaction_mismatch, expired_transaction, duplicate_parameter, missing_iss, iss_mismatch, missing_state, state_mismatch, authorization_error, missing_code
IdTokenErrormalformed, typ, alg, kid_missing, key_not_found, signature, iss, aud, azp, exp, iat, nonce, sub, auth_time, max_age, acr, jwks_unavailable
OAuthEndpointErroroauth_error, rate_limited, server_error, malformed_response, transport, with endpoint (par, token, revocation), status, oauthError, description and retryAfterSeconds
LogoutTokenErrorThe back-channel counterpart of IdTokenError.

A 5xx, a timeout and an OAuth error are surfaced, never retried. The module makes two kinds of retry: the single DPoP nonce retry, and the bounded retry of a 429 described next. Both rebuild the whole request, including a fresh client assertion and a fresh DPoP proof.

Retrying a 429#

A 429 from the issuer is retried for the pushed authorization, token and revocation requests and for the discovery and key-set fetches. Nothing else is retried. The wait honours the Retry-After header (a number of seconds or an HTTP date) plus a little random jitter. Without the header it backs off exponentially with jitter.

retry memberDefaultMeaning
maxAttempts3Total attempts including the first, from 1 to 5. 1 turns the retry off.
baseDelayMs250The first back-off ceiling when there is no Retry-After. It doubles per attempt.
maxDelayMs5000The longest single wait, at most 60000. A Retry-After longer than this is not waited out: the call fails at once and the error carries the value.
totalBudgetMs20000The most time one call may spend waiting and requesting, at most 120000.

When the attempts run out, OAuthEndpointError has reason rate_limited, status 429, retryAfterSeconds from the last answer and attempts, the number of requests made. A discovery or key-set fetch that runs out becomes a DiscoveryError with reason unavailable. A value outside these bounds is refused when the module is created.

Worked example#

// Login routeconst { url, transaction } = await rp.startAuthorization({ scope: 'openid profile offline_access' })await saveSealed(session, transaction)return redirect(url)// Callback routeconst { tokens, idToken } = await rp.handleCallback(new URL(request.url), await loadSealed(session))// Later: rotate. Persist the new refresh token before dropping the old one.const next = await rp.refresh(tokens.refreshToken)

saveSealed and loadSealed are yours.

Security notes#

  • Seal the transaction. If a person can edit it, the nonce and redirect checks prove nothing.
  • Serialize refreshes per token, and persist the new token first. See Refresh token.
  • Never log the result of handleCallback. It holds credentials.

Verified against commit 5d85077