@aooth/idp 0.1.8

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/dist/index.mjs ADDED
@@ -0,0 +1,943 @@
1
+ import { createHash, createHmac, randomBytes } from "node:crypto";
2
+ import { defaultClock } from "@aooth/auth";
3
+ import { SignJWT, createRemoteJWKSet, errors, importPKCS8, jwtVerify } from "jose";
4
+ import { UserAuthError, pickDefinedProfile } from "@aooth/user";
5
+ //#region src/errors.ts
6
+ const defaultMessages = {
7
+ UNKNOWN_PROVIDER: "Unknown identity provider",
8
+ INVALID_CONFIG: "Invalid OAuth provider configuration",
9
+ STATE_INVALID: "Sign-in could not be verified",
10
+ STATE_EXPIRED: "Sign-in expired — please try again",
11
+ PROVIDER_DENIED: "Sign-in was cancelled",
12
+ EXCHANGE_FAILED: "Could not complete sign-in with the provider",
13
+ JWKS_FAILED: "Could not verify the provider's signing keys",
14
+ ID_TOKEN_INVALID: "The provider's identity token could not be validated",
15
+ EMAIL_UNAVAILABLE: "The provider did not supply a usable email address"
16
+ };
17
+ var OAuthError = class extends Error {
18
+ type;
19
+ details;
20
+ name = "OAuthError";
21
+ constructor(type, message, details) {
22
+ super(message ?? defaultMessages[type]);
23
+ this.type = type;
24
+ this.details = details;
25
+ }
26
+ };
27
+ //#endregion
28
+ //#region src/types.ts
29
+ /** Structural check used by the registry to feature-detect `applyDefaults`. */
30
+ function isConfigurableProvider(p) {
31
+ return typeof p.applyDefaults === "function";
32
+ }
33
+ /** Default username strategy: the verified email, else the stable `provider:subject`. */
34
+ function defaultUsernameStrategy(p) {
35
+ return p.email ?? `${p.provider}:${p.subject}`;
36
+ }
37
+ /** Apply RFC §4 safe defaults over a partial policy. */
38
+ function resolveFederatedPolicy(policy = {}) {
39
+ return {
40
+ emailMatch: policy.emailMatch ?? "require-interactive-link",
41
+ allowSignup: policy.allowSignup ?? true,
42
+ usernameStrategy: policy.usernameStrategy ?? defaultUsernameStrategy,
43
+ trustEmailVerifiedFrom: policy.trustEmailVerifiedFrom ?? []
44
+ };
45
+ }
46
+ //#endregion
47
+ //#region src/pkce.ts
48
+ /**
49
+ * PKCE / CSRF primitives (RFC 7636 + OIDC nonce). All values are URL-safe
50
+ * base64url with no padding, so they ride in query params and cookies without
51
+ * escaping. Pure `node:crypto` — no I/O, fully deterministic-testable by
52
+ * stubbing nothing (only the randomness varies).
53
+ */
54
+ function base64url(buf) {
55
+ return buf.toString("base64url");
56
+ }
57
+ /**
58
+ * Mint a fresh PKCE pair. The 32-byte verifier base64url-encodes to 43 chars,
59
+ * comfortably inside RFC 7636's 43–128 range.
60
+ */
61
+ function createPkcePair() {
62
+ const verifier = base64url(randomBytes(32));
63
+ return {
64
+ verifier,
65
+ challenge: pkceChallengeFor(verifier),
66
+ method: "S256"
67
+ };
68
+ }
69
+ /** Recompute the S256 challenge for a known verifier (verification side). */
70
+ function pkceChallengeFor(verifier) {
71
+ return base64url(createHash("sha256").update(verifier).digest());
72
+ }
73
+ /** OIDC `nonce` — minted at `/start`, bound into state, asserted in `exchange()`. */
74
+ function generateNonce(bytes = 32) {
75
+ return base64url(randomBytes(bytes));
76
+ }
77
+ /** Random component bound into the signed `state` (anti-CSRF / anti-replay). */
78
+ function generateRandomState(bytes = 32) {
79
+ return base64url(randomBytes(bytes));
80
+ }
81
+ /**
82
+ * STATELESS PKCE: deterministically DERIVE the code verifier + OIDC nonce from
83
+ * a non-secret `seed` and the server's HMAC `secret`, instead of minting them
84
+ * with {@link createPkcePair}/{@link generateNonce} and persisting the secret
85
+ * half server-side.
86
+ *
87
+ * The `seed` is the same high-entropy value carried (in the clear) by the
88
+ * signed-state `random` and the double-submit CSRF cookie — so the round-trip
89
+ * needs NO server-side flow store: `/start` derives `{ verifier, nonce }` to
90
+ * build the authorize request, and the callback re-derives the identical pair
91
+ * from `state.random` to redeem the `code`. Because the verifier is
92
+ * `HMAC(secret, seed)` and the secret never leaves the server, exposing the
93
+ * seed in the URL discloses nothing — an attacker cannot recover the verifier.
94
+ * Distinct domain-separation prefixes keep the verifier and the nonce
95
+ * independent (neither is recoverable from the other).
96
+ *
97
+ * Same output range as {@link createPkcePair}: a 32-byte HMAC-SHA256 digest →
98
+ * a 43-char base64url verifier, inside RFC 7636's 43–128.
99
+ */
100
+ function deriveSeededPkce(secret, seed) {
101
+ const verifier = base64url(createHmac("sha256", secret).update(`aooth/pkce-verifier:${seed}`).digest());
102
+ return {
103
+ verifier,
104
+ nonce: base64url(createHmac("sha256", secret).update(`aooth/oidc-nonce:${seed}`).digest()),
105
+ challenge: pkceChallengeFor(verifier),
106
+ method: "S256"
107
+ };
108
+ }
109
+ //#endregion
110
+ //#region src/state.ts
111
+ const DEFAULT_TTL_SEC = 600;
112
+ function toKey(secret) {
113
+ return typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
114
+ }
115
+ /**
116
+ * Sign the binding into a compact HS256 JWT. Field names are abbreviated to
117
+ * keep the URL `state` short; {@link verifyState} maps them back.
118
+ */
119
+ async function signState(payload, secret, opts = {}) {
120
+ const clock = opts.clock ?? defaultClock;
121
+ const nowSec = Math.floor(clock.now() / 1e3);
122
+ const claims = {
123
+ rnd: payload.random,
124
+ prv: payload.provider,
125
+ rdr: payload.redirect
126
+ };
127
+ if (payload.verifier !== void 0) claims.vrf = payload.verifier;
128
+ if (payload.nonce !== void 0) claims.non = payload.nonce;
129
+ if (payload.handle !== void 0) claims.hdl = payload.handle;
130
+ if (payload.userId !== void 0) claims.uid = payload.userId;
131
+ return new SignJWT(claims).setProtectedHeader({ alg: "HS256" }).setIssuedAt(nowSec).setExpirationTime(nowSec + (opts.ttlSec ?? DEFAULT_TTL_SEC)).sign(toKey(secret));
132
+ }
133
+ /**
134
+ * Verify + decode the signed state. Pins `HS256` (rejects `alg:none` / key
135
+ * confusion). Throws {@link OAuthError} `STATE_EXPIRED` past TTL,
136
+ * `STATE_INVALID` on any other signature/shape failure.
137
+ */
138
+ async function verifyState(token, secret, opts = {}) {
139
+ const clock = opts.clock ?? defaultClock;
140
+ let payload;
141
+ try {
142
+ payload = (await jwtVerify(token, toKey(secret), {
143
+ algorithms: ["HS256"],
144
+ currentDate: new Date(clock.now())
145
+ })).payload;
146
+ } catch (err) {
147
+ if (err instanceof errors.JWTExpired) throw new OAuthError("STATE_EXPIRED");
148
+ throw new OAuthError("STATE_INVALID");
149
+ }
150
+ const random = payload.rnd;
151
+ const provider = payload.prv;
152
+ const redirect = payload.rdr;
153
+ if (typeof random !== "string" || typeof provider !== "string" || typeof redirect !== "string") throw new OAuthError("STATE_INVALID", "Malformed state payload");
154
+ const out = {
155
+ random,
156
+ provider,
157
+ redirect
158
+ };
159
+ if (typeof payload.vrf === "string") out.verifier = payload.vrf;
160
+ if (typeof payload.non === "string") out.nonce = payload.non;
161
+ if (typeof payload.hdl === "string") out.handle = payload.hdl;
162
+ if (typeof payload.uid === "string") out.userId = payload.uid;
163
+ return out;
164
+ }
165
+ //#endregion
166
+ //#region src/providers/oauth2-shared.ts
167
+ /** Resolve the effective `fetch`: an injected impl, else the global `fetch`. */
168
+ function resolveFetch(impl) {
169
+ return impl ?? globalThis.fetch;
170
+ }
171
+ /**
172
+ * `fetch` a JSON endpoint, failing CLOSED as an {@link OAuthError} on a network
173
+ * error, a non-2xx status, or a non-JSON body. `label` names the call in the
174
+ * thrown message; `errorType` selects the error class — token / provider-API
175
+ * failures are `EXCHANGE_FAILED`, OIDC discovery fails as `JWKS_FAILED`.
176
+ *
177
+ * Returns the parsed body as `unknown` — the caller narrows / applies any
178
+ * provider-specific post-checks (GitHub's HTTP-200 `{ error }`, an
179
+ * `access_token` presence guard, the OIDC discovery issuer match, …).
180
+ */
181
+ async function fetchJson(fetchFn, url, init, opts) {
182
+ const errorType = opts.errorType ?? "EXCHANGE_FAILED";
183
+ let res;
184
+ try {
185
+ res = await fetchFn(url, init);
186
+ } catch (err) {
187
+ throw new OAuthError(errorType, `${opts.label} request failed`, { cause: String(err) });
188
+ }
189
+ if (!res.ok) throw new OAuthError(errorType, `${opts.label} returned ${res.status}`, { status: res.status });
190
+ try {
191
+ return await res.json();
192
+ } catch {
193
+ throw new OAuthError(errorType, `${opts.label} returned a non-JSON body`);
194
+ }
195
+ }
196
+ /**
197
+ * Build the `302`-target authorization URL shared by every provider:
198
+ * `redirect_uri` + `state` + the PKCE S256 `code_challenge`. The OAuth2/OIDC
199
+ * extras (`response_type`, `client_id`, `scope`, `nonce`, provider params) are
200
+ * layered on via {@link BuildAuthorizeUrlOptions}.
201
+ */
202
+ function buildAuthorizeUrl(endpoint, args, opts = {}) {
203
+ const url = new URL(endpoint);
204
+ if (opts.responseType) url.searchParams.set("response_type", "code");
205
+ if (opts.clientId) url.searchParams.set("client_id", opts.clientId);
206
+ url.searchParams.set("redirect_uri", args.redirectUri);
207
+ if (opts.scopes) url.searchParams.set("scope", (args.scopes ?? opts.scopes).join(" "));
208
+ url.searchParams.set("state", args.state);
209
+ url.searchParams.set("code_challenge", args.codeChallenge);
210
+ url.searchParams.set("code_challenge_method", "S256");
211
+ if (opts.nonce && args.nonce) url.searchParams.set("nonce", args.nonce);
212
+ for (const [k, v] of Object.entries(opts.extraParams ?? {})) url.searchParams.set(k, v);
213
+ return url.toString();
214
+ }
215
+ //#endregion
216
+ //#region src/providers/oidc.ts
217
+ const DEFAULT_SCOPES$1 = [
218
+ "openid",
219
+ "email",
220
+ "profile"
221
+ ];
222
+ const DEFAULT_SIGNING_ALGS = ["RS256", "ES256"];
223
+ const DEFAULT_CLOCK_TOLERANCE_SEC = 5;
224
+ const DEFAULT_JWKS_CACHE_MS = 36e5;
225
+ /**
226
+ * Generic OpenID Connect provider: authorization-code + PKCE, full discovery,
227
+ * remote JWKS, and the OIDC Core 3.1.3.7 ID-token validation list (RFC §7).
228
+ * Verification fails CLOSED — a JWKS/discovery fetch failure is an
229
+ * `OAuthError('JWKS_FAILED')`, never a silent accept.
230
+ *
231
+ * `GoogleProvider` is a thin subclass pinning the issuer + algs.
232
+ */
233
+ var OidcProvider = class {
234
+ id;
235
+ issuer;
236
+ clientId;
237
+ clientSecret;
238
+ scopes;
239
+ signingAlgs;
240
+ explicitEndpoints;
241
+ injectedDiscovery;
242
+ injectedJwks;
243
+ clockToleranceSec;
244
+ jwksCacheTtlMs;
245
+ clockImpl;
246
+ fetchImpl;
247
+ discoveryCache;
248
+ jwksCache;
249
+ constructor(opts) {
250
+ if (!opts.issuer) throw new OAuthError("INVALID_CONFIG", "OIDC provider requires an 'issuer'");
251
+ if (!opts.clientId) throw new OAuthError("INVALID_CONFIG", "OIDC provider requires a 'clientId'");
252
+ if (!opts.clientSecret) throw new OAuthError("INVALID_CONFIG", "OIDC provider requires a 'clientSecret'");
253
+ this.id = opts.id ?? `oidc:${opts.issuer}`;
254
+ this.issuer = opts.issuer;
255
+ this.clientId = opts.clientId;
256
+ this.clientSecret = opts.clientSecret;
257
+ this.scopes = opts.scopes ?? DEFAULT_SCOPES$1;
258
+ this.signingAlgs = opts.idTokenSigningAlgs ?? DEFAULT_SIGNING_ALGS;
259
+ if (opts.authorizationEndpoint && opts.tokenEndpoint && opts.jwksUri) this.explicitEndpoints = {
260
+ issuer: opts.issuer,
261
+ authorization_endpoint: opts.authorizationEndpoint,
262
+ token_endpoint: opts.tokenEndpoint,
263
+ jwks_uri: opts.jwksUri
264
+ };
265
+ this.injectedDiscovery = opts.discovery;
266
+ this.injectedJwks = opts.jwks;
267
+ this.clockToleranceSec = opts.clockToleranceSec;
268
+ this.jwksCacheTtlMs = opts.jwksCacheTtlMs;
269
+ this.clockImpl = opts.clock;
270
+ this.fetchImpl = opts.fetch;
271
+ }
272
+ /** Registry-injected shared config; a ctor value always wins (decision #2). */
273
+ applyDefaults(shared) {
274
+ this.clockToleranceSec ??= shared.clockToleranceSec;
275
+ this.jwksCacheTtlMs ??= shared.jwks?.cacheTtlMs;
276
+ this.clockImpl ??= shared.clock;
277
+ this.fetchImpl ??= shared.fetch;
278
+ }
279
+ get clock() {
280
+ return this.clockImpl ?? defaultClock;
281
+ }
282
+ get fetchFn() {
283
+ return resolveFetch(this.fetchImpl);
284
+ }
285
+ /**
286
+ * The `client_secret` sent to the token endpoint: a static string as-is, or a
287
+ * {@link ClientSecretFactory}'s per-request value (Apple's minted ES256 JWT).
288
+ * Resolved lazily here so a factory sees the registry-injected clock.
289
+ */
290
+ resolveClientSecret() {
291
+ return typeof this.clientSecret === "function" ? this.clientSecret({ clock: this.clock }) : this.clientSecret;
292
+ }
293
+ /**
294
+ * Extra authorization-request query params merged into {@link authorizationUrl}.
295
+ * Default none; AppleProvider returns `{ response_mode: 'form_post' }` (required
296
+ * by Apple whenever `email`/`name` scope is requested).
297
+ */
298
+ extraAuthorizationParams(_args) {
299
+ return {};
300
+ }
301
+ async authorizationUrl(args) {
302
+ const { authorization_endpoint } = await this.resolveEndpoints();
303
+ return buildAuthorizeUrl(authorization_endpoint, args, {
304
+ responseType: true,
305
+ clientId: this.clientId,
306
+ scopes: this.scopes,
307
+ nonce: true,
308
+ extraParams: this.extraAuthorizationParams(args)
309
+ });
310
+ }
311
+ async exchange(args) {
312
+ const { token_endpoint, jwks_uri } = await this.resolveEndpoints();
313
+ const tokens = await this.postToken(token_endpoint, args);
314
+ const idToken = tokens.id_token;
315
+ if (typeof idToken !== "string") throw new OAuthError("ID_TOKEN_INVALID", "Token response carried no id_token");
316
+ const accessToken = typeof tokens.access_token === "string" ? tokens.access_token : void 0;
317
+ return this.verifyIdToken(idToken, accessToken, jwks_uri, args.expectedNonce);
318
+ }
319
+ async postToken(tokenEndpoint, args) {
320
+ const body = new URLSearchParams({
321
+ grant_type: "authorization_code",
322
+ code: args.code,
323
+ redirect_uri: args.redirectUri,
324
+ client_id: this.clientId,
325
+ client_secret: await this.resolveClientSecret(),
326
+ code_verifier: args.codeVerifier
327
+ });
328
+ return await fetchJson(this.fetchFn, tokenEndpoint, {
329
+ method: "POST",
330
+ headers: {
331
+ "content-type": "application/x-www-form-urlencoded",
332
+ accept: "application/json"
333
+ },
334
+ body: body.toString()
335
+ }, { label: "Token endpoint" });
336
+ }
337
+ async verifyIdToken(idToken, accessToken, jwksUri, expectedNonce) {
338
+ const keyResolver = await this.getJwks(jwksUri);
339
+ let payload;
340
+ let alg;
341
+ try {
342
+ const result = await jwtVerify(idToken, keyResolver, {
343
+ issuer: this.issuer,
344
+ audience: this.clientId,
345
+ algorithms: this.signingAlgs,
346
+ clockTolerance: this.clockToleranceSec ?? DEFAULT_CLOCK_TOLERANCE_SEC,
347
+ currentDate: new Date(this.clock.now())
348
+ });
349
+ payload = result.payload;
350
+ alg = result.protectedHeader.alg;
351
+ } catch (err) {
352
+ const code = err?.code ?? "";
353
+ if (code.startsWith("ERR_JWKS")) throw new OAuthError("JWKS_FAILED", "Could not resolve the provider's signing key", { code });
354
+ throw new OAuthError("ID_TOKEN_INVALID", "ID token failed verification", { code });
355
+ }
356
+ if (typeof payload.sub !== "string" || payload.sub.length === 0) throw new OAuthError("ID_TOKEN_INVALID", "ID token has no subject");
357
+ if (expectedNonce !== void 0 && payload.nonce !== expectedNonce) throw new OAuthError("ID_TOKEN_INVALID", "ID token nonce mismatch");
358
+ if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== this.clientId) throw new OAuthError("ID_TOKEN_INVALID", "ID token azp does not match client");
359
+ if (accessToken !== void 0 && typeof payload.at_hash === "string") {
360
+ if (payload.at_hash !== atHash(accessToken, alg)) throw new OAuthError("ID_TOKEN_INVALID", "ID token at_hash mismatch");
361
+ }
362
+ return this.normalize(payload);
363
+ }
364
+ /**
365
+ * Map verified claims → {@link NormalizedProfile}. `protected` so a provider
366
+ * (Apple) can post-process — e.g. coerce Apple's spec-violating STRING
367
+ * `email_verified` after the base has applied the strict boolean-only rule.
368
+ */
369
+ normalize(payload) {
370
+ const profile = {
371
+ provider: this.id,
372
+ subject: payload.sub,
373
+ raw: payload
374
+ };
375
+ if (typeof payload.email === "string") profile.email = payload.email;
376
+ if (typeof payload.email_verified === "boolean") profile.emailVerified = payload.email_verified;
377
+ if (typeof payload.name === "string") profile.displayName = payload.name;
378
+ if (typeof payload.picture === "string") profile.avatarUrl = payload.picture;
379
+ return profile;
380
+ }
381
+ async resolveEndpoints() {
382
+ if (this.explicitEndpoints) return this.explicitEndpoints;
383
+ if (this.injectedDiscovery) return this.validateDiscovery(this.injectedDiscovery);
384
+ if (this.discoveryCache) return this.discoveryCache;
385
+ const wellKnown = `${this.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
386
+ const doc = await fetchJson(this.fetchFn, wellKnown, { headers: { accept: "application/json" } }, {
387
+ label: "OIDC discovery",
388
+ errorType: "JWKS_FAILED"
389
+ });
390
+ this.discoveryCache = this.validateDiscovery(doc);
391
+ return this.discoveryCache;
392
+ }
393
+ /** OIDC discovery MUST echo the requested issuer (guards a swapped doc). */
394
+ validateDiscovery(doc) {
395
+ if (doc.issuer !== this.issuer) throw new OAuthError("INVALID_CONFIG", "Discovery issuer does not match configured issuer", {
396
+ expected: this.issuer,
397
+ got: doc.issuer
398
+ });
399
+ if (!doc.authorization_endpoint || !doc.token_endpoint || !doc.jwks_uri) throw new OAuthError("INVALID_CONFIG", "Discovery document is missing required endpoints");
400
+ return doc;
401
+ }
402
+ async getJwks(jwksUri) {
403
+ if (this.injectedJwks) return this.injectedJwks;
404
+ if (this.jwksCache?.uri === jwksUri) return this.jwksCache.resolver;
405
+ const resolver = createRemoteJWKSet(new URL(jwksUri), { cacheMaxAge: this.jwksCacheTtlMs ?? DEFAULT_JWKS_CACHE_MS });
406
+ this.jwksCache = {
407
+ uri: jwksUri,
408
+ resolver
409
+ };
410
+ return resolver;
411
+ }
412
+ };
413
+ /**
414
+ * OIDC at_hash (Core 3.1.3.6): base64url of the left-most half of the access
415
+ * token's hash, the hash chosen by the id_token's `alg` (RS256 → SHA-256).
416
+ */
417
+ function atHash(accessToken, alg) {
418
+ const digest = createHash(alg.endsWith("384") ? "sha384" : alg.endsWith("512") ? "sha512" : "sha256").update(accessToken).digest();
419
+ return digest.subarray(0, digest.length / 2).toString("base64url");
420
+ }
421
+ //#endregion
422
+ //#region src/providers/google.ts
423
+ /** Google's stable OIDC issuer — endpoints + JWKS are discovered from it. */
424
+ const GOOGLE_ISSUER = "https://accounts.google.com";
425
+ /**
426
+ * Google Sign-In (OIDC). Pins `id: 'google'`, the Google issuer, and `RS256`
427
+ * (Google's only ID-token alg). Everything else — discovery, JWKS rotation,
428
+ * the full §7 validation — is inherited from {@link OidcProvider}.
429
+ */
430
+ var GoogleProvider = class extends OidcProvider {
431
+ constructor(opts) {
432
+ super({
433
+ ...opts,
434
+ id: "google",
435
+ issuer: GOOGLE_ISSUER,
436
+ idTokenSigningAlgs: opts.idTokenSigningAlgs ?? ["RS256"]
437
+ });
438
+ }
439
+ };
440
+ //#endregion
441
+ //#region src/providers/github.ts
442
+ const GITHUB_AUTHORIZE = "https://github.com/login/oauth/authorize";
443
+ const GITHUB_TOKEN = "https://github.com/login/oauth/access_token";
444
+ const GITHUB_USER = "https://api.github.com/user";
445
+ const GITHUB_EMAILS = "https://api.github.com/user/emails";
446
+ const DEFAULT_SCOPES = ["read:user", "user:email"];
447
+ const DEFAULT_USER_AGENT = "aoothjs";
448
+ /**
449
+ * Sign in with GitHub — pure **OAuth2** (no OpenID Connect): there is no
450
+ * `id_token`, no JWKS, and no nonce. After the authorization-code + PKCE
451
+ * exchange yields an access token, the profile is read from GitHub's REST API
452
+ * (`GET /user` + `GET /user/emails`).
453
+ *
454
+ * **`emailVerified` is strict** (RFC IDP.md §3.4): it is `true` ONLY when the
455
+ * user's PRIMARY email is GitHub-verified. A non-primary or unverified address
456
+ * yields `false` — never trust a GitHub email as proof-of-control unless it is
457
+ * the verified primary.
458
+ *
459
+ * Implements {@link ConfigurableProvider} so the registry can inject a shared
460
+ * `fetch` (deterministic tests) / `clock`; a constructor value always wins.
461
+ */
462
+ var GithubProvider = class {
463
+ id;
464
+ clientId;
465
+ clientSecret;
466
+ scopes;
467
+ userAgent;
468
+ authorizationEndpoint;
469
+ tokenEndpoint;
470
+ userEndpoint;
471
+ emailsEndpoint;
472
+ fetchImpl;
473
+ constructor(opts) {
474
+ if (!opts.clientId || !opts.clientSecret) throw new OAuthError("INVALID_CONFIG", "GithubProvider requires 'clientId' and 'clientSecret'");
475
+ this.id = opts.id ?? "github";
476
+ this.clientId = opts.clientId;
477
+ this.clientSecret = opts.clientSecret;
478
+ this.scopes = opts.scopes ?? DEFAULT_SCOPES;
479
+ this.userAgent = opts.userAgent ?? DEFAULT_USER_AGENT;
480
+ this.authorizationEndpoint = opts.authorizationEndpoint ?? GITHUB_AUTHORIZE;
481
+ this.tokenEndpoint = opts.tokenEndpoint ?? GITHUB_TOKEN;
482
+ this.userEndpoint = opts.userEndpoint ?? GITHUB_USER;
483
+ this.emailsEndpoint = opts.emailsEndpoint ?? GITHUB_EMAILS;
484
+ this.fetchImpl = opts.fetch;
485
+ }
486
+ /** Registry-injected shared config; a ctor value always wins (decision #2). */
487
+ applyDefaults(shared) {
488
+ this.fetchImpl ??= shared.fetch;
489
+ }
490
+ get fetchFn() {
491
+ return resolveFetch(this.fetchImpl);
492
+ }
493
+ authorizationUrl(args) {
494
+ return Promise.resolve(buildAuthorizeUrl(this.authorizationEndpoint, args, {
495
+ responseType: true,
496
+ clientId: this.clientId,
497
+ scopes: this.scopes
498
+ }));
499
+ }
500
+ async exchange(args) {
501
+ const accessToken = await this.redeemCode(args);
502
+ const [user, primary] = await Promise.all([this.fetchUser(accessToken), this.fetchPrimaryEmail(accessToken)]);
503
+ const { email, emailVerified } = primary ?? (user.email ? {
504
+ email: user.email,
505
+ emailVerified: false
506
+ } : {});
507
+ const profile = {
508
+ provider: this.id,
509
+ subject: String(user.id),
510
+ raw: user
511
+ };
512
+ if (email !== void 0) profile.email = email;
513
+ if (emailVerified !== void 0) profile.emailVerified = emailVerified;
514
+ const displayName = user.name ?? user.login;
515
+ if (displayName) profile.displayName = displayName;
516
+ if (user.avatar_url) profile.avatarUrl = user.avatar_url;
517
+ return profile;
518
+ }
519
+ /** POST the authorization `code` → access token (GitHub returns JSON when asked). */
520
+ async redeemCode(args) {
521
+ const body = new URLSearchParams({
522
+ grant_type: "authorization_code",
523
+ code: args.code,
524
+ redirect_uri: args.redirectUri,
525
+ client_id: this.clientId,
526
+ client_secret: this.clientSecret,
527
+ code_verifier: args.codeVerifier
528
+ });
529
+ const json = await fetchJson(this.fetchFn, this.tokenEndpoint, {
530
+ method: "POST",
531
+ headers: {
532
+ "content-type": "application/x-www-form-urlencoded",
533
+ accept: "application/json",
534
+ "user-agent": this.userAgent
535
+ },
536
+ body: body.toString()
537
+ }, { label: "GitHub token endpoint" });
538
+ if (typeof json.error === "string") throw new OAuthError("EXCHANGE_FAILED", "GitHub rejected the authorization code", { error: json.error });
539
+ if (typeof json.access_token !== "string" || json.access_token.length === 0) throw new OAuthError("EXCHANGE_FAILED", "GitHub token response carried no access_token");
540
+ return json.access_token;
541
+ }
542
+ async fetchUser(accessToken) {
543
+ const user = await this.apiGet(this.userEndpoint, accessToken);
544
+ if (user.id === void 0 || user.id === null || typeof user.login !== "string") throw new OAuthError("EXCHANGE_FAILED", "GitHub /user response was missing id/login");
545
+ return user;
546
+ }
547
+ /**
548
+ * Resolve the verified PRIMARY `/user/emails` entry, `emailVerified` strictly
549
+ * from its `verified` flag. Returns `undefined` when the `user:email` scope is
550
+ * absent (call fails), the body isn't an array, or there's no primary entry —
551
+ * the caller then degrades to the public `/user` email (unverified).
552
+ */
553
+ async fetchPrimaryEmail(accessToken) {
554
+ let entries;
555
+ try {
556
+ entries = await this.apiGet(this.emailsEndpoint, accessToken);
557
+ } catch {
558
+ return;
559
+ }
560
+ if (!Array.isArray(entries)) return void 0;
561
+ const primary = entries.find((e) => e && e.primary);
562
+ if (primary && typeof primary.email === "string") return {
563
+ email: primary.email,
564
+ emailVerified: primary.verified
565
+ };
566
+ }
567
+ apiGet(url, accessToken) {
568
+ return fetchJson(this.fetchFn, url, {
569
+ method: "GET",
570
+ headers: {
571
+ authorization: `Bearer ${accessToken}`,
572
+ accept: "application/vnd.github+json",
573
+ "user-agent": this.userAgent
574
+ }
575
+ }, { label: `GitHub API (${url})` });
576
+ }
577
+ };
578
+ //#endregion
579
+ //#region src/providers/apple.ts
580
+ /** Apple's stable OIDC issuer — endpoints + JWKS are discovered from it. */
581
+ const APPLE_ISSUER = "https://appleid.apple.com";
582
+ /** Apple ID tokens are always ES256. */
583
+ const APPLE_SIGNING_ALGS = ["ES256"];
584
+ /** Default scope — `openid email`; `name` is omitted (Apple sends it once, out-of-band; deferred). */
585
+ const DEFAULT_APPLE_SCOPES = ["openid", "email"];
586
+ /** Default client-secret JWT lifetime (1h). Apple's hard ceiling is ~6 months (15777000s). */
587
+ const DEFAULT_SECRET_TTL_SEC = 3600;
588
+ /** A minute of slack so a cached secret is never served right at its expiry. */
589
+ const SECRET_RENEW_SLACK_SEC = 60;
590
+ /**
591
+ * Build Apple's dynamic `client_secret` source: a closure that mints — and
592
+ * caches — a short-lived ES256 JWT signed with the `.p8` key. Header
593
+ * `{ alg: 'ES256', kid }`, claims `{ iss: teamId, iat, exp, aud: APPLE_ISSUER,
594
+ * sub: clientId }`. The signed JWT is reused until shortly before its `exp`, and
595
+ * the (comparatively costly) `.p8` import is memoized — so a burst of exchanges
596
+ * shares one signature and parses the key at most once. The clock comes from the
597
+ * per-call context, so a registry-injected clock still drives `iat`/`exp`.
598
+ */
599
+ function makeAppleClientSecretFactory(cfg) {
600
+ let cachedSecret;
601
+ let importedKey;
602
+ return async ({ clock }) => {
603
+ const nowSec = Math.floor(clock.now() / 1e3);
604
+ if (cachedSecret && cachedSecret.expSec - nowSec > SECRET_RENEW_SLACK_SEC) return cachedSecret.jwt;
605
+ let key;
606
+ try {
607
+ importedKey ??= importPKCS8(cfg.privateKey, "ES256");
608
+ key = await importedKey;
609
+ } catch (err) {
610
+ importedKey = void 0;
611
+ throw new OAuthError("INVALID_CONFIG", "AppleProvider could not import the '.p8' private key", { cause: String(err) });
612
+ }
613
+ const expSec = nowSec + cfg.ttlSec;
614
+ const jwt = await new SignJWT({}).setProtectedHeader({
615
+ alg: "ES256",
616
+ kid: cfg.keyId
617
+ }).setIssuer(cfg.teamId).setIssuedAt(nowSec).setExpirationTime(expSec).setAudience(APPLE_ISSUER).setSubject(cfg.clientId).sign(key);
618
+ cachedSecret = {
619
+ jwt,
620
+ expSec
621
+ };
622
+ return jwt;
623
+ };
624
+ }
625
+ /**
626
+ * Sign in with Apple (OIDC). A thin subclass of {@link OidcProvider} — Apple IS
627
+ * an OpenID Connect provider (issuer `https://appleid.apple.com`, discoverable
628
+ * endpoints + JWKS), so the whole §7 ID-token validation, JWKS rotation, PKCE,
629
+ * and discovery are inherited. Only the things Apple does differently are
630
+ * wired/overridden here:
631
+ *
632
+ * 1. **No static client secret.** Apple's `client_secret` is a short-lived
633
+ * **ES256 JWT** signed with the developer's `.p8` key. The constructor wires
634
+ * a {@link ClientSecretFactory} ({@link makeAppleClientSecretFactory}) that
635
+ * mints + caches it per token exchange — no base seam to override.
636
+ * 2. **`response_mode=form_post`.** Apple requires it whenever `email`/`name`
637
+ * scope is requested, so the callback is a cross-site **POST**. The
638
+ * `@aooth/auth-moost` `OAuthController` bounces that POST back to the normal
639
+ * GET callback path, so everything downstream (state, CSRF, exchange) is
640
+ * byte-identical to Google/GitHub.
641
+ * 3. **String `email_verified`.** Apple violates OIDC by sending
642
+ * `email_verified` (and `is_private_email`) as the STRING `"true"`/`"false"`;
643
+ * {@link normalize} coerces it after the base's strict boolean-only pass.
644
+ *
645
+ * NOTE: the user's NAME arrives only on the FIRST authorization, in the
646
+ * form_post `user` field (never in the id_token) — v1 deliberately does not
647
+ * capture it, so `displayName` is undefined for Apple. Collect a display name
648
+ * post-signup if you need one.
649
+ */
650
+ var AppleProvider = class extends OidcProvider {
651
+ constructor(opts) {
652
+ if (!opts.teamId || !opts.keyId || !opts.privateKey) throw new OAuthError("INVALID_CONFIG", "AppleProvider requires 'teamId', 'keyId', and 'privateKey'");
653
+ super({
654
+ ...opts,
655
+ id: "apple",
656
+ issuer: APPLE_ISSUER,
657
+ clientSecret: makeAppleClientSecretFactory({
658
+ clientId: opts.clientId,
659
+ teamId: opts.teamId,
660
+ keyId: opts.keyId,
661
+ privateKey: opts.privateKey,
662
+ ttlSec: opts.clientSecretTtlSec ?? DEFAULT_SECRET_TTL_SEC
663
+ }),
664
+ idTokenSigningAlgs: APPLE_SIGNING_ALGS,
665
+ scopes: opts.scopes ?? DEFAULT_APPLE_SCOPES
666
+ });
667
+ }
668
+ /** `email`/`name` scope makes Apple POST the callback — declare `form_post`. */
669
+ extraAuthorizationParams(_args) {
670
+ return { response_mode: "form_post" };
671
+ }
672
+ /** Coerce Apple's STRING `email_verified` (the base sets only a real boolean). */
673
+ normalize(payload) {
674
+ const profile = super.normalize(payload);
675
+ if (profile.emailVerified === void 0) {
676
+ const ev = payload.email_verified;
677
+ if (ev === "true") profile.emailVerified = true;
678
+ else if (ev === "false") profile.emailVerified = false;
679
+ }
680
+ return profile;
681
+ }
682
+ };
683
+ //#endregion
684
+ //#region src/providers/fake.ts
685
+ /**
686
+ * Deterministic, network-free {@link IdentityProvider} for unit tests and the
687
+ * `DEMO_MODE=test` fake-IdP e2e route (RFC §9). `exchange` resolves a `code`
688
+ * to a pre-registered profile (or the default), so the whole
689
+ * `start → callback → resolveUser → gates` round trip runs offline.
690
+ *
691
+ * It does NOT verify a nonce — it is the trusted test double; the real §7
692
+ * nonce/JWKS assertions are exercised against {@link OidcProvider} with `jose`.
693
+ */
694
+ var FakeIdentityProvider = class {
695
+ id;
696
+ authorizationEndpoint;
697
+ defaultProfile;
698
+ profiles = /* @__PURE__ */ new Map();
699
+ constructor(opts = {}) {
700
+ this.id = opts.id ?? "fake";
701
+ this.authorizationEndpoint = opts.authorizationEndpoint ?? "https://fake-idp.test/authorize";
702
+ this.defaultProfile = opts.defaultProfile;
703
+ }
704
+ /** Register the profile a given `code` resolves to. Returns `this` for chaining. */
705
+ setProfile(code, profile) {
706
+ this.profiles.set(code, profile);
707
+ return this;
708
+ }
709
+ authorizationUrl(args) {
710
+ return Promise.resolve(buildAuthorizeUrl(this.authorizationEndpoint, args, { nonce: true }));
711
+ }
712
+ exchange(args) {
713
+ const base = this.profiles.get(args.code) ?? this.defaultProfile;
714
+ if (!base) return Promise.reject(new OAuthError("EXCHANGE_FAILED", `Fake IdP has no profile for code '${args.code}'`));
715
+ return Promise.resolve({
716
+ ...base,
717
+ provider: this.id
718
+ });
719
+ }
720
+ };
721
+ //#endregion
722
+ //#region src/registry.ts
723
+ const DEFAULT_CALLBACK_TEMPLATE = "/auth/oauth/:provider/callback";
724
+ /**
725
+ * Holds the configured {@link IdentityProvider}s + {@link FederatedPolicy} +
726
+ * the shared signing/verification config (decision #2). Framework-agnostic —
727
+ * phase 3 only DI-binds it. On construction it injects the shared config into
728
+ * each provider that implements `applyDefaults`, and builds the per-provider
729
+ * fixed redirect URIs.
730
+ */
731
+ var OAuthProviderRegistry = class {
732
+ baseUrl;
733
+ stateSecret;
734
+ policy;
735
+ providers = /* @__PURE__ */ new Map();
736
+ callbackPathTemplate;
737
+ constructor(opts) {
738
+ if (!opts.baseUrl) throw new OAuthError("INVALID_CONFIG", "OAuthProviderRegistry requires a 'baseUrl'");
739
+ if (!opts.stateSecret) throw new OAuthError("INVALID_CONFIG", "OAuthProviderRegistry requires a 'stateSecret'");
740
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
741
+ this.stateSecret = opts.stateSecret;
742
+ this.policy = resolveFederatedPolicy(opts.policy);
743
+ this.callbackPathTemplate = opts.callbackPathTemplate ?? DEFAULT_CALLBACK_TEMPLATE;
744
+ const shared = {
745
+ clockToleranceSec: opts.clockToleranceSec,
746
+ jwks: opts.jwks,
747
+ clock: opts.clock,
748
+ fetch: opts.fetch
749
+ };
750
+ for (const p of opts.providers) {
751
+ if (this.providers.has(p.id)) throw new OAuthError("INVALID_CONFIG", `Duplicate provider id '${p.id}'`);
752
+ if (isConfigurableProvider(p)) p.applyDefaults(shared);
753
+ this.providers.set(p.id, p);
754
+ }
755
+ }
756
+ /** Is a provider with this id registered? */
757
+ has(id) {
758
+ return this.providers.has(id);
759
+ }
760
+ /** Resolve a provider, or `undefined`. */
761
+ get(id) {
762
+ return this.providers.get(id);
763
+ }
764
+ /** Resolve a provider, or throw `OAuthError('UNKNOWN_PROVIDER')` (→ HTTP 404 in phase 3). */
765
+ require(id) {
766
+ const p = this.providers.get(id);
767
+ if (!p) throw new OAuthError("UNKNOWN_PROVIDER", void 0, { provider: id });
768
+ return p;
769
+ }
770
+ /** Registered provider ids, in insertion order. */
771
+ ids() {
772
+ return [...this.providers.keys()];
773
+ }
774
+ /** All registered providers, in insertion order. */
775
+ list() {
776
+ return [...this.providers.values()];
777
+ }
778
+ /** The callback path for a provider (template with `:provider` substituted). */
779
+ callbackPath(providerId) {
780
+ return this.callbackPathTemplate.replace(":provider", encodeURIComponent(providerId));
781
+ }
782
+ /** The FIXED, exact-match-registered `redirect_uri` for a provider (`baseUrl` + callback path). */
783
+ redirectUri(providerId) {
784
+ return `${this.baseUrl}${this.callbackPath(providerId)}`;
785
+ }
786
+ /** Sign a state payload with the registry's `stateSecret`. */
787
+ signState(payload, opts) {
788
+ return signState(payload, this.stateSecret, opts);
789
+ }
790
+ /** Verify + decode a state token against the registry's `stateSecret`. */
791
+ verifyState(token, opts) {
792
+ return verifyState(token, this.stateSecret, opts);
793
+ }
794
+ /**
795
+ * Derive the stateless PKCE verifier + OIDC nonce from the signed-state
796
+ * `random` seed, using the registry's `stateSecret`. `/start` and the
797
+ * callback call this with the SAME seed to obtain the SAME pair without any
798
+ * server-side flow store. See {@link deriveSeededPkce}.
799
+ */
800
+ deriveSeededPkce(seed) {
801
+ return deriveSeededPkce(this.stateSecret, seed);
802
+ }
803
+ };
804
+ //#endregion
805
+ //#region src/federated-login-service.ts
806
+ /**
807
+ * The core federated-login logic (RFC §3.5): map a verified provider profile to
808
+ * an aooth user id. Pure orchestration over `UserService` + `FederatedIdentityStore`
809
+ * — no HTTP, no workflow, no token issuance (those are phase 3). Account-state
810
+ * gating, MFA, and consent are NOT done here; they run as the existing login
811
+ * workflow tail after the phase-3 `oauth-exchange` step sets the subject.
812
+ *
813
+ * Generic over the user shape `T` so a consumer's `UserService<DemoUser>` plugs
814
+ * in directly; `resolveUser` only reads base `UserCredentials` fields.
815
+ */
816
+ var FederatedLoginService = class {
817
+ users;
818
+ federated;
819
+ policy;
820
+ constructor(deps) {
821
+ this.users = deps.users;
822
+ this.federated = deps.federated;
823
+ this.policy = resolveFederatedPolicy(deps.policy);
824
+ }
825
+ /**
826
+ * Resolve a verified {@link NormalizedProfile} to an outcome (decision #1):
827
+ *
828
+ * 1. **Known** `(provider, subject)` → `linked`.
829
+ * 2. **Email match** (fresh verified email via `findByHandle`):
830
+ * - `auto-link-if-verified` + trusted + verified → link → `auto-linked`;
831
+ * - otherwise (incl. `require-interactive-link`, or auto-link conditions
832
+ * unmet) → `needs-link` (caller completes via {@link linkIdentity}).
833
+ * - `create-separate` ignores the match and falls to (3).
834
+ * 3. **New** → `denied` if `allowSignup === false`; else create + activate +
835
+ * link → `created`.
836
+ *
837
+ * Every resolved outcome (`linked`/`auto-linked`/`created`) stamps
838
+ * `lastLoginAt` + refreshes the row's display snapshot via `touchLogin`.
839
+ */
840
+ async resolveUser(profile) {
841
+ const known = await this.federated.find(profile.provider, profile.subject);
842
+ if (known) {
843
+ await this.touch(profile);
844
+ return {
845
+ kind: "linked",
846
+ userId: known.userId,
847
+ isNew: false
848
+ };
849
+ }
850
+ if (profile.email && this.policy.emailMatch !== "create-separate") {
851
+ const candidate = await this.users.findByHandle(profile.email);
852
+ if (candidate) {
853
+ if (this.policy.emailMatch === "auto-link-if-verified" && this.canAutoLink(profile)) {
854
+ await this.federated.link(this.newIdentity(profile, candidate.id));
855
+ await this.touch(profile);
856
+ return {
857
+ kind: "auto-linked",
858
+ userId: candidate.id,
859
+ isNew: false
860
+ };
861
+ }
862
+ return {
863
+ kind: "needs-link",
864
+ candidateUserId: candidate.id
865
+ };
866
+ }
867
+ }
868
+ if (!this.policy.allowSignup) return {
869
+ kind: "denied",
870
+ reason: "signup-disabled"
871
+ };
872
+ const userId = await this.createFederatedUser(profile);
873
+ await this.users.activateAccount(userId);
874
+ await this.federated.link(this.newIdentity(profile, userId));
875
+ await this.touch(profile);
876
+ return {
877
+ kind: "created",
878
+ userId,
879
+ isNew: true
880
+ };
881
+ }
882
+ /**
883
+ * Complete an interactive link (the `needs-link` outcome): attach
884
+ * `(provider, subject)` to an **already-authenticated** `userId`. Idempotent
885
+ * when it is already that user's. Throws `UserAuthError('ALREADY_EXISTS')`
886
+ * when the identity is already linked to a DIFFERENT user — the
887
+ * confused-deputy / account-injection guard (RFC §4).
888
+ *
889
+ * The CSRF / state↔session-userId binding that proves the request truly
890
+ * speaks for `userId` is the phase-3 controller's responsibility.
891
+ */
892
+ async linkIdentity(params) {
893
+ const existing = await this.federated.find(params.provider, params.subject);
894
+ if (existing) {
895
+ if (existing.userId === params.userId) {
896
+ await this.federated.touchLogin(params.provider, params.subject, params.profile ? pickDefinedProfile(params.profile) : void 0);
897
+ return existing;
898
+ }
899
+ throw new UserAuthError("ALREADY_EXISTS", "This provider account is linked to a different user");
900
+ }
901
+ const rec = {
902
+ provider: params.provider,
903
+ subject: params.subject,
904
+ userId: params.userId,
905
+ ...params.profile ? pickDefinedProfile(params.profile) : {}
906
+ };
907
+ return this.federated.link(rec);
908
+ }
909
+ /** Email-verified AND the provider is explicitly trusted (RFC §4). */
910
+ canAutoLink(profile) {
911
+ return profile.emailVerified === true && this.policy.trustEmailVerifiedFrom.includes(profile.provider);
912
+ }
913
+ /**
914
+ * Create a fresh user for an unmatched identity. Tries the policy username;
915
+ * on an `ALREADY_EXISTS` conflict falls back to the federated-unique
916
+ * `${provider}:${subject}`. Deliberately does NOT set the account `email`
917
+ * handle — promoting a provider email to the unique login handle is a gated
918
+ * phase-3 concern; the verified email is kept on the federated row instead.
919
+ */
920
+ async createFederatedUser(profile) {
921
+ const desired = this.policy.usernameStrategy(profile);
922
+ const fallback = `${profile.provider}:${profile.subject}`;
923
+ try {
924
+ return (await this.users.createUser(desired)).id;
925
+ } catch (err) {
926
+ if (err instanceof UserAuthError && err.type === "ALREADY_EXISTS" && desired !== fallback) return (await this.users.createUser(fallback)).id;
927
+ throw err;
928
+ }
929
+ }
930
+ newIdentity(profile, userId) {
931
+ return {
932
+ provider: profile.provider,
933
+ subject: profile.subject,
934
+ userId,
935
+ ...pickDefinedProfile(profile)
936
+ };
937
+ }
938
+ touch(profile) {
939
+ return this.federated.touchLogin(profile.provider, profile.subject, pickDefinedProfile(profile));
940
+ }
941
+ };
942
+ //#endregion
943
+ export { AppleProvider, FakeIdentityProvider, FederatedLoginService, GithubProvider, GoogleProvider, OAuthError, OAuthProviderRegistry, OidcProvider, createPkcePair, defaultUsernameStrategy, deriveSeededPkce, generateNonce, generateRandomState, isConfigurableProvider, pkceChallengeFor, resolveFederatedPolicy, signState, verifyState };