@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,235 @@
|
|
|
1
|
+
import { runtimeFetch, type FetchLike, type FetchResponse } from "./contracts.ts";
|
|
2
|
+
import { SsoError } from "./errors.ts";
|
|
3
|
+
import type { Oauth2ProviderConfig, SsoTokenAuth } from "./providers.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generic OAuth2 authorization-code provider (D-120).
|
|
7
|
+
*
|
|
8
|
+
* Unlike OIDC there is no discovery or ID token, so the identity comes from the userinfo endpoint
|
|
9
|
+
* plus a dotted-path mapping declared in facilis.config.ts. Email verification is claim-driven:
|
|
10
|
+
* a provider that never reports the claim must opt in with `assumeEmailVerified`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/;
|
|
14
|
+
|
|
15
|
+
export interface TokenPayload {
|
|
16
|
+
readonly accessToken: string;
|
|
17
|
+
readonly idToken?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The subset of a provider the token endpoint needs; OIDC and OAuth2 share it. */
|
|
21
|
+
export interface TokenClientConfig {
|
|
22
|
+
readonly clientId: string;
|
|
23
|
+
readonly clientSecret?: string;
|
|
24
|
+
readonly tokenAuth: SsoTokenAuth;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RequestTokenInput {
|
|
28
|
+
readonly endpoint: string;
|
|
29
|
+
readonly client: TokenClientConfig;
|
|
30
|
+
readonly form: Readonly<Record<string, string>>;
|
|
31
|
+
readonly fetch?: FetchLike;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function requireFetch(fetch: FetchLike | undefined): FetchLike {
|
|
35
|
+
return fetch ?? runtimeFetch();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function basicAuthorization(clientId: string, clientSecret: string): string {
|
|
39
|
+
const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`);
|
|
40
|
+
let binary = "";
|
|
41
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
42
|
+
return `Basic ${btoa(binary)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* POSTs an authorization-code grant to the token endpoint with the configured client auth mode.
|
|
47
|
+
*
|
|
48
|
+
* The response is parsed for `access_token`/`id_token` only; a provider error body is never
|
|
49
|
+
* surfaced (it can echo the code), just the safe `sso_exchange_failed` code.
|
|
50
|
+
*/
|
|
51
|
+
export async function requestToken(input: RequestTokenInput): Promise<TokenPayload> {
|
|
52
|
+
const form = new URLSearchParams(input.form);
|
|
53
|
+
form.set("client_id", input.client.clientId);
|
|
54
|
+
const headers: Record<string, string> = {
|
|
55
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
56
|
+
accept: "application/json",
|
|
57
|
+
};
|
|
58
|
+
if (input.client.tokenAuth === "client_secret_basic") {
|
|
59
|
+
if (input.client.clientSecret === undefined) throw new SsoError("sso_provider_unavailable");
|
|
60
|
+
headers["authorization"] = basicAuthorization(input.client.clientId, input.client.clientSecret);
|
|
61
|
+
} else if (input.client.tokenAuth === "client_secret_post") {
|
|
62
|
+
if (input.client.clientSecret === undefined) throw new SsoError("sso_provider_unavailable");
|
|
63
|
+
form.set("client_secret", input.client.clientSecret);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let response: FetchResponse;
|
|
67
|
+
try {
|
|
68
|
+
response = await requireFetch(input.fetch)(input.endpoint, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers,
|
|
71
|
+
body: form.toString(),
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new SsoError("sso_exchange_failed", "token request failed", { cause: error });
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok) throw new SsoError("sso_exchange_failed", `token endpoint ${response.status}`);
|
|
77
|
+
|
|
78
|
+
let payload: unknown;
|
|
79
|
+
try {
|
|
80
|
+
payload = await response.json();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new SsoError("sso_exchange_failed", "token response is not JSON", { cause: error });
|
|
83
|
+
}
|
|
84
|
+
if (typeof payload !== "object" || payload === null) {
|
|
85
|
+
throw new SsoError("sso_exchange_failed", "token response shape");
|
|
86
|
+
}
|
|
87
|
+
const record = payload as Record<string, unknown>;
|
|
88
|
+
const accessToken = readString(record, "access_token");
|
|
89
|
+
if (accessToken === undefined) throw new SsoError("sso_exchange_failed", "no access_token");
|
|
90
|
+
const idToken = readString(record, "id_token");
|
|
91
|
+
return idToken === undefined ? { accessToken } : { accessToken, idToken };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reads a dotted path (`data.user.email`) out of parsed userinfo JSON. */
|
|
95
|
+
export function readDottedPath(value: unknown, path: string): unknown {
|
|
96
|
+
let current: unknown = value;
|
|
97
|
+
for (const segment of path.split(".")) {
|
|
98
|
+
if (typeof current !== "object" || current === null) return undefined;
|
|
99
|
+
current = (current as Record<string, unknown>)[segment];
|
|
100
|
+
}
|
|
101
|
+
return current;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readString(record: Record<string, unknown>, key: string): string | undefined {
|
|
105
|
+
const value = record[key];
|
|
106
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Truthiness of a verification claim: boolean true or the string "true" only. */
|
|
110
|
+
export function readVerifiedClaim(value: unknown): boolean | undefined {
|
|
111
|
+
if (value === true || value === "true") return true;
|
|
112
|
+
if (value === false || value === "false") return false;
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function requireString(value: unknown): string | undefined {
|
|
117
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Subjects are often numeric in OAuth2 userinfo (e.g. a GitHub `id`). */
|
|
121
|
+
function requireScalar(value: unknown): string | undefined {
|
|
122
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
123
|
+
return requireString(value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The authorization URL: response_type=code, the configured scopes, state, PKCE S256 and the
|
|
128
|
+
* redirect URI. PKCE is sent unconditionally (RFC 6749 lets a provider ignore it) so a public
|
|
129
|
+
* `tokenAuth: "none"` client is protected exactly like the OIDC flow (D-120).
|
|
130
|
+
*/
|
|
131
|
+
export function buildOauth2AuthorizeUrl(
|
|
132
|
+
provider: Oauth2ProviderConfig,
|
|
133
|
+
redirectUri: string,
|
|
134
|
+
state: string,
|
|
135
|
+
codeChallenge: string,
|
|
136
|
+
): string {
|
|
137
|
+
const url = new URL(provider.authorizationEndpoint);
|
|
138
|
+
url.searchParams.set("response_type", "code");
|
|
139
|
+
url.searchParams.set("client_id", provider.clientId);
|
|
140
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
141
|
+
url.searchParams.set("state", state);
|
|
142
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
143
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
144
|
+
if (provider.scopes.length > 0) url.searchParams.set("scope", provider.scopes.join(" "));
|
|
145
|
+
return url.toString();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface Oauth2TokenInput {
|
|
149
|
+
readonly code: string;
|
|
150
|
+
readonly redirectUri: string;
|
|
151
|
+
/** PKCE verifier stored in the single-use state row; required, like the OIDC flow. */
|
|
152
|
+
readonly codeVerifier: string;
|
|
153
|
+
readonly fetch?: FetchLike;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function exchangeOauth2Code(
|
|
157
|
+
client: TokenClientConfig,
|
|
158
|
+
provider: Oauth2ProviderConfig,
|
|
159
|
+
input: Oauth2TokenInput,
|
|
160
|
+
): Promise<TokenPayload> {
|
|
161
|
+
return requestToken({
|
|
162
|
+
endpoint: provider.tokenEndpoint,
|
|
163
|
+
client,
|
|
164
|
+
form: {
|
|
165
|
+
grant_type: "authorization_code",
|
|
166
|
+
code: input.code,
|
|
167
|
+
redirect_uri: input.redirectUri,
|
|
168
|
+
code_verifier: input.codeVerifier,
|
|
169
|
+
},
|
|
170
|
+
...(input.fetch === undefined ? {} : { fetch: input.fetch }),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function fetchUserinfo(
|
|
175
|
+
provider: Oauth2ProviderConfig,
|
|
176
|
+
accessToken: string,
|
|
177
|
+
fetch: FetchLike,
|
|
178
|
+
): Promise<Record<string, unknown>> {
|
|
179
|
+
let response: FetchResponse;
|
|
180
|
+
try {
|
|
181
|
+
response = await fetch(provider.userinfoEndpoint, {
|
|
182
|
+
method: "GET",
|
|
183
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new SsoError("sso_exchange_failed", "userinfo request failed", { cause: error });
|
|
187
|
+
}
|
|
188
|
+
if (!response.ok) throw new SsoError("sso_exchange_failed", `userinfo ${response.status}`);
|
|
189
|
+
let payload: unknown;
|
|
190
|
+
try {
|
|
191
|
+
payload = await response.json();
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw new SsoError("sso_exchange_failed", "userinfo is not JSON", { cause: error });
|
|
194
|
+
}
|
|
195
|
+
if (typeof payload !== "object" || payload === null) {
|
|
196
|
+
throw new SsoError("sso_exchange_failed", "userinfo shape");
|
|
197
|
+
}
|
|
198
|
+
return payload as Record<string, unknown>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface Oauth2Identity {
|
|
202
|
+
readonly subject: string;
|
|
203
|
+
readonly email: string;
|
|
204
|
+
readonly emailVerified: boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Maps the userinfo JSON onto the identity key via the configured dotted paths. */
|
|
208
|
+
export function resolveOauth2Identity(
|
|
209
|
+
provider: Oauth2ProviderConfig,
|
|
210
|
+
userinfo: Record<string, unknown>,
|
|
211
|
+
): Oauth2Identity {
|
|
212
|
+
const subject = requireScalar(readDottedPath(userinfo, provider.mapping.subject));
|
|
213
|
+
if (subject === undefined) throw new SsoError("sso_identity_invalid", "no subject mapping");
|
|
214
|
+
const rawEmail = requireString(readDottedPath(userinfo, provider.mapping.email));
|
|
215
|
+
if (rawEmail === undefined || !EMAIL_PATTERN.test(rawEmail)) {
|
|
216
|
+
throw new SsoError("sso_identity_invalid", "no email mapping");
|
|
217
|
+
}
|
|
218
|
+
const claim =
|
|
219
|
+
provider.mapping.emailVerified === undefined
|
|
220
|
+
? undefined
|
|
221
|
+
: readVerifiedClaim(readDottedPath(userinfo, provider.mapping.emailVerified));
|
|
222
|
+
const emailVerified = claim ?? provider.assumeEmailVerified;
|
|
223
|
+
return { subject, email: rawEmail, emailVerified };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Token exchange + userinfo + mapping, the complete generic-OAuth2 identity resolution. */
|
|
227
|
+
export async function resolveOauth2IdentityFromCode(
|
|
228
|
+
client: TokenClientConfig,
|
|
229
|
+
provider: Oauth2ProviderConfig,
|
|
230
|
+
input: Oauth2TokenInput,
|
|
231
|
+
): Promise<Oauth2Identity> {
|
|
232
|
+
const token = await exchangeOauth2Code(client, provider, input);
|
|
233
|
+
const userinfo = await fetchUserinfo(provider, token.accessToken, input.fetch ?? runtimeFetch());
|
|
234
|
+
return resolveOauth2Identity(provider, userinfo);
|
|
235
|
+
}
|
package/src/sso/oidc.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
runtimeFetch,
|
|
5
|
+
type FetchLike,
|
|
6
|
+
type FetchResponse,
|
|
7
|
+
type IdentityClaims,
|
|
8
|
+
} from "./contracts.ts";
|
|
9
|
+
import { SsoError } from "./errors.ts";
|
|
10
|
+
import { requestToken, readVerifiedClaim, type TokenPayload } from "./oauth2.ts";
|
|
11
|
+
import type { OidcProviderConfig } from "./providers.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OpenID Connect authorization code flow with PKCE and nonce (D-120).
|
|
15
|
+
*
|
|
16
|
+
* Discovery is fetched once per provider and cached with a TTL; the ID token is verified with
|
|
17
|
+
* `jose` against the provider JWKS, the issuer, the client id and the nonce. Tests inject the
|
|
18
|
+
* fetch (discovery) and the JWKS resolver (`createLocalJWKSet`), so the suite needs no network.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const OIDC_DISCOVERY_TTL_MS = 5 * 60 * 1000;
|
|
22
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/;
|
|
23
|
+
|
|
24
|
+
export interface OidcDiscovery {
|
|
25
|
+
readonly issuer: string;
|
|
26
|
+
readonly authorizationEndpoint: string;
|
|
27
|
+
readonly tokenEndpoint: string;
|
|
28
|
+
readonly userinfoEndpoint?: string;
|
|
29
|
+
readonly jwksUri: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OidcDeps {
|
|
33
|
+
readonly fetch?: FetchLike;
|
|
34
|
+
readonly now?: () => number;
|
|
35
|
+
readonly jwksResolver?: (jwksUri: string) => JWTVerifyGetKey;
|
|
36
|
+
readonly discoveryTtlMs?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CachedDiscovery {
|
|
40
|
+
readonly value: OidcDiscovery;
|
|
41
|
+
readonly fetchedAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const discoveryCache = new Map<string, CachedDiscovery>();
|
|
45
|
+
|
|
46
|
+
/** Clears the per-provider discovery cache (tests, and a deliberate provider reconfiguration). */
|
|
47
|
+
export function clearOidcDiscoveryCache(): void {
|
|
48
|
+
discoveryCache.clear();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readRecord(value: unknown): Record<string, unknown> | null {
|
|
52
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readUrl(record: Record<string, unknown>, key: string): string | undefined {
|
|
56
|
+
const value = record[key];
|
|
57
|
+
if (typeof value !== "string") return undefined;
|
|
58
|
+
try {
|
|
59
|
+
const url = new URL(value);
|
|
60
|
+
return url.protocol === "https:" || url.protocol === "http:" ? value : undefined;
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function discoveryUrl(issuer: string): string {
|
|
67
|
+
return `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseDiscovery(value: unknown): OidcDiscovery {
|
|
71
|
+
const record = readRecord(value);
|
|
72
|
+
if (record === null) throw new SsoError("sso_provider_unavailable", "discovery shape");
|
|
73
|
+
const authorizationEndpoint = readUrl(record, "authorization_endpoint");
|
|
74
|
+
const tokenEndpoint = readUrl(record, "token_endpoint");
|
|
75
|
+
const jwksUri = readUrl(record, "jwks_uri");
|
|
76
|
+
const issuer = typeof record["issuer"] === "string" ? record["issuer"] : "";
|
|
77
|
+
if (authorizationEndpoint === undefined || tokenEndpoint === undefined || jwksUri === undefined) {
|
|
78
|
+
throw new SsoError("sso_provider_unavailable", "discovery is missing an endpoint");
|
|
79
|
+
}
|
|
80
|
+
const userinfoEndpoint = readUrl(record, "userinfo_endpoint");
|
|
81
|
+
return {
|
|
82
|
+
issuer,
|
|
83
|
+
authorizationEndpoint,
|
|
84
|
+
tokenEndpoint,
|
|
85
|
+
jwksUri,
|
|
86
|
+
...(userinfoEndpoint === undefined ? {} : { userinfoEndpoint }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fetches and caches `<issuer>/.well-known/openid-configuration` for one provider. */
|
|
91
|
+
export async function discoverOidc(
|
|
92
|
+
provider: OidcProviderConfig,
|
|
93
|
+
deps: OidcDeps = {},
|
|
94
|
+
): Promise<OidcDiscovery> {
|
|
95
|
+
const now = deps.now?.() ?? Date.now();
|
|
96
|
+
const ttl = deps.discoveryTtlMs ?? OIDC_DISCOVERY_TTL_MS;
|
|
97
|
+
const key = `${provider.id}\u0000${provider.issuer}`;
|
|
98
|
+
const cached = discoveryCache.get(key);
|
|
99
|
+
if (cached !== undefined && now - cached.fetchedAt < ttl) return cached.value;
|
|
100
|
+
|
|
101
|
+
const fetch = deps.fetch ?? runtimeFetch();
|
|
102
|
+
let response: FetchResponse;
|
|
103
|
+
try {
|
|
104
|
+
response = await fetch(discoveryUrl(provider.issuer), {
|
|
105
|
+
method: "GET",
|
|
106
|
+
headers: { accept: "application/json" },
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw new SsoError("sso_provider_unavailable", "discovery request failed", { cause: error });
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new SsoError("sso_provider_unavailable", `discovery ${response.status}`);
|
|
113
|
+
}
|
|
114
|
+
let payload: unknown;
|
|
115
|
+
try {
|
|
116
|
+
payload = await response.json();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new SsoError("sso_provider_unavailable", "discovery is not JSON", { cause: error });
|
|
119
|
+
}
|
|
120
|
+
const discovery = parseDiscovery(payload);
|
|
121
|
+
discoveryCache.set(key, { value: discovery, fetchedAt: now });
|
|
122
|
+
return discovery;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface OidcAuthorizeInput {
|
|
126
|
+
readonly redirectUri: string;
|
|
127
|
+
readonly state: string;
|
|
128
|
+
readonly nonce: string;
|
|
129
|
+
readonly codeChallenge: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The authorization URL: response_type=code, scopes, state, nonce and PKCE S256. */
|
|
133
|
+
export function buildOidcAuthorizeUrl(
|
|
134
|
+
provider: OidcProviderConfig,
|
|
135
|
+
discovery: OidcDiscovery,
|
|
136
|
+
input: OidcAuthorizeInput,
|
|
137
|
+
): string {
|
|
138
|
+
const url = new URL(discovery.authorizationEndpoint);
|
|
139
|
+
url.searchParams.set("response_type", "code");
|
|
140
|
+
url.searchParams.set("client_id", provider.clientId);
|
|
141
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
142
|
+
url.searchParams.set("scope", provider.scopes.join(" "));
|
|
143
|
+
url.searchParams.set("state", input.state);
|
|
144
|
+
url.searchParams.set("nonce", input.nonce);
|
|
145
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
146
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
147
|
+
return url.toString();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface OidcTokenInput {
|
|
151
|
+
readonly code: string;
|
|
152
|
+
readonly redirectUri: string;
|
|
153
|
+
readonly codeVerifier: string;
|
|
154
|
+
readonly fetch?: FetchLike;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function exchangeOidcCode(
|
|
158
|
+
client: { clientId: string; clientSecret?: string; tokenAuth: OidcProviderConfig["tokenAuth"] },
|
|
159
|
+
discovery: OidcDiscovery,
|
|
160
|
+
input: OidcTokenInput,
|
|
161
|
+
): Promise<TokenPayload> {
|
|
162
|
+
return requestToken({
|
|
163
|
+
endpoint: discovery.tokenEndpoint,
|
|
164
|
+
client,
|
|
165
|
+
form: {
|
|
166
|
+
grant_type: "authorization_code",
|
|
167
|
+
code: input.code,
|
|
168
|
+
redirect_uri: input.redirectUri,
|
|
169
|
+
code_verifier: input.codeVerifier,
|
|
170
|
+
},
|
|
171
|
+
...(input.fetch === undefined ? {} : { fetch: input.fetch }),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface OidcClaims {
|
|
176
|
+
readonly subject: string;
|
|
177
|
+
readonly email?: string;
|
|
178
|
+
readonly emailVerified: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readString(record: Record<string, unknown>, key: string): string | undefined {
|
|
182
|
+
const value = record[key];
|
|
183
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Verifies an ID token: signature (JWKS), issuer, audience, expiry (jose) and the nonce. */
|
|
187
|
+
export async function verifyOidcIdToken(
|
|
188
|
+
provider: OidcProviderConfig,
|
|
189
|
+
discovery: OidcDiscovery,
|
|
190
|
+
idToken: string,
|
|
191
|
+
nonce: string,
|
|
192
|
+
deps: OidcDeps = {},
|
|
193
|
+
): Promise<OidcClaims> {
|
|
194
|
+
try {
|
|
195
|
+
const resolver =
|
|
196
|
+
deps.jwksResolver?.(discovery.jwksUri) ?? createRemoteJWKSet(new URL(discovery.jwksUri));
|
|
197
|
+
const { payload } = await jwtVerify(idToken, resolver, {
|
|
198
|
+
issuer: provider.issuer,
|
|
199
|
+
audience: provider.clientId,
|
|
200
|
+
});
|
|
201
|
+
const tokenNonce = payload["nonce"];
|
|
202
|
+
if (typeof tokenNonce !== "string" || tokenNonce !== nonce) {
|
|
203
|
+
throw new SsoError("sso_identity_invalid", "id_token nonce mismatch");
|
|
204
|
+
}
|
|
205
|
+
const subject = payload.sub;
|
|
206
|
+
if (typeof subject !== "string" || subject.length === 0) {
|
|
207
|
+
throw new SsoError("sso_identity_invalid", "id_token has no subject");
|
|
208
|
+
}
|
|
209
|
+
const email = typeof payload["email"] === "string" ? payload["email"] : undefined;
|
|
210
|
+
const claim = readVerifiedClaim(payload["email_verified"]);
|
|
211
|
+
return {
|
|
212
|
+
subject,
|
|
213
|
+
...(email === undefined ? {} : { email }),
|
|
214
|
+
emailVerified: claim ?? provider.assumeEmailVerified,
|
|
215
|
+
};
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error instanceof SsoError) throw error;
|
|
218
|
+
throw new SsoError("sso_identity_invalid", "id_token verification failed", { cause: error });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function fetchUserinfo(
|
|
223
|
+
discovery: OidcDiscovery,
|
|
224
|
+
accessToken: string,
|
|
225
|
+
fetch: FetchLike,
|
|
226
|
+
): Promise<Record<string, unknown>> {
|
|
227
|
+
if (discovery.userinfoEndpoint === undefined) {
|
|
228
|
+
throw new SsoError("sso_identity_invalid", "no userinfo endpoint");
|
|
229
|
+
}
|
|
230
|
+
let response: FetchResponse;
|
|
231
|
+
try {
|
|
232
|
+
response = await fetch(discovery.userinfoEndpoint, {
|
|
233
|
+
method: "GET",
|
|
234
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
|
|
235
|
+
});
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw new SsoError("sso_exchange_failed", "userinfo request failed", { cause: error });
|
|
238
|
+
}
|
|
239
|
+
if (!response.ok) throw new SsoError("sso_exchange_failed", `userinfo ${response.status}`);
|
|
240
|
+
let payload: unknown;
|
|
241
|
+
try {
|
|
242
|
+
payload = await response.json();
|
|
243
|
+
} catch (error) {
|
|
244
|
+
throw new SsoError("sso_exchange_failed", "userinfo is not JSON", { cause: error });
|
|
245
|
+
}
|
|
246
|
+
const record = readRecord(payload);
|
|
247
|
+
if (record === null) throw new SsoError("sso_exchange_failed", "userinfo shape");
|
|
248
|
+
return record;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolves the OIDC identity: ID-token claims, with a userinfo fallback only when the email
|
|
253
|
+
* claim is absent. The userinfo `sub` must agree with the verified ID-token subject.
|
|
254
|
+
*/
|
|
255
|
+
export async function resolveOidcIdentity(
|
|
256
|
+
provider: OidcProviderConfig,
|
|
257
|
+
discovery: OidcDiscovery,
|
|
258
|
+
token: TokenPayload,
|
|
259
|
+
nonce: string,
|
|
260
|
+
deps: OidcDeps = {},
|
|
261
|
+
): Promise<IdentityClaims> {
|
|
262
|
+
if (token.idToken === undefined) throw new SsoError("sso_identity_invalid", "no id_token");
|
|
263
|
+
const claims = await verifyOidcIdToken(provider, discovery, token.idToken, nonce, deps);
|
|
264
|
+
let email = claims.email;
|
|
265
|
+
let emailVerified = claims.emailVerified;
|
|
266
|
+
|
|
267
|
+
if (email === undefined) {
|
|
268
|
+
const userinfo = await fetchUserinfo(
|
|
269
|
+
discovery,
|
|
270
|
+
token.accessToken,
|
|
271
|
+
deps.fetch ?? runtimeFetch(),
|
|
272
|
+
);
|
|
273
|
+
const userinfoSubject = readString(userinfo, "sub");
|
|
274
|
+
if (userinfoSubject !== undefined && userinfoSubject !== claims.subject) {
|
|
275
|
+
throw new SsoError("sso_identity_invalid", "userinfo subject mismatch");
|
|
276
|
+
}
|
|
277
|
+
email = readString(userinfo, "email");
|
|
278
|
+
emailVerified = readVerifiedClaim(userinfo["email_verified"]) ?? provider.assumeEmailVerified;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (email === undefined || !EMAIL_PATTERN.test(email)) {
|
|
282
|
+
throw new SsoError("sso_identity_invalid", "no email claim");
|
|
283
|
+
}
|
|
284
|
+
return { provider: provider.id, subject: claims.subject, email, emailVerified };
|
|
285
|
+
}
|
package/src/sso/paths.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { normalizeRedirectTo } from "./state.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* URL helpers for the SSO flow (D-120), shared by the login orchestration and its tests.
|
|
5
|
+
*
|
|
6
|
+
* Kept out of login.ts so the orchestration stays under the 300-line limit; nothing here needs
|
|
7
|
+
* the flow dependencies, only the public origin and the single-use state row.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Fallback after a login when the attempt carried no valid same-origin path. */
|
|
11
|
+
export const DEFAULT_POST_LOGIN_PATH = "/";
|
|
12
|
+
|
|
13
|
+
/** The callback URL registered at the IdP for one provider. */
|
|
14
|
+
export function ssoCallbackUrl(publicOrigin: string, providerId: string): string {
|
|
15
|
+
return `${publicOrigin.replace(/\/+$/, "")}/api/auth/sso/${encodeURIComponent(providerId)}/callback`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The post-login path for a state row; an absent or invalid value falls back to the app root. */
|
|
19
|
+
export function postLoginPath(state: { readonly redirectTo: string | null }): string {
|
|
20
|
+
return normalizeRedirectTo(state.redirectTo) ?? DEFAULT_POST_LOGIN_PATH;
|
|
21
|
+
}
|
package/src/sso/pkce.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { encodeBase64, randomToken, webcrypto } from "../crypto.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PKCE (RFC 7636) for the authorization-code flows (D-120).
|
|
5
|
+
*
|
|
6
|
+
* Both OIDC and generic OAuth2 use S256 unconditionally: a provider that does not implement PKCE
|
|
7
|
+
* ignores the unknown parameters (RFC 6749), while a public `tokenAuth: "none"` client is
|
|
8
|
+
* protected against authorization-code interception. Kept in its own module so the OIDC flow
|
|
9
|
+
* and the login orchestration stay under the 300-line limit.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function toBase64Url(bytes: Uint8Array): string {
|
|
13
|
+
return encodeBase64(bytes).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function s256(verifier: string): Promise<string> {
|
|
17
|
+
const digest = await webcrypto().subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
18
|
+
return toBase64Url(new Uint8Array(digest));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PkcePair {
|
|
22
|
+
readonly verifier: string;
|
|
23
|
+
readonly challenge: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** PKCE S256 pair: a 43-character base64url verifier and its SHA-256 challenge. */
|
|
27
|
+
export async function createPkcePair(): Promise<PkcePair> {
|
|
28
|
+
const verifier = randomToken(32);
|
|
29
|
+
return { verifier, challenge: await s256(verifier) };
|
|
30
|
+
}
|