@starci/auth 0.5.0 → 0.6.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 +11 -0
- package/README.md +103 -14
- package/dist/client/oidc/index.d.ts +5 -0
- package/dist/client/oidc/index.js +3 -0
- package/dist/client/oidc/oidc-client.d.ts +85 -0
- package/dist/client/oidc/oidc-client.js +304 -0
- package/dist/client/oidc/pkce.d.ts +15 -0
- package/dist/client/oidc/pkce.js +29 -0
- package/dist/contracts/index.d.ts +12 -0
- package/dist/contracts/index.js +14 -0
- package/dist/contracts/oidc.d.ts +148 -0
- package/dist/contracts/oidc.js +68 -0
- package/dist/oidc/admin.d.ts +42 -0
- package/dist/oidc/admin.js +139 -0
- package/dist/oidc/broker.d.ts +57 -0
- package/dist/oidc/broker.js +77 -0
- package/dist/oidc/client.d.ts +58 -0
- package/dist/oidc/client.js +119 -0
- package/dist/oidc/discovery.d.ts +23 -0
- package/dist/oidc/discovery.js +64 -0
- package/dist/oidc/errors.d.ts +10 -0
- package/dist/oidc/errors.js +10 -0
- package/dist/oidc/grants.d.ts +41 -0
- package/dist/oidc/grants.js +82 -0
- package/dist/oidc/index.d.ts +35 -0
- package/dist/oidc/index.js +18 -0
- package/dist/oidc/issuers/github-actions.d.ts +54 -0
- package/dist/oidc/issuers/github-actions.js +84 -0
- package/dist/oidc/issuers/google.d.ts +30 -0
- package/dist/oidc/issuers/google.js +39 -0
- package/dist/oidc/issuers/index.d.ts +8 -0
- package/dist/oidc/issuers/index.js +4 -0
- package/dist/oidc/issuers/issuer.d.ts +79 -0
- package/dist/oidc/issuers/issuer.js +39 -0
- package/dist/oidc/issuers/keycloak.d.ts +26 -0
- package/dist/oidc/issuers/keycloak.js +41 -0
- package/dist/oidc/jwks.d.ts +26 -0
- package/dist/oidc/jwks.js +80 -0
- package/dist/oidc/oidc.d.ts +32 -0
- package/dist/oidc/oidc.js +21 -0
- package/dist/oidc/oidc.module.d.ts +34 -0
- package/dist/oidc/oidc.module.js +51 -0
- package/dist/oidc/oidc.tokens.d.ts +18 -0
- package/dist/oidc/oidc.tokens.js +18 -0
- package/dist/oidc/options.d.ts +119 -0
- package/dist/oidc/options.js +98 -0
- package/dist/oidc/primitives.d.ts +15 -0
- package/dist/oidc/primitives.js +11 -0
- package/dist/oidc/refresh.d.ts +10 -0
- package/dist/oidc/refresh.js +24 -0
- package/dist/oidc/roles.d.ts +12 -0
- package/dist/oidc/roles.js +51 -0
- package/dist/oidc/tokens.d.ts +26 -0
- package/dist/oidc/tokens.js +79 -0
- package/dist/oidc/transport.d.ts +8 -0
- package/dist/oidc/transport.js +19 -0
- package/dist/oidc/verifier.d.ts +24 -0
- package/dist/oidc/verifier.js +64 -0
- package/dist/server/auth.module.d.ts +1 -1
- package/dist/server/auth.module.js +20 -6
- package/dist/server/auth.tokens.d.ts +2 -0
- package/dist/server/auth.tokens.js +2 -0
- package/dist/server/doors.d.ts +14 -8
- package/dist/server/doors.js +22 -11
- package/dist/server/flows.d.ts +13 -5
- package/dist/server/flows.js +37 -18
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +3 -4
- package/dist/server/options.d.ts +11 -13
- package/dist/server/ports.d.ts +8 -1
- package/dist/server/providers/github.d.ts +59 -0
- package/dist/server/providers/github.js +111 -0
- package/dist/server/providers/google.d.ts +56 -0
- package/dist/server/providers/google.js +87 -0
- package/dist/server/providers/index.d.ts +14 -0
- package/dist/server/providers/index.js +9 -0
- package/dist/server/providers/oauth-state.d.ts +47 -0
- package/dist/server/providers/oauth-state.js +74 -0
- package/dist/server/providers/provider.d.ts +58 -0
- package/dist/server/providers/provider.js +32 -0
- package/dist/server/providers/registry.d.ts +30 -0
- package/dist/server/providers/registry.js +37 -0
- package/dist/server/strategies.d.ts +3 -18
- package/dist/server/strategies.js +2 -41
- package/dist/server/verifier.d.ts +1 -1
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/oidc.d.ts +175 -0
- package/dist/testing/oidc.js +493 -0
- package/package.json +22 -7
- package/dist/server/google-state.d.ts +0 -33
- package/dist/server/google-state.js +0 -73
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { OIDC_ALGORITHMS, OIDC_DEFAULT_ALGORITHMS, OIDC_ERROR_CODES } from "../contracts/oidc.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: {
|
|
57
|
+
accept: "application/json",
|
|
58
|
+
...(request.form === undefined ? {} : { "content-type": "application/x-www-form-urlencoded" }),
|
|
59
|
+
...(request.body === undefined ? {} : { "content-type": "application/json" }),
|
|
60
|
+
...request.headers,
|
|
61
|
+
},
|
|
62
|
+
...(request.form === undefined ? {} : { body: new URLSearchParams(request.form).toString() }),
|
|
63
|
+
...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
|
|
64
|
+
});
|
|
65
|
+
const body = await response.json().catch(() => undefined);
|
|
66
|
+
const location = response.headers.get("location");
|
|
67
|
+
return { status: response.status, body, ...(location === null ? {} : { location }) };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
/** Validates the registration and applies the defaults. A key that does not meet the contract throws a `ConfigurationInvalid` error naming it. */
|
|
71
|
+
export const resolveOptions = (options) => {
|
|
72
|
+
const insecure = options.allowInsecureHttp ?? false;
|
|
73
|
+
if (!isAdmissibleUrl(options.issuer, insecure) || new URL(options.issuer).search !== "")
|
|
74
|
+
throw invalid("issuer");
|
|
75
|
+
if (options.client !== undefined)
|
|
76
|
+
checkClient(options.client, insecure);
|
|
77
|
+
const jwks = options.jwks;
|
|
78
|
+
return {
|
|
79
|
+
issuer: options.issuer,
|
|
80
|
+
audience: audiencesOf(options.audience),
|
|
81
|
+
algorithms: algorithmsOf(options.algorithms),
|
|
82
|
+
authorizedParty: options.authorizedParty,
|
|
83
|
+
client: options.client,
|
|
84
|
+
realmRoles: options.roles?.realm ?? true,
|
|
85
|
+
roleClients: options.roles?.clients ?? [],
|
|
86
|
+
roleClaims: options.roles?.claims ?? [],
|
|
87
|
+
timeoutMs: positive(options.timeoutMs, 10_000, "timeoutMs"),
|
|
88
|
+
clockToleranceSeconds: toleranceOf(options.clockToleranceSeconds),
|
|
89
|
+
discoveryTtlMs: positive(options.discoveryTtlMs, HOUR_MS, "discoveryTtlMs"),
|
|
90
|
+
jwksTtlMs: positive(jwks?.ttlMs, 10 * MINUTE_MS, "jwks.ttlMs"),
|
|
91
|
+
jwksCooldownMs: positive(jwks?.refreshCooldownMs, MINUTE_MS / 2, "jwks.refreshCooldownMs"),
|
|
92
|
+
jwksStaleMs: positive(jwks?.staleTtlMs, 24 * HOUR_MS, "jwks.staleTtlMs"),
|
|
93
|
+
jwksMaxKeys: positive(jwks?.maxKeys, 100, "jwks.maxKeys"),
|
|
94
|
+
allowInsecureHttp: insecure,
|
|
95
|
+
http: options.http ?? fetchHttp,
|
|
96
|
+
clock: options.clock ?? systemClock,
|
|
97
|
+
};
|
|
98
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The secret holder, the secret input, its reveal and the constant-time comparison: one copy for both editions. */
|
|
2
|
+
export { revealed, sameValue } from "../server/primitives.js";
|
|
3
|
+
export type { Revealable, SecretInput } from "../server/primitives.js";
|
|
4
|
+
/** A record: a non-null object that is not an array (`isPlainRecord` of `@starci/common`). */
|
|
5
|
+
export { isPlainRecord as isRecord } from "@starci/common";
|
|
6
|
+
/** 256 unpredictable bits as base64url: a state, a nonce, a PKCE verifier or an opaque reference. */
|
|
7
|
+
export declare const randomReference: () => string;
|
|
8
|
+
/** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). */
|
|
9
|
+
export declare const pkceChallenge: (verifier: string) => string;
|
|
10
|
+
/** The clock the package reads. */
|
|
11
|
+
export interface OidcClock {
|
|
12
|
+
now(): Date;
|
|
13
|
+
}
|
|
14
|
+
/** The system clock. */
|
|
15
|
+
export declare const systemClock: OidcClock;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
/** The secret holder, the secret input, its reveal and the constant-time comparison: one copy for both editions. */
|
|
3
|
+
export { revealed, sameValue } from "../server/primitives.js";
|
|
4
|
+
/** A record: a non-null object that is not an array (`isPlainRecord` of `@starci/common`). */
|
|
5
|
+
export { isPlainRecord as isRecord } from "@starci/common";
|
|
6
|
+
/** 256 unpredictable bits as base64url: a state, a nonce, a PKCE verifier or an opaque reference. */
|
|
7
|
+
export const randomReference = () => randomBytes(32).toString("base64url");
|
|
8
|
+
/** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). */
|
|
9
|
+
export const pkceChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
10
|
+
/** The system clock. */
|
|
11
|
+
export const systemClock = { now: () => new Date() };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The refresh token of a token response and the instant it stops being accepted. */
|
|
2
|
+
export interface OidcRefresh {
|
|
3
|
+
readonly refreshToken: string;
|
|
4
|
+
readonly refreshExpiresAt: Date;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The refresh token of a token endpoint answer and its expiry: `refresh_expires_in` (seconds from `now`), else the token's own `exp`.
|
|
8
|
+
* `undefined` when the answer holds no refresh token or no way to date it (an offline token has neither), so the caller refuses it.
|
|
9
|
+
*/
|
|
10
|
+
export declare const refreshOf: (body: unknown, now: Date) => OidcRefresh | undefined;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { decodeJwt } from "jose";
|
|
2
|
+
import { isRecord } from "./primitives.js";
|
|
3
|
+
const SECOND_MS = 1000;
|
|
4
|
+
/** The `exp` of a refresh token, read without trusting it: it only dates a token the provider itself just issued over the verified channel. */
|
|
5
|
+
const expiryOf = (token) => {
|
|
6
|
+
try {
|
|
7
|
+
const { exp } = decodeJwt(token);
|
|
8
|
+
return typeof exp === "number" && Number.isFinite(exp) && exp > 0 ? exp * SECOND_MS : undefined;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The refresh token of a token endpoint answer and its expiry: `refresh_expires_in` (seconds from `now`), else the token's own `exp`.
|
|
16
|
+
* `undefined` when the answer holds no refresh token or no way to date it (an offline token has neither), so the caller refuses it.
|
|
17
|
+
*/
|
|
18
|
+
export const refreshOf = (body, now) => {
|
|
19
|
+
if (!isRecord(body) || typeof body.refresh_token !== "string" || body.refresh_token === "")
|
|
20
|
+
return undefined;
|
|
21
|
+
const lifetime = body.refresh_expires_in;
|
|
22
|
+
const expiresAt = typeof lifetime === "number" && Number.isFinite(lifetime) && lifetime > 0 ? now.getTime() + lifetime * SECOND_MS : expiryOf(body.refresh_token);
|
|
23
|
+
return expiresAt === undefined ? undefined : { refreshToken: body.refresh_token, refreshExpiresAt: new Date(expiresAt) };
|
|
24
|
+
};
|
|
@@ -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/oidc.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,26 @@
|
|
|
1
|
+
import type { JWTPayload } from "jose";
|
|
2
|
+
import type { OidcAlgorithm, OidcOutcome } from "../contracts/oidc.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
|
+
/** The issuers the `iss` claim may name (the registration's issuer when absent): a preset whose issuer has two spellings lists both. */
|
|
10
|
+
readonly issuer?: readonly string[] | undefined;
|
|
11
|
+
/** Claims the token must carry. */
|
|
12
|
+
readonly requiredClaims?: readonly string[] | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The one verifier of the provider's signed tokens (access tokens and ID tokens). The algorithm is pinned to the registration's list
|
|
16
|
+
* 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`,
|
|
17
|
+
* and the issuer, audience, expiry and not-before are checked against the registration and the clock.
|
|
18
|
+
*/
|
|
19
|
+
export declare class OidcTokenVerifier {
|
|
20
|
+
#private;
|
|
21
|
+
constructor(options: ResolvedOidcOptions, keys: OidcKeys);
|
|
22
|
+
/** The algorithms this verifier admits. */
|
|
23
|
+
get algorithms(): readonly OidcAlgorithm[];
|
|
24
|
+
/** The claims of a token that passed every check, or a refusal naming why. A provider that cannot be reached throws `Unavailable`. */
|
|
25
|
+
verify(token: string, expectations: TokenExpectations, now?: Date): Promise<OidcOutcome<JWTPayload>>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
2
|
+
import { OIDC_ACCESS_TOKEN_INVALID } from "../contracts/oidc.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: expectations.issuer === undefined ? this.#options.issuer : [...expectations.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,8 @@
|
|
|
1
|
+
import type { OidcHttpRequest, OidcHttpResponse, ResolvedOidcOptions } from "./options.js";
|
|
2
|
+
/**
|
|
3
|
+
* Sends one request through the registration's `OidcHttp` port with its deadline. A lost connection, a timeout, a 5xx and a 429 throw
|
|
4
|
+
* `Unavailable` (a provider outage, never a refusal of the person); any other answer is returned for the caller to judge.
|
|
5
|
+
*/
|
|
6
|
+
export declare const send: (options: ResolvedOidcOptions, request: Omit<OidcHttpRequest, "timeoutMs">) => Promise<OidcHttpResponse>;
|
|
7
|
+
/** The OAuth `error` code of an answer (`invalid_grant`), or undefined. Never the description, which may name the person. */
|
|
8
|
+
export declare const errorCodeOf: (body: unknown) => string | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { OIDC_ERROR_CODES } from "../contracts/oidc.js";
|
|
2
|
+
import { OidcError } from "./errors.js";
|
|
3
|
+
import { isRecord } from "./primitives.js";
|
|
4
|
+
/** The answers that mean the provider is struggling rather than refusing: a server error and a throttle. */
|
|
5
|
+
const isOutage = (status) => status >= 500 || status === 429;
|
|
6
|
+
/**
|
|
7
|
+
* Sends one request through the registration's `OidcHttp` port with its deadline. A lost connection, a timeout, a 5xx and a 429 throw
|
|
8
|
+
* `Unavailable` (a provider outage, never a refusal of the person); any other answer is returned for the caller to judge.
|
|
9
|
+
*/
|
|
10
|
+
export const send = async (options, request) => {
|
|
11
|
+
const response = await options.http.request({ ...request, timeoutMs: options.timeoutMs }).catch((cause) => {
|
|
12
|
+
throw new OidcError(OIDC_ERROR_CODES.Unavailable, { cause });
|
|
13
|
+
});
|
|
14
|
+
if (isOutage(response.status))
|
|
15
|
+
throw new OidcError(OIDC_ERROR_CODES.Unavailable);
|
|
16
|
+
return response;
|
|
17
|
+
};
|
|
18
|
+
/** The OAuth `error` code of an answer (`invalid_grant`), or undefined. Never the description, which may name the person. */
|
|
19
|
+
export const errorCodeOf = (body) => (isRecord(body) && typeof body.error === "string" ? body.error : undefined);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { JWTPayload } from "jose";
|
|
2
|
+
import type { OidcOutcome, OidcPrincipal, PrincipalVerifier } from "../contracts/oidc.js";
|
|
3
|
+
import type { ResolvedOidcOptions } from "./options.js";
|
|
4
|
+
import type { OidcTokenVerifier } from "./tokens.js";
|
|
5
|
+
/** What turning verified claims into a principal reads of a registration. */
|
|
6
|
+
export type PrincipalRules = Pick<ResolvedOidcOptions, "issuer" | "authorizedParty" | "realmRoles" | "roleClients" | "roleClaims">;
|
|
7
|
+
/**
|
|
8
|
+
* The principal verified access-token claims stand for: a bounded subject, an access token type (`Bearer`, or none), the authorized party when
|
|
9
|
+
* the registration names one, and roles of the right shape; any other token is a refusal naming why. Shared by the verifier of the module and
|
|
10
|
+
* the Keycloak issuer preset.
|
|
11
|
+
*/
|
|
12
|
+
export declare const principalOf: (claims: JWTPayload, rules: PrincipalRules) => OidcOutcome<OidcPrincipal>;
|
|
13
|
+
/**
|
|
14
|
+
* The `PrincipalVerifier` of the full edition: a bearer access token of the provider, checked against the discovered issuer's JWKS
|
|
15
|
+
* (algorithm pinned, issuer, audience, expiry), becomes the principal a guard reads. The principal has the shape of `@starci/auth`'s, with
|
|
16
|
+
* the roles the issuer granted added, so guards, `@Public` and `@Credential` do not change between editions.
|
|
17
|
+
*/
|
|
18
|
+
export declare class OidcPrincipalVerifier implements PrincipalVerifier {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(options: ResolvedOidcOptions, tokens: OidcTokenVerifier);
|
|
21
|
+
verify(token: string, now?: Date): Promise<OidcOutcome<OidcPrincipal>>;
|
|
22
|
+
}
|
|
23
|
+
/** 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. */
|
|
24
|
+
export declare const refuseSessions: PrincipalVerifier;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { OIDC_ACCESS_TOKEN_INVALID, OIDC_CLAIMS, OIDC_ROLE, OIDC_SUBJECT_MAX_LENGTH } from "../contracts/oidc.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 principal verified access-token claims stand for: a bounded subject, an access token type (`Bearer`, or none), the authorized party when
|
|
9
|
+
* the registration names one, and roles of the right shape; any other token is a refusal naming why. Shared by the verifier of the module and
|
|
10
|
+
* the Keycloak issuer preset.
|
|
11
|
+
*/
|
|
12
|
+
export const principalOf = (claims, rules) => {
|
|
13
|
+
const subject = text(claims.sub);
|
|
14
|
+
if (subject === undefined || subject.length > OIDC_SUBJECT_MAX_LENGTH || typeof claims.exp !== "number")
|
|
15
|
+
return refuse("claims");
|
|
16
|
+
const type = claims[OIDC_CLAIMS.tokenType];
|
|
17
|
+
if (type !== undefined && type !== ACCESS_TOKEN_TYPE)
|
|
18
|
+
return refuse("token-type");
|
|
19
|
+
if (rules.authorizedParty !== undefined && claims[OIDC_CLAIMS.authorizedParty] !== rules.authorizedParty)
|
|
20
|
+
return refuse("authorized-party");
|
|
21
|
+
const roles = rolesOf(claims, { realm: rules.realmRoles, clients: rules.roleClients, claims: rules.roleClaims });
|
|
22
|
+
if (roles === undefined)
|
|
23
|
+
return refuse("claims");
|
|
24
|
+
const email = text(claims[OIDC_CLAIMS.email]);
|
|
25
|
+
const name = text(claims[OIDC_CLAIMS.name]);
|
|
26
|
+
const username = text(claims[OIDC_CLAIMS.username]);
|
|
27
|
+
return {
|
|
28
|
+
kind: "ok",
|
|
29
|
+
value: {
|
|
30
|
+
id: subject,
|
|
31
|
+
role: OIDC_ROLE,
|
|
32
|
+
sessionId: text(claims[OIDC_CLAIMS.sessionId]) ?? text(claims[OIDC_CLAIMS.tokenId]) ?? subject,
|
|
33
|
+
...(email === undefined ? {} : { email }),
|
|
34
|
+
...(name === undefined ? {} : { name }),
|
|
35
|
+
...(username === undefined ? {} : { username }),
|
|
36
|
+
roles,
|
|
37
|
+
issuer: rules.issuer,
|
|
38
|
+
expiresAt: new Date(claims.exp * 1000),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The `PrincipalVerifier` of the full edition: a bearer access token of the provider, checked against the discovered issuer's JWKS
|
|
44
|
+
* (algorithm pinned, issuer, audience, expiry), becomes the principal a guard reads. The principal has the shape of `@starci/auth`'s, with
|
|
45
|
+
* the roles the issuer granted added, so guards, `@Public` and `@Credential` do not change between editions.
|
|
46
|
+
*/
|
|
47
|
+
export class OidcPrincipalVerifier {
|
|
48
|
+
#options;
|
|
49
|
+
#tokens;
|
|
50
|
+
constructor(options, tokens) {
|
|
51
|
+
this.#options = options;
|
|
52
|
+
this.#tokens = tokens;
|
|
53
|
+
}
|
|
54
|
+
async verify(token, now = this.#options.clock.now()) {
|
|
55
|
+
const verified = await this.#tokens.verify(token, { audience: this.#options.audience }, now);
|
|
56
|
+
if (verified.kind === "refused")
|
|
57
|
+
return verified;
|
|
58
|
+
return principalOf(verified.value, this.#options);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** 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. */
|
|
62
|
+
export const refuseSessions = {
|
|
63
|
+
verify: () => Promise.resolve(refuse("claims")),
|
|
64
|
+
};
|
|
@@ -17,7 +17,7 @@ export type AuthRegisterOptions = typeof OPTIONS_TYPE;
|
|
|
17
17
|
export type AuthRegisterAsyncOptions = typeof ASYNC_OPTIONS_TYPE;
|
|
18
18
|
/**
|
|
19
19
|
* The sign-in of a lite app as one Nest capability: the token signer and verifier, the passwords, the sign-in budget, the
|
|
20
|
-
* rotating sessions, the refresh cookie, the passport flows (local and
|
|
20
|
+
* rotating sessions, the refresh cookie, the passport flows (local, and each sign-in provider with state and PKCE) and the doors that run them.
|
|
21
21
|
* Registered once at the api root; the app implements the stores (`SessionStore`, `AccountDirectory`, `RateCounter`) and the
|
|
22
22
|
* `AuthAuthenticator` over its own tables.
|
|
23
23
|
*/
|
|
@@ -2,15 +2,16 @@ import { ConfigurableModuleBuilder, Module } from "@nestjs/common";
|
|
|
2
2
|
import { AuthDoors } from "./doors.js";
|
|
3
3
|
import { RefreshCookie } from "./cookie.js";
|
|
4
4
|
import { AuthFlows } from "./flows.js";
|
|
5
|
-
import { GoogleStateClient } from "./google-state.js";
|
|
6
5
|
import { AuthPasswords } from "./passwords.js";
|
|
6
|
+
import { GOOGLE_PROVIDER_ID, GoogleStateClient } from "./providers/google.js";
|
|
7
|
+
import { buildProviders, providersOf } from "./providers/registry.js";
|
|
7
8
|
import { randomIds, systemClock } from "./runtime.js";
|
|
8
9
|
import { AuthSessions } from "./sessions.js";
|
|
9
|
-
import {
|
|
10
|
+
import { PassportLocalStrategy } from "./strategies.js";
|
|
10
11
|
import { AuthTokens } from "./tokens.js";
|
|
11
12
|
import { AuthPrincipalVerifier } from "./verifier.js";
|
|
12
13
|
import { SignInBudget } from "./budget.js";
|
|
13
|
-
import { AUTH_AUTHENTICATOR, AUTH_DOORS_SERVICE, AUTH_FLOWS, AUTH_GOOGLE_STATE, AUTH_GOOGLE_STRATEGY, AUTH_LOCAL_STRATEGY, AUTH_PASSWORDS, AUTH_PRINCIPAL_VERIFIER, AUTH_REFRESH_COOKIE, AUTH_SESSIONS, AUTH_SIGN_IN_BUDGET, AUTH_TOKENS, } from "./auth.tokens.js";
|
|
14
|
+
import { AUTH_AUTHENTICATOR, AUTH_DOORS_SERVICE, AUTH_FLOWS, AUTH_GOOGLE_STATE, AUTH_GOOGLE_STRATEGY, AUTH_LOCAL_STRATEGY, AUTH_PASSWORDS, AUTH_PRINCIPAL_VERIFIER, AUTH_PROVIDERS, AUTH_REFRESH_COOKIE, AUTH_SESSIONS, AUTH_SIGN_IN_BUDGET, AUTH_TOKENS, } from "./auth.tokens.js";
|
|
14
15
|
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = new ConfigurableModuleBuilder({ moduleName: "Auth" })
|
|
15
16
|
.setClassMethodName("register")
|
|
16
17
|
.setFactoryMethodName("createAuthOptions")
|
|
@@ -44,10 +45,23 @@ const providers = (authenticator) => [
|
|
|
44
45
|
inject: [AUTH_AUTHENTICATOR],
|
|
45
46
|
useFactory: (authenticator) => new PassportLocalStrategy(authenticator),
|
|
46
47
|
},
|
|
48
|
+
{
|
|
49
|
+
provide: AUTH_PROVIDERS,
|
|
50
|
+
inject: [MODULE_OPTIONS_TOKEN, AUTH_AUTHENTICATOR, AUTH_TOKENS, AUTH_GOOGLE_STATE],
|
|
51
|
+
useFactory: (options, authenticator, tokens, googleState) => buildProviders({
|
|
52
|
+
providers: providersOf(options),
|
|
53
|
+
authenticator,
|
|
54
|
+
tokens,
|
|
55
|
+
clock: options.clock ?? systemClock,
|
|
56
|
+
ids: options.ids ?? randomIds,
|
|
57
|
+
returnOrigin: options.returnOrigin,
|
|
58
|
+
googleState,
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
47
61
|
{
|
|
48
62
|
provide: AUTH_GOOGLE_STRATEGY,
|
|
49
|
-
inject: [
|
|
50
|
-
useFactory: (
|
|
63
|
+
inject: [AUTH_PROVIDERS],
|
|
64
|
+
useFactory: (entries) => entries.get(GOOGLE_PROVIDER_ID)?.strategy ?? null,
|
|
51
65
|
},
|
|
52
66
|
{
|
|
53
67
|
provide: AUTH_DOORS_SERVICE,
|
|
@@ -68,7 +82,7 @@ const withProviders = (base, authenticator) => ({
|
|
|
68
82
|
});
|
|
69
83
|
/**
|
|
70
84
|
* The sign-in of a lite app as one Nest capability: the token signer and verifier, the passwords, the sign-in budget, the
|
|
71
|
-
* rotating sessions, the refresh cookie, the passport flows (local and
|
|
85
|
+
* rotating sessions, the refresh cookie, the passport flows (local, and each sign-in provider with state and PKCE) and the doors that run them.
|
|
72
86
|
* Registered once at the api root; the app implements the stores (`SessionStore`, `AccountDirectory`, `RateCounter`) and the
|
|
73
87
|
* `AuthAuthenticator` over its own tables.
|
|
74
88
|
*/
|
|
@@ -18,6 +18,8 @@ export declare const AUTH_FLOWS: unique symbol;
|
|
|
18
18
|
export declare const AUTH_LOCAL_STRATEGY: unique symbol;
|
|
19
19
|
/** Token of the Google passport strategy, or null when the deployment has no Google client. */
|
|
20
20
|
export declare const AUTH_GOOGLE_STRATEGY: unique symbol;
|
|
21
|
+
/** Token of the registered sign-in providers: each one's definition, state store and passport strategy, by provider id. */
|
|
22
|
+
export declare const AUTH_PROVIDERS: unique symbol;
|
|
21
23
|
/** Token of `AuthDoors`, what a controller calls. */
|
|
22
24
|
export declare const AUTH_DOORS_SERVICE: unique symbol;
|
|
23
25
|
/** Token of the `PrincipalVerifier` the identity domain injects. */
|
|
@@ -18,6 +18,8 @@ export const AUTH_FLOWS = Symbol.for("@starci/auth/flows");
|
|
|
18
18
|
export const AUTH_LOCAL_STRATEGY = Symbol.for("@starci/auth/local-strategy");
|
|
19
19
|
/** Token of the Google passport strategy, or null when the deployment has no Google client. */
|
|
20
20
|
export const AUTH_GOOGLE_STRATEGY = Symbol.for("@starci/auth/google-strategy");
|
|
21
|
+
/** Token of the registered sign-in providers: each one's definition, state store and passport strategy, by provider id. */
|
|
22
|
+
export const AUTH_PROVIDERS = Symbol.for("@starci/auth/providers");
|
|
21
23
|
/** Token of `AuthDoors`, what a controller calls. */
|
|
22
24
|
export const AUTH_DOORS_SERVICE = Symbol.for("@starci/auth/doors");
|
|
23
25
|
/** Token of the `PrincipalVerifier` the identity domain injects. */
|
package/dist/server/doors.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import type { AccessGrant, GoogleRedirect } from "../contracts/index.js";
|
|
1
|
+
import type { AccessGrant, GoogleRedirect, ProviderRedirect } from "../contracts/index.js";
|
|
2
2
|
import type { RefreshCookie } from "./cookie.js";
|
|
3
3
|
import type { AuthFlows } from "./flows.js";
|
|
4
4
|
import type { HttpExchange } from "./http.js";
|
|
5
5
|
import type { AuthLogger, SessionTokens } from "./ports.js";
|
|
6
6
|
import type { AuthSessions } from "./sessions.js";
|
|
7
|
-
/** The status of
|
|
7
|
+
/** The status of a provider's callback redirect. */
|
|
8
8
|
export declare const SEE_OTHER = 303;
|
|
9
|
+
/** The log event of a failed sign-in through one provider (`auth.github-sign-in-failed`). */
|
|
10
|
+
export declare const providerSignInFailedEvent: (provider: string) => string;
|
|
9
11
|
/** The log event of a failed Google sign-in. */
|
|
10
|
-
export declare const GOOGLE_SIGN_IN_FAILED_EVENT
|
|
12
|
+
export declare const GOOGLE_SIGN_IN_FAILED_EVENT: string;
|
|
11
13
|
/** The body a sign-in, refresh or session door answers. */
|
|
12
14
|
export declare const accessOf: (tokens: SessionTokens) => AccessGrant;
|
|
13
15
|
/** What the doors need. */
|
|
@@ -33,12 +35,16 @@ export declare class AuthDoors {
|
|
|
33
35
|
session(exchange: HttpExchange): Promise<AccessGrant>;
|
|
34
36
|
/** Ends the session of the cookie and clears it. */
|
|
35
37
|
logout(exchange: HttpExchange): Promise<void>;
|
|
36
|
-
/** Sends the browser to the
|
|
37
|
-
|
|
38
|
+
/** Sends the browser to the consent screen of a registered provider (`/auth/<id>`); an unregistered one is refused. */
|
|
39
|
+
beginProvider(provider: string, exchange: HttpExchange): Promise<void>;
|
|
38
40
|
/**
|
|
39
|
-
* Finishes
|
|
40
|
-
* and its cookie set. A failure never throws: the browser is sent back to the sign-in page, because a
|
|
41
|
-
* nowhere to show an error body.
|
|
41
|
+
* Finishes a provider's round trip (`/auth/<id>/callback`): state, PKCE and profile are verified, the account found, linked or
|
|
42
|
+
* created, its session opened and its cookie set. A failure never throws: the browser is sent back to the sign-in page, because a
|
|
43
|
+
* redirect-only door has nowhere to show an error body.
|
|
42
44
|
*/
|
|
45
|
+
completeProvider(provider: string, exchange: HttpExchange): Promise<ProviderRedirect>;
|
|
46
|
+
/** Sends the browser to the Google consent screen. */
|
|
47
|
+
beginGoogle(exchange: HttpExchange): Promise<void>;
|
|
48
|
+
/** Finishes the Google round trip, as `completeProvider("google", ...)`. */
|
|
43
49
|
completeGoogle(exchange: HttpExchange): Promise<GoogleRedirect>;
|
|
44
50
|
}
|
package/dist/server/doors.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
import { GOOGLE_PROVIDER_ID } from "./providers/google.js";
|
|
2
|
+
/** The status of a provider's callback redirect. */
|
|
2
3
|
export const SEE_OTHER = 303;
|
|
4
|
+
/** The log event of a failed sign-in through one provider (`auth.github-sign-in-failed`). */
|
|
5
|
+
export const providerSignInFailedEvent = (provider) => `auth.${provider}-sign-in-failed`;
|
|
3
6
|
/** The log event of a failed Google sign-in. */
|
|
4
|
-
export const GOOGLE_SIGN_IN_FAILED_EVENT =
|
|
7
|
+
export const GOOGLE_SIGN_IN_FAILED_EVENT = providerSignInFailedEvent(GOOGLE_PROVIDER_ID);
|
|
5
8
|
/** The body a sign-in, refresh or session door answers. */
|
|
6
9
|
export const accessOf = (tokens) => ({
|
|
7
10
|
accessToken: tokens.accessToken,
|
|
@@ -46,26 +49,34 @@ export class AuthDoors {
|
|
|
46
49
|
await this.#deps.sessions.end(this.#deps.cookie.read(exchange.req));
|
|
47
50
|
this.#deps.cookie.clear(exchange.res, exchange.req);
|
|
48
51
|
}
|
|
49
|
-
/** Sends the browser to the
|
|
50
|
-
|
|
51
|
-
return this.#deps.flows.
|
|
52
|
+
/** Sends the browser to the consent screen of a registered provider (`/auth/<id>`); an unregistered one is refused. */
|
|
53
|
+
beginProvider(provider, exchange) {
|
|
54
|
+
return this.#deps.flows.begin(provider, exchange);
|
|
52
55
|
}
|
|
53
56
|
/**
|
|
54
|
-
* Finishes
|
|
55
|
-
* and its cookie set. A failure never throws: the browser is sent back to the sign-in page, because a
|
|
56
|
-
* nowhere to show an error body.
|
|
57
|
+
* Finishes a provider's round trip (`/auth/<id>/callback`): state, PKCE and profile are verified, the account found, linked or
|
|
58
|
+
* created, its session opened and its cookie set. A failure never throws: the browser is sent back to the sign-in page, because a
|
|
59
|
+
* redirect-only door has nowhere to show an error body.
|
|
57
60
|
*/
|
|
58
|
-
async
|
|
61
|
+
async completeProvider(provider, exchange) {
|
|
59
62
|
try {
|
|
60
|
-
const completion = await this.#deps.flows.
|
|
63
|
+
const completion = await this.#deps.flows.complete(provider, exchange);
|
|
61
64
|
const tokens = await this.#deps.sessions.start(completion.subject, this.#deps.cookie.deviceOf(exchange.req));
|
|
62
65
|
this.#deps.cookie.issue(exchange.res, tokens, exchange.req);
|
|
63
66
|
return { url: `${this.#deps.returnOrigin}${completion.next}`, statusCode: SEE_OTHER };
|
|
64
67
|
}
|
|
65
68
|
catch (cause) {
|
|
66
69
|
this.#deps.cookie.clear(exchange.res, exchange.req);
|
|
67
|
-
this.#deps.logger?.warn(
|
|
70
|
+
this.#deps.logger?.warn(providerSignInFailedEvent(provider), { cause: String(cause) });
|
|
68
71
|
return { url: `${this.#deps.returnOrigin}/sign-in?auth=error`, statusCode: SEE_OTHER };
|
|
69
72
|
}
|
|
70
73
|
}
|
|
74
|
+
/** Sends the browser to the Google consent screen. */
|
|
75
|
+
beginGoogle(exchange) {
|
|
76
|
+
return this.beginProvider(GOOGLE_PROVIDER_ID, exchange);
|
|
77
|
+
}
|
|
78
|
+
/** Finishes the Google round trip, as `completeProvider("google", ...)`. */
|
|
79
|
+
completeGoogle(exchange) {
|
|
80
|
+
return this.completeProvider(GOOGLE_PROVIDER_ID, exchange);
|
|
81
|
+
}
|
|
71
82
|
}
|