@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.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +3 -0
- package/dist/client/oidc-client.d.ts +85 -0
- package/dist/client/oidc-client.js +304 -0
- package/dist/client/pkce.d.ts +15 -0
- package/dist/client/pkce.js +29 -0
- package/dist/contracts/index.d.ts +137 -0
- package/dist/contracts/index.js +55 -0
- package/dist/server/client.d.ts +48 -0
- package/dist/server/client.js +114 -0
- package/dist/server/discovery.d.ts +21 -0
- package/dist/server/discovery.js +63 -0
- package/dist/server/errors.d.ts +11 -0
- package/dist/server/errors.js +14 -0
- package/dist/server/index.d.ts +22 -0
- package/dist/server/index.js +12 -0
- package/dist/server/jwks.d.ts +22 -0
- package/dist/server/jwks.js +80 -0
- package/dist/server/oidc.d.ts +23 -0
- package/dist/server/oidc.js +21 -0
- package/dist/server/oidc.module.d.ts +33 -0
- package/dist/server/oidc.module.js +47 -0
- package/dist/server/oidc.tokens.d.ts +12 -0
- package/dist/server/oidc.tokens.js +12 -0
- package/dist/server/options.d.ts +113 -0
- package/dist/server/options.js +91 -0
- package/dist/server/primitives.d.ts +22 -0
- package/dist/server/primitives.js +17 -0
- package/dist/server/roles.d.ts +12 -0
- package/dist/server/roles.js +51 -0
- package/dist/server/tokens.d.ts +24 -0
- package/dist/server/tokens.js +79 -0
- package/dist/server/verifier.d.ts +15 -0
- package/dist/server/verifier.js +58 -0
- package/dist/testing/index.d.ts +142 -0
- package/dist/testing/index.js +354 -0
- package/package.json +104 -4
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { OidcClient } from "./client.js";
|
|
2
|
+
import { OidcDiscovery } from "./discovery.js";
|
|
3
|
+
import { OidcKeys } from "./jwks.js";
|
|
4
|
+
import { resolveOptions } from "./options.js";
|
|
5
|
+
import { OidcTokenVerifier } from "./tokens.js";
|
|
6
|
+
import { OidcPrincipalVerifier } from "./verifier.js";
|
|
7
|
+
/** Builds one registration without Nest: the module uses it, and so can a script or a spec. Invalid options throw a `ConfigurationInvalid` error. */
|
|
8
|
+
export const createOidc = (input) => {
|
|
9
|
+
const options = resolveOptions(input);
|
|
10
|
+
const discovery = new OidcDiscovery(options);
|
|
11
|
+
const keys = new OidcKeys(options, discovery);
|
|
12
|
+
const tokens = new OidcTokenVerifier(options, keys);
|
|
13
|
+
return {
|
|
14
|
+
options,
|
|
15
|
+
discovery,
|
|
16
|
+
keys,
|
|
17
|
+
tokens,
|
|
18
|
+
verifier: new OidcPrincipalVerifier(options, tokens),
|
|
19
|
+
client: options.client === undefined ? null : new OidcClient(options, discovery, tokens),
|
|
20
|
+
};
|
|
21
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ConfigurableModuleHost, DynamicModule } from "@nestjs/common";
|
|
2
|
+
import type { OidcOptions } from "./options.js";
|
|
3
|
+
/** The extras every registration sets beside the options. */
|
|
4
|
+
export interface OidcModuleExtras {
|
|
5
|
+
/** Whether the module is global. */
|
|
6
|
+
readonly isGlobal: boolean;
|
|
7
|
+
}
|
|
8
|
+
type Host = ConfigurableModuleHost<OidcOptions, "register", "createOidcOptions", OidcModuleExtras>;
|
|
9
|
+
/** The generated base class of the module (its `register` and `registerAsync`). */
|
|
10
|
+
export declare const ConfigurableModuleClass: Host["ConfigurableModuleClass"];
|
|
11
|
+
/** The injection token of the options the registration supplied. */
|
|
12
|
+
export declare const MODULE_OPTIONS_TOKEN: Host["MODULE_OPTIONS_TOKEN"];
|
|
13
|
+
/** Carries the type of the synchronous registration. */
|
|
14
|
+
export declare const OPTIONS_TYPE: Host["OPTIONS_TYPE"];
|
|
15
|
+
/** Carries the type of the asynchronous registration. */
|
|
16
|
+
export declare const ASYNC_OPTIONS_TYPE: Host["ASYNC_OPTIONS_TYPE"];
|
|
17
|
+
/** What `OidcModule.register` takes: the options, plus `isGlobal`. */
|
|
18
|
+
export type OidcRegisterOptions = typeof OPTIONS_TYPE;
|
|
19
|
+
/** What `OidcModule.registerAsync` takes: `imports`, `inject` and one of `useFactory`, `useClass`, `useExisting`, plus `isGlobal`. */
|
|
20
|
+
export type OidcRegisterAsyncOptions = typeof ASYNC_OPTIONS_TYPE;
|
|
21
|
+
/**
|
|
22
|
+
* The OIDC client of a full app as one Nest capability: the discovery document, the provider's signing keys, the token verifier, the
|
|
23
|
+
* `PrincipalVerifier` guards inject and, when the registration declares a client, the authorization code flow. Registered once at the api
|
|
24
|
+
* root; the app parses its settings (the package reads no environment) and passes them as typed options. A registration whose options do
|
|
25
|
+
* not meet the contract fails the boot with a `ConfigurationInvalid` error.
|
|
26
|
+
*/
|
|
27
|
+
export declare class OidcModule extends ConfigurableModuleClass {
|
|
28
|
+
/** Registers the module from options known now. */
|
|
29
|
+
static register(options: OidcRegisterOptions): DynamicModule;
|
|
30
|
+
/** Registers the module from options a factory, class or existing provider supplies. */
|
|
31
|
+
static registerAsync(options: OidcRegisterAsyncOptions): DynamicModule;
|
|
32
|
+
}
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ConfigurableModuleBuilder, Module } from "@nestjs/common";
|
|
2
|
+
import { createOidc } from "./oidc.js";
|
|
3
|
+
import { OIDC_CLIENT, OIDC_DISCOVERY, OIDC_KEYS, OIDC_PRINCIPAL_VERIFIER, OIDC_REGISTRATION, OIDC_TOKEN_VERIFIER } from "./oidc.tokens.js";
|
|
4
|
+
const built = new ConfigurableModuleBuilder({ moduleName: "Oidc" })
|
|
5
|
+
.setClassMethodName("register")
|
|
6
|
+
.setFactoryMethodName("createOidcOptions")
|
|
7
|
+
.setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
|
|
8
|
+
.build();
|
|
9
|
+
/** The generated base class of the module (its `register` and `registerAsync`). */
|
|
10
|
+
export const ConfigurableModuleClass = built.ConfigurableModuleClass;
|
|
11
|
+
/** The injection token of the options the registration supplied. */
|
|
12
|
+
export const MODULE_OPTIONS_TOKEN = built.MODULE_OPTIONS_TOKEN;
|
|
13
|
+
/** Carries the type of the synchronous registration. */
|
|
14
|
+
export const OPTIONS_TYPE = built.OPTIONS_TYPE;
|
|
15
|
+
/** Carries the type of the asynchronous registration. */
|
|
16
|
+
export const ASYNC_OPTIONS_TYPE = built.ASYNC_OPTIONS_TYPE;
|
|
17
|
+
const providers = [
|
|
18
|
+
{ provide: OIDC_REGISTRATION, inject: [MODULE_OPTIONS_TOKEN], useFactory: (options) => createOidc(options) },
|
|
19
|
+
{ provide: OIDC_DISCOVERY, inject: [OIDC_REGISTRATION], useFactory: (oidc) => oidc.discovery },
|
|
20
|
+
{ provide: OIDC_KEYS, inject: [OIDC_REGISTRATION], useFactory: (oidc) => oidc.keys },
|
|
21
|
+
{ provide: OIDC_TOKEN_VERIFIER, inject: [OIDC_REGISTRATION], useFactory: (oidc) => oidc.tokens },
|
|
22
|
+
{ provide: OIDC_CLIENT, inject: [OIDC_REGISTRATION], useFactory: (oidc) => oidc.client },
|
|
23
|
+
{ provide: OIDC_PRINCIPAL_VERIFIER, inject: [OIDC_REGISTRATION], useFactory: (oidc) => oidc.verifier },
|
|
24
|
+
];
|
|
25
|
+
const EXPORTED = [OIDC_DISCOVERY, OIDC_KEYS, OIDC_TOKEN_VERIFIER, OIDC_CLIENT, OIDC_PRINCIPAL_VERIFIER];
|
|
26
|
+
const withProviders = (base) => ({
|
|
27
|
+
...base,
|
|
28
|
+
providers: [...(base.providers ?? []), ...providers],
|
|
29
|
+
exports: [...(base.exports ?? []), ...EXPORTED],
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* The OIDC client of a full app as one Nest capability: the discovery document, the provider's signing keys, the token verifier, the
|
|
33
|
+
* `PrincipalVerifier` guards inject and, when the registration declares a client, the authorization code flow. Registered once at the api
|
|
34
|
+
* root; the app parses its settings (the package reads no environment) and passes them as typed options. A registration whose options do
|
|
35
|
+
* not meet the contract fails the boot with a `ConfigurationInvalid` error.
|
|
36
|
+
*/
|
|
37
|
+
export class OidcModule extends ConfigurableModuleClass {
|
|
38
|
+
/** Registers the module from options known now. */
|
|
39
|
+
static register(options) {
|
|
40
|
+
return withProviders(super.register(options));
|
|
41
|
+
}
|
|
42
|
+
/** Registers the module from options a factory, class or existing provider supplies. */
|
|
43
|
+
static registerAsync(options) {
|
|
44
|
+
return withProviders(super.registerAsync(options));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
Module({})(OidcModule);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Token of the `Oidc` registration (the parts below are its fields). */
|
|
2
|
+
export declare const OIDC_REGISTRATION: unique symbol;
|
|
3
|
+
/** Token of `OidcDiscovery`. */
|
|
4
|
+
export declare const OIDC_DISCOVERY: unique symbol;
|
|
5
|
+
/** Token of `OidcKeys`, the provider's signing keys. */
|
|
6
|
+
export declare const OIDC_KEYS: unique symbol;
|
|
7
|
+
/** Token of `OidcTokenVerifier`. */
|
|
8
|
+
export declare const OIDC_TOKEN_VERIFIER: unique symbol;
|
|
9
|
+
/** Token of `OidcClient`, the authorization code flow; null when the registration declares no client. */
|
|
10
|
+
export declare const OIDC_CLIENT: unique symbol;
|
|
11
|
+
/** Token of the `PrincipalVerifier` the identity domain injects. */
|
|
12
|
+
export declare const OIDC_PRINCIPAL_VERIFIER: unique symbol;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Token of the `Oidc` registration (the parts below are its fields). */
|
|
2
|
+
export const OIDC_REGISTRATION = Symbol.for("@starci/auth-oidc/registration");
|
|
3
|
+
/** Token of `OidcDiscovery`. */
|
|
4
|
+
export const OIDC_DISCOVERY = Symbol.for("@starci/auth-oidc/discovery");
|
|
5
|
+
/** Token of `OidcKeys`, the provider's signing keys. */
|
|
6
|
+
export const OIDC_KEYS = Symbol.for("@starci/auth-oidc/keys");
|
|
7
|
+
/** Token of `OidcTokenVerifier`. */
|
|
8
|
+
export const OIDC_TOKEN_VERIFIER = Symbol.for("@starci/auth-oidc/token-verifier");
|
|
9
|
+
/** Token of `OidcClient`, the authorization code flow; null when the registration declares no client. */
|
|
10
|
+
export const OIDC_CLIENT = Symbol.for("@starci/auth-oidc/client");
|
|
11
|
+
/** Token of the `PrincipalVerifier` the identity domain injects. */
|
|
12
|
+
export const OIDC_PRINCIPAL_VERIFIER = Symbol.for("@starci/auth-oidc/principal-verifier");
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { OidcAlgorithm } from "../contracts/index.js";
|
|
2
|
+
import type { OidcClock, SecretInput } from "./primitives.js";
|
|
3
|
+
/** One outbound request of the provider protocol. */
|
|
4
|
+
export interface OidcHttpRequest {
|
|
5
|
+
readonly method: "GET" | "POST";
|
|
6
|
+
readonly url: string;
|
|
7
|
+
/** The deadline of this request, in milliseconds. */
|
|
8
|
+
readonly timeoutMs: number;
|
|
9
|
+
/** A form-encoded body (`application/x-www-form-urlencoded`) for a POST. */
|
|
10
|
+
readonly form?: Readonly<Record<string, string>>;
|
|
11
|
+
}
|
|
12
|
+
/** The answer to an outbound request: the status and the parsed JSON body (`undefined` when the body is not JSON). */
|
|
13
|
+
export interface OidcHttpResponse {
|
|
14
|
+
readonly status: number;
|
|
15
|
+
readonly body: unknown;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The outbound HTTP port. An app passes its own bounded client (`platform/http`) through its glue, so the provider is spoken to
|
|
19
|
+
* through the one door the app owns; a registration without it uses the platform `fetch` with the request's deadline.
|
|
20
|
+
*/
|
|
21
|
+
export interface OidcHttp {
|
|
22
|
+
/** Sends one request; a transport failure rejects, any answer resolves. */
|
|
23
|
+
request(request: OidcHttpRequest): Promise<OidcHttpResponse>;
|
|
24
|
+
}
|
|
25
|
+
/** Which roles of a token become the principal's roles. */
|
|
26
|
+
export interface OidcRoleMapping {
|
|
27
|
+
/** Read `realm_access.roles` (true when absent). */
|
|
28
|
+
readonly realm?: boolean | undefined;
|
|
29
|
+
/** Read `resource_access.<client>.roles` of each of these clients. */
|
|
30
|
+
readonly clients?: readonly string[] | undefined;
|
|
31
|
+
/** Read these flat claims, each an array of text (`groups`, `roles`). */
|
|
32
|
+
readonly claims?: readonly string[] | undefined;
|
|
33
|
+
}
|
|
34
|
+
/** How the JWKS is cached. */
|
|
35
|
+
export interface OidcJwksOptions {
|
|
36
|
+
/** How long a fetched key set is used without asking again (10 minutes). */
|
|
37
|
+
readonly ttlMs?: number | undefined;
|
|
38
|
+
/** The least time between two fetches that an unknown `kid` may cause, so a forged `kid` cannot make the provider a target (30 seconds). */
|
|
39
|
+
readonly refreshCooldownMs?: number | undefined;
|
|
40
|
+
/** How long an expired key set is still used when the provider cannot be reached (24 hours). */
|
|
41
|
+
readonly staleTtlMs?: number | undefined;
|
|
42
|
+
/** The most keys a set may hold (100). */
|
|
43
|
+
readonly maxKeys?: number | undefined;
|
|
44
|
+
}
|
|
45
|
+
/** The registered client of the authorization code flow. Absent for an API that only verifies bearer tokens. */
|
|
46
|
+
export interface OidcClientRegistration {
|
|
47
|
+
/** The client id; the audience of the ID token. */
|
|
48
|
+
readonly clientId: string;
|
|
49
|
+
/** The client secret of a confidential client, revealed only for the token request. Absent for a public client. */
|
|
50
|
+
readonly clientSecret?: SecretInput | undefined;
|
|
51
|
+
/** The exact registered redirect address. */
|
|
52
|
+
readonly callbackUri: string;
|
|
53
|
+
/** The exact registered address the provider returns to after logout. */
|
|
54
|
+
readonly postLogoutUri: string;
|
|
55
|
+
/** The requested scope (`openid`). */
|
|
56
|
+
readonly scope?: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
/** Everything the package needs, typed and parsed by the app (the package reads no environment). */
|
|
59
|
+
export interface OidcOptions {
|
|
60
|
+
/** The exact issuer: the `iss` claim of every token and the `issuer` of the discovery document. */
|
|
61
|
+
readonly issuer: string;
|
|
62
|
+
/** The audience an access token must name: one of these. */
|
|
63
|
+
readonly audience: string | readonly string[];
|
|
64
|
+
/** The algorithms a token may be signed with (`RS256`). Asymmetric only. */
|
|
65
|
+
readonly algorithms?: readonly OidcAlgorithm[] | undefined;
|
|
66
|
+
/** When set, an access token's `azp` claim must equal it. */
|
|
67
|
+
readonly authorizedParty?: string | undefined;
|
|
68
|
+
/** The authorization code flow client; needed to build an authorization address, exchange a code or build a logout address. */
|
|
69
|
+
readonly client?: OidcClientRegistration | undefined;
|
|
70
|
+
/** Which roles become the principal's roles (the realm roles). */
|
|
71
|
+
readonly roles?: OidcRoleMapping | undefined;
|
|
72
|
+
/** The deadline of every provider request, in milliseconds (10000). */
|
|
73
|
+
readonly timeoutMs?: number | undefined;
|
|
74
|
+
/** The clock skew a token's `exp` and `nbf` tolerate, in seconds (5). */
|
|
75
|
+
readonly clockToleranceSeconds?: number | undefined;
|
|
76
|
+
/** How long a discovery document is used (1 hour). */
|
|
77
|
+
readonly discoveryTtlMs?: number | undefined;
|
|
78
|
+
/** The JWKS cache. */
|
|
79
|
+
readonly jwks?: OidcJwksOptions | undefined;
|
|
80
|
+
/** Admit `http:` addresses. Only for a provider on a developer machine or a test double; never in a deployment. */
|
|
81
|
+
readonly allowInsecureHttp?: boolean | undefined;
|
|
82
|
+
/** The outbound HTTP port (the platform `fetch` when absent). */
|
|
83
|
+
readonly http?: OidcHttp | undefined;
|
|
84
|
+
/** The clock (system time when absent). */
|
|
85
|
+
readonly clock?: OidcClock | undefined;
|
|
86
|
+
}
|
|
87
|
+
/** The options after validation, with every default applied. */
|
|
88
|
+
export interface ResolvedOidcOptions {
|
|
89
|
+
readonly issuer: string;
|
|
90
|
+
readonly audience: readonly string[];
|
|
91
|
+
readonly algorithms: readonly OidcAlgorithm[];
|
|
92
|
+
readonly authorizedParty: string | undefined;
|
|
93
|
+
readonly client: OidcClientRegistration | undefined;
|
|
94
|
+
readonly realmRoles: boolean;
|
|
95
|
+
readonly roleClients: readonly string[];
|
|
96
|
+
readonly roleClaims: readonly string[];
|
|
97
|
+
readonly timeoutMs: number;
|
|
98
|
+
readonly clockToleranceSeconds: number;
|
|
99
|
+
readonly discoveryTtlMs: number;
|
|
100
|
+
readonly jwksTtlMs: number;
|
|
101
|
+
readonly jwksCooldownMs: number;
|
|
102
|
+
readonly jwksStaleMs: number;
|
|
103
|
+
readonly jwksMaxKeys: number;
|
|
104
|
+
readonly allowInsecureHttp: boolean;
|
|
105
|
+
readonly http: OidcHttp;
|
|
106
|
+
readonly clock: OidcClock;
|
|
107
|
+
}
|
|
108
|
+
/** True when the address is an absolute, credential-free URL on the scheme the registration admits (https; http only when allowed). */
|
|
109
|
+
export declare const isAdmissibleUrl: (value: string, insecure: boolean) => boolean;
|
|
110
|
+
/** The platform `fetch` as the outbound port: JSON answers are parsed, the request's deadline aborts it. */
|
|
111
|
+
export declare const fetchHttp: OidcHttp;
|
|
112
|
+
/** Validates the registration and applies the defaults. A key that does not meet the contract throws a `ConfigurationInvalid` error naming it. */
|
|
113
|
+
export declare const resolveOptions: (options: OidcOptions) => ResolvedOidcOptions;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { OIDC_ALGORITHMS, OIDC_DEFAULT_ALGORITHMS, OIDC_ERROR_CODES } from "../contracts/index.js";
|
|
2
|
+
import { OidcError } from "./errors.js";
|
|
3
|
+
import { systemClock } from "./primitives.js";
|
|
4
|
+
const MINUTE_MS = 60_000;
|
|
5
|
+
const HOUR_MS = 3_600_000;
|
|
6
|
+
const invalid = (key) => new OidcError(OIDC_ERROR_CODES.ConfigurationInvalid, { params: { key } });
|
|
7
|
+
/** True when the address is an absolute, credential-free URL on the scheme the registration admits (https; http only when allowed). */
|
|
8
|
+
export const isAdmissibleUrl = (value, insecure) => {
|
|
9
|
+
if (!URL.canParse(value))
|
|
10
|
+
return false;
|
|
11
|
+
const url = new URL(value);
|
|
12
|
+
const scheme = url.protocol === "https:" || (insecure && url.protocol === "http:");
|
|
13
|
+
return scheme && url.username === "" && url.password === "" && url.hash === "";
|
|
14
|
+
};
|
|
15
|
+
const positive = (value, fallback, key) => {
|
|
16
|
+
const chosen = value ?? fallback;
|
|
17
|
+
if (!Number.isFinite(chosen) || chosen <= 0)
|
|
18
|
+
throw invalid(key);
|
|
19
|
+
return chosen;
|
|
20
|
+
};
|
|
21
|
+
const toleranceOf = (value) => {
|
|
22
|
+
const chosen = value ?? 5;
|
|
23
|
+
if (!Number.isFinite(chosen) || chosen < 0)
|
|
24
|
+
throw invalid("clockToleranceSeconds");
|
|
25
|
+
return chosen;
|
|
26
|
+
};
|
|
27
|
+
const audiencesOf = (audience) => {
|
|
28
|
+
const list = typeof audience === "string" ? [audience] : audience;
|
|
29
|
+
if (list.length === 0 || list.some((entry) => entry === ""))
|
|
30
|
+
throw invalid("audience");
|
|
31
|
+
return list;
|
|
32
|
+
};
|
|
33
|
+
const algorithmsOf = (algorithms) => {
|
|
34
|
+
const list = algorithms ?? OIDC_DEFAULT_ALGORITHMS;
|
|
35
|
+
const admitted = OIDC_ALGORITHMS;
|
|
36
|
+
if (list.length === 0 || list.some((algorithm) => !admitted.includes(algorithm)))
|
|
37
|
+
throw invalid("algorithms");
|
|
38
|
+
return list;
|
|
39
|
+
};
|
|
40
|
+
const checkClient = (client, insecure) => {
|
|
41
|
+
if (client.clientId === "")
|
|
42
|
+
throw invalid("client.clientId");
|
|
43
|
+
for (const key of ["callbackUri", "postLogoutUri"]) {
|
|
44
|
+
const value = client[key];
|
|
45
|
+
if (!isAdmissibleUrl(value, insecure) || new URL(value).search !== "")
|
|
46
|
+
throw invalid(`client.${key}`);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/** The platform `fetch` as the outbound port: JSON answers are parsed, the request's deadline aborts it. */
|
|
50
|
+
export const fetchHttp = {
|
|
51
|
+
async request(request) {
|
|
52
|
+
const response = await fetch(request.url, {
|
|
53
|
+
method: request.method,
|
|
54
|
+
redirect: "manual",
|
|
55
|
+
signal: AbortSignal.timeout(request.timeoutMs),
|
|
56
|
+
headers: request.form === undefined ? { accept: "application/json" } : { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
|
57
|
+
...(request.form === undefined ? {} : { body: new URLSearchParams(request.form).toString() }),
|
|
58
|
+
});
|
|
59
|
+
const body = await response.json().catch(() => undefined);
|
|
60
|
+
return { status: response.status, body };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
/** Validates the registration and applies the defaults. A key that does not meet the contract throws a `ConfigurationInvalid` error naming it. */
|
|
64
|
+
export const resolveOptions = (options) => {
|
|
65
|
+
const insecure = options.allowInsecureHttp ?? false;
|
|
66
|
+
if (!isAdmissibleUrl(options.issuer, insecure) || new URL(options.issuer).search !== "")
|
|
67
|
+
throw invalid("issuer");
|
|
68
|
+
if (options.client !== undefined)
|
|
69
|
+
checkClient(options.client, insecure);
|
|
70
|
+
const jwks = options.jwks;
|
|
71
|
+
return {
|
|
72
|
+
issuer: options.issuer,
|
|
73
|
+
audience: audiencesOf(options.audience),
|
|
74
|
+
algorithms: algorithmsOf(options.algorithms),
|
|
75
|
+
authorizedParty: options.authorizedParty,
|
|
76
|
+
client: options.client,
|
|
77
|
+
realmRoles: options.roles?.realm ?? true,
|
|
78
|
+
roleClients: options.roles?.clients ?? [],
|
|
79
|
+
roleClaims: options.roles?.claims ?? [],
|
|
80
|
+
timeoutMs: positive(options.timeoutMs, 10_000, "timeoutMs"),
|
|
81
|
+
clockToleranceSeconds: toleranceOf(options.clockToleranceSeconds),
|
|
82
|
+
discoveryTtlMs: positive(options.discoveryTtlMs, HOUR_MS, "discoveryTtlMs"),
|
|
83
|
+
jwksTtlMs: positive(jwks?.ttlMs, 10 * MINUTE_MS, "jwks.ttlMs"),
|
|
84
|
+
jwksCooldownMs: positive(jwks?.refreshCooldownMs, MINUTE_MS / 2, "jwks.refreshCooldownMs"),
|
|
85
|
+
jwksStaleMs: positive(jwks?.staleTtlMs, 24 * HOUR_MS, "jwks.staleTtlMs"),
|
|
86
|
+
jwksMaxKeys: positive(jwks?.maxKeys, 100, "jwks.maxKeys"),
|
|
87
|
+
allowInsecureHttp: insecure,
|
|
88
|
+
http: options.http ?? fetchHttp,
|
|
89
|
+
clock: options.clock ?? systemClock,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** A value read out of a secret holder (the app's `Secret` class has this shape). */
|
|
2
|
+
export interface Revealable {
|
|
3
|
+
reveal(): string;
|
|
4
|
+
}
|
|
5
|
+
/** A secret given as text or as a holder the app already keeps. */
|
|
6
|
+
export type SecretInput = string | Revealable;
|
|
7
|
+
/** The text of a secret input; called only where the secret is sent. */
|
|
8
|
+
export declare const revealed: (secret: SecretInput) => string;
|
|
9
|
+
/** A record: a non-null object that is not an array. */
|
|
10
|
+
export declare const isRecord: (value: unknown) => value is Readonly<Record<string, unknown>>;
|
|
11
|
+
/** True when two texts are equal, compared in constant time over equal-length buffers. */
|
|
12
|
+
export declare const sameValue: (given: string, expected: string) => boolean;
|
|
13
|
+
/** 256 unpredictable bits as base64url: a state, a nonce, a PKCE verifier or an opaque reference. */
|
|
14
|
+
export declare const randomReference: () => string;
|
|
15
|
+
/** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). */
|
|
16
|
+
export declare const pkceChallenge: (verifier: string) => string;
|
|
17
|
+
/** The clock the package reads. */
|
|
18
|
+
export interface OidcClock {
|
|
19
|
+
now(): Date;
|
|
20
|
+
}
|
|
21
|
+
/** The system clock. */
|
|
22
|
+
export declare const systemClock: OidcClock;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/** The text of a secret input; called only where the secret is sent. */
|
|
3
|
+
export const revealed = (secret) => (typeof secret === "string" ? secret : secret.reveal());
|
|
4
|
+
/** A record: a non-null object that is not an array. */
|
|
5
|
+
export const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
/** True when two texts are equal, compared in constant time over equal-length buffers. */
|
|
7
|
+
export const sameValue = (given, expected) => {
|
|
8
|
+
const left = Buffer.from(given);
|
|
9
|
+
const right = Buffer.from(expected);
|
|
10
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
11
|
+
};
|
|
12
|
+
/** 256 unpredictable bits as base64url: a state, a nonce, a PKCE verifier or an opaque reference. */
|
|
13
|
+
export const randomReference = () => randomBytes(32).toString("base64url");
|
|
14
|
+
/** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). */
|
|
15
|
+
export const pkceChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
16
|
+
/** The system clock. */
|
|
17
|
+
export const systemClock = { now: () => new Date() };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Which claims hold roles. */
|
|
2
|
+
export interface RoleSources {
|
|
3
|
+
readonly realm: boolean;
|
|
4
|
+
readonly clients: readonly string[];
|
|
5
|
+
readonly claims: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The roles a verified token grants: `realm_access.roles` (when the mapping reads the realm), `resource_access.<client>.roles` of each
|
|
9
|
+
* mapped client and each mapped flat claim. Sorted, without repeats. A claim of the wrong shape, or past the bounds, makes the whole
|
|
10
|
+
* token untrusted (`undefined`): a role is an authorization input, so it is never guessed from a malformed claim.
|
|
11
|
+
*/
|
|
12
|
+
export declare const rolesOf: (payload: Readonly<Record<string, unknown>>, sources: RoleSources) => readonly string[] | undefined;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { OIDC_CLAIMS, OIDC_ROLE_BOUNDS } from "../contracts/index.js";
|
|
2
|
+
import { isRecord } from "./primitives.js";
|
|
3
|
+
/** The roles of a claim value shaped `{ roles: string[] }`, or undefined when the shape is wrong. */
|
|
4
|
+
const rolesOfContainer = (value) => {
|
|
5
|
+
if (value === undefined)
|
|
6
|
+
return [];
|
|
7
|
+
return isRecord(value) ? rolesOfList(value.roles) : undefined;
|
|
8
|
+
};
|
|
9
|
+
/** The roles of a claim value shaped `string[]`, or undefined when it is not one of bounded text. */
|
|
10
|
+
const rolesOfList = (value) => {
|
|
11
|
+
if (value === undefined)
|
|
12
|
+
return [];
|
|
13
|
+
if (!Array.isArray(value) || value.length > OIDC_ROLE_BOUNDS.maxRoles)
|
|
14
|
+
return undefined;
|
|
15
|
+
const list = value;
|
|
16
|
+
const texts = list.filter((role) => typeof role === "string" && role.length > 0 && role.length <= OIDC_ROLE_BOUNDS.maxLength);
|
|
17
|
+
return texts.length === list.length ? texts : undefined;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The roles a verified token grants: `realm_access.roles` (when the mapping reads the realm), `resource_access.<client>.roles` of each
|
|
21
|
+
* mapped client and each mapped flat claim. Sorted, without repeats. A claim of the wrong shape, or past the bounds, makes the whole
|
|
22
|
+
* token untrusted (`undefined`): a role is an authorization input, so it is never guessed from a malformed claim.
|
|
23
|
+
*/
|
|
24
|
+
export const rolesOf = (payload, sources) => {
|
|
25
|
+
const granted = new Set();
|
|
26
|
+
const add = (roles) => {
|
|
27
|
+
if (roles === undefined)
|
|
28
|
+
return false;
|
|
29
|
+
for (const role of roles)
|
|
30
|
+
granted.add(role);
|
|
31
|
+
return true;
|
|
32
|
+
};
|
|
33
|
+
if (sources.realm && !add(rolesOfContainer(payload[OIDC_CLAIMS.realmAccess])))
|
|
34
|
+
return undefined;
|
|
35
|
+
if (sources.clients.length > 0) {
|
|
36
|
+
const access = payload[OIDC_CLAIMS.resourceAccess];
|
|
37
|
+
if (access !== undefined && !isRecord(access))
|
|
38
|
+
return undefined;
|
|
39
|
+
for (const client of sources.clients) {
|
|
40
|
+
if (!add(rolesOfContainer(access === undefined ? undefined : access[client])))
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const claim of sources.claims) {
|
|
45
|
+
if (!add(rolesOfList(payload[claim])))
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (granted.size > OIDC_ROLE_BOUNDS.maxRoles)
|
|
49
|
+
return undefined;
|
|
50
|
+
return [...granted].sort();
|
|
51
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { JWTPayload } from "jose";
|
|
2
|
+
import type { OidcAlgorithm, OidcOutcome } from "../contracts/index.js";
|
|
3
|
+
import type { OidcKeys } from "./jwks.js";
|
|
4
|
+
import type { ResolvedOidcOptions } from "./options.js";
|
|
5
|
+
/** What one verification expects beyond the issuer and the pinned algorithms. */
|
|
6
|
+
export interface TokenExpectations {
|
|
7
|
+
/** The audience: the token must name one of these. */
|
|
8
|
+
readonly audience: readonly string[];
|
|
9
|
+
/** Claims the token must carry. */
|
|
10
|
+
readonly requiredClaims?: readonly string[] | undefined;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The one verifier of the provider's signed tokens (access tokens and ID tokens). The algorithm is pinned to the registration's list
|
|
14
|
+
* before any key is looked up (so `none` and a symmetric algorithm never reach a key), the key comes from the provider's JWKS by `kid`,
|
|
15
|
+
* and the issuer, audience, expiry and not-before are checked against the registration and the clock.
|
|
16
|
+
*/
|
|
17
|
+
export declare class OidcTokenVerifier {
|
|
18
|
+
#private;
|
|
19
|
+
constructor(options: ResolvedOidcOptions, keys: OidcKeys);
|
|
20
|
+
/** The algorithms this verifier admits. */
|
|
21
|
+
get algorithms(): readonly OidcAlgorithm[];
|
|
22
|
+
/** The claims of a token that passed every check, or a refusal naming why. A provider that cannot be reached throws `Unavailable`. */
|
|
23
|
+
verify(token: string, expectations: TokenExpectations, now?: Date): Promise<OidcOutcome<JWTPayload>>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
2
|
+
import { OIDC_ACCESS_TOKEN_INVALID } from "../contracts/index.js";
|
|
3
|
+
const refuse = (reason) => ({ kind: "refused", code: OIDC_ACCESS_TOKEN_INVALID, detail: { reason } });
|
|
4
|
+
/** The reason a jose error stands for. */
|
|
5
|
+
const reasonOf = (error) => {
|
|
6
|
+
if (error instanceof errors.JWTExpired)
|
|
7
|
+
return "expired";
|
|
8
|
+
if (error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JWKSNoMatchingKey || error instanceof errors.JWKSMultipleMatchingKeys)
|
|
9
|
+
return "signature";
|
|
10
|
+
if (error instanceof errors.JOSEAlgNotAllowed)
|
|
11
|
+
return "algorithm";
|
|
12
|
+
if (error instanceof errors.JWTClaimValidationFailed) {
|
|
13
|
+
switch (error.claim) {
|
|
14
|
+
case "iss":
|
|
15
|
+
return "issuer";
|
|
16
|
+
case "aud":
|
|
17
|
+
return "audience";
|
|
18
|
+
case "nbf":
|
|
19
|
+
case "iat":
|
|
20
|
+
return "not-yet-valid";
|
|
21
|
+
case "exp":
|
|
22
|
+
return "expired";
|
|
23
|
+
default:
|
|
24
|
+
return "claims";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return error instanceof errors.JWTInvalid || error instanceof errors.JWSInvalid ? "malformed" : "signature";
|
|
28
|
+
};
|
|
29
|
+
/** The header of a token read without trusting it, only to pick the verification key; `undefined` when it is not a JWS header. */
|
|
30
|
+
const headerOf = (token) => {
|
|
31
|
+
try {
|
|
32
|
+
const header = decodeProtectedHeader(token);
|
|
33
|
+
return { alg: header.alg, kid: header.kid };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The one verifier of the provider's signed tokens (access tokens and ID tokens). The algorithm is pinned to the registration's list
|
|
41
|
+
* before any key is looked up (so `none` and a symmetric algorithm never reach a key), the key comes from the provider's JWKS by `kid`,
|
|
42
|
+
* and the issuer, audience, expiry and not-before are checked against the registration and the clock.
|
|
43
|
+
*/
|
|
44
|
+
export class OidcTokenVerifier {
|
|
45
|
+
#options;
|
|
46
|
+
#keys;
|
|
47
|
+
constructor(options, keys) {
|
|
48
|
+
this.#options = options;
|
|
49
|
+
this.#keys = keys;
|
|
50
|
+
}
|
|
51
|
+
/** The algorithms this verifier admits. */
|
|
52
|
+
get algorithms() {
|
|
53
|
+
return this.#options.algorithms;
|
|
54
|
+
}
|
|
55
|
+
/** The claims of a token that passed every check, or a refusal naming why. A provider that cannot be reached throws `Unavailable`. */
|
|
56
|
+
async verify(token, expectations, now = this.#options.clock.now()) {
|
|
57
|
+
const header = headerOf(token);
|
|
58
|
+
if (header === undefined)
|
|
59
|
+
return refuse("malformed");
|
|
60
|
+
const admitted = this.#options.algorithms;
|
|
61
|
+
if (header.alg === undefined || !admitted.includes(header.alg))
|
|
62
|
+
return refuse("algorithm");
|
|
63
|
+
const keys = await this.#keys.keysFor(header.kid);
|
|
64
|
+
try {
|
|
65
|
+
const { payload } = await jwtVerify(token, keys, {
|
|
66
|
+
algorithms: [...this.#options.algorithms],
|
|
67
|
+
issuer: this.#options.issuer,
|
|
68
|
+
audience: [...expectations.audience],
|
|
69
|
+
currentDate: now,
|
|
70
|
+
clockTolerance: this.#options.clockToleranceSeconds,
|
|
71
|
+
requiredClaims: ["exp", "sub", ...(expectations.requiredClaims ?? [])],
|
|
72
|
+
});
|
|
73
|
+
return { kind: "ok", value: payload };
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return refuse(reasonOf(error));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { OidcOutcome, OidcPrincipal, PrincipalVerifier } from "../contracts/index.js";
|
|
2
|
+
import type { ResolvedOidcOptions } from "./options.js";
|
|
3
|
+
import type { OidcTokenVerifier } from "./tokens.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `PrincipalVerifier` of the full edition: a bearer access token of the provider, checked against the discovered issuer's JWKS
|
|
6
|
+
* (algorithm pinned, issuer, audience, expiry), becomes the principal a guard reads. The principal has the shape of `@starci/auth`'s, with
|
|
7
|
+
* the roles the issuer granted added, so guards, `@Public` and `@Credential` do not change between editions.
|
|
8
|
+
*/
|
|
9
|
+
export declare class OidcPrincipalVerifier implements PrincipalVerifier {
|
|
10
|
+
#private;
|
|
11
|
+
constructor(options: ResolvedOidcOptions, tokens: OidcTokenVerifier);
|
|
12
|
+
verify(token: string, now?: Date): Promise<OidcOutcome<OidcPrincipal>>;
|
|
13
|
+
}
|
|
14
|
+
/** The verifier of an app that serves no user session: every token is refused, so the default-deny guard admits only its public and credential doors. */
|
|
15
|
+
export declare const refuseSessions: PrincipalVerifier;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { OIDC_ACCESS_TOKEN_INVALID, OIDC_CLAIMS, OIDC_ROLE, OIDC_SUBJECT_MAX_LENGTH } from "../contracts/index.js";
|
|
2
|
+
import { rolesOf } from "./roles.js";
|
|
3
|
+
/** The `typ` claim of an access token Keycloak issues. An ID token (`ID`) or a refresh token presented as a bearer is not one. */
|
|
4
|
+
const ACCESS_TOKEN_TYPE = "Bearer";
|
|
5
|
+
const refuse = (reason) => ({ kind: "refused", code: OIDC_ACCESS_TOKEN_INVALID, detail: { reason } });
|
|
6
|
+
const text = (value) => (typeof value === "string" && value !== "" ? value : undefined);
|
|
7
|
+
/**
|
|
8
|
+
* The `PrincipalVerifier` of the full edition: a bearer access token of the provider, checked against the discovered issuer's JWKS
|
|
9
|
+
* (algorithm pinned, issuer, audience, expiry), becomes the principal a guard reads. The principal has the shape of `@starci/auth`'s, with
|
|
10
|
+
* the roles the issuer granted added, so guards, `@Public` and `@Credential` do not change between editions.
|
|
11
|
+
*/
|
|
12
|
+
export class OidcPrincipalVerifier {
|
|
13
|
+
#options;
|
|
14
|
+
#tokens;
|
|
15
|
+
constructor(options, tokens) {
|
|
16
|
+
this.#options = options;
|
|
17
|
+
this.#tokens = tokens;
|
|
18
|
+
}
|
|
19
|
+
async verify(token, now = this.#options.clock.now()) {
|
|
20
|
+
const verified = await this.#tokens.verify(token, { audience: this.#options.audience }, now);
|
|
21
|
+
if (verified.kind === "refused")
|
|
22
|
+
return verified;
|
|
23
|
+
const claims = verified.value;
|
|
24
|
+
const subject = text(claims.sub);
|
|
25
|
+
if (subject === undefined || subject.length > OIDC_SUBJECT_MAX_LENGTH || typeof claims.exp !== "number")
|
|
26
|
+
return refuse("claims");
|
|
27
|
+
const type = claims[OIDC_CLAIMS.tokenType];
|
|
28
|
+
if (type !== undefined && type !== ACCESS_TOKEN_TYPE)
|
|
29
|
+
return refuse("token-type");
|
|
30
|
+
const party = this.#options.authorizedParty;
|
|
31
|
+
if (party !== undefined && claims[OIDC_CLAIMS.authorizedParty] !== party)
|
|
32
|
+
return refuse("authorized-party");
|
|
33
|
+
const roles = rolesOf(claims, { realm: this.#options.realmRoles, clients: this.#options.roleClients, claims: this.#options.roleClaims });
|
|
34
|
+
if (roles === undefined)
|
|
35
|
+
return refuse("claims");
|
|
36
|
+
const email = text(claims[OIDC_CLAIMS.email]);
|
|
37
|
+
const name = text(claims[OIDC_CLAIMS.name]);
|
|
38
|
+
const username = text(claims[OIDC_CLAIMS.username]);
|
|
39
|
+
return {
|
|
40
|
+
kind: "ok",
|
|
41
|
+
value: {
|
|
42
|
+
id: subject,
|
|
43
|
+
role: OIDC_ROLE,
|
|
44
|
+
sessionId: text(claims[OIDC_CLAIMS.sessionId]) ?? text(claims[OIDC_CLAIMS.tokenId]) ?? subject,
|
|
45
|
+
...(email === undefined ? {} : { email }),
|
|
46
|
+
...(name === undefined ? {} : { name }),
|
|
47
|
+
...(username === undefined ? {} : { username }),
|
|
48
|
+
roles,
|
|
49
|
+
issuer: this.#options.issuer,
|
|
50
|
+
expiresAt: new Date(claims.exp * 1000),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The verifier of an app that serves no user session: every token is refused, so the default-deny guard admits only its public and credential doors. */
|
|
56
|
+
export const refuseSessions = {
|
|
57
|
+
verify: () => Promise.resolve(refuse("claims")),
|
|
58
|
+
};
|