@facilis.dev/auth 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/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/api-clients.d.ts +71 -0
- package/dist/api-clients.js +186 -0
- package/dist/crypto.d.ts +31 -0
- package/dist/crypto.js +76 -0
- package/dist/email-verification.d.ts +45 -0
- package/dist/email-verification.js +72 -0
- package/dist/email.d.ts +7 -0
- package/dist/email.js +9 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/password.d.ts +7 -0
- package/dist/password.js +54 -0
- package/dist/roles.d.ts +40 -0
- package/dist/roles.js +46 -0
- package/dist/seed.d.ts +28 -0
- package/dist/seed.js +115 -0
- package/dist/session.d.ts +66 -0
- package/dist/session.js +161 -0
- package/dist/sso/contracts.d.ts +52 -0
- package/dist/sso/contracts.js +8 -0
- package/dist/sso/errors.d.ts +22 -0
- package/dist/sso/errors.js +45 -0
- package/dist/sso/identity.d.ts +41 -0
- package/dist/sso/identity.js +162 -0
- package/dist/sso/index.d.ts +27 -0
- package/dist/sso/index.js +16 -0
- package/dist/sso/login.d.ts +60 -0
- package/dist/sso/login.js +209 -0
- package/dist/sso/oauth2.d.ts +52 -0
- package/dist/sso/oauth2.js +180 -0
- package/dist/sso/oidc.d.ts +60 -0
- package/dist/sso/oidc.js +201 -0
- package/dist/sso/paths.d.ts +14 -0
- package/dist/sso/paths.js +17 -0
- package/dist/sso/pkce.d.ts +6 -0
- package/dist/sso/pkce.js +21 -0
- package/dist/sso/providers.d.ts +100 -0
- package/dist/sso/providers.js +70 -0
- package/dist/sso/saml.d.ts +44 -0
- package/dist/sso/saml.js +159 -0
- package/dist/sso/state.d.ts +34 -0
- package/dist/sso/state.js +91 -0
- package/dist/sso/tables.d.ts +306 -0
- package/dist/sso/tables.js +59 -0
- package/dist/tables.d.ts +769 -0
- package/dist/tables.js +148 -0
- package/package.json +45 -4
- package/src/api-clients.ts +261 -0
- package/src/crypto.ts +101 -0
- package/src/email-verification.ts +107 -0
- package/src/email.ts +9 -0
- package/src/index.ts +63 -0
- package/src/password.ts +74 -0
- package/src/roles.ts +77 -0
- package/src/seed.ts +146 -0
- package/src/session.ts +209 -0
- package/src/sso/contracts.ts +71 -0
- package/src/sso/errors.ts +53 -0
- package/src/sso/identity.ts +220 -0
- package/src/sso/index.ts +105 -0
- package/src/sso/login.ts +296 -0
- package/src/sso/oauth2.ts +235 -0
- package/src/sso/oidc.ts +285 -0
- package/src/sso/paths.ts +21 -0
- package/src/sso/pkce.ts +30 -0
- package/src/sso/providers.ts +176 -0
- package/src/sso/saml.ts +222 -0
- package/src/sso/state.ts +114 -0
- package/src/sso/tables.ts +77 -0
- package/src/tables.ts +214 -0
- package/src/test-support.ts +149 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { runtimeFetch } from "./contracts.js";
|
|
2
|
+
import { SsoError } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* Generic OAuth2 authorization-code provider (D-120).
|
|
5
|
+
*
|
|
6
|
+
* Unlike OIDC there is no discovery or ID token, so the identity comes from the userinfo endpoint
|
|
7
|
+
* plus a dotted-path mapping declared in facilis.config.ts. Email verification is claim-driven:
|
|
8
|
+
* a provider that never reports the claim must opt in with `assumeEmailVerified`.
|
|
9
|
+
*/
|
|
10
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/;
|
|
11
|
+
function requireFetch(fetch) {
|
|
12
|
+
return fetch ?? runtimeFetch();
|
|
13
|
+
}
|
|
14
|
+
function basicAuthorization(clientId, clientSecret) {
|
|
15
|
+
const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`);
|
|
16
|
+
let binary = "";
|
|
17
|
+
for (const byte of bytes)
|
|
18
|
+
binary += String.fromCharCode(byte);
|
|
19
|
+
return `Basic ${btoa(binary)}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* POSTs an authorization-code grant to the token endpoint with the configured client auth mode.
|
|
23
|
+
*
|
|
24
|
+
* The response is parsed for `access_token`/`id_token` only; a provider error body is never
|
|
25
|
+
* surfaced (it can echo the code), just the safe `sso_exchange_failed` code.
|
|
26
|
+
*/
|
|
27
|
+
export async function requestToken(input) {
|
|
28
|
+
const form = new URLSearchParams(input.form);
|
|
29
|
+
form.set("client_id", input.client.clientId);
|
|
30
|
+
const headers = {
|
|
31
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
32
|
+
accept: "application/json",
|
|
33
|
+
};
|
|
34
|
+
if (input.client.tokenAuth === "client_secret_basic") {
|
|
35
|
+
if (input.client.clientSecret === undefined)
|
|
36
|
+
throw new SsoError("sso_provider_unavailable");
|
|
37
|
+
headers["authorization"] = basicAuthorization(input.client.clientId, input.client.clientSecret);
|
|
38
|
+
}
|
|
39
|
+
else if (input.client.tokenAuth === "client_secret_post") {
|
|
40
|
+
if (input.client.clientSecret === undefined)
|
|
41
|
+
throw new SsoError("sso_provider_unavailable");
|
|
42
|
+
form.set("client_secret", input.client.clientSecret);
|
|
43
|
+
}
|
|
44
|
+
let response;
|
|
45
|
+
try {
|
|
46
|
+
response = await requireFetch(input.fetch)(input.endpoint, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers,
|
|
49
|
+
body: form.toString(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw new SsoError("sso_exchange_failed", "token request failed", { cause: error });
|
|
54
|
+
}
|
|
55
|
+
if (!response.ok)
|
|
56
|
+
throw new SsoError("sso_exchange_failed", `token endpoint ${response.status}`);
|
|
57
|
+
let payload;
|
|
58
|
+
try {
|
|
59
|
+
payload = await response.json();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
throw new SsoError("sso_exchange_failed", "token response is not JSON", { cause: error });
|
|
63
|
+
}
|
|
64
|
+
if (typeof payload !== "object" || payload === null) {
|
|
65
|
+
throw new SsoError("sso_exchange_failed", "token response shape");
|
|
66
|
+
}
|
|
67
|
+
const record = payload;
|
|
68
|
+
const accessToken = readString(record, "access_token");
|
|
69
|
+
if (accessToken === undefined)
|
|
70
|
+
throw new SsoError("sso_exchange_failed", "no access_token");
|
|
71
|
+
const idToken = readString(record, "id_token");
|
|
72
|
+
return idToken === undefined ? { accessToken } : { accessToken, idToken };
|
|
73
|
+
}
|
|
74
|
+
/** Reads a dotted path (`data.user.email`) out of parsed userinfo JSON. */
|
|
75
|
+
export function readDottedPath(value, path) {
|
|
76
|
+
let current = value;
|
|
77
|
+
for (const segment of path.split(".")) {
|
|
78
|
+
if (typeof current !== "object" || current === null)
|
|
79
|
+
return undefined;
|
|
80
|
+
current = current[segment];
|
|
81
|
+
}
|
|
82
|
+
return current;
|
|
83
|
+
}
|
|
84
|
+
function readString(record, key) {
|
|
85
|
+
const value = record[key];
|
|
86
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
87
|
+
}
|
|
88
|
+
/** Truthiness of a verification claim: boolean true or the string "true" only. */
|
|
89
|
+
export function readVerifiedClaim(value) {
|
|
90
|
+
if (value === true || value === "true")
|
|
91
|
+
return true;
|
|
92
|
+
if (value === false || value === "false")
|
|
93
|
+
return false;
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
function requireString(value) {
|
|
97
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
98
|
+
}
|
|
99
|
+
/** Subjects are often numeric in OAuth2 userinfo (e.g. a GitHub `id`). */
|
|
100
|
+
function requireScalar(value) {
|
|
101
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
102
|
+
return String(value);
|
|
103
|
+
return requireString(value);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The authorization URL: response_type=code, the configured scopes, state, PKCE S256 and the
|
|
107
|
+
* redirect URI. PKCE is sent unconditionally (RFC 6749 lets a provider ignore it) so a public
|
|
108
|
+
* `tokenAuth: "none"` client is protected exactly like the OIDC flow (D-120).
|
|
109
|
+
*/
|
|
110
|
+
export function buildOauth2AuthorizeUrl(provider, redirectUri, state, codeChallenge) {
|
|
111
|
+
const url = new URL(provider.authorizationEndpoint);
|
|
112
|
+
url.searchParams.set("response_type", "code");
|
|
113
|
+
url.searchParams.set("client_id", provider.clientId);
|
|
114
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
115
|
+
url.searchParams.set("state", state);
|
|
116
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
117
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
118
|
+
if (provider.scopes.length > 0)
|
|
119
|
+
url.searchParams.set("scope", provider.scopes.join(" "));
|
|
120
|
+
return url.toString();
|
|
121
|
+
}
|
|
122
|
+
export function exchangeOauth2Code(client, provider, input) {
|
|
123
|
+
return requestToken({
|
|
124
|
+
endpoint: provider.tokenEndpoint,
|
|
125
|
+
client,
|
|
126
|
+
form: {
|
|
127
|
+
grant_type: "authorization_code",
|
|
128
|
+
code: input.code,
|
|
129
|
+
redirect_uri: input.redirectUri,
|
|
130
|
+
code_verifier: input.codeVerifier,
|
|
131
|
+
},
|
|
132
|
+
...(input.fetch === undefined ? {} : { fetch: input.fetch }),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async function fetchUserinfo(provider, accessToken, fetch) {
|
|
136
|
+
let response;
|
|
137
|
+
try {
|
|
138
|
+
response = await fetch(provider.userinfoEndpoint, {
|
|
139
|
+
method: "GET",
|
|
140
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
throw new SsoError("sso_exchange_failed", "userinfo request failed", { cause: error });
|
|
145
|
+
}
|
|
146
|
+
if (!response.ok)
|
|
147
|
+
throw new SsoError("sso_exchange_failed", `userinfo ${response.status}`);
|
|
148
|
+
let payload;
|
|
149
|
+
try {
|
|
150
|
+
payload = await response.json();
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
throw new SsoError("sso_exchange_failed", "userinfo is not JSON", { cause: error });
|
|
154
|
+
}
|
|
155
|
+
if (typeof payload !== "object" || payload === null) {
|
|
156
|
+
throw new SsoError("sso_exchange_failed", "userinfo shape");
|
|
157
|
+
}
|
|
158
|
+
return payload;
|
|
159
|
+
}
|
|
160
|
+
/** Maps the userinfo JSON onto the identity key via the configured dotted paths. */
|
|
161
|
+
export function resolveOauth2Identity(provider, userinfo) {
|
|
162
|
+
const subject = requireScalar(readDottedPath(userinfo, provider.mapping.subject));
|
|
163
|
+
if (subject === undefined)
|
|
164
|
+
throw new SsoError("sso_identity_invalid", "no subject mapping");
|
|
165
|
+
const rawEmail = requireString(readDottedPath(userinfo, provider.mapping.email));
|
|
166
|
+
if (rawEmail === undefined || !EMAIL_PATTERN.test(rawEmail)) {
|
|
167
|
+
throw new SsoError("sso_identity_invalid", "no email mapping");
|
|
168
|
+
}
|
|
169
|
+
const claim = provider.mapping.emailVerified === undefined
|
|
170
|
+
? undefined
|
|
171
|
+
: readVerifiedClaim(readDottedPath(userinfo, provider.mapping.emailVerified));
|
|
172
|
+
const emailVerified = claim ?? provider.assumeEmailVerified;
|
|
173
|
+
return { subject, email: rawEmail, emailVerified };
|
|
174
|
+
}
|
|
175
|
+
/** Token exchange + userinfo + mapping, the complete generic-OAuth2 identity resolution. */
|
|
176
|
+
export async function resolveOauth2IdentityFromCode(client, provider, input) {
|
|
177
|
+
const token = await exchangeOauth2Code(client, provider, input);
|
|
178
|
+
const userinfo = await fetchUserinfo(provider, token.accessToken, input.fetch ?? runtimeFetch());
|
|
179
|
+
return resolveOauth2Identity(provider, userinfo);
|
|
180
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type JWTVerifyGetKey } from "jose";
|
|
2
|
+
import { type FetchLike, type IdentityClaims } from "./contracts.js";
|
|
3
|
+
import { type TokenPayload } from "./oauth2.js";
|
|
4
|
+
import type { OidcProviderConfig } from "./providers.js";
|
|
5
|
+
/**
|
|
6
|
+
* OpenID Connect authorization code flow with PKCE and nonce (D-120).
|
|
7
|
+
*
|
|
8
|
+
* Discovery is fetched once per provider and cached with a TTL; the ID token is verified with
|
|
9
|
+
* `jose` against the provider JWKS, the issuer, the client id and the nonce. Tests inject the
|
|
10
|
+
* fetch (discovery) and the JWKS resolver (`createLocalJWKSet`), so the suite needs no network.
|
|
11
|
+
*/
|
|
12
|
+
export declare const OIDC_DISCOVERY_TTL_MS: number;
|
|
13
|
+
export interface OidcDiscovery {
|
|
14
|
+
readonly issuer: string;
|
|
15
|
+
readonly authorizationEndpoint: string;
|
|
16
|
+
readonly tokenEndpoint: string;
|
|
17
|
+
readonly userinfoEndpoint?: string;
|
|
18
|
+
readonly jwksUri: string;
|
|
19
|
+
}
|
|
20
|
+
export interface OidcDeps {
|
|
21
|
+
readonly fetch?: FetchLike;
|
|
22
|
+
readonly now?: () => number;
|
|
23
|
+
readonly jwksResolver?: (jwksUri: string) => JWTVerifyGetKey;
|
|
24
|
+
readonly discoveryTtlMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Clears the per-provider discovery cache (tests, and a deliberate provider reconfiguration). */
|
|
27
|
+
export declare function clearOidcDiscoveryCache(): void;
|
|
28
|
+
/** Fetches and caches `<issuer>/.well-known/openid-configuration` for one provider. */
|
|
29
|
+
export declare function discoverOidc(provider: OidcProviderConfig, deps?: OidcDeps): Promise<OidcDiscovery>;
|
|
30
|
+
export interface OidcAuthorizeInput {
|
|
31
|
+
readonly redirectUri: string;
|
|
32
|
+
readonly state: string;
|
|
33
|
+
readonly nonce: string;
|
|
34
|
+
readonly codeChallenge: string;
|
|
35
|
+
}
|
|
36
|
+
/** The authorization URL: response_type=code, scopes, state, nonce and PKCE S256. */
|
|
37
|
+
export declare function buildOidcAuthorizeUrl(provider: OidcProviderConfig, discovery: OidcDiscovery, input: OidcAuthorizeInput): string;
|
|
38
|
+
export interface OidcTokenInput {
|
|
39
|
+
readonly code: string;
|
|
40
|
+
readonly redirectUri: string;
|
|
41
|
+
readonly codeVerifier: string;
|
|
42
|
+
readonly fetch?: FetchLike;
|
|
43
|
+
}
|
|
44
|
+
export declare function exchangeOidcCode(client: {
|
|
45
|
+
clientId: string;
|
|
46
|
+
clientSecret?: string;
|
|
47
|
+
tokenAuth: OidcProviderConfig["tokenAuth"];
|
|
48
|
+
}, discovery: OidcDiscovery, input: OidcTokenInput): Promise<TokenPayload>;
|
|
49
|
+
export interface OidcClaims {
|
|
50
|
+
readonly subject: string;
|
|
51
|
+
readonly email?: string;
|
|
52
|
+
readonly emailVerified: boolean;
|
|
53
|
+
}
|
|
54
|
+
/** Verifies an ID token: signature (JWKS), issuer, audience, expiry (jose) and the nonce. */
|
|
55
|
+
export declare function verifyOidcIdToken(provider: OidcProviderConfig, discovery: OidcDiscovery, idToken: string, nonce: string, deps?: OidcDeps): Promise<OidcClaims>;
|
|
56
|
+
/**
|
|
57
|
+
* Resolves the OIDC identity: ID-token claims, with a userinfo fallback only when the email
|
|
58
|
+
* claim is absent. The userinfo `sub` must agree with the verified ID-token subject.
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveOidcIdentity(provider: OidcProviderConfig, discovery: OidcDiscovery, token: TokenPayload, nonce: string, deps?: OidcDeps): Promise<IdentityClaims>;
|
package/dist/sso/oidc.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
2
|
+
import { runtimeFetch, } from "./contracts.js";
|
|
3
|
+
import { SsoError } from "./errors.js";
|
|
4
|
+
import { requestToken, readVerifiedClaim } from "./oauth2.js";
|
|
5
|
+
/**
|
|
6
|
+
* OpenID Connect authorization code flow with PKCE and nonce (D-120).
|
|
7
|
+
*
|
|
8
|
+
* Discovery is fetched once per provider and cached with a TTL; the ID token is verified with
|
|
9
|
+
* `jose` against the provider JWKS, the issuer, the client id and the nonce. Tests inject the
|
|
10
|
+
* fetch (discovery) and the JWKS resolver (`createLocalJWKSet`), so the suite needs no network.
|
|
11
|
+
*/
|
|
12
|
+
export const OIDC_DISCOVERY_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/;
|
|
14
|
+
const discoveryCache = new Map();
|
|
15
|
+
/** Clears the per-provider discovery cache (tests, and a deliberate provider reconfiguration). */
|
|
16
|
+
export function clearOidcDiscoveryCache() {
|
|
17
|
+
discoveryCache.clear();
|
|
18
|
+
}
|
|
19
|
+
function readRecord(value) {
|
|
20
|
+
return typeof value === "object" && value !== null ? value : null;
|
|
21
|
+
}
|
|
22
|
+
function readUrl(record, key) {
|
|
23
|
+
const value = record[key];
|
|
24
|
+
if (typeof value !== "string")
|
|
25
|
+
return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(value);
|
|
28
|
+
return url.protocol === "https:" || url.protocol === "http:" ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function discoveryUrl(issuer) {
|
|
35
|
+
return `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
|
|
36
|
+
}
|
|
37
|
+
function parseDiscovery(value) {
|
|
38
|
+
const record = readRecord(value);
|
|
39
|
+
if (record === null)
|
|
40
|
+
throw new SsoError("sso_provider_unavailable", "discovery shape");
|
|
41
|
+
const authorizationEndpoint = readUrl(record, "authorization_endpoint");
|
|
42
|
+
const tokenEndpoint = readUrl(record, "token_endpoint");
|
|
43
|
+
const jwksUri = readUrl(record, "jwks_uri");
|
|
44
|
+
const issuer = typeof record["issuer"] === "string" ? record["issuer"] : "";
|
|
45
|
+
if (authorizationEndpoint === undefined || tokenEndpoint === undefined || jwksUri === undefined) {
|
|
46
|
+
throw new SsoError("sso_provider_unavailable", "discovery is missing an endpoint");
|
|
47
|
+
}
|
|
48
|
+
const userinfoEndpoint = readUrl(record, "userinfo_endpoint");
|
|
49
|
+
return {
|
|
50
|
+
issuer,
|
|
51
|
+
authorizationEndpoint,
|
|
52
|
+
tokenEndpoint,
|
|
53
|
+
jwksUri,
|
|
54
|
+
...(userinfoEndpoint === undefined ? {} : { userinfoEndpoint }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Fetches and caches `<issuer>/.well-known/openid-configuration` for one provider. */
|
|
58
|
+
export async function discoverOidc(provider, deps = {}) {
|
|
59
|
+
const now = deps.now?.() ?? Date.now();
|
|
60
|
+
const ttl = deps.discoveryTtlMs ?? OIDC_DISCOVERY_TTL_MS;
|
|
61
|
+
const key = `${provider.id}\u0000${provider.issuer}`;
|
|
62
|
+
const cached = discoveryCache.get(key);
|
|
63
|
+
if (cached !== undefined && now - cached.fetchedAt < ttl)
|
|
64
|
+
return cached.value;
|
|
65
|
+
const fetch = deps.fetch ?? runtimeFetch();
|
|
66
|
+
let response;
|
|
67
|
+
try {
|
|
68
|
+
response = await fetch(discoveryUrl(provider.issuer), {
|
|
69
|
+
method: "GET",
|
|
70
|
+
headers: { accept: "application/json" },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw new SsoError("sso_provider_unavailable", "discovery request failed", { cause: error });
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
throw new SsoError("sso_provider_unavailable", `discovery ${response.status}`);
|
|
78
|
+
}
|
|
79
|
+
let payload;
|
|
80
|
+
try {
|
|
81
|
+
payload = await response.json();
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw new SsoError("sso_provider_unavailable", "discovery is not JSON", { cause: error });
|
|
85
|
+
}
|
|
86
|
+
const discovery = parseDiscovery(payload);
|
|
87
|
+
discoveryCache.set(key, { value: discovery, fetchedAt: now });
|
|
88
|
+
return discovery;
|
|
89
|
+
}
|
|
90
|
+
/** The authorization URL: response_type=code, scopes, state, nonce and PKCE S256. */
|
|
91
|
+
export function buildOidcAuthorizeUrl(provider, discovery, input) {
|
|
92
|
+
const url = new URL(discovery.authorizationEndpoint);
|
|
93
|
+
url.searchParams.set("response_type", "code");
|
|
94
|
+
url.searchParams.set("client_id", provider.clientId);
|
|
95
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
96
|
+
url.searchParams.set("scope", provider.scopes.join(" "));
|
|
97
|
+
url.searchParams.set("state", input.state);
|
|
98
|
+
url.searchParams.set("nonce", input.nonce);
|
|
99
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
100
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
101
|
+
return url.toString();
|
|
102
|
+
}
|
|
103
|
+
export function exchangeOidcCode(client, discovery, input) {
|
|
104
|
+
return requestToken({
|
|
105
|
+
endpoint: discovery.tokenEndpoint,
|
|
106
|
+
client,
|
|
107
|
+
form: {
|
|
108
|
+
grant_type: "authorization_code",
|
|
109
|
+
code: input.code,
|
|
110
|
+
redirect_uri: input.redirectUri,
|
|
111
|
+
code_verifier: input.codeVerifier,
|
|
112
|
+
},
|
|
113
|
+
...(input.fetch === undefined ? {} : { fetch: input.fetch }),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function readString(record, key) {
|
|
117
|
+
const value = record[key];
|
|
118
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
119
|
+
}
|
|
120
|
+
/** Verifies an ID token: signature (JWKS), issuer, audience, expiry (jose) and the nonce. */
|
|
121
|
+
export async function verifyOidcIdToken(provider, discovery, idToken, nonce, deps = {}) {
|
|
122
|
+
try {
|
|
123
|
+
const resolver = deps.jwksResolver?.(discovery.jwksUri) ?? createRemoteJWKSet(new URL(discovery.jwksUri));
|
|
124
|
+
const { payload } = await jwtVerify(idToken, resolver, {
|
|
125
|
+
issuer: provider.issuer,
|
|
126
|
+
audience: provider.clientId,
|
|
127
|
+
});
|
|
128
|
+
const tokenNonce = payload["nonce"];
|
|
129
|
+
if (typeof tokenNonce !== "string" || tokenNonce !== nonce) {
|
|
130
|
+
throw new SsoError("sso_identity_invalid", "id_token nonce mismatch");
|
|
131
|
+
}
|
|
132
|
+
const subject = payload.sub;
|
|
133
|
+
if (typeof subject !== "string" || subject.length === 0) {
|
|
134
|
+
throw new SsoError("sso_identity_invalid", "id_token has no subject");
|
|
135
|
+
}
|
|
136
|
+
const email = typeof payload["email"] === "string" ? payload["email"] : undefined;
|
|
137
|
+
const claim = readVerifiedClaim(payload["email_verified"]);
|
|
138
|
+
return {
|
|
139
|
+
subject,
|
|
140
|
+
...(email === undefined ? {} : { email }),
|
|
141
|
+
emailVerified: claim ?? provider.assumeEmailVerified,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error instanceof SsoError)
|
|
146
|
+
throw error;
|
|
147
|
+
throw new SsoError("sso_identity_invalid", "id_token verification failed", { cause: error });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function fetchUserinfo(discovery, accessToken, fetch) {
|
|
151
|
+
if (discovery.userinfoEndpoint === undefined) {
|
|
152
|
+
throw new SsoError("sso_identity_invalid", "no userinfo endpoint");
|
|
153
|
+
}
|
|
154
|
+
let response;
|
|
155
|
+
try {
|
|
156
|
+
response = await fetch(discovery.userinfoEndpoint, {
|
|
157
|
+
method: "GET",
|
|
158
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
throw new SsoError("sso_exchange_failed", "userinfo request failed", { cause: error });
|
|
163
|
+
}
|
|
164
|
+
if (!response.ok)
|
|
165
|
+
throw new SsoError("sso_exchange_failed", `userinfo ${response.status}`);
|
|
166
|
+
let payload;
|
|
167
|
+
try {
|
|
168
|
+
payload = await response.json();
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
throw new SsoError("sso_exchange_failed", "userinfo is not JSON", { cause: error });
|
|
172
|
+
}
|
|
173
|
+
const record = readRecord(payload);
|
|
174
|
+
if (record === null)
|
|
175
|
+
throw new SsoError("sso_exchange_failed", "userinfo shape");
|
|
176
|
+
return record;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolves the OIDC identity: ID-token claims, with a userinfo fallback only when the email
|
|
180
|
+
* claim is absent. The userinfo `sub` must agree with the verified ID-token subject.
|
|
181
|
+
*/
|
|
182
|
+
export async function resolveOidcIdentity(provider, discovery, token, nonce, deps = {}) {
|
|
183
|
+
if (token.idToken === undefined)
|
|
184
|
+
throw new SsoError("sso_identity_invalid", "no id_token");
|
|
185
|
+
const claims = await verifyOidcIdToken(provider, discovery, token.idToken, nonce, deps);
|
|
186
|
+
let email = claims.email;
|
|
187
|
+
let emailVerified = claims.emailVerified;
|
|
188
|
+
if (email === undefined) {
|
|
189
|
+
const userinfo = await fetchUserinfo(discovery, token.accessToken, deps.fetch ?? runtimeFetch());
|
|
190
|
+
const userinfoSubject = readString(userinfo, "sub");
|
|
191
|
+
if (userinfoSubject !== undefined && userinfoSubject !== claims.subject) {
|
|
192
|
+
throw new SsoError("sso_identity_invalid", "userinfo subject mismatch");
|
|
193
|
+
}
|
|
194
|
+
email = readString(userinfo, "email");
|
|
195
|
+
emailVerified = readVerifiedClaim(userinfo["email_verified"]) ?? provider.assumeEmailVerified;
|
|
196
|
+
}
|
|
197
|
+
if (email === undefined || !EMAIL_PATTERN.test(email)) {
|
|
198
|
+
throw new SsoError("sso_identity_invalid", "no email claim");
|
|
199
|
+
}
|
|
200
|
+
return { provider: provider.id, subject: claims.subject, email, emailVerified };
|
|
201
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL helpers for the SSO flow (D-120), shared by the login orchestration and its tests.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of login.ts so the orchestration stays under the 300-line limit; nothing here needs
|
|
5
|
+
* the flow dependencies, only the public origin and the single-use state row.
|
|
6
|
+
*/
|
|
7
|
+
/** Fallback after a login when the attempt carried no valid same-origin path. */
|
|
8
|
+
export declare const DEFAULT_POST_LOGIN_PATH = "/";
|
|
9
|
+
/** The callback URL registered at the IdP for one provider. */
|
|
10
|
+
export declare function ssoCallbackUrl(publicOrigin: string, providerId: string): string;
|
|
11
|
+
/** The post-login path for a state row; an absent or invalid value falls back to the app root. */
|
|
12
|
+
export declare function postLoginPath(state: {
|
|
13
|
+
readonly redirectTo: string | null;
|
|
14
|
+
}): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { normalizeRedirectTo } from "./state.js";
|
|
2
|
+
/**
|
|
3
|
+
* URL helpers for the SSO flow (D-120), shared by the login orchestration and its tests.
|
|
4
|
+
*
|
|
5
|
+
* Kept out of login.ts so the orchestration stays under the 300-line limit; nothing here needs
|
|
6
|
+
* the flow dependencies, only the public origin and the single-use state row.
|
|
7
|
+
*/
|
|
8
|
+
/** Fallback after a login when the attempt carried no valid same-origin path. */
|
|
9
|
+
export const DEFAULT_POST_LOGIN_PATH = "/";
|
|
10
|
+
/** The callback URL registered at the IdP for one provider. */
|
|
11
|
+
export function ssoCallbackUrl(publicOrigin, providerId) {
|
|
12
|
+
return `${publicOrigin.replace(/\/+$/, "")}/api/auth/sso/${encodeURIComponent(providerId)}/callback`;
|
|
13
|
+
}
|
|
14
|
+
/** The post-login path for a state row; an absent or invalid value falls back to the app root. */
|
|
15
|
+
export function postLoginPath(state) {
|
|
16
|
+
return normalizeRedirectTo(state.redirectTo) ?? DEFAULT_POST_LOGIN_PATH;
|
|
17
|
+
}
|
package/dist/sso/pkce.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { encodeBase64, randomToken, webcrypto } from "../crypto.js";
|
|
2
|
+
/**
|
|
3
|
+
* PKCE (RFC 7636) for the authorization-code flows (D-120).
|
|
4
|
+
*
|
|
5
|
+
* Both OIDC and generic OAuth2 use S256 unconditionally: a provider that does not implement PKCE
|
|
6
|
+
* ignores the unknown parameters (RFC 6749), while a public `tokenAuth: "none"` client is
|
|
7
|
+
* protected against authorization-code interception. Kept in its own module so the OIDC flow
|
|
8
|
+
* and the login orchestration stay under the 300-line limit.
|
|
9
|
+
*/
|
|
10
|
+
function toBase64Url(bytes) {
|
|
11
|
+
return encodeBase64(bytes).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
12
|
+
}
|
|
13
|
+
async function s256(verifier) {
|
|
14
|
+
const digest = await webcrypto().subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
15
|
+
return toBase64Url(new Uint8Array(digest));
|
|
16
|
+
}
|
|
17
|
+
/** PKCE S256 pair: a 43-character base64url verifier and its SHA-256 challenge. */
|
|
18
|
+
export async function createPkcePair() {
|
|
19
|
+
const verifier = randomToken(32);
|
|
20
|
+
return { verifier, challenge: await s256(verifier) };
|
|
21
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider resolution against the environment (D-120).
|
|
3
|
+
*
|
|
4
|
+
* The config commits the ENV VAR NAMES that hold secrets; the values never appear in the project.
|
|
5
|
+
* A provider whose secret (or SAML certificate) is missing is unusable: it is hidden from the
|
|
6
|
+
* public list and a start attempt answers `sso_provider_unavailable`. The value itself is never
|
|
7
|
+
* read for logging, compared or echoed.
|
|
8
|
+
*
|
|
9
|
+
* The types here mirror the `auth.sso` section of facilis.config.ts structurally. @facilis.dev/core
|
|
10
|
+
* already validates that config and depends on @facilis.dev/auth, so auth cannot import the core
|
|
11
|
+
* types without a package cycle; a parsed core provider is assignable to these shapes.
|
|
12
|
+
*/
|
|
13
|
+
export type SsoProviderKind = "oidc" | "oauth2" | "saml";
|
|
14
|
+
export type SsoTokenAuth = "client_secret_post" | "client_secret_basic" | "none";
|
|
15
|
+
export interface SsoProviderBase {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly label?: string;
|
|
18
|
+
readonly autoProvision: boolean;
|
|
19
|
+
readonly defaultRole?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface OidcProviderConfig extends SsoProviderBase {
|
|
22
|
+
readonly kind: "oidc";
|
|
23
|
+
readonly issuer: string;
|
|
24
|
+
readonly clientId: string;
|
|
25
|
+
readonly clientSecretEnv?: string;
|
|
26
|
+
readonly scopes: readonly string[];
|
|
27
|
+
readonly tokenAuth: SsoTokenAuth;
|
|
28
|
+
readonly assumeEmailVerified: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface Oauth2Mapping {
|
|
31
|
+
readonly subject: string;
|
|
32
|
+
readonly email: string;
|
|
33
|
+
readonly emailVerified?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface Oauth2ProviderConfig extends SsoProviderBase {
|
|
36
|
+
readonly kind: "oauth2";
|
|
37
|
+
readonly authorizationEndpoint: string;
|
|
38
|
+
readonly tokenEndpoint: string;
|
|
39
|
+
readonly userinfoEndpoint: string;
|
|
40
|
+
readonly clientId: string;
|
|
41
|
+
readonly clientSecretEnv?: string;
|
|
42
|
+
readonly scopes: readonly string[];
|
|
43
|
+
readonly tokenAuth: SsoTokenAuth;
|
|
44
|
+
readonly mapping: Oauth2Mapping;
|
|
45
|
+
readonly assumeEmailVerified: boolean;
|
|
46
|
+
}
|
|
47
|
+
export interface SamlProviderConfig extends SsoProviderBase {
|
|
48
|
+
readonly kind: "saml";
|
|
49
|
+
readonly entryPoint: string;
|
|
50
|
+
/** SP entity id declared to the IdP, and the expected assertion audience. */
|
|
51
|
+
readonly issuer: string;
|
|
52
|
+
readonly certificateEnv: string;
|
|
53
|
+
readonly emailAttribute: string;
|
|
54
|
+
readonly wantAssertionsSigned: boolean;
|
|
55
|
+
readonly wantResponseSigned: boolean;
|
|
56
|
+
}
|
|
57
|
+
export type SsoProviderConfig = OidcProviderConfig | Oauth2ProviderConfig | SamlProviderConfig;
|
|
58
|
+
/**
|
|
59
|
+
* The identity-policy projection of a provider: exactly the fields identity resolution needs,
|
|
60
|
+
* so the flow never hands the whole config (and its secrets) to the linking code.
|
|
61
|
+
*/
|
|
62
|
+
export declare function identityPolicy(config: SsoProviderConfig): {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly autoProvision: boolean;
|
|
65
|
+
readonly defaultRole?: string;
|
|
66
|
+
};
|
|
67
|
+
/** Structural shape of `config.auth.sso` after defineConfig applied its defaults. */
|
|
68
|
+
export interface SsoConfigLike {
|
|
69
|
+
readonly providers: readonly SsoProviderConfig[];
|
|
70
|
+
readonly failureRedirect: string;
|
|
71
|
+
readonly stateTtlMs: number;
|
|
72
|
+
}
|
|
73
|
+
/** What the login page may see: no secret, no endpoint beyond the framework start URL. */
|
|
74
|
+
export interface PublicSsoProvider {
|
|
75
|
+
readonly id: string;
|
|
76
|
+
readonly label: string;
|
|
77
|
+
readonly kind: SsoProviderKind;
|
|
78
|
+
/** Framework-owned start URL, always relative to the application origin. */
|
|
79
|
+
readonly startUrl: string;
|
|
80
|
+
}
|
|
81
|
+
/** A provider plus the environment values the flow needs; never serialised. */
|
|
82
|
+
export interface ResolvedSsoProvider {
|
|
83
|
+
readonly config: SsoProviderConfig;
|
|
84
|
+
readonly clientSecret?: string;
|
|
85
|
+
readonly idpCertificate?: string;
|
|
86
|
+
}
|
|
87
|
+
export declare const SSO_NAMESPACE = "/api/auth/sso";
|
|
88
|
+
/** The environment variable whose absence makes a provider unusable, if any. */
|
|
89
|
+
export declare function missingCredentialEnv(provider: SsoProviderConfig): string | undefined;
|
|
90
|
+
/** True when every environment value this provider needs is present. */
|
|
91
|
+
export declare function isProviderConfigured(provider: SsoProviderConfig, environment?: Readonly<Record<string, string | undefined>>): boolean;
|
|
92
|
+
/** The public list rendered by the login page; unconfigured providers are omitted. */
|
|
93
|
+
export declare function publicProviderList(config: SsoConfigLike, environment?: Readonly<Record<string, string | undefined>>): PublicSsoProvider[];
|
|
94
|
+
/**
|
|
95
|
+
* Finds a configured provider and reads its environment secret(s).
|
|
96
|
+
*
|
|
97
|
+
* Unknown provider => `sso_provider_unknown`; a known provider missing its env value =>
|
|
98
|
+
* `sso_provider_unavailable` (the caller logs a warning with the ENV VAR NAME only).
|
|
99
|
+
*/
|
|
100
|
+
export declare function resolveProvider(config: SsoConfigLike, id: string, environment?: Readonly<Record<string, string | undefined>>): ResolvedSsoProvider;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SsoError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The identity-policy projection of a provider: exactly the fields identity resolution needs,
|
|
4
|
+
* so the flow never hands the whole config (and its secrets) to the linking code.
|
|
5
|
+
*/
|
|
6
|
+
export function identityPolicy(config) {
|
|
7
|
+
return {
|
|
8
|
+
id: config.id,
|
|
9
|
+
autoProvision: config.autoProvision,
|
|
10
|
+
...(config.defaultRole === undefined ? {} : { defaultRole: config.defaultRole }),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export const SSO_NAMESPACE = "/api/auth/sso";
|
|
14
|
+
/** The environment variable whose absence makes a provider unusable, if any. */
|
|
15
|
+
export function missingCredentialEnv(provider) {
|
|
16
|
+
if (provider.kind === "saml")
|
|
17
|
+
return provider.certificateEnv;
|
|
18
|
+
if (provider.tokenAuth === "none")
|
|
19
|
+
return undefined;
|
|
20
|
+
return provider.clientSecretEnv;
|
|
21
|
+
}
|
|
22
|
+
function readEnv(name, environment) {
|
|
23
|
+
if (name === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
const value = environment[name];
|
|
26
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
27
|
+
}
|
|
28
|
+
/** True when every environment value this provider needs is present. */
|
|
29
|
+
export function isProviderConfigured(provider, environment = process.env) {
|
|
30
|
+
if (provider.kind === "saml") {
|
|
31
|
+
return readEnv(provider.certificateEnv, environment) !== undefined;
|
|
32
|
+
}
|
|
33
|
+
if (provider.tokenAuth === "none")
|
|
34
|
+
return true;
|
|
35
|
+
return readEnv(provider.clientSecretEnv, environment) !== undefined;
|
|
36
|
+
}
|
|
37
|
+
/** The public list rendered by the login page; unconfigured providers are omitted. */
|
|
38
|
+
export function publicProviderList(config, environment = process.env) {
|
|
39
|
+
return config.providers
|
|
40
|
+
.filter((provider) => isProviderConfigured(provider, environment))
|
|
41
|
+
.map((provider) => ({
|
|
42
|
+
id: provider.id,
|
|
43
|
+
label: provider.label ?? provider.id,
|
|
44
|
+
kind: provider.kind,
|
|
45
|
+
startUrl: `${SSO_NAMESPACE}/${encodeURIComponent(provider.id)}/start`,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Finds a configured provider and reads its environment secret(s).
|
|
50
|
+
*
|
|
51
|
+
* Unknown provider => `sso_provider_unknown`; a known provider missing its env value =>
|
|
52
|
+
* `sso_provider_unavailable` (the caller logs a warning with the ENV VAR NAME only).
|
|
53
|
+
*/
|
|
54
|
+
export function resolveProvider(config, id, environment = process.env) {
|
|
55
|
+
const provider = config.providers.find((candidate) => candidate.id === id);
|
|
56
|
+
if (provider === undefined)
|
|
57
|
+
throw new SsoError("sso_provider_unknown");
|
|
58
|
+
if (provider.kind === "saml") {
|
|
59
|
+
const idpCertificate = readEnv(provider.certificateEnv, environment);
|
|
60
|
+
if (idpCertificate === undefined)
|
|
61
|
+
throw new SsoError("sso_provider_unavailable");
|
|
62
|
+
return { config: provider, idpCertificate };
|
|
63
|
+
}
|
|
64
|
+
if (provider.tokenAuth === "none")
|
|
65
|
+
return { config: provider };
|
|
66
|
+
const clientSecret = readEnv(provider.clientSecretEnv, environment);
|
|
67
|
+
if (clientSecret === undefined)
|
|
68
|
+
throw new SsoError("sso_provider_unavailable");
|
|
69
|
+
return { config: provider, clientSecret };
|
|
70
|
+
}
|