@starci/auth-oidc 0.0.0-stage → 0.1.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +73 -2
  4. package/dist/client/index.d.ts +5 -0
  5. package/dist/client/index.js +3 -0
  6. package/dist/client/oidc-client.d.ts +85 -0
  7. package/dist/client/oidc-client.js +304 -0
  8. package/dist/client/pkce.d.ts +15 -0
  9. package/dist/client/pkce.js +29 -0
  10. package/dist/contracts/index.d.ts +137 -0
  11. package/dist/contracts/index.js +55 -0
  12. package/dist/server/client.d.ts +48 -0
  13. package/dist/server/client.js +114 -0
  14. package/dist/server/discovery.d.ts +21 -0
  15. package/dist/server/discovery.js +63 -0
  16. package/dist/server/errors.d.ts +11 -0
  17. package/dist/server/errors.js +14 -0
  18. package/dist/server/index.d.ts +22 -0
  19. package/dist/server/index.js +12 -0
  20. package/dist/server/jwks.d.ts +22 -0
  21. package/dist/server/jwks.js +80 -0
  22. package/dist/server/oidc.d.ts +23 -0
  23. package/dist/server/oidc.js +21 -0
  24. package/dist/server/oidc.module.d.ts +33 -0
  25. package/dist/server/oidc.module.js +47 -0
  26. package/dist/server/oidc.tokens.d.ts +12 -0
  27. package/dist/server/oidc.tokens.js +12 -0
  28. package/dist/server/options.d.ts +113 -0
  29. package/dist/server/options.js +91 -0
  30. package/dist/server/primitives.d.ts +22 -0
  31. package/dist/server/primitives.js +17 -0
  32. package/dist/server/roles.d.ts +12 -0
  33. package/dist/server/roles.js +51 -0
  34. package/dist/server/tokens.d.ts +24 -0
  35. package/dist/server/tokens.js +79 -0
  36. package/dist/server/verifier.d.ts +15 -0
  37. package/dist/server/verifier.js +58 -0
  38. package/dist/testing/index.d.ts +142 -0
  39. package/dist/testing/index.js +354 -0
  40. package/package.json +104 -4
@@ -0,0 +1,137 @@
1
+ /**
2
+ * What both sides of an OIDC sign-in share: the algorithms a token may be signed with, the wire codes, the claim names and the
3
+ * shapes of the answers. The server module and the front-end client read these constants, so the two never drift.
4
+ */
5
+ /** The signature algorithms a verifier may be pinned to: asymmetric only. `none` and every HMAC algorithm are not in the list and are refused at registration. */
6
+ export declare const OIDC_ALGORITHMS: readonly ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"];
7
+ /** One admitted signature algorithm. */
8
+ export type OidcAlgorithm = (typeof OIDC_ALGORITHMS)[number];
9
+ /** The algorithm list a registration pins when it names none: what Keycloak signs with by default. */
10
+ export declare const OIDC_DEFAULT_ALGORITHMS: readonly OidcAlgorithm[];
11
+ /** The `role` every verified principal carries, the one `@starci/auth` gives its principals, so a guard reads one value in both editions. */
12
+ export declare const OIDC_ROLE = "authenticated";
13
+ /**
14
+ * The wire code a verifier refuses a token with: the code `@starci/auth`'s verifier uses, so the guard and the filter that map it
15
+ * to a 401 are the same in both editions. The specific reason travels in the refusal's `detail.reason`, for the log, never for the client.
16
+ */
17
+ export declare const OIDC_ACCESS_TOKEN_INVALID = "PASSPORT_ACCESS_TOKEN_INVALID";
18
+ /** The codes of the errors the package throws: a provider that cannot be reached is `Unavailable`, a response that cannot be trusted is `Rejected`. */
19
+ export declare const OIDC_ERROR_CODES: {
20
+ readonly ConfigurationInvalid: "OIDC_CONFIGURATION_INVALID";
21
+ readonly Unavailable: "OIDC_UNAVAILABLE";
22
+ readonly Rejected: "OIDC_REJECTED";
23
+ };
24
+ /** One code a thrown OIDC error carries. */
25
+ export type OidcErrorCode = (typeof OIDC_ERROR_CODES)[keyof typeof OIDC_ERROR_CODES];
26
+ /** How a thrown code travels: the same families as `@starci/auth`'s error kinds. */
27
+ export type OidcErrorKind = "invalid" | "unavailable" | "unauthenticated";
28
+ /** The kind of every thrown code. */
29
+ export declare const OIDC_ERROR_KINDS: Readonly<Record<OidcErrorCode, OidcErrorKind>>;
30
+ /** Why a token was refused: the reason a refusal's detail names. */
31
+ export type OidcRefusalReason = "malformed" | "algorithm" | "unknown-key" | "signature" | "issuer" | "audience" | "authorized-party" | "expired" | "not-yet-valid" | "token-type" | "claims";
32
+ /** The claim names read beyond the registered ones. */
33
+ export declare const OIDC_CLAIMS: {
34
+ readonly sessionId: "sid";
35
+ readonly tokenId: "jti";
36
+ readonly email: "email";
37
+ readonly emailVerified: "email_verified";
38
+ readonly name: "name";
39
+ readonly username: "preferred_username";
40
+ readonly authorizedParty: "azp";
41
+ readonly realmAccess: "realm_access";
42
+ readonly resourceAccess: "resource_access";
43
+ readonly tokenType: "typ";
44
+ readonly nonce: "nonce";
45
+ };
46
+ /** The most roles a token may carry and the longest a role may be: a token past either is refused, so a claim cannot grow without bound. */
47
+ export declare const OIDC_ROLE_BOUNDS: {
48
+ readonly maxRoles: 128;
49
+ readonly maxLength: 256;
50
+ };
51
+ /** The longest `sub` a principal may have. */
52
+ export declare const OIDC_SUBJECT_MAX_LENGTH = 255;
53
+ /** The principal a verified access token stands for. Assignable to `@starci/auth`'s `AuthPrincipal`, with the roles the issuer granted. */
54
+ export interface OidcPrincipal {
55
+ /** The account: the `sub` claim. */
56
+ readonly id: string;
57
+ /** Always `authenticated`: a guard that checks the role of the lite principal checks this one the same way. */
58
+ readonly role: typeof OIDC_ROLE;
59
+ /** The provider session: the `sid` claim, else the token id (`jti`), else the subject. */
60
+ readonly sessionId: string;
61
+ /** The verified `email` claim, when the token has one. */
62
+ readonly email?: string;
63
+ /** The display name, when the token has one. */
64
+ readonly name?: string;
65
+ /** The `preferred_username`, when the token has one. */
66
+ readonly username?: string;
67
+ /** The roles the issuer granted, from the realm and from the clients the registration maps, sorted and without repeats. */
68
+ readonly roles: readonly string[];
69
+ /** The issuer that signed the token. */
70
+ readonly issuer: string;
71
+ /** When the token stops being accepted. */
72
+ readonly expiresAt: Date;
73
+ }
74
+ /** The refusal of a verifier. */
75
+ export interface OidcRefusal {
76
+ readonly kind: "refused";
77
+ readonly code: typeof OIDC_ACCESS_TOKEN_INVALID;
78
+ readonly detail?: Readonly<Record<string, unknown>>;
79
+ }
80
+ /** The result of a check that can refuse: the same two shapes as `@starci/auth`'s `Outcome`. */
81
+ export type OidcOutcome<T> = {
82
+ readonly kind: "ok";
83
+ readonly value: T;
84
+ } | OidcRefusal;
85
+ /**
86
+ * What a guard asks of an identity source: the principal of a bearer token, or a refusal. The same interface as
87
+ * `PrincipalVerifier` of `@starci/auth/server` (the package's own specs hold that this verifier is assignable to that one, and its principal to `AuthPrincipal`), so
88
+ * guards, `@Public` and `@Credential` do not change between editions.
89
+ */
90
+ export interface PrincipalVerifier {
91
+ /** Verifies one token at one instant. A provider that cannot be reached throws an `OidcError` of kind `unavailable`; an untrusted token is a refusal. */
92
+ verify(token: string, now?: Date): Promise<OidcOutcome<OidcPrincipal>>;
93
+ }
94
+ /** What a completed authorization-code flow established, after the ID token passed signature, issuer, audience, expiry and nonce. */
95
+ export interface OidcIdentity {
96
+ /** The issuer that signed the ID token. */
97
+ readonly issuer: string;
98
+ /** The verified account subject. */
99
+ readonly subject: string;
100
+ /** The roles the ID token carries. */
101
+ readonly roles: readonly string[];
102
+ /** The verified email, when the token has one. */
103
+ readonly email?: string;
104
+ /** The provider session, when the token names one. */
105
+ readonly sessionId?: string;
106
+ /** The verified token expiry, which bounds a local session. */
107
+ readonly expiresAt: Date;
108
+ }
109
+ /** The person a front-end session belongs to. */
110
+ export interface OidcSessionUser {
111
+ /** The subject. */
112
+ readonly id: string;
113
+ /** The email, or empty text when the token has none. */
114
+ readonly email: string;
115
+ }
116
+ /** The kinds of failure a front-end call ends with. */
117
+ export type OidcFailureKind = "refused" | "not-found" | "invalid" | "unavailable";
118
+ /** The outcome of a front-end call: a value, or a failure carrying its kind and a code. The same shape as `AuthOutcome` of `@starci/auth/contracts`. */
119
+ export type OidcClientOutcome<T> = {
120
+ readonly success: true;
121
+ readonly message: "ok";
122
+ readonly data: T;
123
+ } | {
124
+ readonly success: false;
125
+ readonly message: OidcFailureKind;
126
+ readonly error: string;
127
+ };
128
+ /** The codes a front-end failure carries. */
129
+ export declare const OIDC_CLIENT_CODES: {
130
+ readonly State: "OIDC_STATE_INVALID";
131
+ readonly Nonce: "OIDC_NONCE_INVALID";
132
+ readonly Issuer: "OIDC_ISSUER_MISMATCH";
133
+ readonly Provider: "OIDC_PROVIDER_REFUSED";
134
+ readonly Token: "OIDC_TOKEN_RESPONSE_INVALID";
135
+ readonly Transport: "OIDC_TRANSPORT";
136
+ readonly NoLogout: "OIDC_NO_LOGOUT_ENDPOINT";
137
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What both sides of an OIDC sign-in share: the algorithms a token may be signed with, the wire codes, the claim names and the
3
+ * shapes of the answers. The server module and the front-end client read these constants, so the two never drift.
4
+ */
5
+ /** The signature algorithms a verifier may be pinned to: asymmetric only. `none` and every HMAC algorithm are not in the list and are refused at registration. */
6
+ export const OIDC_ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"];
7
+ /** The algorithm list a registration pins when it names none: what Keycloak signs with by default. */
8
+ export const OIDC_DEFAULT_ALGORITHMS = ["RS256"];
9
+ /** The `role` every verified principal carries, the one `@starci/auth` gives its principals, so a guard reads one value in both editions. */
10
+ export const OIDC_ROLE = "authenticated";
11
+ /**
12
+ * The wire code a verifier refuses a token with: the code `@starci/auth`'s verifier uses, so the guard and the filter that map it
13
+ * to a 401 are the same in both editions. The specific reason travels in the refusal's `detail.reason`, for the log, never for the client.
14
+ */
15
+ export const OIDC_ACCESS_TOKEN_INVALID = "PASSPORT_ACCESS_TOKEN_INVALID";
16
+ /** The codes of the errors the package throws: a provider that cannot be reached is `Unavailable`, a response that cannot be trusted is `Rejected`. */
17
+ export const OIDC_ERROR_CODES = {
18
+ ConfigurationInvalid: "OIDC_CONFIGURATION_INVALID",
19
+ Unavailable: "OIDC_UNAVAILABLE",
20
+ Rejected: "OIDC_REJECTED",
21
+ };
22
+ /** The kind of every thrown code. */
23
+ export const OIDC_ERROR_KINDS = {
24
+ [OIDC_ERROR_CODES.ConfigurationInvalid]: "invalid",
25
+ [OIDC_ERROR_CODES.Unavailable]: "unavailable",
26
+ [OIDC_ERROR_CODES.Rejected]: "unauthenticated",
27
+ };
28
+ /** The claim names read beyond the registered ones. */
29
+ export const OIDC_CLAIMS = {
30
+ sessionId: "sid",
31
+ tokenId: "jti",
32
+ email: "email",
33
+ emailVerified: "email_verified",
34
+ name: "name",
35
+ username: "preferred_username",
36
+ authorizedParty: "azp",
37
+ realmAccess: "realm_access",
38
+ resourceAccess: "resource_access",
39
+ tokenType: "typ",
40
+ nonce: "nonce",
41
+ };
42
+ /** The most roles a token may carry and the longest a role may be: a token past either is refused, so a claim cannot grow without bound. */
43
+ export const OIDC_ROLE_BOUNDS = { maxRoles: 128, maxLength: 256 };
44
+ /** The longest `sub` a principal may have. */
45
+ export const OIDC_SUBJECT_MAX_LENGTH = 255;
46
+ /** The codes a front-end failure carries. */
47
+ export const OIDC_CLIENT_CODES = {
48
+ State: "OIDC_STATE_INVALID",
49
+ Nonce: "OIDC_NONCE_INVALID",
50
+ Issuer: "OIDC_ISSUER_MISMATCH",
51
+ Provider: "OIDC_PROVIDER_REFUSED",
52
+ Token: "OIDC_TOKEN_RESPONSE_INVALID",
53
+ Transport: "OIDC_TRANSPORT",
54
+ NoLogout: "OIDC_NO_LOGOUT_ENDPOINT",
55
+ };
@@ -0,0 +1,48 @@
1
+ import type { OidcIdentity } from "../contracts/index.js";
2
+ import type { OidcDiscovery } from "./discovery.js";
3
+ import type { ResolvedOidcOptions } from "./options.js";
4
+ import type { OidcTokenVerifier } from "./tokens.js";
5
+ /** The proof values the app owns and sends to the authorization endpoint. */
6
+ export interface OidcAuthorization {
7
+ /** One-use transaction state. */
8
+ readonly state: string;
9
+ /** Binds the ID token to the browser transaction. */
10
+ readonly nonce: string;
11
+ /** The base64url SHA-256 digest of the retained PKCE verifier (`pkceChallenge`). */
12
+ readonly challenge: string;
13
+ /** `none`, `login`, `consent` or `select_account`, when the flow must not (or must) show a login page. */
14
+ readonly prompt?: "none" | "login" | "consent" | "select_account" | undefined;
15
+ }
16
+ /** A claimed callback, exchanged exactly once. */
17
+ export interface OidcExchange {
18
+ /** The transient authorization code; never returned to the browser. */
19
+ readonly code: string;
20
+ /** The retained PKCE verifier. */
21
+ readonly verifier: string;
22
+ /** The nonce retained with the transaction. */
23
+ readonly nonce: string;
24
+ /** The `iss` of the authorization response, when the provider sent one; a supplied issuer must equal the configured one. */
25
+ readonly issuer?: string | undefined;
26
+ }
27
+ /** The address a browser is sent to for RP-initiated logout. */
28
+ export interface OidcLogout {
29
+ /** Sent as `id_token_hint` when the app retained the ID token. */
30
+ readonly idTokenHint?: string | undefined;
31
+ /** Sent as `state`. */
32
+ readonly state?: string | undefined;
33
+ }
34
+ /**
35
+ * The authorization code flow of one registered client: it builds the authorization address (S256 PKCE, nonce, state), exchanges a
36
+ * code once at the token endpoint and verifies the ID token (signature, issuer, audience = the client, expiry, nonce) before it answers
37
+ * an identity, and builds the RP-initiated logout address. It never returns or logs a provider token.
38
+ */
39
+ export declare class OidcClient {
40
+ #private;
41
+ constructor(options: ResolvedOidcOptions, discovery: OidcDiscovery, tokens: OidcTokenVerifier);
42
+ /** The address the browser goes to for the registered code flow. */
43
+ authorization(input: OidcAuthorization): Promise<string>;
44
+ /** Exchanges a claimed code once, then verifies the ID token and answers the identity it asserts. */
45
+ exchange(input: OidcExchange): Promise<OidcIdentity>;
46
+ /** The RP-initiated logout address of the registered client. */
47
+ logout(input?: OidcLogout): Promise<string>;
48
+ }
@@ -0,0 +1,114 @@
1
+ import { OIDC_CLAIMS, OIDC_ERROR_CODES, OIDC_SUBJECT_MAX_LENGTH } from "../contracts/index.js";
2
+ import { OidcError } from "./errors.js";
3
+ import { isRecord, revealed, sameValue } from "./primitives.js";
4
+ import { rolesOf } from "./roles.js";
5
+ const rejected = (cause) => new OidcError(OIDC_ERROR_CODES.Rejected, cause === undefined ? {} : { cause });
6
+ /**
7
+ * The authorization code flow of one registered client: it builds the authorization address (S256 PKCE, nonce, state), exchanges a
8
+ * code once at the token endpoint and verifies the ID token (signature, issuer, audience = the client, expiry, nonce) before it answers
9
+ * an identity, and builds the RP-initiated logout address. It never returns or logs a provider token.
10
+ */
11
+ export class OidcClient {
12
+ #options;
13
+ #discovery;
14
+ #tokens;
15
+ constructor(options, discovery, tokens) {
16
+ this.#options = options;
17
+ this.#discovery = discovery;
18
+ this.#tokens = tokens;
19
+ }
20
+ /** The address the browser goes to for the registered code flow. */
21
+ async authorization(input) {
22
+ const client = this.#registration();
23
+ const metadata = await this.#discovery.get();
24
+ const url = new URL(metadata.authorizationEndpoint);
25
+ url.search = new URLSearchParams({
26
+ response_type: "code",
27
+ client_id: client.clientId,
28
+ redirect_uri: client.callbackUri,
29
+ scope: client.scope ?? "openid",
30
+ state: input.state,
31
+ nonce: input.nonce,
32
+ code_challenge: input.challenge,
33
+ code_challenge_method: "S256",
34
+ ...(input.prompt === undefined ? {} : { prompt: input.prompt }),
35
+ }).toString();
36
+ return url.toString();
37
+ }
38
+ /** Exchanges a claimed code once, then verifies the ID token and answers the identity it asserts. */
39
+ async exchange(input) {
40
+ const client = this.#registration();
41
+ if (input.issuer !== undefined && input.issuer !== this.#options.issuer)
42
+ throw rejected();
43
+ const metadata = await this.#discovery.get();
44
+ const response = await this.#options.http
45
+ .request({
46
+ method: "POST",
47
+ url: metadata.tokenEndpoint,
48
+ timeoutMs: this.#options.timeoutMs,
49
+ form: {
50
+ grant_type: "authorization_code",
51
+ client_id: client.clientId,
52
+ ...(client.clientSecret === undefined ? {} : { client_secret: revealed(client.clientSecret) }),
53
+ redirect_uri: client.callbackUri,
54
+ code: input.code,
55
+ code_verifier: input.verifier,
56
+ },
57
+ })
58
+ .catch((cause) => {
59
+ throw new OidcError(OIDC_ERROR_CODES.Unavailable, { cause });
60
+ });
61
+ if (response.status >= 500)
62
+ throw new OidcError(OIDC_ERROR_CODES.Unavailable);
63
+ if (response.status !== 200 || !isRecord(response.body) || typeof response.body.id_token !== "string")
64
+ throw rejected();
65
+ return this.#identityOf(response.body.id_token, input.nonce);
66
+ }
67
+ /** The RP-initiated logout address of the registered client. */
68
+ async logout(input = {}) {
69
+ const client = this.#registration();
70
+ const { endSessionEndpoint } = await this.#discovery.get();
71
+ if (endSessionEndpoint === undefined)
72
+ throw rejected();
73
+ const url = new URL(endSessionEndpoint);
74
+ url.search = new URLSearchParams({
75
+ client_id: client.clientId,
76
+ post_logout_redirect_uri: client.postLogoutUri,
77
+ ...(input.idTokenHint === undefined ? {} : { id_token_hint: input.idTokenHint }),
78
+ ...(input.state === undefined ? {} : { state: input.state }),
79
+ }).toString();
80
+ return url.toString();
81
+ }
82
+ #registration() {
83
+ const client = this.#options.client;
84
+ if (client === undefined)
85
+ throw new OidcError(OIDC_ERROR_CODES.ConfigurationInvalid, { params: { key: "client" } });
86
+ return client;
87
+ }
88
+ async #identityOf(idToken, nonce) {
89
+ const client = this.#registration();
90
+ const verified = await this.#tokens.verify(idToken, { audience: [client.clientId], requiredClaims: [OIDC_CLAIMS.nonce] });
91
+ if (verified.kind === "refused")
92
+ throw rejected();
93
+ const claims = verified.value;
94
+ const received = claims[OIDC_CLAIMS.nonce];
95
+ if (typeof received !== "string" || !sameValue(received, nonce))
96
+ throw rejected();
97
+ const { sub, exp } = claims;
98
+ if (typeof sub !== "string" || sub === "" || sub.length > OIDC_SUBJECT_MAX_LENGTH || typeof exp !== "number" || !Number.isSafeInteger(exp))
99
+ throw rejected();
100
+ const roles = rolesOf(claims, { realm: this.#options.realmRoles, clients: this.#options.roleClients, claims: this.#options.roleClaims });
101
+ if (roles === undefined)
102
+ throw rejected();
103
+ const email = claims[OIDC_CLAIMS.email];
104
+ const sessionId = claims[OIDC_CLAIMS.sessionId];
105
+ return {
106
+ issuer: this.#options.issuer,
107
+ subject: sub,
108
+ roles,
109
+ ...(typeof email === "string" && email !== "" ? { email } : {}),
110
+ ...(typeof sessionId === "string" && sessionId !== "" ? { sessionId } : {}),
111
+ expiresAt: new Date(exp * 1000),
112
+ };
113
+ }
114
+ }
@@ -0,0 +1,21 @@
1
+ import type { ResolvedOidcOptions } from "./options.js";
2
+ /** The endpoints of the discovery document the package uses, each validated against the issuer. */
3
+ export interface OidcMetadata {
4
+ readonly issuer: string;
5
+ readonly authorizationEndpoint: string;
6
+ readonly tokenEndpoint: string;
7
+ readonly jwksUri: string;
8
+ /** Absent when the provider offers no RP-initiated logout. */
9
+ readonly endSessionEndpoint: string | undefined;
10
+ }
11
+ /** The discovery address of an issuer. */
12
+ export declare const discoveryUrl: (issuer: string) => string;
13
+ /** Narrows a discovery document: its `issuer` is the configured issuer exactly, and every endpoint passes `endpointOf`. */
14
+ export declare const readMetadata: (value: unknown, options: ResolvedOidcOptions) => OidcMetadata;
15
+ /** Fetches and caches the discovery document of the issuer; concurrent callers share one request. */
16
+ export declare class OidcDiscovery {
17
+ #private;
18
+ constructor(options: ResolvedOidcOptions);
19
+ /** The metadata: the cached document while it is fresh, else a new one. A provider that cannot be asked throws `Unavailable`; the cached document is never used past its life, so an outage never keeps stale endpoints. */
20
+ get(): Promise<OidcMetadata>;
21
+ }
@@ -0,0 +1,63 @@
1
+ import { OIDC_ERROR_CODES } from "../contracts/index.js";
2
+ import { OidcError } from "./errors.js";
3
+ import { isAdmissibleUrl } from "./options.js";
4
+ import { isRecord } from "./primitives.js";
5
+ const DISCOVERY_PATH = "/.well-known/openid-configuration";
6
+ /** The discovery address of an issuer. */
7
+ export const discoveryUrl = (issuer) => `${issuer.replace(/\/$/u, "")}${DISCOVERY_PATH}`;
8
+ const rejected = () => new OidcError(OIDC_ERROR_CODES.Rejected);
9
+ /**
10
+ * An endpoint the provider advertises is trusted only on the issuer's own origin, over the admitted scheme, without credentials or a
11
+ * fragment: a discovery document cannot send the client secret or the browser to another host.
12
+ */
13
+ const endpointOf = (value, options) => {
14
+ if (typeof value !== "string" || !isAdmissibleUrl(value, options.allowInsecureHttp))
15
+ throw rejected();
16
+ if (new URL(value).origin !== new URL(options.issuer).origin)
17
+ throw rejected();
18
+ return value;
19
+ };
20
+ /** Narrows a discovery document: its `issuer` is the configured issuer exactly, and every endpoint passes `endpointOf`. */
21
+ export const readMetadata = (value, options) => {
22
+ if (!isRecord(value) || value.issuer !== options.issuer)
23
+ throw rejected();
24
+ return {
25
+ issuer: options.issuer,
26
+ authorizationEndpoint: endpointOf(value.authorization_endpoint, options),
27
+ tokenEndpoint: endpointOf(value.token_endpoint, options),
28
+ jwksUri: endpointOf(value.jwks_uri, options),
29
+ endSessionEndpoint: value.end_session_endpoint === undefined ? undefined : endpointOf(value.end_session_endpoint, options),
30
+ };
31
+ };
32
+ /** Fetches and caches the discovery document of the issuer; concurrent callers share one request. */
33
+ export class OidcDiscovery {
34
+ #options;
35
+ #held;
36
+ #pending;
37
+ constructor(options) {
38
+ this.#options = options;
39
+ }
40
+ /** The metadata: the cached document while it is fresh, else a new one. A provider that cannot be asked throws `Unavailable`; the cached document is never used past its life, so an outage never keeps stale endpoints. */
41
+ async get() {
42
+ const now = this.#options.clock.now().getTime();
43
+ if (this.#held !== undefined && now - this.#held.at < this.#options.discoveryTtlMs)
44
+ return this.#held.metadata;
45
+ this.#pending ??= this.#fetch().finally(() => {
46
+ this.#pending = undefined;
47
+ });
48
+ return this.#pending;
49
+ }
50
+ async #fetch() {
51
+ const options = this.#options;
52
+ const response = await options.http
53
+ .request({ method: "GET", url: discoveryUrl(options.issuer), timeoutMs: options.timeoutMs })
54
+ .catch((cause) => {
55
+ throw new OidcError(OIDC_ERROR_CODES.Unavailable, { cause });
56
+ });
57
+ if (response.status !== 200)
58
+ throw new OidcError(OIDC_ERROR_CODES.Unavailable);
59
+ const metadata = readMetadata(response.body, options);
60
+ this.#held = { metadata, at: options.clock.now().getTime() };
61
+ return metadata;
62
+ }
63
+ }
@@ -0,0 +1,11 @@
1
+ import type { OidcErrorCode, OidcErrorKind } from "../contracts/index.js";
2
+ /** The one error class the package throws: a stable code, the kind it travels as, the key a configuration error names and the cause, never provider prose or a token. */
3
+ export declare class OidcError extends Error {
4
+ readonly code: OidcErrorCode;
5
+ readonly kind: OidcErrorKind;
6
+ readonly params: Readonly<Record<string, string>>;
7
+ constructor(code: OidcErrorCode, options?: {
8
+ readonly cause?: unknown;
9
+ readonly params?: Readonly<Record<string, string>>;
10
+ });
11
+ }
@@ -0,0 +1,14 @@
1
+ import { OIDC_ERROR_KINDS } from "../contracts/index.js";
2
+ /** The one error class the package throws: a stable code, the kind it travels as, the key a configuration error names and the cause, never provider prose or a token. */
3
+ export class OidcError extends Error {
4
+ code;
5
+ kind;
6
+ params;
7
+ constructor(code, options = {}) {
8
+ super(code, options.cause === undefined ? undefined : { cause: options.cause });
9
+ this.name = "OidcError";
10
+ this.code = code;
11
+ this.kind = OIDC_ERROR_KINDS[code];
12
+ this.params = options.params ?? {};
13
+ }
14
+ }
@@ -0,0 +1,22 @@
1
+ export { OidcClient } from "./client.js";
2
+ export type { OidcAuthorization, OidcExchange, OidcLogout } from "./client.js";
3
+ export { OidcDiscovery, discoveryUrl, readMetadata } from "./discovery.js";
4
+ export type { OidcMetadata } from "./discovery.js";
5
+ export { OidcError } from "./errors.js";
6
+ export { OidcKeys, readKeySet } from "./jwks.js";
7
+ export type { OidcKeySet } from "./jwks.js";
8
+ export { OidcModule, MODULE_OPTIONS_TOKEN as OIDC_OPTIONS } from "./oidc.module.js";
9
+ export type { OidcModuleExtras, OidcRegisterAsyncOptions, OidcRegisterOptions } from "./oidc.module.js";
10
+ export { createOidc } from "./oidc.js";
11
+ export type { Oidc } from "./oidc.js";
12
+ export { OIDC_CLIENT, OIDC_DISCOVERY, OIDC_KEYS, OIDC_PRINCIPAL_VERIFIER, OIDC_REGISTRATION, OIDC_TOKEN_VERIFIER } from "./oidc.tokens.js";
13
+ export { fetchHttp, isAdmissibleUrl, resolveOptions } from "./options.js";
14
+ export type { OidcClientRegistration, OidcHttp, OidcHttpRequest, OidcHttpResponse, OidcJwksOptions, OidcOptions, OidcRoleMapping, ResolvedOidcOptions, } from "./options.js";
15
+ export { isRecord, pkceChallenge, randomReference, sameValue, systemClock } from "./primitives.js";
16
+ export type { OidcClock, Revealable, SecretInput } from "./primitives.js";
17
+ export { rolesOf } from "./roles.js";
18
+ export type { RoleSources } from "./roles.js";
19
+ export { OidcTokenVerifier } from "./tokens.js";
20
+ export type { TokenExpectations } from "./tokens.js";
21
+ export { OidcPrincipalVerifier, refuseSessions } from "./verifier.js";
22
+ export type { OidcOutcome, OidcPrincipal, PrincipalVerifier } from "../contracts/index.js";
@@ -0,0 +1,12 @@
1
+ export { OidcClient } from "./client.js";
2
+ export { OidcDiscovery, discoveryUrl, readMetadata } from "./discovery.js";
3
+ export { OidcError } from "./errors.js";
4
+ export { OidcKeys, readKeySet } from "./jwks.js";
5
+ export { OidcModule, MODULE_OPTIONS_TOKEN as OIDC_OPTIONS } from "./oidc.module.js";
6
+ export { createOidc } from "./oidc.js";
7
+ export { OIDC_CLIENT, OIDC_DISCOVERY, OIDC_KEYS, OIDC_PRINCIPAL_VERIFIER, OIDC_REGISTRATION, OIDC_TOKEN_VERIFIER } from "./oidc.tokens.js";
8
+ export { fetchHttp, isAdmissibleUrl, resolveOptions } from "./options.js";
9
+ export { isRecord, pkceChallenge, randomReference, sameValue, systemClock } from "./primitives.js";
10
+ export { rolesOf } from "./roles.js";
11
+ export { OidcTokenVerifier } from "./tokens.js";
12
+ export { OidcPrincipalVerifier, refuseSessions } from "./verifier.js";
@@ -0,0 +1,22 @@
1
+ import { createLocalJWKSet } from "jose";
2
+ import type { JSONWebKeySet } from "jose";
3
+ import type { OidcDiscovery } from "./discovery.js";
4
+ import type { ResolvedOidcOptions } from "./options.js";
5
+ /** The key set jose selects a verification key from. */
6
+ export type OidcKeySet = ReturnType<typeof createLocalJWKSet>;
7
+ /**
8
+ * Narrows a fetched document to public signature keys. Keys the provider marks for encryption are dropped (Keycloak publishes
9
+ * them beside its signing keys); a document with no key left, with more than `maxKeys`, or with a private or symmetric key is rejected.
10
+ */
11
+ export declare const readKeySet: (value: unknown, maxKeys: number) => JSONWebKeySet;
12
+ /**
13
+ * The provider's signing keys: fetched from the discovered `jwks_uri`, cached for `ttlMs`, and refreshed when a token names a `kid` the
14
+ * cache lacks (the provider rotated), at most once per `refreshCooldownMs`. When the provider cannot be reached the expired set is still
15
+ * used for `staleTtlMs`, so a provider outage does not sign everybody out; past that the call throws `Unavailable`. Concurrent callers share one fetch.
16
+ */
17
+ export declare class OidcKeys {
18
+ #private;
19
+ constructor(options: ResolvedOidcOptions, discovery: OidcDiscovery);
20
+ /** The key set to verify a token whose header names `kid` (undefined when it names none). */
21
+ keysFor(kid: string | undefined): Promise<OidcKeySet>;
22
+ }
@@ -0,0 +1,80 @@
1
+ import { createLocalJWKSet } from "jose";
2
+ import { OIDC_ERROR_CODES } from "../contracts/index.js";
3
+ import { OidcError } from "./errors.js";
4
+ import { isRecord } from "./primitives.js";
5
+ /** The members that make a JWK private or symmetric: a key set carrying one is never verification input. */
6
+ const PRIVATE_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "k", "oth"];
7
+ const KEY_TYPES = ["RSA", "EC", "OKP"];
8
+ const isPublicKey = (key) => isRecord(key) && typeof key.kty === "string" && KEY_TYPES.includes(key.kty) && PRIVATE_MEMBERS.every((member) => key[member] === undefined);
9
+ /**
10
+ * Narrows a fetched document to public signature keys. Keys the provider marks for encryption are dropped (Keycloak publishes
11
+ * them beside its signing keys); a document with no key left, with more than `maxKeys`, or with a private or symmetric key is rejected.
12
+ */
13
+ export const readKeySet = (value, maxKeys) => {
14
+ if (!isRecord(value) || !Array.isArray(value.keys) || value.keys.length === 0 || value.keys.length > maxKeys)
15
+ throw new OidcError(OIDC_ERROR_CODES.Rejected);
16
+ const keys = value.keys;
17
+ if (!keys.every(isPublicKey))
18
+ throw new OidcError(OIDC_ERROR_CODES.Rejected);
19
+ const signing = keys.filter(isPublicKey).filter((key) => key.use !== "enc");
20
+ if (signing.length === 0)
21
+ throw new OidcError(OIDC_ERROR_CODES.Rejected);
22
+ // Every key was checked above to be public key material of a known type; jose checks the members it needs when it selects a key.
23
+ return { keys: signing.map((key) => ({ ...key })) };
24
+ };
25
+ /** The key ids a held set publishes. */
26
+ const kidsOf = (set) => new Set(set.keys.flatMap((key) => (typeof key.kid === "string" ? [key.kid] : [])));
27
+ /**
28
+ * The provider's signing keys: fetched from the discovered `jwks_uri`, cached for `ttlMs`, and refreshed when a token names a `kid` the
29
+ * cache lacks (the provider rotated), at most once per `refreshCooldownMs`. When the provider cannot be reached the expired set is still
30
+ * used for `staleTtlMs`, so a provider outage does not sign everybody out; past that the call throws `Unavailable`. Concurrent callers share one fetch.
31
+ */
32
+ export class OidcKeys {
33
+ #options;
34
+ #discovery;
35
+ #held;
36
+ #pending;
37
+ constructor(options, discovery) {
38
+ this.#options = options;
39
+ this.#discovery = discovery;
40
+ }
41
+ /** The key set to verify a token whose header names `kid` (undefined when it names none). */
42
+ async keysFor(kid) {
43
+ const options = this.#options;
44
+ const now = options.clock.now().getTime();
45
+ const held = this.#held;
46
+ if (held !== undefined) {
47
+ const age = now - held.at;
48
+ const known = kid === undefined || kidsOf(held.set).has(kid);
49
+ if (age < options.jwksTtlMs && (known || age < options.jwksCooldownMs))
50
+ return held.local;
51
+ }
52
+ try {
53
+ return (await this.#refresh()).local;
54
+ }
55
+ catch (cause) {
56
+ if (held !== undefined && now - held.at < options.jwksStaleMs)
57
+ return held.local;
58
+ throw cause;
59
+ }
60
+ }
61
+ #refresh() {
62
+ this.#pending ??= this.#fetch().finally(() => {
63
+ this.#pending = undefined;
64
+ });
65
+ return this.#pending;
66
+ }
67
+ async #fetch() {
68
+ const options = this.#options;
69
+ const { jwksUri } = await this.#discovery.get();
70
+ const response = await options.http.request({ method: "GET", url: jwksUri, timeoutMs: options.timeoutMs }).catch((cause) => {
71
+ throw new OidcError(OIDC_ERROR_CODES.Unavailable, { cause });
72
+ });
73
+ if (response.status !== 200)
74
+ throw new OidcError(OIDC_ERROR_CODES.Unavailable);
75
+ const set = readKeySet(response.body, options.jwksMaxKeys);
76
+ const held = { set, local: createLocalJWKSet(set), at: options.clock.now().getTime() };
77
+ this.#held = held;
78
+ return held;
79
+ }
80
+ }
@@ -0,0 +1,23 @@
1
+ import { OidcClient } from "./client.js";
2
+ import { OidcDiscovery } from "./discovery.js";
3
+ import { OidcKeys } from "./jwks.js";
4
+ import type { OidcOptions, ResolvedOidcOptions } from "./options.js";
5
+ import { OidcTokenVerifier } from "./tokens.js";
6
+ import { OidcPrincipalVerifier } from "./verifier.js";
7
+ /** The parts of one OIDC registration, built from validated options. */
8
+ export interface Oidc {
9
+ /** The options after validation, defaults applied. */
10
+ readonly options: ResolvedOidcOptions;
11
+ /** The discovery document of the issuer. */
12
+ readonly discovery: OidcDiscovery;
13
+ /** The provider's signing keys. */
14
+ readonly keys: OidcKeys;
15
+ /** The verifier of the provider's signed tokens. */
16
+ readonly tokens: OidcTokenVerifier;
17
+ /** The `PrincipalVerifier` guards inject. */
18
+ readonly verifier: OidcPrincipalVerifier;
19
+ /** The authorization code flow client; null when the registration declares no client. */
20
+ readonly client: OidcClient | null;
21
+ }
22
+ /** Builds one registration without Nest: the module uses it, and so can a script or a spec. Invalid options throw a `ConfigurationInvalid` error. */
23
+ export declare const createOidc: (input: OidcOptions) => Oidc;