@fonderie/auth 7.1.1 → 7.3.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/brain/outcomes.md CHANGED
@@ -9,6 +9,13 @@ downloading tarballs.
9
9
 
10
10
  ## Database tables (after all migrations)
11
11
 
12
+ ### `fonderie_consumed_tokens`
13
+
14
+ ```sql
15
+ token_hash TEXT PRIMARY KEY
16
+ expires_at TIMESTAMPTZ NOT NULL
17
+ ```
18
+
12
19
  ### `fonderie_email_verifications`
13
20
 
14
21
  ```sql
@@ -129,10 +136,13 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
129
136
 
130
137
  | Method | Path | Middleware chain (auth / validation / handler) |
131
138
  |---|---|---|
139
+ | GET | `/auth/apple` | `oauth.appleInit` |
140
+ | POST | `/auth/apple/callback` | `ipLimit('login') → oauth.appleCallback` |
141
+ | POST | `/auth/apple/native` | `ipLimit('login') → validate(appleNativeSchema) → oauth.appleNative` |
132
142
  | POST | `/auth/email/forgot` | `ipLimit('forgot') → validate(forgotPasswordSchema) → acctLimit('forgot') → auth.forgotPassword` |
133
143
  | POST | `/auth/email/reset` | `ipLimit('reset') → validate(resetPasswordSchema) → auth.resetPassword` |
134
144
  | GET | `/auth/google` | `oauth.googleInit` |
135
- | GET | `/auth/google/callback` | `oauth.googleCallback` |
145
+ | GET | `/auth/google/callback` | `ipLimit('login') → oauth.googleCallback` |
136
146
  | POST | `/auth/login` | `ipLimit('login') → validate(loginSchema) → acctLimit('login') → auth.login` |
137
147
  | GET | `/auth/login-history` | `requireAuth → user.loginHistory` |
138
148
  | POST | `/auth/logout` | `requireAuth → validate(refreshSchema) → auth.logout` |
@@ -56,7 +56,7 @@ interface IAuthConfig extends IAuthSecrets, IAuthRuntimeConfig {
56
56
  secureCookies?: boolean;
57
57
  rateLimit?: IAuthRateLimitConfig | false;
58
58
  accessTokenDuration?: string;
59
- providers: ('email' | 'phone' | 'google' | 'github')[];
59
+ providers: ('email' | 'phone' | 'google' | 'github' | 'apple')[];
60
60
  appName?: string;
61
61
  passwordResetUrl?: string;
62
62
  resolve?: (ctx: {
@@ -75,6 +75,14 @@ interface IAuthSecrets {
75
75
  clientSecret: string;
76
76
  redirectUri: string;
77
77
  };
78
+ apple?: {
79
+ clientId: string;
80
+ teamId: string;
81
+ keyId: string;
82
+ privateKey: string;
83
+ redirectUri: string;
84
+ nativeClientIds?: string[];
85
+ };
78
86
  }
79
87
 
80
88
  interface IAuthRuntimeConfig {
@@ -145,7 +153,7 @@ interface ISessionDTO {
145
153
 
146
154
  function validate(schema: IRequestSchema): Middleware
147
155
 
148
- namespace schemas — exports: ChangePasswordInput, LoginInput, RegisterInput, ResetPasswordInput, changePasswordSchema, forgotPasswordSchema, loginSchema, mfaTokenSchema, refreshSchema, registerSchema, resetPasswordSchema, updateEmailSchema, updatePhoneSchema, updatePreferencesSchema, updateProfileSchema, verifySchema
156
+ namespace schemas — exports: ChangePasswordInput, LoginInput, RegisterInput, ResetPasswordInput, appleNativeSchema, changePasswordSchema, forgotPasswordSchema, loginSchema, mfaTokenSchema, refreshSchema, registerSchema, resetPasswordSchema, updateEmailSchema, updatePhoneSchema, updatePreferencesSchema, updateProfileSchema, verifySchema
149
157
 
150
158
  type RegisterInput = z.infer<typeof registerSchema>;
151
159
 
package/dist/index.cjs CHANGED
@@ -168,6 +168,7 @@ function buildAuthAccountLimiter(route, store, config) {
168
168
  // src/schemas.ts
169
169
  var schemas_exports = {};
170
170
  __export(schemas_exports, {
171
+ appleNativeSchema: () => appleNativeSchema,
171
172
  changePasswordSchema: () => changePasswordSchema,
172
173
  forgotPasswordSchema: () => forgotPasswordSchema,
173
174
  loginSchema: () => loginSchema,
@@ -199,6 +200,12 @@ var loginSchema = import_zod.z.union([
199
200
  import_zod.z.object({ email, password: import_zod.z.string().min(1).max(128) }),
200
201
  import_zod.z.object({ phone })
201
202
  ]);
203
+ var appleNativeSchema = import_zod.z.object({
204
+ identityToken: import_zod.z.string().min(1).max(8192),
205
+ // Optional nonce the app bound to the native request; when present we check
206
+ // it against the token's `nonce` claim to defend against replay.
207
+ nonce: import_zod.z.string().max(256).optional()
208
+ });
202
209
  var refreshSchema = import_zod.z.object({ refreshToken: import_zod.z.string().min(1).optional() });
203
210
  var forgotPasswordSchema = import_zod.z.object({ email });
204
211
  var resetPasswordSchema = import_zod.z.union([
@@ -729,7 +736,8 @@ var UserModel = class {
729
736
  `INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
730
737
  VALUES ($1, now(), $2, $3)
731
738
  ON CONFLICT (email) DO UPDATE
732
- SET provider = $2, provider_id = $3
739
+ SET provider = $2, provider_id = $3,
740
+ email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
733
741
  RETURNING id`,
734
742
  [email2, provider, providerId]
735
743
  );
@@ -2295,11 +2303,169 @@ function userController(store, config, bus) {
2295
2303
 
2296
2304
  // src/controllers/oauth.controller.ts
2297
2305
  var import_node_crypto7 = require("crypto");
2306
+ var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
2298
2307
  var import_core8 = require("@fonderie/core");
2308
+
2309
+ // src/models/consumed-token.model.ts
2310
+ var ConsumedTokenModel = class {
2311
+ constructor(store) {
2312
+ this.store = store;
2313
+ }
2314
+ store;
2315
+ // True if the token had not been consumed before (caller may proceed); false
2316
+ // if this is a replay. `expiresAt` is the token's own exp — its TTL here.
2317
+ async consumeOnce(tokenHash, expiresAt) {
2318
+ const rows = await this.store.query(
2319
+ `INSERT INTO fonderie_consumed_tokens (token_hash, expires_at)
2320
+ VALUES ($1, $2)
2321
+ ON CONFLICT (token_hash) DO NOTHING
2322
+ RETURNING token_hash`,
2323
+ [tokenHash, expiresAt]
2324
+ );
2325
+ return rows.length > 0;
2326
+ }
2327
+ // Opportunistic, fire-and-forget cleanup of expired rows (index-backed).
2328
+ // Recording history must never block or fail a login.
2329
+ sweepExpiredSafe() {
2330
+ void this.store.query(`DELETE FROM fonderie_consumed_tokens WHERE expires_at < now()`).catch(() => {
2331
+ });
2332
+ }
2333
+ };
2334
+
2335
+ // src/controllers/oauth.controller.ts
2336
+ var APPLE_AUTH_URL = "https://appleid.apple.com/auth/authorize";
2337
+ var APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
2338
+ var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
2339
+ var APPLE_ISSUER = "https://appleid.apple.com";
2340
+ function mintAppleClientSecret(apple) {
2341
+ const now = Math.floor(Date.now() / 1e3);
2342
+ return import_jsonwebtoken2.default.sign(
2343
+ { iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
2344
+ apple.privateKey,
2345
+ { algorithm: "ES256", keyid: apple.keyId }
2346
+ );
2347
+ }
2348
+ var appleKeyCache = null;
2349
+ var appleKeyInflight = null;
2350
+ var appleKeyLastAttempt = 0;
2351
+ var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
2352
+ var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
2353
+ var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
2354
+ function refreshAppleKeys() {
2355
+ if (appleKeyInflight) return appleKeyInflight;
2356
+ appleKeyLastAttempt = Date.now();
2357
+ appleKeyInflight = (async () => {
2358
+ try {
2359
+ const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
2360
+ const data = await res.json();
2361
+ if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
2362
+ } catch {
2363
+ } finally {
2364
+ appleKeyInflight = null;
2365
+ }
2366
+ })();
2367
+ return appleKeyInflight;
2368
+ }
2369
+ async function getApplePublicKey(kid) {
2370
+ const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
2371
+ const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
2372
+ let jwk = find();
2373
+ if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
2374
+ await refreshAppleKeys();
2375
+ jwk = find();
2376
+ }
2377
+ if (!jwk) return null;
2378
+ try {
2379
+ return (0, import_node_crypto7.createPublicKey)({ key: jwk, format: "jwk" });
2380
+ } catch {
2381
+ return null;
2382
+ }
2383
+ }
2384
+ async function verifyAppleIdToken(idToken, opts) {
2385
+ if (opts.audiences.length === 0) return null;
2386
+ const decoded = import_jsonwebtoken2.default.decode(idToken, { complete: true });
2387
+ if (!decoded || typeof decoded === "string") return null;
2388
+ const kid = decoded.header.kid;
2389
+ if (!kid) return null;
2390
+ const key = await getApplePublicKey(kid);
2391
+ if (!key) return null;
2392
+ try {
2393
+ const payload = import_jsonwebtoken2.default.verify(idToken, key, {
2394
+ algorithms: ["RS256"],
2395
+ issuer: APPLE_ISSUER,
2396
+ // jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
2397
+ // list and matches the token's aud against ANY entry (guarded non-empty
2398
+ // above).
2399
+ audience: opts.audiences
2400
+ });
2401
+ if (opts.nonce !== void 0 && payload.nonce !== opts.nonce) return null;
2402
+ return payload;
2403
+ } catch {
2404
+ return null;
2405
+ }
2406
+ }
2407
+ var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2299
2408
  function oauthController(store, config) {
2300
2409
  const users = new UserModel(store);
2301
2410
  const sessions = new SessionModel(store);
2302
2411
  const loginEvents = new LoginEventModel(store);
2412
+ const consumedTokens = new ConsumedTokenModel(store);
2413
+ const completeAppleLogin = async (ctx, claims) => {
2414
+ const meta = requestMeta(ctx);
2415
+ if (!claims.email) {
2416
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "No email in Apple identity token");
2417
+ }
2418
+ if (!claims.emailVerified) {
2419
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Apple account email is not verified");
2420
+ }
2421
+ const normalizedEmail = normalizeEmailSafe(claims.email);
2422
+ if (!normalizedEmail) {
2423
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid email in Apple identity token");
2424
+ }
2425
+ const upserted = await users.upsertByProvider(normalizedEmail, "apple", claims.sub ?? "");
2426
+ if (!upserted) {
2427
+ loginEvents.recordSafe({
2428
+ userId: null,
2429
+ emailAttempted: normalizedEmail,
2430
+ method: "oauth-apple",
2431
+ outcome: "failed",
2432
+ failureReason: "provider_upsert_failed",
2433
+ ...meta
2434
+ });
2435
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2436
+ }
2437
+ const fullUser = await users.findById(upserted.id);
2438
+ if (!fullUser) {
2439
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2440
+ }
2441
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
2442
+ await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2443
+ loginEvents.recordSafe({
2444
+ userId: upserted.id,
2445
+ emailAttempted: normalizedEmail,
2446
+ method: "oauth-apple",
2447
+ outcome: "success",
2448
+ ...meta
2449
+ });
2450
+ return Response.json(
2451
+ {
2452
+ reason: "APPLE_AUTH_SUCCESS",
2453
+ explanation: "Apple authentication successful.",
2454
+ result: {
2455
+ tokens: { access: accessToken, refresh: refreshToken },
2456
+ user: toUserDTO(fullUser)
2457
+ }
2458
+ },
2459
+ {
2460
+ status: 200,
2461
+ headers: cookieHeaders([
2462
+ ...tokenPairCookies(accessToken, refreshToken, config),
2463
+ // One-time value — clear the (SameSite=None) state cookie.
2464
+ "oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"
2465
+ ])
2466
+ }
2467
+ );
2468
+ };
2303
2469
  return {
2304
2470
  googleInit: async (_ctx) => {
2305
2471
  const google = config.google;
@@ -2451,6 +2617,133 @@ function oauthController(store, config) {
2451
2617
  ])
2452
2618
  }
2453
2619
  );
2620
+ },
2621
+ appleInit: async (_ctx) => {
2622
+ const apple = config.apple;
2623
+ if (!apple) {
2624
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
2625
+ }
2626
+ const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
2627
+ const params = new URLSearchParams({
2628
+ client_id: apple.clientId,
2629
+ redirect_uri: apple.redirectUri,
2630
+ response_type: "code",
2631
+ // form_post is REQUIRED to receive name/email; Apple then POSTs the
2632
+ // callback cross-site.
2633
+ response_mode: "form_post",
2634
+ scope: "name email",
2635
+ state
2636
+ });
2637
+ const url = `${APPLE_AUTH_URL}?${params}`;
2638
+ return Response.json(
2639
+ {
2640
+ reason: "APPLE_AUTH_URL",
2641
+ explanation: "Redirect the user to the returned URL to begin Apple OAuth.",
2642
+ result: { url }
2643
+ },
2644
+ {
2645
+ status: 200,
2646
+ headers: cookieHeaders([
2647
+ `oauth_state=${state}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=600`
2648
+ ])
2649
+ }
2650
+ );
2651
+ },
2652
+ appleCallback: async (ctx) => {
2653
+ const apple = config.apple;
2654
+ if (!apple) {
2655
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
2656
+ }
2657
+ const pre = ctx.meta["body"];
2658
+ const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
2659
+ const code = form.get("code");
2660
+ if (!code) {
2661
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2662
+ }
2663
+ const returnedState = form.get("state") ?? "";
2664
+ const cookieHeader = ctx.request.headers.get("cookie") ?? "";
2665
+ const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
2666
+ if (!returnedState || !expectedState || !(0, import_core8.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
2667
+ return (0, import_core8.setApiResponse)(
2668
+ import_core8.HTTP.BAD_REQUEST,
2669
+ "APPLE_AUTH_FAILED",
2670
+ "OAuth state mismatch \u2014 restart the sign-in flow"
2671
+ );
2672
+ }
2673
+ const tokenRes = await fetch(APPLE_TOKEN_URL, {
2674
+ method: "POST",
2675
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2676
+ body: new URLSearchParams({
2677
+ code,
2678
+ client_id: apple.clientId,
2679
+ client_secret: mintAppleClientSecret(apple),
2680
+ redirect_uri: apple.redirectUri,
2681
+ grant_type: "authorization_code"
2682
+ })
2683
+ });
2684
+ const tokenData = await tokenRes.json();
2685
+ if (!tokenData.id_token) {
2686
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
2687
+ }
2688
+ const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
2689
+ if (!claims) {
2690
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
2691
+ }
2692
+ return completeAppleLogin(ctx, {
2693
+ email: claims.email,
2694
+ sub: claims.sub,
2695
+ emailVerified: appleEmailVerified(claims)
2696
+ });
2697
+ },
2698
+ appleNative: async (ctx) => {
2699
+ const apple = config.apple;
2700
+ if (!apple) {
2701
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
2702
+ }
2703
+ const body = ctx.meta["body"];
2704
+ const identityToken = body?.identityToken;
2705
+ if (!identityToken) {
2706
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
2707
+ }
2708
+ const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
2709
+ const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
2710
+ if (!claims) {
2711
+ const meta = requestMeta(ctx);
2712
+ loginEvents.recordSafe({
2713
+ userId: null,
2714
+ emailAttempted: null,
2715
+ method: "oauth-apple",
2716
+ outcome: "failed",
2717
+ failureReason: "invalid_identity_token",
2718
+ ...meta
2719
+ });
2720
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
2721
+ }
2722
+ const tokenHash = (0, import_node_crypto7.createHash)("sha256").update(identityToken).digest("hex");
2723
+ const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
2724
+ const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
2725
+ if (!firstUse) {
2726
+ const meta = requestMeta(ctx);
2727
+ loginEvents.recordSafe({
2728
+ userId: null,
2729
+ emailAttempted: claims.email ?? null,
2730
+ method: "oauth-apple",
2731
+ outcome: "failed",
2732
+ failureReason: "token_replayed",
2733
+ ...meta
2734
+ });
2735
+ return (0, import_core8.setApiResponse)(
2736
+ import_core8.HTTP.UNAUTHORIZED,
2737
+ "APPLE_AUTH_FAILED",
2738
+ "This Apple identity token has already been used"
2739
+ );
2740
+ }
2741
+ consumedTokens.sweepExpiredSafe();
2742
+ return completeAppleLogin(ctx, {
2743
+ email: claims.email,
2744
+ sub: claims.sub,
2745
+ emailVerified: appleEmailVerified(claims)
2746
+ });
2454
2747
  }
2455
2748
  };
2456
2749
  }
@@ -2517,7 +2810,21 @@ function buildAuthRoutes(store, config, bus) {
2517
2810
  if (config.providers.includes("google")) {
2518
2811
  routes.push(
2519
2812
  ["GET", "/auth/google", oauth.googleInit],
2520
- ["GET", "/auth/google/callback", oauth.googleCallback]
2813
+ // ipLimit: an unauthenticated caller can otherwise force one outbound
2814
+ // token-exchange to Google per request (the state check doesn't gate it).
2815
+ ["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback]
2816
+ );
2817
+ }
2818
+ if (config.providers.includes("apple")) {
2819
+ routes.push(
2820
+ // Web redirect flow. The callback is a POST (response_mode=form_post);
2821
+ // its urlencoded body is parsed inside the controller. ipLimit for the
2822
+ // same reason as Google's callback (bounds outbound token exchanges).
2823
+ ["GET", "/auth/apple", oauth.appleInit],
2824
+ ["POST", "/auth/apple/callback", ipLimit("login"), oauth.appleCallback],
2825
+ // Native (iOS) flow: the app posts the identityToken from the Apple
2826
+ // sheet. ipLimit guards token brute-forcing; validate bounds the body.
2827
+ ["POST", "/auth/apple/native", ipLimit("login"), (0, import_middlewares.validate)(appleNativeSchema), oauth.appleNative]
2521
2828
  );
2522
2829
  }
2523
2830
  return routes;
@@ -2566,6 +2873,29 @@ function collectAuthConfigProblems(config) {
2566
2873
  });
2567
2874
  }
2568
2875
  }
2876
+ if (config.apple) {
2877
+ const a = config.apple;
2878
+ if (!a.clientId || !a.teamId || !a.keyId || !a.privateKey || !a.redirectUri) {
2879
+ problems.push({
2880
+ module: MODULE,
2881
+ severity: "error",
2882
+ message: "apple OAuth is configured but clientId, teamId, keyId, privateKey, or redirectUri is missing"
2883
+ });
2884
+ } else if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(a.privateKey)) {
2885
+ problems.push({
2886
+ module: MODULE,
2887
+ severity: "error",
2888
+ message: "apple.privateKey does not look like a PEM .p8 key (expected `-----BEGIN PRIVATE KEY-----`)"
2889
+ });
2890
+ }
2891
+ if (a.nativeClientIds?.some((id) => !id || id.trim() === "" || id.includes("*"))) {
2892
+ problems.push({
2893
+ module: MODULE,
2894
+ severity: "error",
2895
+ message: "apple.nativeClientIds must be exact bundle ids \u2014 empty or wildcard entries would accept identity tokens minted for other apps"
2896
+ });
2897
+ }
2898
+ }
2569
2899
  if (config.mfa && !config.mfaSecretKey) {
2570
2900
  problems.push({
2571
2901
  module: MODULE,