import { createRemoteJWKSet, customFetch, decodeProtectedHeader, errors as joseErrors, jwtVerify } from 'jose';
import { ASYMMETRIC_JWS_ALGS, SUPPORTED_ID_TOKEN_ALGS } from './algs.js';
import { discoverOpenIdProvider, validateOpenIdProviderMetadata } from './discovery.js';
import { DPOP_ALGS, DPoPKey, DPoPNonceCache, MemoryKeyStore, wantsDpopNonce } from './dpop.js';
import { OAuthError, OIDCClientError, ValidationError } from './errors.js';
import { httpRequest, readJson } from './http.js';
import { codeChallengeS256, generateCodeVerifier, randomToken } from './pkce.js';
import { registerClient } from './registration.js';
import { TransactionStore } from './stores.js';
import { TokenSet } from './token-set.js';
import { base64url, digest, Emitter, isSecureUrl, timingSafeEqual } from './util.js';
/**
* JWT `typ` values that mark a token as something other than an ID token. Presenting one of them as an
* ID token is token confusion (RFC 8725 §3.11), so it is refused even when its signature verifies.
*/
/** Authorization request parameters `extraParams` may not set: the client sets them, and the callback relies on them. */
const RESERVED_AUTHORIZATION_PARAMS = Object.freeze(['response_type', 'response_mode', 'client_id', 'redirect_uri', 'state',
'nonce', 'code_challenge', 'code_challenge_method', 'dpop_jkt', 'request', 'request_uri', 'resource']);
const NOT_ID_TOKEN_TYPES = ['at+jwt', 'application/at+jwt', 'logout+jwt', 'secevent+jwt', 'dpop+jwt',
'token-introspection+jwt', 'software-statement+jwt', 'jwt-bearer'];
/** Parameters that must never arrive at a code-flow redirect URI (tokens in the front channel). */
const FRONT_CHANNEL_TOKENS = ['access_token', 'id_token', 'refresh_token', 'token_type'];
/** Endpoints whose URLs must use TLS. */
const ENDPOINTS = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri', 'userinfo_endpoint',
'revocation_endpoint', 'end_session_endpoint', 'pushed_authorization_request_endpoint', 'registration_endpoint'];
/**
* @typedef {object} OIDCClientConfig
* @property {string} [issuer] Issuer; metadata is loaded from `<issuer>/.well-known/openid-configuration`.
* @property {Record<string, any>} [metadata] A provider metadata document to use instead of discovery.
* @property {string} clientId A PUBLIC client (token_endpoint_auth_method "none").
* @property {string} [redirectUri]
* @property {string} [postLogoutRedirectUri]
* @property {string} [scope] Default "openid profile email offline_access" (offline_access only if supported).
* @property {string|string[]} [resource] RFC 8707 resource indicator(s): audience-restricts the tokens.
* @property {string} [idTokenSignedResponseAlg] Expected ID token `alg` (default RS256).
* @property {'auto'|'always'|'never'} [par] Pushed Authorization Requests (RFC 9126). Default "auto": used when advertised.
* @property {boolean|{ key?: DPoPKey, keyStore?: import('./dpop.js').KeyStore, alg?: string }} [dpop]
* DPoP (RFC 9449). `true` generates a non-extractable key kept in memory; pass `keyStore`
* (e.g. `new IndexedDBKeyStore()`) to keep it across page loads.
* @property {string} [userinfoSignedResponseAlg] The only `alg` a signed UserInfo response may use. Default: any asymmetric algorithm.
* @property {boolean} [requireDpop] With DPoP enabled, refuse a token response whose token_type is Bearer
* (RFC 9449 §5: the AS may fall back, but this client asked for bound tokens). Default: true when
* `dpop` is set. A refresh of DPoP-bound tokens never accepts Bearer, whatever this says.
* @property {TransactionStore} [transactionStore] Where state/nonce/PKCE verifier wait for the callback (default sessionStorage).
* @property {number} [transactionTtlSec] How long an authorization request stays redeemable (default 600).
* @property {number} [maxIdTokenAgeSec] Reject ID tokens whose `iat` is older than this (default 600).
* @property {boolean} [enforceAcr] When acr_values were requested, require the ID token's `acr` to be one of them (default true).
* @property {boolean} [requireAuthTime] Require `auth_time` in every ID token (default false).
* @property {boolean} [allowInsecureRequests] Allow non-TLS endpoints and redirect URIs other than loopback (default false).
* @property {number} [clockToleranceSec] Allowed clock skew (default 30).
* @property {number} [httpTimeoutMs] Per-request timeout (default 10000).
* @property {number} [discoveryCacheTtlMs] How long discovered metadata is cached (default 1h).
* @property {typeof fetch} [fetch] Custom fetch implementation.
*/
/**
* OpenID Connect Relying Party for a PUBLIC client (a browser app): Authorization Code + PKCE, with the
* protections RFC 9700 (OAuth 2.0 Security Best Current Practice) and OpenID Connect call for.
* It holds no client secret and supports no confidential-client authentication.
*
* Emits: "tokens" (TokenSet) whenever the token endpoint issues new tokens.
*/
export class OIDCClient extends Emitter {
/** @type {Record<string, any>} */
metadata;
/** @type {DPoPKey|undefined} */
dpopKey;
#jwks;
#config;
// RFC 9449 §8 and §9: the authorization server's nonce and a resource server's are separate, even when
// both are on one origin (UserInfo at the OP), so each has its own cache.
#asNonces = new DPoPNonceCache();
#rsNonces = new DPoPNonceCache();
#usedStates = new Map();
#usePar = false;
/** @param {OIDCClientConfig} config */
constructor(config) {
super();
if (!config?.issuer && !config?.metadata?.issuer) throw new TypeError('issuer or metadata is required');
if (!config.clientId) throw new TypeError('clientId is required');
for (const secretOption of ['clientSecret', 'privateJwk', 'tokenEndpointAuthMethod']) {
if (config[secretOption] !== undefined && !(secretOption === 'tokenEndpointAuthMethod' && config[secretOption] === 'none')) {
throw new TypeError(`${secretOption} is not supported: this client is a public client (token_endpoint_auth_method "none")`);
}
}
this.#config = {
clockToleranceSec: 30,
httpTimeoutMs: 10_000,
idTokenSignedResponseAlg: 'RS256',
par: 'auto',
dpop: false,
transactionTtlSec: 600,
maxIdTokenAgeSec: 600,
enforceAcr: true,
requireAuthTime: false,
allowInsecureRequests: false,
...config,
requireDpop: config.requireDpop ?? !!config.dpop,
issuer: config.issuer ?? config.metadata.issuer,
};
this.#config.transactionStore ??= new TransactionStore();
}
/**
* Creates a client and loads the provider's discovery document.
* @param {OIDCClientConfig} config
* @returns {Promise<OIDCClient>}
*/
static async discover(config) {
const client = new OIDCClient(config);
await client.init();
return client;
}
/**
* Creates a client from an already-obtained OpenID Provider metadata document (validated as if discovered).
* @param {Record<string, any>} metadata
* @param {Omit<OIDCClientConfig, 'metadata'>} config
* @returns {Promise<OIDCClient>}
*/
static async fromMetadata(metadata, config) {
return OIDCClient.discover({ ...config, metadata });
}
/**
* Registers a new PUBLIC client (OpenID Connect Dynamic Client Registration 1.0) and returns a client
* configured from the registration. `token_endpoint_auth_method` is always "none".
* @param {Record<string, any>} providerMetadata
* @param {Record<string, any>} clientMetadata RFC 7591 metadata, e.g. `{ redirect_uris, id_token_signed_response_alg }`
* @param {Omit<OIDCClientConfig, 'clientId' | 'metadata'> & { initialAccessToken?: string }} [config]
* @returns {Promise<{ client: OIDCClient, registration: import('./registration.js').ClientRegistration }>}
*/
static async register(providerMetadata, clientMetadata, { initialAccessToken, ...config } = {}) {
validateOpenIdProviderMetadata(providerMetadata);
const request = { ...clientMetadata, token_endpoint_auth_method: 'none' };
if (config.dpop && request.dpop_bound_access_tokens === undefined) request.dpop_bound_access_tokens = true;
const registration = await registerClient(providerMetadata, request, {
initialAccessToken,
allowInsecureRequests: config.allowInsecureRequests,
timeoutMs: config.httpTimeoutMs,
fetch: config.fetch,
});
if (registration.client_secret) {
// A confidential registration is not what was asked for, and this client would ignore the secret.
throw new ValidationError('The OP registered a confidential client (it issued a client_secret); only public clients are supported', { code: 'registration_mismatch', response: registration });
}
const client = await OIDCClient.fromMetadata(providerMetadata, {
redirectUri: registration.redirect_uris?.[0] ?? request.redirect_uris[0],
postLogoutRedirectUri: registration.post_logout_redirect_uris?.[0] ?? request.post_logout_redirect_uris?.[0],
...config,
clientId: registration.client_id,
idTokenSignedResponseAlg: registration.id_token_signed_response_alg ?? request.id_token_signed_response_alg ?? 'RS256',
});
return { client, registration };
}
get issuer() { return this.#config.issuer; }
get clientId() { return this.#config.clientId; }
/** The effective settings, for display. */
get config() {
const { metadata, transactionStore, dpop, fetch: _f, ...rest } = this.#config;
return { ...rest, tokenEndpointAuthMethod: 'none', dpop: !!this.dpopKey, par: this.#usePar };
}
/** Which security features are active, for display and audit. */
get securityFeatures() {
return {
pkce: 'S256',
state: 'random 256-bit, single use, expiring, issuer-bound, constant-time compared',
nonce: true,
issuerIdentification: this.metadata?.authorization_response_iss_parameter_supported ? 'required (RFC 9207)' : 'checked when present (RFC 9207)',
par: this.#usePar,
dpop: this.dpopKey ? { alg: this.dpopKey.alg, jkt: this.dpopKey.thumbprint } : false,
resourceIndicators: this.#resources().length ? this.#resources() : false,
tlsEnforced: !this.#config.allowInsecureRequests,
idToken: `alg ${this.#config.idTokenSignedResponseAlg}, typ checked, iat ≤ ${this.#config.maxIdTokenAgeSec}s, at_hash, acr ${this.#config.enforceAcr ? 'enforced' : 'not enforced'}`,
};
}
async init() {
const c = this.#config;
this.metadata = c.metadata
? validateOpenIdProviderMetadata(c.metadata, c.issuer)
: await discoverOpenIdProvider(c.issuer, { timeoutMs: c.httpTimeoutMs, fetch: c.fetch, cacheTtlMs: c.discoveryCacheTtlMs });
const m = this.metadata;
// TLS for every endpoint and redirect URI (RFC 9700 §2.6, OIDC Core §16.17), loopback excepted.
if (!c.allowInsecureRequests) {
for (const key of ENDPOINTS) {
if (m[key] && !isSecureUrl(m[key])) throw new ValidationError(`${key} must use https: ${m[key]}`, { code: 'insecure_endpoint' });
}
for (const [name, uri] of [['redirectUri', c.redirectUri], ['postLogoutRedirectUri', c.postLogoutRedirectUri]]) {
if (uri && !isSecureUrl(uri)) throw new ValidationError(`${name} must use https (or loopback http): ${uri}`, { code: 'insecure_redirect_uri' });
}
}
// PKCE downgrade protection (RFC 9700 §2.1.1): refuse an OP that says it does not do S256.
if (Array.isArray(m.code_challenge_methods_supported) && !m.code_challenge_methods_supported.includes('S256')) {
throw new ValidationError('The OP does not support PKCE with S256', { code: 'pkce_not_supported' });
}
// A public client authenticates with "none" only.
const methods = m.token_endpoint_auth_methods_supported;
if (Array.isArray(methods) && !methods.includes('none')) {
throw new ValidationError('The OP does not accept public clients (token_endpoint_auth_method "none")', { code: 'public_client_not_supported' });
}
if (!SUPPORTED_ID_TOKEN_ALGS.includes(c.idTokenSignedResponseAlg)) {
throw new ValidationError(`ID token alg ${c.idTokenSignedResponseAlg} cannot be verified by this library (supported: ${SUPPORTED_ID_TOKEN_ALGS.join(', ')})`);
}
if (!m.id_token_signing_alg_values_supported.includes(c.idTokenSignedResponseAlg)) {
throw new ValidationError(`OP does not support ID token alg ${c.idTokenSignedResponseAlg}`);
}
// PAR (RFC 9126).
const parEndpoint = m.pushed_authorization_request_endpoint;
if (m.require_pushed_authorization_requests && !parEndpoint) {
throw new ValidationError('The OP requires PAR but advertises no pushed_authorization_request_endpoint');
}
if (c.par === 'always' && !parEndpoint) throw new ValidationError('par: "always" but the OP has no pushed_authorization_request_endpoint');
if (c.par === 'never' && m.require_pushed_authorization_requests) throw new ValidationError('The OP requires PAR (require_pushed_authorization_requests)');
this.#usePar = !!parEndpoint && (c.par === 'always' || c.par === 'auto' || m.require_pushed_authorization_requests);
// DPoP (RFC 9449).
if (c.dpop) {
const opts = typeof c.dpop === 'object' ? c.dpop : {};
const offered = m.dpop_signing_alg_values_supported;
const alg = opts.alg ?? (Array.isArray(offered) ? DPOP_ALGS.find((a) => offered.includes(a)) : 'ES256');
if (!alg) throw new ValidationError(`The OP accepts no DPoP algorithm this library creates (${DPOP_ALGS.join(', ')})`, { code: 'dpop_not_supported' });
this.dpopKey = opts.key ?? await DPoPKey.loadOrCreate(opts.keyStore ?? new MemoryKeyStore(), { alg });
}
this.#jwks = createRemoteJWKSet(new URL(m.jwks_uri), {
timeoutDuration: c.httpTimeoutMs,
...(c.fetch ? { [customFetch]: c.fetch } : {}),
});
if (!c.scope) {
const offline = m.scopes_supported?.includes('offline_access') ? ' offline_access' : '';
c.scope = `openid profile email${offline}`;
}
c.transactionStore.sweep?.(c.transactionTtlSec * 1000);
return this;
}
#requireMetadata() {
if (!this.metadata) throw new OIDCClientError('Client not initialised; call init() or OIDCClient.discover()');
}
#resources() {
const r = this.#config.resource;
return r === undefined ? [] : [].concat(r);
}
// ---------------------------------------------------------------------------
// Authorization request
// ---------------------------------------------------------------------------
/**
* Starts an Authorization Code + PKCE (S256) sign-in. The transaction (state, nonce, code verifier)
* is stored in the transaction store under its `state`, and `callback()` consumes it.
* Uses PAR when enabled, and binds the authorization code to the DPoP key (`dpop_jkt`).
*
* @param {object} [opts]
* @param {string} [opts.scope]
* @param {string} [opts.redirectUri]
* @param {string} [opts.prompt]
* @param {number} [opts.maxAge]
* @param {string} [opts.loginHint]
* @param {string} [opts.idTokenHint] A previously issued ID token naming the account (and session) meant.
* @param {string} [opts.acrValues] Space-separated; with `enforceAcr`, the ID token's acr must be one of these.
* @param {string} [opts.expectedSub] The callback refuses an ID token about anybody else (`account_mismatch`):
* when a request names an account, a sign-in as another person must not be taken for it.
* @param {any} [opts.context] JSON-serialisable data kept with the transaction and handed back by
* `completeAuthorization()` (and on an OAuthError as `err.context`).
* @param {Record<string,string>} [opts.extraParams]
* @returns {Promise<{ url: string, state: string }>}
*/
async createAuthorizationRequest(opts = {}) {
this.#requireMetadata();
const c = this.#config;
const redirectUri = opts.redirectUri ?? c.redirectUri;
if (!redirectUri) throw new TypeError('redirectUri is required');
if (!c.allowInsecureRequests && !isSecureUrl(redirectUri)) throw new ValidationError(`redirectUri must use https (or loopback http): ${redirectUri}`, { code: 'insecure_redirect_uri' });
// extraParams adds parameters (e.g. `claims`, `ui_locales`); it may not replace the ones this method
// sets for PKCE, state, nonce, the redirect URI, DPoP binding or the response type.
const reserved = Object.keys(opts.extraParams ?? {}).filter((k) => RESERVED_AUTHORIZATION_PARAMS.includes(k));
if (reserved.length) {
throw new TypeError(`extraParams may not set ${reserved.join(', ')}: the client sets them itself`);
}
const state = randomToken();
const nonce = randomToken();
const codeVerifier = generateCodeVerifier();
const params = {
response_type: 'code',
client_id: c.clientId,
redirect_uri: redirectUri,
scope: opts.scope ?? c.scope,
state,
nonce,
code_challenge: await codeChallengeS256(codeVerifier),
code_challenge_method: 'S256',
prompt: opts.prompt,
max_age: opts.maxAge,
login_hint: opts.loginHint,
id_token_hint: opts.idTokenHint,
acr_values: opts.acrValues,
...opts.extraParams,
dpop_jkt: this.dpopKey?.thumbprint,
};
// offline_access requires prompt=consent per OIDC Core §11 unless the caller chose otherwise.
if (params.scope.split(' ').includes('offline_access') && params.prompt === undefined) params.prompt = 'consent';
const query = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null) query.set(k, String(v));
for (const r of this.#resources()) query.append('resource', r);
let url;
if (this.#usePar) {
const par = await this.#postForm(this.metadata.pushed_authorization_request_endpoint, query);
if (!par?.request_uri) throw new ValidationError('PAR response has no request_uri', { code: 'invalid_par_response' });
url = new URL(this.metadata.authorization_endpoint);
url.searchParams.set('client_id', c.clientId);
url.searchParams.set('request_uri', par.request_uri);
} else {
url = new URL(this.metadata.authorization_endpoint);
for (const [k, v] of query) url.searchParams.append(k, v);
}
c.transactionStore.set(state, {
state,
nonce,
codeVerifier,
redirectUri,
maxAge: opts.maxAge,
acrValues: opts.acrValues,
expectedSub: opts.expectedSub,
context: opts.context,
issuer: this.metadata.issuer,
clientId: c.clientId,
createdAt: Date.now(),
});
return { url: url.href, state };
}
/**
* Completes the sign-in from the redirect: consumes the stored transaction for the returned `state`
* (single use), checks it, redeems the code with the PKCE verifier (and a DPoP proof) and validates
* the ID token.
*
* @param {URLSearchParams|Record<string,string>|string} response The redirect URL, or its query parameters.
* @returns {Promise<TokenSet>}
*/
async callback(response) {
return (await this.completeAuthorization(response)).tokenSet;
}
/**
* `callback()`, also answering the `context` the request was created with. An error the OP returned
* (e.g. `login_required` or `account_selection_required` to a `prompt=none` request) is thrown as an
* OAuthError carrying that context as `err.context`.
*
* @param {URLSearchParams|Record<string,string>|string} response
* @returns {Promise<{ tokenSet: TokenSet, context: any }>}
*/
async completeAuthorization(response) {
this.#requireMetadata();
const c = this.#config;
const p = toParams(response);
// Front-channel token injection: a code-flow redirect never carries tokens (RFC 9700 §4.5, §2.1.2).
const injected = FRONT_CHANNEL_TOKENS.filter((k) => p.has(k));
if (injected.length) throw new ValidationError(`Authorization response carries ${injected.join(', ')}; tokens never arrive in the front channel`, { code: 'front_channel_token' });
// Parameter pollution: every response parameter appears at most once (RFC 6749 §3.1).
for (const k of new Set(p.keys())) {
if (p.getAll(k).length > 1) throw new ValidationError(`Authorization response repeats "${k}"`, { code: 'duplicate_parameter' });
}
// STATE: look up and REMOVE the transaction (single use), then check it thoroughly.
const returned = p.get('state');
if (!returned) throw new ValidationError('state missing from authorization response', { code: 'state_missing' });
const transaction = c.transactionStore.take(returned);
if (!transaction) {
throw new ValidationError(this.#usedStates.has(returned)
? 'This authorization response was already used (state replayed)'
: 'No sign-in in progress for this state (unknown, already used, or from another browser tab/session)', { code: this.#usedStates.has(returned) ? 'state_reused' : 'state_mismatch' });
}
this.#rememberState(returned);
if (!(await timingSafeEqual(returned, transaction.state))) throw new ValidationError('state mismatch', { code: 'state_mismatch' });
if (Date.now() - transaction.createdAt > c.transactionTtlSec * 1000) {
throw new ValidationError(`The sign-in took longer than ${c.transactionTtlSec}s; start again`, { code: 'transaction_expired' });
}
// The transaction belongs to this OP and this client (mix-up defence when several are configured).
if (transaction.issuer !== this.metadata.issuer || transaction.clientId !== c.clientId) {
throw new ValidationError('This sign-in was started with a different identity provider or client', { code: 'issuer_mismatch' });
}
// RFC 9207 issuer identification.
const iss = p.get('iss');
if (iss !== null && iss !== this.metadata.issuer) throw new ValidationError(`iss mismatch in authorization response: ${iss}`, { code: 'issuer_mismatch' });
if (iss === null && this.metadata.authorization_response_iss_parameter_supported) {
throw new ValidationError('iss missing from authorization response', { code: 'issuer_missing' });
}
if (p.get('error')) {
const err = new OAuthError({ error: p.get('error'), error_description: p.get('error_description') ?? undefined, error_uri: p.get('error_uri') ?? undefined });
err.context = transaction.context;
throw err;
}
const code = p.get('code');
if (!code) throw new ValidationError('code missing from authorization response', { code: 'code_missing' });
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: transaction.redirectUri,
code_verifier: transaction.codeVerifier,
});
for (const r of this.#resources()) body.append('resource', r);
const tokens = await this.#tokenRequest(body);
if (!tokens.id_token) throw new ValidationError('Token response did not include an id_token', { code: 'id_token_missing' });
const claims = await this.validateIdToken(tokens.id_token, {
nonce: transaction.nonce,
maxAge: transaction.maxAge,
acrValues: transaction.acrValues,
accessToken: tokens.access_token,
});
// The request named an account; a sign-in as somebody else is not that account.
if (transaction.expectedSub !== undefined && claims.sub !== transaction.expectedSub) {
await Promise.allSettled([
tokens.refresh_token && this.revoke(tokens.refresh_token, 'refresh_token'),
this.revoke(tokens.access_token, 'access_token'),
]);
throw new ValidationError('The identity provider signed in a different account from the one requested', { code: 'account_mismatch', response: { expected: transaction.expectedSub, sub: claims.sub } });
}
const tokenSet = new TokenSet(tokens, claims);
this.emit('tokens', tokenSet);
return { tokenSet, context: transaction.context };
}
#rememberState(state) {
const now = Date.now();
this.#usedStates.set(state, now);
const cutoff = now - this.#config.transactionTtlSec * 1000;
for (const [s, at] of this.#usedStates) {
if (at >= cutoff && this.#usedStates.size <= 1000) break;
this.#usedStates.delete(s);
}
}
// ---------------------------------------------------------------------------
// Refresh
// ---------------------------------------------------------------------------
/**
* Refresh Token grant (OIDC Core §12). Keeps the previous refresh token when the OP doesn't rotate it,
* validates any new ID token against the previous claims (§12.2), and sends a DPoP proof with the same
* key when the tokens are DPoP-bound.
*
* With a bare refresh token string there are no previous claims to hold a new ID token to, so pass
* `previousClaims` (the claims of the ID token from the original sign-in); without them a response that
* carries an ID token is refused.
*
* @param {TokenSet|string} tokenSetOrRefreshToken
* @param {{ scope?: string, previousClaims?: object }} [opts]
*/
async refresh(tokenSetOrRefreshToken, { scope, previousClaims: givenClaims } = {}) {
this.#requireMetadata();
const previous = typeof tokenSetOrRefreshToken === 'string' ? undefined : TokenSet.from(tokenSetOrRefreshToken);
const refreshToken = previous ? previous.refresh_token : tokenSetOrRefreshToken;
if (!refreshToken) throw new OIDCClientError('No refresh_token available', { code: 'no_refresh_token' });
const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken });
if (scope) body.set('scope', scope);
for (const r of this.#resources()) body.append('resource', r);
const tokens = await this.#tokenRequest(body);
// A DPoP-bound grant must stay bound: a Bearer reply to it is a downgrade (RFC 9449 §5).
if (/^dpop$/i.test(previous?.token_type ?? '') && !/^dpop$/i.test(tokens.token_type)) {
throw new ValidationError(`Refresh of DPoP-bound tokens returned token_type ${tokens.token_type}`, { code: 'dpop_downgrade' });
}
const previousClaims = previous?.claims ?? givenClaims;
let claims = previousClaims;
if (tokens.id_token) {
if (!previousClaims) {
throw new ValidationError('The refresh returned an ID token, and there are no previous ID token claims to check it against (OIDC Core §12.2); pass a TokenSet or previousClaims',
{ code: 'invalid_id_token' });
}
claims = await this.validateIdToken(tokens.id_token, { accessToken: tokens.access_token, previousClaims });
}
const tokenSet = new TokenSet({
...tokens,
refresh_token: tokens.refresh_token ?? refreshToken,
id_token: tokens.id_token ?? previous?.id_token,
}, claims);
this.emit('tokens', tokenSet);
return tokenSet;
}
// ---------------------------------------------------------------------------
// Calling protected resources (UserInfo, SSF, APIs)
// ---------------------------------------------------------------------------
/**
* The Authorization (and DPoP) headers for a request to a resource server with an access token.
* A DPoP-bound token (token_type "DPoP") gets a fresh proof bound to this method, URL and token.
*
* @param {TokenSet|{access_token: string, token_type?: string}|string} token
* @param {{ method?: string, url: string, nonce?: string }} request
* @returns {Promise<Record<string, string>>} `authorization`, and `dpop` for a DPoP-bound token
*/
async resourceHeaders(token, { method = 'GET', url, nonce }) {
const { access_token: accessToken, token_type: type } = typeof token === 'string' ? { access_token: token, token_type: 'Bearer' } : token;
if (/^dpop$/i.test(type ?? '')) {
if (!this.dpopKey) throw new OIDCClientError('A DPoP-bound token needs the DPoP key it was issued to', { code: 'dpop_key_missing' });
return {
authorization: `DPoP ${accessToken}`,
dpop: await this.dpopKey.proof({ method, url, accessToken, nonce: nonce ?? this.#rsNonces.get(url) }),
};
}
return { authorization: `Bearer ${accessToken}` };
}
/**
* fetch() against a protected resource with an access token, retrying once when the resource server
* asks for a DPoP nonce (RFC 9449 §9).
*
* @param {TokenSet|{access_token: string, token_type?: string}|string} token
* @param {string} url
* @param {RequestInit} [init]
*/
async fetchResource(token, url, init = {}) {
const method = init.method ?? 'GET';
const send = async (nonce) => httpRequest(url, {
...this.#httpOpts(),
...init,
headers: { ...init.headers, ...(await this.resourceHeaders(token, { method, url, nonce })) },
});
let res = await send();
const nonce = this.#rsNonces.update(url, res);
if (wantsDpopNonce(res)) {
await res.body?.cancel();
res = await send(nonce);
this.#rsNonces.update(url, res);
}
return res;
}
/**
* Calls the UserInfo endpoint (OIDC Core §5.3). The response must be about the person the ID token is
* about (§5.3.2): its `sub` is checked against `expectedSub`, or else the TokenSet's ID token `sub`.
* With a bare access token and no `expectedSub` there is nothing to check against, so it is refused.
* @param {TokenSet|{access_token: string, token_type?: string}|string} token
* @param {{ expectedSub?: string }} [opts]
*/
async userinfo(token, { expectedSub = typeof token === 'object' ? token?.claims?.sub : undefined } = {}) {
if (!expectedSub) {
throw new ValidationError('UserInfo needs the ID token sub to check the response against (OIDC Core §5.3.2): pass a TokenSet with an ID token, or expectedSub',
{ code: 'sub_mismatch' });
}
this.#requireMetadata();
if (!this.metadata.userinfo_endpoint) throw new OIDCClientError('OP has no userinfo_endpoint');
const res = await this.fetchResource(token, this.metadata.userinfo_endpoint, { headers: { accept: 'application/json, application/jwt' } });
if (res.status === 401) {
const www = res.headers.get('www-authenticate') ?? '';
await res.body?.cancel();
throw new OAuthError({ error: /error="([^"]+)"/.exec(www)?.[1] ?? 'invalid_token', error_description: www, status: 401 });
}
let claims;
if ((res.headers.get('content-type') ?? '').includes('application/jwt')) {
const jwt = await res.text();
({ payload: claims } = await jwtVerify(jwt, this.#jwks, {
algorithms: this.#config.userinfoSignedResponseAlg ? [this.#config.userinfoSignedResponseAlg] : ASYMMETRIC_JWS_ALGS,
issuer: this.metadata.issuer,
audience: this.#config.clientId,
clockTolerance: this.#config.clockToleranceSec,
}));
} else {
claims = await readJson(res);
}
if (!claims || typeof claims !== 'object' || Array.isArray(claims)) {
throw new ValidationError('UserInfo response is not a JSON object', { code: 'invalid_userinfo' });
}
if (claims.sub !== expectedSub) throw new ValidationError('UserInfo sub does not match ID token sub', { code: 'sub_mismatch' });
return claims;
}
/**
* Token revocation (RFC 7009), as a public client. Resolves false when the OP has no revocation endpoint.
* @param {string} token
* @param {'access_token'|'refresh_token'} [hint]
*/
async revoke(token, hint) {
this.#requireMetadata();
if (!this.metadata.revocation_endpoint || !token) return false;
const body = new URLSearchParams({ token, client_id: this.#config.clientId });
if (hint) body.set('token_type_hint', hint);
const res = await httpRequest(this.metadata.revocation_endpoint, {
...this.#httpOpts(),
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body,
});
if (!res.ok) await readJson(res);
else await res.body?.cancel();
return true;
}
// ---------------------------------------------------------------------------
// Logout
// ---------------------------------------------------------------------------
/**
* Builds an RP-Initiated Logout 1.0 request with a fresh `state`, stored for
* `validateEndSessionCallback()`. Resolves undefined when the OP has no end_session_endpoint.
* `context` is kept with the state and handed back by `completeEndSession()`.
* @param {{ idTokenHint?: string, postLogoutRedirectUri?: string, logoutHint?: string, context?: any }} [opts]
* @returns {Promise<{ url: string, state: string }|undefined>}
*/
async createEndSessionRequest({ idTokenHint, postLogoutRedirectUri, logoutHint, context } = {}) {
this.#requireMetadata();
if (!this.metadata.end_session_endpoint) return undefined;
const state = randomToken();
const redirect = postLogoutRedirectUri ?? this.#config.postLogoutRedirectUri;
if (redirect && !this.#config.allowInsecureRequests && !isSecureUrl(redirect)) {
throw new ValidationError(`postLogoutRedirectUri must use https (or loopback http): ${redirect}`, { code: 'insecure_redirect_uri' });
}
const url = new URL(this.metadata.end_session_endpoint);
const params = { id_token_hint: idTokenHint, post_logout_redirect_uri: redirect, client_id: this.#config.clientId, logout_hint: logoutHint, state: redirect ? state : undefined };
for (const [k, v] of Object.entries(params)) if (v) url.searchParams.set(k, v);
if (redirect) this.#config.transactionStore.set(`logout:${state}`, { state, context, createdAt: Date.now() });
return { url: url.href, state };
}
/**
* Checks the `state` the OP returned to the post-logout redirect URI. Throws when it is missing,
* unknown, reused or expired.
* @param {URLSearchParams|Record<string,string>|string} response
*/
async validateEndSessionCallback(response) {
await this.completeEndSession(response);
return true;
}
/**
* `validateEndSessionCallback()`, answering the `context` the logout request was created with.
* @param {URLSearchParams|Record<string,string>|string} response
* @returns {Promise<{ context: any }>}
*/
async completeEndSession(response) {
const returned = toParams(response).get('state');
if (!returned) throw new ValidationError('state missing from logout response', { code: 'state_missing' });
const held = this.#config.transactionStore.take(`logout:${returned}`);
if (!held || !(await timingSafeEqual(returned, held.state))) throw new ValidationError('Unknown or already-used logout state', { code: 'state_mismatch' });
if (Date.now() - held.createdAt > this.#config.transactionTtlSec * 1000) throw new ValidationError('The logout took too long', { code: 'transaction_expired' });
return { context: held.context };
}
// ---------------------------------------------------------------------------
// ID Token validation (OIDC Core §3.1.3.7, §12.2; RFC 8725)
// ---------------------------------------------------------------------------
/**
* @param {string} idToken
* @param {{ nonce?: string, maxAge?: number, acrValues?: string, accessToken?: string, previousClaims?: object }} [opts]
* @returns {Promise<Record<string, any>>} the validated claims
*/
async validateIdToken(idToken, { nonce, maxAge, acrValues, accessToken, previousClaims } = {}) {
this.#requireMetadata();
const c = this.#config;
const expectedAlg = c.idTokenSignedResponseAlg;
let header;
try {
header = decodeProtectedHeader(idToken);
} catch (err) {
throw new ValidationError('ID token is not a valid JWS', { code: 'invalid_id_token', cause: err });
}
if (header.alg !== expectedAlg) throw new ValidationError(`Unexpected ID token alg ${header.alg}; expected ${expectedAlg}`, { code: 'invalid_id_token' });
if (header.typ && NOT_ID_TOKEN_TYPES.includes(String(header.typ).toLowerCase())) {
throw new ValidationError(`A "${header.typ}" token is not an ID token`, { code: 'invalid_id_token' });
}
if (header.crit) throw new ValidationError('ID token has critical header parameters this client does not understand', { code: 'invalid_id_token' });
let claims;
try {
({ payload: claims } = await jwtVerify(idToken, this.#jwks, {
algorithms: [expectedAlg],
issuer: this.metadata.issuer,
audience: c.clientId,
clockTolerance: c.clockToleranceSec,
maxTokenAge: c.maxIdTokenAgeSec > 0 ? c.maxIdTokenAgeSec : undefined,
requiredClaims: ['iss', 'sub', 'aud', 'exp', 'iat'],
}));
} catch (err) {
const msg = err instanceof joseErrors.JOSEError ? err.message : String(err);
throw new ValidationError(`ID token validation failed: ${msg}`, { code: 'invalid_id_token', cause: err });
}
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (aud.length > 1 && !claims.azp) throw new ValidationError('ID token has multiple audiences but no azp', { code: 'invalid_id_token' });
if (claims.azp !== undefined && claims.azp !== c.clientId) throw new ValidationError('ID token azp does not match client_id', { code: 'invalid_id_token' });
if (nonce !== undefined && !(await timingSafeEqual(String(claims.nonce ?? ''), nonce))) {
throw new ValidationError('ID token nonce mismatch', { code: 'nonce_mismatch' });
}
if ((maxAge !== undefined || c.requireAuthTime) && typeof claims.auth_time !== 'number') {
throw new ValidationError('ID token has no auth_time', { code: 'invalid_id_token' });
}
if (maxAge !== undefined && claims.auth_time + maxAge + c.clockToleranceSec < Math.floor(Date.now() / 1000)) {
throw new ValidationError('auth_time is older than max_age; re-authentication required', { code: 'max_age_exceeded' });
}
if (acrValues && c.enforceAcr) {
const wanted = acrValues.split(' ').filter(Boolean);
if (!wanted.includes(claims.acr)) {
throw new ValidationError(`ID token acr "${claims.acr ?? '(none)'}" is not one of the requested acr_values (${acrValues})`, { code: 'acr_mismatch' });
}
}
if (accessToken && claims.at_hash !== undefined && claims.at_hash !== await tokenHash(accessToken, header)) {
throw new ValidationError('ID token at_hash does not match access token', { code: 'invalid_id_token' });
}
if (previousClaims) {
// OIDC Core §12.2: iss, sub and azp the same (azp absent if it was absent), aud the same.
for (const claim of ['iss', 'sub', 'azp']) {
if (claims[claim] !== previousClaims[claim]) throw new ValidationError(`Refreshed ID token ${claim} changed`, { code: 'invalid_id_token' });
}
const audOf = (v) => [...new Set([].concat(v ?? []))].sort().join(' ');
if (audOf(claims.aud) !== audOf(previousClaims.aud)) {
throw new ValidationError('Refreshed ID token aud changed', { code: 'invalid_id_token' });
}
// §12.2 lets a refreshed ID token leave auth_time out; one it carries must be the original time.
if (previousClaims.auth_time !== undefined && claims.auth_time !== undefined && claims.auth_time !== previousClaims.auth_time) {
throw new ValidationError('Refreshed ID token auth_time changed', { code: 'invalid_id_token' });
}
}
return claims;
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
#httpOpts() {
return { timeoutMs: this.#config.httpTimeoutMs, fetch: this.#config.fetch };
}
/**
* POSTs a form to an authorization-server endpoint as this public client, with a DPoP proof when
* enabled, retrying once when the AS demands a DPoP nonce (RFC 9449 §8).
*/
async #postForm(endpoint, form) {
form.set('client_id', this.#config.clientId);
const send = async (nonce) => {
const headers = { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' };
if (this.dpopKey) headers.dpop = await this.dpopKey.proof({ method: 'POST', url: endpoint, nonce: nonce ?? this.#asNonces.get(endpoint) });
return httpRequest(endpoint, { ...this.#httpOpts(), method: 'POST', headers, body: form });
};
let res = await send();
let nonce = this.#asNonces.update(endpoint, res);
if (this.dpopKey && res.status === 400 && nonce) {
const text = await res.clone().text();
if (/"error"\s*:\s*"use_dpop_nonce"/.test(text)) {
await res.body?.cancel();
res = await send(nonce);
nonce = this.#asNonces.update(endpoint, res);
}
}
return readJson(res);
}
async #tokenRequest(form) {
const tokens = await this.#postForm(this.metadata.token_endpoint, form);
if (!tokens?.access_token) throw new ValidationError('Token response missing access_token', { code: 'invalid_token_response' });
if (!/^(bearer|dpop)$/i.test(tokens.token_type ?? '')) throw new ValidationError(`Unsupported token_type ${tokens.token_type}`, { code: 'invalid_token_response' });
if (/^dpop$/i.test(tokens.token_type) && !this.dpopKey) throw new ValidationError('The OP issued a DPoP-bound token but DPoP is not enabled', { code: 'invalid_token_response' });
if (this.dpopKey && this.#config.requireDpop && !/^dpop$/i.test(tokens.token_type)) {
throw new ValidationError(`DPoP is required, and the OP issued token_type ${tokens.token_type}`, { code: 'dpop_downgrade' });
}
if (tokens.expires_in !== undefined && !(Number.isFinite(Number(tokens.expires_in)) && Number(tokens.expires_in) >= 0)) {
throw new ValidationError(`Invalid expires_in ${tokens.expires_in}`, { code: 'invalid_token_response' });
}
// expires_at, issued_at and claims are this client's own fields, computed from what it validated. The
// response doesn't get to set them.
const { expires_at: _a, issued_at: _i, claims: _c, ...clean } = tokens;
return clean;
}
}
function toParams(input) {
if (input instanceof URLSearchParams) return input;
if (typeof input === 'string') {
if (/^[a-z][a-z0-9+.-]*:/i.test(input) || input.startsWith('/')) {
const u = new URL(input, 'http://localhost');
// A fragment response is not a code-flow response; include it so injected tokens are caught.
const all = new URLSearchParams(u.search);
if (u.hash.length > 1) for (const [k, v] of new URLSearchParams(u.hash.slice(1))) all.append(k, v);
return all;
}
return new URLSearchParams(input.replace(/^[?#]/, ''));
}
return new URLSearchParams(Object.entries(input ?? {}).filter(([, v]) => typeof v === 'string'));
}
/** at_hash: left-most half of the hash of the ASCII value, base64url encoded (OIDC Core §3.1.3.6). */
async function tokenHash(value, { alg }) {
const bits = (alg === 'EdDSA' || alg === 'Ed25519') ? '512' : alg.slice(-3);
const d = await digest(value, `SHA-${bits}`);
return base64url(d.subarray(0, d.length / 2));
}