@adonis-agora/authkit-client 0.18.9 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Authorization-code flow in two calls: one to start it, one to finish it.
3
+ *
4
+ * `buildAuthorizeUrl` + `exchangeCode` are the raw steps; these two wrap them
5
+ * with everything a callback must check and is easy to forget — `state`, the
6
+ * RFC 9207 `iss` parameter, PKCE and the id_token (signature, `aud`, `nonce`…).
7
+ *
8
+ * ```ts
9
+ * const { url, pending } = await createAuthorizationRequest({ issuer, clientId, redirectUri, scopes, endpoints })
10
+ * session.put('oidc_pending', pending)
11
+ * // ...IdP redirects back...
12
+ * const { tokens, claims } = await completeAuthorization({
13
+ * issuer, clientId, clientSecret, redirectUri, endpoints,
14
+ * pending: session.pull('oidc_pending'),
15
+ * callbackParams: request.qs(),
16
+ * })
17
+ * ```
18
+ *
19
+ * Inside an AdonisJS app, prefer the `AuthkitClientManager` methods of the same
20
+ * name — they fill the config and the discovered endpoints in for you.
21
+ */
22
+ import { randomBytes } from 'node:crypto';
23
+ import { base64url } from 'jose';
24
+ import { conventionEndpoints } from './discovery.js';
25
+ import { InvalidIdTokenError, validateIdToken, } from './id_token.js';
26
+ import { buildAuthorizeUrl, exchangeCode, generatePkce } from './oidc_login.js';
27
+ const random = () => base64url.encode(randomBytes(32));
28
+ /** Builds the authorize URL with fresh `state`, `nonce` and PKCE (S256). */
29
+ export async function createAuthorizationRequest(p) {
30
+ const { verifier, challenge } = await generatePkce();
31
+ const state = random();
32
+ const nonce = random();
33
+ const extraParams = { ...p.extraParams };
34
+ if (p.maxAge !== undefined)
35
+ extraParams.max_age = p.maxAge;
36
+ const url = buildAuthorizeUrl({
37
+ issuer: p.issuer,
38
+ clientId: p.clientId,
39
+ redirectUri: p.redirectUri,
40
+ scopes: p.scopes,
41
+ state,
42
+ nonce,
43
+ codeChallenge: challenge,
44
+ authorizationEndpoint: p.endpoints?.authorizationEndpoint,
45
+ extraParams,
46
+ });
47
+ const pending = { state, nonce, codeVerifier: verifier };
48
+ if (p.maxAge !== undefined)
49
+ pending.maxAge = p.maxAge;
50
+ return { url, pending };
51
+ }
52
+ export class OidcCallbackError extends Error {
53
+ code;
54
+ constructor(code, message, options) {
55
+ super(message, { cause: options?.cause });
56
+ this.code = code;
57
+ this.name = 'OidcCallbackError';
58
+ this.idpError = options?.idpError;
59
+ }
60
+ idpError;
61
+ /**
62
+ * True when the right answer is to send the user back to login: the callback is
63
+ * stale or did not belong to this browser, or the IdP sent the user back with an
64
+ * error (`access_denied`, `login_required`…; see `idpError` to tell them apart).
65
+ * False for real failures — a mix-up, a failed token exchange or id_token check.
66
+ */
67
+ get restartable() {
68
+ return (this.code === 'missing_pending' ||
69
+ this.code === 'state_mismatch' ||
70
+ this.code === 'idp_error' ||
71
+ this.code === 'missing_code');
72
+ }
73
+ }
74
+ /**
75
+ * Finishes the authorization-code flow: checks `state` and `iss`, exchanges the
76
+ * code (with the PKCE verifier) and validates the id_token, `nonce` included.
77
+ *
78
+ * Throws {@link OidcCallbackError}; `error.restartable` tells "send the user back
79
+ * to login" from a real failure.
80
+ */
81
+ export async function completeAuthorization(p) {
82
+ const q = p.callbackParams;
83
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
84
+ const { issuer, tokenEndpoint, jwksUri } = p.endpoints ?? conventionEndpoints(p.issuer);
85
+ const pending = p.pending;
86
+ if (!pending?.state || !pending.nonce || !pending.codeVerifier) {
87
+ throw new OidcCallbackError('missing_pending', 'No pending authorization for this callback');
88
+ }
89
+ if (str(q.state) !== pending.state) {
90
+ throw new OidcCallbackError('state_mismatch', 'Callback state does not match');
91
+ }
92
+ const iss = str(q.iss);
93
+ if (iss !== undefined && iss !== issuer) {
94
+ throw new OidcCallbackError('issuer_mismatch', `Callback iss ${iss} is not ${issuer}`);
95
+ }
96
+ const idpError = str(q.error);
97
+ if (idpError) {
98
+ const description = str(q.error_description);
99
+ throw new OidcCallbackError('idp_error', `IdP returned ${idpError}${description ? `: ${description}` : ''}`, { idpError: { error: idpError, description } });
100
+ }
101
+ const code = str(q.code);
102
+ if (!code)
103
+ throw new OidcCallbackError('missing_code', 'Callback has no code');
104
+ let tokens;
105
+ try {
106
+ tokens = await exchangeCode({
107
+ issuer: p.issuer,
108
+ clientId: p.clientId,
109
+ clientSecret: p.clientSecret,
110
+ redirectUri: p.redirectUri,
111
+ code,
112
+ codeVerifier: pending.codeVerifier,
113
+ tokenEndpoint,
114
+ fetchImpl: p.fetchImpl,
115
+ resilience: p.resilience,
116
+ });
117
+ }
118
+ catch (error) {
119
+ throw new OidcCallbackError('token_exchange_failed', error.message, {
120
+ cause: error,
121
+ });
122
+ }
123
+ if (!tokens.idToken) {
124
+ throw new OidcCallbackError('missing_id_token', 'Token endpoint returned no id_token — is "openid" in the scopes?');
125
+ }
126
+ let claims;
127
+ try {
128
+ claims = await validateIdToken(tokens.idToken, {
129
+ ...p.idToken,
130
+ issuer,
131
+ clientId: p.clientId,
132
+ jwksUri,
133
+ nonce: pending.nonce,
134
+ maxAge: pending.maxAge,
135
+ });
136
+ }
137
+ catch (error) {
138
+ const message = error instanceof InvalidIdTokenError ? error.message : String(error);
139
+ throw new OidcCallbackError('invalid_id_token', message, { cause: error });
140
+ }
141
+ return { tokens, claims };
142
+ }
@@ -16,7 +16,7 @@ export interface ValidateLogoutTokenOptions {
16
16
  */
17
17
  jwksUri?: string;
18
18
  /**
19
- * Função de chave já resolvida (ex.: `createRemoteJWKSet` ou `createLocalJWKSet`).
19
+ * Função de chave já resolvida (ex.: `createLocalJWKSet`).
20
20
  * Tem precedência sobre `jwksUri`; útil em testes para injetar uma chave local.
21
21
  */
22
22
  keys?: JWTVerifyGetKey;
@@ -1,19 +1,7 @@
1
- import { createRemoteJWKSet, jwtVerify } from 'jose';
1
+ import { jwtVerify } from 'jose';
2
+ import { DEFAULT_ALGS, getJwks } from './jwks.js';
2
3
  /** Event type da claim `events` exigido pelo OIDC Back-Channel Logout. */
3
4
  export const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
4
- /** Algoritmos de assinatura aceitos no logout_token (defesa contra alg-confusion). */
5
- const DEFAULT_ALGS = [
6
- 'RS256',
7
- 'RS384',
8
- 'RS512',
9
- 'PS256',
10
- 'PS384',
11
- 'PS512',
12
- 'ES256',
13
- 'ES384',
14
- 'ES512',
15
- 'EdDSA',
16
- ];
17
5
  /** Erro tipado lançado quando o logout_token não satisfaz as regras do spec. */
18
6
  export class InvalidLogoutTokenError extends Error {
19
7
  constructor(message) {
@@ -34,7 +22,7 @@ export class InvalidLogoutTokenError extends Error {
34
22
  * Lança {@link InvalidLogoutTokenError} em qualquer violação.
35
23
  */
36
24
  export async function validateLogoutToken(token, opts) {
37
- const keys = opts.keys ?? createRemoteJWKSet(new URL(opts.jwksUri ?? `${opts.issuer}/jwks`));
25
+ const keys = opts.keys ?? getJwks(opts.jwksUri ?? `${opts.issuer}/jwks`);
38
26
  let payload;
39
27
  try {
40
28
  const verified = await jwtVerify(token, keys, {
@@ -1,5 +1,6 @@
1
1
  import type { Identity } from '@adonis-agora/authkit-core';
2
2
  import type { SessionIndex } from './backchannel_logout.js';
3
+ import type { OidcEndpoints } from './discovery.js';
3
4
  import type { ResiliencePolicy } from './http/resilient_fetch.js';
4
5
  import { type ResolverFactory, resolvers } from './resolvers/factory.js';
5
6
  import type { RevocationStore } from './revocation/revocation_store.js';
@@ -55,6 +56,23 @@ export interface ClientConfigInput {
55
56
  * estruturalmente — o authkit-client não importa o pacote de resiliência.
56
57
  */
57
58
  resilience?: ResiliencePolicy;
59
+ /**
60
+ * Resolve the IdP endpoints from `{issuer}/.well-known/openid-configuration`.
61
+ * Default: `true` — works with authkit-server and any third-party IdP
62
+ * (Keycloak, Auth0, Okta, Entra…).
63
+ *
64
+ * Discovery runs lazily, on first use, so the app boots while the IdP is down.
65
+ * A failure throws `OidcDiscoveryError` and is retried on the next request; a
66
+ * previously discovered document keeps being served while refetches fail.
67
+ *
68
+ * `false` skips the network and uses `endpoints` on top of the authkit-server
69
+ * convention (`/auth`, `/token`, `/jwks`, `/session/end`).
70
+ */
71
+ discovery?: boolean | {
72
+ cacheTtlMs?: number;
73
+ };
74
+ /** Per-field endpoint overrides; always win over discovery. */
75
+ endpoints?: Partial<OidcEndpoints>;
58
76
  }
59
77
  export interface ResolvedClientConfig {
60
78
  issuer: string;
@@ -74,5 +92,11 @@ export interface ResolvedClientConfig {
74
92
  backchannelStore?: RevocationStore;
75
93
  /** Política de resiliência opcional p/ chamadas HTTP de saída ao IdP. Ver {@link ClientConfigInput.resilience}. */
76
94
  resilience?: ResiliencePolicy;
95
+ /** `false` when discovery is off; otherwise its options. See {@link ClientConfigInput.discovery}. */
96
+ discovery: false | {
97
+ cacheTtlMs?: number;
98
+ };
99
+ /** Endpoint overrides. See {@link ClientConfigInput.endpoints}. */
100
+ endpoints?: Partial<OidcEndpoints>;
77
101
  }
78
102
  export declare function defineConfig(config: ClientConfigInput): import("@adonisjs/core/types").ConfigProvider<ResolvedClientConfig>;
@@ -29,6 +29,12 @@ export function defineConfig(config) {
29
29
  sessionIndex: config.sessionIndex,
30
30
  backchannelStore: store,
31
31
  resilience: config.resilience,
32
+ discovery: config.discovery === false
33
+ ? false
34
+ : typeof config.discovery === 'object'
35
+ ? config.discovery
36
+ : {},
37
+ endpoints: config.endpoints,
32
38
  };
33
39
  });
34
40
  }
@@ -1,23 +1,28 @@
1
1
  /**
2
2
  * OIDC Discovery (RFC 8414 / OpenID Connect Discovery 1.0).
3
3
  *
4
- * Os helpers de fluxo deste pacote historicamente assumiam as rotas do
5
- * oidc-provider (`/auth`, `/token`, `/jwks`…) — convenção do authkit-server.
6
- * IdPs de terceiros (Keycloak, Auth0, Okta, Entra…) usam caminhos próprios,
7
- * publicados em `{issuer}/.well-known/openid-configuration`.
4
+ * The flow helpers in this package historically assumed oidc-provider's routes
5
+ * (`/auth`, `/token`, `/jwks`…) — the authkit-server convention. Third-party IdPs
6
+ * (Keycloak, Auth0, Okta, Entra…) use their own paths, published at
7
+ * `{issuer}/.well-known/openid-configuration`.
8
8
  *
9
- * `discoverEndpoints()` resolve os endpoints reais do IdP (com cache por
10
- * issuer) e cai na convenção do oidc-provider quando o documento não está
11
- * disponível — então authkit-server continua funcionando offline/sem rede.
9
+ * `discoverEndpoints()` resolves the IdP's real endpoints (cached per issuer).
12
10
  *
13
11
  * ```ts
14
- * const endpoints = await discoverEndpoints(issuer)
12
+ * const endpoints = await discoverEndpoints(issuer, { onError: 'throw' })
15
13
  * const url = buildAuthorizeUrl({ issuer, authorizationEndpoint: endpoints.authorizationEndpoint, ... })
16
14
  * const tokens = await exchangeCode({ issuer, tokenEndpoint: endpoints.tokenEndpoint, ... })
17
15
  * ```
18
16
  */
19
17
  import { type ResiliencePolicy } from './http/resilient_fetch.js';
20
18
  export interface OidcEndpoints {
19
+ /**
20
+ * The issuer tokens are checked against (`iss`). From a discovery document it
21
+ * is the document's exact value, so an IdP whose issuer carries a trailing
22
+ * slash (Auth0: `https://tenant.auth0.com/`) validates no matter how the issuer
23
+ * was written in the config; otherwise it is the configured issuer.
24
+ */
25
+ issuer: string;
21
26
  authorizationEndpoint: string;
22
27
  tokenEndpoint: string;
23
28
  jwksUri: string;
@@ -26,30 +31,57 @@ export interface OidcEndpoints {
26
31
  introspectionEndpoint?: string;
27
32
  }
28
33
  /**
29
- * Convenção do oidc-provider (authkit-server) — usada como fallback quando o
30
- * documento de discovery não está acessível.
34
+ * The oidc-provider (authkit-server) convention — used when discovery is off, and
35
+ * as the per-field fallback of `onError: 'fallback'`.
31
36
  */
32
37
  export declare function conventionEndpoints(issuer: string): OidcEndpoints;
38
+ /**
39
+ * Layers endpoint sources, later ones winning field by field. `undefined` never
40
+ * overwrites, so a layer only contributes what it actually has.
41
+ */
42
+ export declare function mergeEndpoints<T extends Partial<OidcEndpoints>>(base: T, ...layers: Array<Partial<OidcEndpoints> | undefined>): T;
43
+ /** Thrown by `discoverEndpoints(..., { onError: 'throw' })` when discovery fails. */
44
+ export declare class OidcDiscoveryError extends Error {
45
+ readonly issuer: string;
46
+ readonly url: string;
47
+ constructor(message: string, issuer: string, url: string, options?: {
48
+ cause?: unknown;
49
+ });
50
+ }
33
51
  export interface DiscoverOptions {
34
- /** Overrides manuais — precedem o documento de discovery, campo a campo. */
52
+ /** Manual overrides — win over the discovery document, field by field. */
35
53
  overrides?: Partial<OidcEndpoints>;
36
- /** TTL do cache por issuer. Default: 15 min. */
54
+ /** Per-issuer cache TTL. Default: 15 min. */
37
55
  cacheTtlMs?: number;
38
56
  fetchImpl?: typeof fetch;
39
57
  /**
40
- * Política de resiliência OPCIONAL p/ a chamada de discovery. Sem ela, é um
41
- * `fetch` puro (nenhuma mudança de comportamento).
58
+ * OPTIONAL resilience policy for the discovery call. Without it, it is a plain
59
+ * `fetch` (no behaviour change).
42
60
  */
43
61
  resilience?: ResiliencePolicy;
62
+ /**
63
+ * What to do when the document cannot be used (network error, non-2xx, invalid
64
+ * JSON, `issuer` mismatch, missing required endpoint):
65
+ *
66
+ * - `'fallback'` (default, back-compat): fall back to the authkit-server
67
+ * convention. Fields missing from a valid document also fall back.
68
+ * - `'throw'`: throw {@link OidcDiscoveryError}. Optional fields missing from
69
+ * the document stay `undefined` instead of being guessed.
70
+ *
71
+ * Either way a failure is never cached — the next call retries — and a
72
+ * previously discovered document keeps being served while refetches fail.
73
+ */
74
+ onError?: 'fallback' | 'throw';
44
75
  }
45
76
  /**
46
- * Resolve os endpoints do IdP via `/.well-known/openid-configuration`.
77
+ * Resolves the IdP endpoints via `/.well-known/openid-configuration`.
47
78
  *
48
- * - Resultado cacheado por issuer (TTL 15 min) — seguro de chamar por request.
49
- * - Falha de rede/404 → fallback silencioso para a convenção do oidc-provider
50
- * (o cache também guarda o fallback, evitando refetch em loop).
51
- * - `overrides` sempre vencem, campo a campo.
79
+ * - Successful documents are cached per issuer (TTL 15 min) — safe to call per request.
80
+ * - The document's `issuer` must match the configured one (OIDC Discovery §4.3;
81
+ * a trailing-slash difference is tolerated and the document's exact value is
82
+ * returned in `issuer`).
83
+ * - `overrides` always win, field by field.
52
84
  */
53
85
  export declare function discoverEndpoints(issuer: string, options?: DiscoverOptions): Promise<OidcEndpoints>;
54
- /** Limpa o cache de discovery (testes). */
86
+ /** Clears the discovery cache (tests). */
55
87
  export declare function __clearDiscoveryCacheForTests(): void;
@@ -1,29 +1,28 @@
1
1
  /**
2
2
  * OIDC Discovery (RFC 8414 / OpenID Connect Discovery 1.0).
3
3
  *
4
- * Os helpers de fluxo deste pacote historicamente assumiam as rotas do
5
- * oidc-provider (`/auth`, `/token`, `/jwks`…) — convenção do authkit-server.
6
- * IdPs de terceiros (Keycloak, Auth0, Okta, Entra…) usam caminhos próprios,
7
- * publicados em `{issuer}/.well-known/openid-configuration`.
4
+ * The flow helpers in this package historically assumed oidc-provider's routes
5
+ * (`/auth`, `/token`, `/jwks`…) — the authkit-server convention. Third-party IdPs
6
+ * (Keycloak, Auth0, Okta, Entra…) use their own paths, published at
7
+ * `{issuer}/.well-known/openid-configuration`.
8
8
  *
9
- * `discoverEndpoints()` resolve os endpoints reais do IdP (com cache por
10
- * issuer) e cai na convenção do oidc-provider quando o documento não está
11
- * disponível — então authkit-server continua funcionando offline/sem rede.
9
+ * `discoverEndpoints()` resolves the IdP's real endpoints (cached per issuer).
12
10
  *
13
11
  * ```ts
14
- * const endpoints = await discoverEndpoints(issuer)
12
+ * const endpoints = await discoverEndpoints(issuer, { onError: 'throw' })
15
13
  * const url = buildAuthorizeUrl({ issuer, authorizationEndpoint: endpoints.authorizationEndpoint, ... })
16
14
  * const tokens = await exchangeCode({ issuer, tokenEndpoint: endpoints.tokenEndpoint, ... })
17
15
  * ```
18
16
  */
19
17
  import { resilientFetch } from './http/resilient_fetch.js';
20
18
  /**
21
- * Convenção do oidc-provider (authkit-server) — usada como fallback quando o
22
- * documento de discovery não está acessível.
19
+ * The oidc-provider (authkit-server) convention — used when discovery is off, and
20
+ * as the per-field fallback of `onError: 'fallback'`.
23
21
  */
24
22
  export function conventionEndpoints(issuer) {
25
23
  const base = issuer.replace(/\/$/, '');
26
24
  return {
25
+ issuer,
27
26
  authorizationEndpoint: `${base}/auth`,
28
27
  tokenEndpoint: `${base}/token`,
29
28
  jwksUri: `${base}/jwks`,
@@ -32,59 +31,122 @@ export function conventionEndpoints(issuer) {
32
31
  introspectionEndpoint: `${base}/token/introspection`,
33
32
  };
34
33
  }
34
+ /**
35
+ * Layers endpoint sources, later ones winning field by field. `undefined` never
36
+ * overwrites, so a layer only contributes what it actually has.
37
+ */
38
+ export function mergeEndpoints(base, ...layers) {
39
+ const merged = { ...base };
40
+ for (const layer of layers) {
41
+ for (const [key, value] of Object.entries(layer ?? {})) {
42
+ if (value !== undefined)
43
+ merged[key] = value;
44
+ }
45
+ }
46
+ return merged;
47
+ }
48
+ /** Thrown by `discoverEndpoints(..., { onError: 'throw' })` when discovery fails. */
49
+ export class OidcDiscoveryError extends Error {
50
+ issuer;
51
+ url;
52
+ constructor(message, issuer, url, options) {
53
+ super(message, options);
54
+ this.issuer = issuer;
55
+ this.url = url;
56
+ this.name = 'OidcDiscoveryError';
57
+ }
58
+ }
35
59
  const DEFAULT_TTL_MS = 15 * 60_000;
36
60
  const cache = new Map();
37
61
  /**
38
- * Resolve os endpoints do IdP via `/.well-known/openid-configuration`.
62
+ * Resolves the IdP endpoints via `/.well-known/openid-configuration`.
39
63
  *
40
- * - Resultado cacheado por issuer (TTL 15 min) — seguro de chamar por request.
41
- * - Falha de rede/404 → fallback silencioso para a convenção do oidc-provider
42
- * (o cache também guarda o fallback, evitando refetch em loop).
43
- * - `overrides` sempre vencem, campo a campo.
64
+ * - Successful documents are cached per issuer (TTL 15 min) — safe to call per request.
65
+ * - The document's `issuer` must match the configured one (OIDC Discovery §4.3;
66
+ * a trailing-slash difference is tolerated and the document's exact value is
67
+ * returned in `issuer`).
68
+ * - `overrides` always win, field by field.
44
69
  */
45
70
  export async function discoverEndpoints(issuer, options = {}) {
46
71
  const base = issuer.replace(/\/$/, '');
47
72
  const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
73
+ const onError = options.onError ?? 'fallback';
48
74
  const cached = cache.get(base);
49
- let endpoints;
75
+ let doc;
50
76
  if (cached && Date.now() - cached.fetchedAt < ttl) {
51
- endpoints = cached.endpoints;
77
+ doc = cached.doc;
52
78
  }
53
79
  else {
54
- endpoints = await fetchDiscovery(base, options.fetchImpl ?? fetch, options.resilience);
55
- cache.set(base, { endpoints, fetchedAt: Date.now() });
80
+ try {
81
+ doc = await fetchDiscovery(base, options.fetchImpl ?? fetch, options.resilience);
82
+ cache.set(base, { doc, fetchedAt: Date.now() });
83
+ }
84
+ catch (error) {
85
+ // Stale-while-error: an IdP blip must not break a client that already knows
86
+ // the endpoints. Nothing is written, so the next call retries.
87
+ if (cached)
88
+ doc = cached.doc;
89
+ else if (onError === 'throw')
90
+ throw error;
91
+ }
56
92
  }
57
- if (!options.overrides)
58
- return endpoints;
59
- const merged = { ...endpoints };
60
- for (const [key, value] of Object.entries(options.overrides)) {
61
- if (value !== undefined)
62
- merged[key] = value;
93
+ // Lowest to highest priority: the convention (fallback mode only — `throw`
94
+ // mode guesses nothing), what the document publishes, the overrides.
95
+ if (onError === 'fallback') {
96
+ return mergeEndpoints(conventionEndpoints(issuer), doc, options.overrides);
63
97
  }
64
- return merged;
98
+ const endpoints = mergeEndpoints({ issuer }, doc, options.overrides);
99
+ assertRequired(base, endpoints);
100
+ return endpoints;
65
101
  }
66
102
  async function fetchDiscovery(base, fetchImpl, resilience) {
67
- const fallback = conventionEndpoints(base);
103
+ const url = `${base}/.well-known/openid-configuration`;
104
+ const fail = (message, cause) => {
105
+ throw new OidcDiscoveryError(`OIDC discovery failed for ${base}: ${message}`, base, url, {
106
+ cause,
107
+ });
108
+ };
109
+ let raw;
68
110
  try {
69
- const res = await resilientFetch(`${base}/.well-known/openid-configuration`, { headers: { accept: 'application/json' } }, resilience, fetchImpl);
111
+ const res = await resilientFetch(url, { headers: { accept: 'application/json' } }, resilience, fetchImpl);
70
112
  if (!res.ok)
71
- return fallback;
72
- const doc = (await res.json());
73
- const str = (v) => (typeof v === 'string' && v ? v : undefined);
74
- return {
75
- authorizationEndpoint: str(doc.authorization_endpoint) ?? fallback.authorizationEndpoint,
76
- tokenEndpoint: str(doc.token_endpoint) ?? fallback.tokenEndpoint,
77
- jwksUri: str(doc.jwks_uri) ?? fallback.jwksUri,
78
- endSessionEndpoint: str(doc.end_session_endpoint) ?? fallback.endSessionEndpoint,
79
- userinfoEndpoint: str(doc.userinfo_endpoint) ?? fallback.userinfoEndpoint,
80
- introspectionEndpoint: str(doc.introspection_endpoint) ?? fallback.introspectionEndpoint,
81
- };
113
+ fail(`${url} returned ${res.status}`);
114
+ raw = (await res.json());
115
+ }
116
+ catch (error) {
117
+ if (error instanceof OidcDiscoveryError)
118
+ throw error;
119
+ return fail(`could not fetch ${url} (${error?.message ?? error})`, error);
82
120
  }
83
- catch {
84
- return fallback;
121
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
122
+ const docIssuer = str(raw?.issuer);
123
+ // OIDC Discovery §4.3: the document must be about the issuer we asked for —
124
+ // otherwise tokens from another issuer would validate against its keys.
125
+ if (!docIssuer || docIssuer.replace(/\/$/, '') !== base) {
126
+ return fail(`document issuer ${JSON.stringify(docIssuer)} does not match the configured issuer`);
127
+ }
128
+ return {
129
+ issuer: docIssuer,
130
+ authorizationEndpoint: str(raw.authorization_endpoint),
131
+ tokenEndpoint: str(raw.token_endpoint),
132
+ jwksUri: str(raw.jwks_uri),
133
+ endSessionEndpoint: str(raw.end_session_endpoint),
134
+ userinfoEndpoint: str(raw.userinfo_endpoint),
135
+ introspectionEndpoint: str(raw.introspection_endpoint),
136
+ };
137
+ }
138
+ /**
139
+ * Throws when neither the document nor the overrides provide an endpoint the
140
+ * login flow cannot work without. Only `onError: 'throw'` needs it — the
141
+ * fallback mode fills the gap with the convention, as it always did.
142
+ */
143
+ function assertRequired(base, endpoints) {
144
+ const missing = ['authorizationEndpoint', 'tokenEndpoint', 'jwksUri'].filter((key) => !endpoints[key]);
145
+ if (missing.length) {
146
+ throw new OidcDiscoveryError(`OIDC discovery failed for ${base}: document is missing ${missing.join(', ')}`, base, `${base}/.well-known/openid-configuration`);
85
147
  }
86
148
  }
87
- /** Limpa o cache de discovery (testes). */
149
+ /** Clears the discovery cache (tests). */
88
150
  export function __clearDiscoveryCacheForTests() {
89
151
  cache.clear();
90
152
  }
@@ -0,0 +1,57 @@
1
+ import { type JWTPayload, type JWTVerifyGetKey } from 'jose';
2
+ /** Typed error thrown when an id_token does not satisfy OIDC Core §3.1.3.7. */
3
+ export declare class InvalidIdTokenError extends Error {
4
+ constructor(message: string, options?: {
5
+ cause?: unknown;
6
+ });
7
+ }
8
+ export interface ValidateIdTokenOptions {
9
+ /** Expected issuer — compared exactly with the `iss` claim. */
10
+ issuer: string;
11
+ /** The RP's client_id; must be in `aud` (and equal `azp` when present). */
12
+ clientId: string;
13
+ /** IdP `jwks_uri`. Default: `${issuer}/jwks` (authkit-server convention). */
14
+ jwksUri?: string;
15
+ /** Pre-resolved key function (e.g. `createLocalJWKSet` in tests). Wins over `jwksUri`. */
16
+ keys?: JWTVerifyGetKey;
17
+ /** Accepted algorithms. Default: asymmetric only. */
18
+ algorithms?: string[];
19
+ /**
20
+ * The nonce sent in the authorization request. When given, the token's `nonce`
21
+ * claim must equal it. Leave it out only where no nonce was sent (refresh).
22
+ */
23
+ nonce?: string;
24
+ /**
25
+ * The `max_age` sent in the authorization request, in seconds. When given, the
26
+ * token must carry `auth_time` and the authentication must be at most this old.
27
+ */
28
+ maxAge?: number;
29
+ /**
30
+ * Expected `sub`. On refresh, OIDC Core §12.2 requires the new id_token to be
31
+ * about the same end-user as the one it replaces.
32
+ */
33
+ subject?: string;
34
+ /** Clock skew tolerance in seconds. Default: 5. */
35
+ clockTolerance?: number;
36
+ /** Current time (tests). */
37
+ currentDate?: Date;
38
+ }
39
+ /** Claims of a validated id_token. `sub`, `iat` and `exp` are guaranteed. */
40
+ export interface IdTokenClaims extends JWTPayload {
41
+ sub: string;
42
+ iat: number;
43
+ exp: number;
44
+ nonce?: string;
45
+ auth_time?: number;
46
+ sid?: string;
47
+ azp?: string;
48
+ }
49
+ /**
50
+ * Validates an id_token as required by OIDC Core §3.1.3.7:
51
+ * signature against the IdP JWKS, `iss`, `aud` ∋ clientId, `azp` (when `aud`
52
+ * has several entries or `azp` is present), `exp`, `iat`, and — when the caller
53
+ * passes them — `nonce`, `auth_time`/`max_age` and `sub`.
54
+ *
55
+ * Throws {@link InvalidIdTokenError} on any violation.
56
+ */
57
+ export declare function validateIdToken(token: string, opts: ValidateIdTokenOptions): Promise<IdTokenClaims>;
@@ -0,0 +1,61 @@
1
+ import { jwtVerify } from 'jose';
2
+ import { DEFAULT_ALGS, getJwks } from './jwks.js';
3
+ /** Typed error thrown when an id_token does not satisfy OIDC Core §3.1.3.7. */
4
+ export class InvalidIdTokenError extends Error {
5
+ constructor(message, options) {
6
+ super(message, options);
7
+ this.name = 'InvalidIdTokenError';
8
+ }
9
+ }
10
+ /**
11
+ * Validates an id_token as required by OIDC Core §3.1.3.7:
12
+ * signature against the IdP JWKS, `iss`, `aud` ∋ clientId, `azp` (when `aud`
13
+ * has several entries or `azp` is present), `exp`, `iat`, and — when the caller
14
+ * passes them — `nonce`, `auth_time`/`max_age` and `sub`.
15
+ *
16
+ * Throws {@link InvalidIdTokenError} on any violation.
17
+ */
18
+ export async function validateIdToken(token, opts) {
19
+ const keys = opts.keys ?? getJwks(opts.jwksUri ?? `${opts.issuer.replace(/\/$/, '')}/jwks`);
20
+ const clockTolerance = opts.clockTolerance ?? 5;
21
+ let payload;
22
+ try {
23
+ const verified = await jwtVerify(token, keys, {
24
+ issuer: opts.issuer,
25
+ audience: opts.clientId,
26
+ algorithms: opts.algorithms ?? DEFAULT_ALGS,
27
+ requiredClaims: ['sub', 'iat', 'exp'],
28
+ clockTolerance,
29
+ currentDate: opts.currentDate,
30
+ });
31
+ payload = verified.payload;
32
+ }
33
+ catch (error) {
34
+ throw new InvalidIdTokenError(`Invalid id_token: ${error.message}`, {
35
+ cause: error,
36
+ });
37
+ }
38
+ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
39
+ if (aud.length > 1 && payload.azp === undefined) {
40
+ throw new InvalidIdTokenError('id_token has several audiences but no azp claim');
41
+ }
42
+ if (payload.azp !== undefined && payload.azp !== opts.clientId) {
43
+ throw new InvalidIdTokenError('id_token azp does not match the client_id');
44
+ }
45
+ if (opts.nonce !== undefined && payload.nonce !== opts.nonce) {
46
+ throw new InvalidIdTokenError('id_token nonce does not match the authorization request');
47
+ }
48
+ if (opts.subject !== undefined && payload.sub !== opts.subject) {
49
+ throw new InvalidIdTokenError('id_token sub does not match the current session');
50
+ }
51
+ if (opts.maxAge !== undefined) {
52
+ if (typeof payload.auth_time !== 'number') {
53
+ throw new InvalidIdTokenError('id_token is missing auth_time, required by max_age');
54
+ }
55
+ const now = Math.floor((opts.currentDate ?? new Date()).getTime() / 1000);
56
+ if (now - payload.auth_time > opts.maxAge + clockTolerance) {
57
+ throw new InvalidIdTokenError('id_token auth_time is older than max_age');
58
+ }
59
+ }
60
+ return payload;
61
+ }