@adonis-agora/authkit-client 0.18.9 → 0.19.1
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.
- package/build/commands/configure.js +6 -0
- package/build/index.d.ts +9 -3
- package/build/index.js +5 -2
- package/build/providers/authkit_client_provider.d.ts +57 -9
- package/build/providers/authkit_client_provider.js +173 -34
- package/build/src/authorization.d.ts +125 -0
- package/build/src/authorization.js +142 -0
- package/build/src/backchannel_logout.d.ts +1 -1
- package/build/src/backchannel_logout.js +3 -15
- package/build/src/define_config.d.ts +24 -0
- package/build/src/define_config.js +6 -0
- package/build/src/discovery.d.ts +52 -20
- package/build/src/discovery.js +104 -42
- package/build/src/id_token.d.ts +57 -0
- package/build/src/id_token.js +61 -0
- package/build/src/jwks.d.ts +15 -0
- package/build/src/jwks.js +36 -0
- package/build/src/oidc_login.d.ts +7 -1
- package/build/src/oidc_login.js +31 -2
- package/build/src/register_oidc_client.js +30 -31
- package/build/src/resolvers/factory.d.ts +9 -2
- package/build/src/resolvers/factory.js +37 -5
- package/build/src/resolvers/jwt_resolver.d.ts +7 -3
- package/build/src/resolvers/jwt_resolver.js +8 -17
- package/build/src/resolvers/opaque_resolver.d.ts +5 -2
- package/build/src/resolvers/opaque_resolver.js +4 -1
- package/build/src/verify_access_token.d.ts +0 -2
- package/build/src/verify_access_token.js +2 -27
- package/build/stubs/controllers/oidc_session_controller.stub +31 -26
- package/package.json +7 -6
- package/skills/authkit-rp-client/SKILL.md +11 -6
- package/stubs/controllers/oidc_session_controller.stub +31 -26
|
@@ -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.: `
|
|
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 {
|
|
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 ??
|
|
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
|
}
|
package/build/src/discovery.d.ts
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OIDC Discovery (RFC 8414 / OpenID Connect Discovery 1.0).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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()`
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
/**
|
|
52
|
+
/** Manual overrides — win over the discovery document, field by field. */
|
|
35
53
|
overrides?: Partial<OidcEndpoints>;
|
|
36
|
-
/**
|
|
54
|
+
/** Per-issuer cache TTL. Default: 15 min. */
|
|
37
55
|
cacheTtlMs?: number;
|
|
38
56
|
fetchImpl?: typeof fetch;
|
|
39
57
|
/**
|
|
40
|
-
*
|
|
41
|
-
* `fetch`
|
|
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
|
-
*
|
|
77
|
+
* Resolves the IdP endpoints via `/.well-known/openid-configuration`.
|
|
47
78
|
*
|
|
48
|
-
* -
|
|
49
|
-
* -
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
/**
|
|
86
|
+
/** Clears the discovery cache (tests). */
|
|
55
87
|
export declare function __clearDiscoveryCacheForTests(): void;
|
package/build/src/discovery.js
CHANGED
|
@@ -1,29 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OIDC Discovery (RFC 8414 / OpenID Connect Discovery 1.0).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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()`
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
62
|
+
* Resolves the IdP endpoints via `/.well-known/openid-configuration`.
|
|
39
63
|
*
|
|
40
|
-
* -
|
|
41
|
-
* -
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
75
|
+
let doc;
|
|
50
76
|
if (cached && Date.now() - cached.fetchedAt < ttl) {
|
|
51
|
-
|
|
77
|
+
doc = cached.doc;
|
|
52
78
|
}
|
|
53
79
|
else {
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
111
|
+
const res = await resilientFetch(url, { headers: { accept: 'application/json' } }, resilience, fetchImpl);
|
|
70
112
|
if (!res.ok)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
/**
|
|
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
|
+
}
|