@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/dist/index.d.cts CHANGED
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.cjs';
2
2
  import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
3
3
  import { IStoreAdapter } from '@fonderie/store';
4
4
  import { EventBus } from '@fonderie/events';
5
- import { I as IAuthConfig } from './session-6B77MAFn.cjs';
6
- export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-6B77MAFn.cjs';
5
+ import { I as IAuthConfig } from './session-Dc5-JNoW.cjs';
6
+ export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-Dc5-JNoW.cjs';
7
7
  export { IUserDTO, toUserDTO } from './dtos/user.cjs';
8
8
  export { requireAuth, validate } from '@fonderie/core/middlewares';
9
9
  import { z } from 'zod';
@@ -101,6 +101,10 @@ declare const loginSchema: z.ZodUnion<readonly [z.ZodObject<{
101
101
  }, z.core.$strip>, z.ZodObject<{
102
102
  phone: z.ZodString;
103
103
  }, z.core.$strip>]>;
104
+ declare const appleNativeSchema: z.ZodObject<{
105
+ identityToken: z.ZodString;
106
+ nonce: z.ZodOptional<z.ZodString>;
107
+ }, z.core.$strip>;
104
108
  declare const refreshSchema: z.ZodObject<{
105
109
  refreshToken: z.ZodOptional<z.ZodString>;
106
110
  }, z.core.$strip>;
@@ -157,6 +161,7 @@ type schemas_ChangePasswordInput = ChangePasswordInput;
157
161
  type schemas_LoginInput = LoginInput;
158
162
  type schemas_RegisterInput = RegisterInput;
159
163
  type schemas_ResetPasswordInput = ResetPasswordInput;
164
+ declare const schemas_appleNativeSchema: typeof appleNativeSchema;
160
165
  declare const schemas_changePasswordSchema: typeof changePasswordSchema;
161
166
  declare const schemas_forgotPasswordSchema: typeof forgotPasswordSchema;
162
167
  declare const schemas_loginSchema: typeof loginSchema;
@@ -170,7 +175,7 @@ declare const schemas_updatePreferencesSchema: typeof updatePreferencesSchema;
170
175
  declare const schemas_updateProfileSchema: typeof updateProfileSchema;
171
176
  declare const schemas_verifySchema: typeof verifySchema;
172
177
  declare namespace schemas {
173
- export { type schemas_ChangePasswordInput as ChangePasswordInput, type schemas_LoginInput as LoginInput, type schemas_RegisterInput as RegisterInput, type schemas_ResetPasswordInput as ResetPasswordInput, schemas_changePasswordSchema as changePasswordSchema, schemas_forgotPasswordSchema as forgotPasswordSchema, schemas_loginSchema as loginSchema, schemas_mfaTokenSchema as mfaTokenSchema, schemas_refreshSchema as refreshSchema, schemas_registerSchema as registerSchema, schemas_resetPasswordSchema as resetPasswordSchema, schemas_updateEmailSchema as updateEmailSchema, schemas_updatePhoneSchema as updatePhoneSchema, schemas_updatePreferencesSchema as updatePreferencesSchema, schemas_updateProfileSchema as updateProfileSchema, schemas_verifySchema as verifySchema };
178
+ export { type schemas_ChangePasswordInput as ChangePasswordInput, type schemas_LoginInput as LoginInput, type schemas_RegisterInput as RegisterInput, type schemas_ResetPasswordInput as ResetPasswordInput, schemas_appleNativeSchema as appleNativeSchema, schemas_changePasswordSchema as changePasswordSchema, schemas_forgotPasswordSchema as forgotPasswordSchema, schemas_loginSchema as loginSchema, schemas_mfaTokenSchema as mfaTokenSchema, schemas_refreshSchema as refreshSchema, schemas_registerSchema as registerSchema, schemas_resetPasswordSchema as resetPasswordSchema, schemas_updateEmailSchema as updateEmailSchema, schemas_updatePhoneSchema as updatePhoneSchema, schemas_updatePreferencesSchema as updatePreferencesSchema, schemas_updateProfileSchema as updateProfileSchema, schemas_verifySchema as verifySchema };
174
179
  }
175
180
 
176
181
  declare function normalizeEmail(email: string): string;
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.js';
2
2
  import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
3
3
  import { IStoreAdapter } from '@fonderie/store';
4
4
  import { EventBus } from '@fonderie/events';
5
- import { I as IAuthConfig } from './session-6B77MAFn.js';
6
- export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-6B77MAFn.js';
5
+ import { I as IAuthConfig } from './session-Dc5-JNoW.js';
6
+ export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-Dc5-JNoW.js';
7
7
  export { IUserDTO, toUserDTO } from './dtos/user.js';
8
8
  export { requireAuth, validate } from '@fonderie/core/middlewares';
9
9
  import { z } from 'zod';
@@ -101,6 +101,10 @@ declare const loginSchema: z.ZodUnion<readonly [z.ZodObject<{
101
101
  }, z.core.$strip>, z.ZodObject<{
102
102
  phone: z.ZodString;
103
103
  }, z.core.$strip>]>;
104
+ declare const appleNativeSchema: z.ZodObject<{
105
+ identityToken: z.ZodString;
106
+ nonce: z.ZodOptional<z.ZodString>;
107
+ }, z.core.$strip>;
104
108
  declare const refreshSchema: z.ZodObject<{
105
109
  refreshToken: z.ZodOptional<z.ZodString>;
106
110
  }, z.core.$strip>;
@@ -157,6 +161,7 @@ type schemas_ChangePasswordInput = ChangePasswordInput;
157
161
  type schemas_LoginInput = LoginInput;
158
162
  type schemas_RegisterInput = RegisterInput;
159
163
  type schemas_ResetPasswordInput = ResetPasswordInput;
164
+ declare const schemas_appleNativeSchema: typeof appleNativeSchema;
160
165
  declare const schemas_changePasswordSchema: typeof changePasswordSchema;
161
166
  declare const schemas_forgotPasswordSchema: typeof forgotPasswordSchema;
162
167
  declare const schemas_loginSchema: typeof loginSchema;
@@ -170,7 +175,7 @@ declare const schemas_updatePreferencesSchema: typeof updatePreferencesSchema;
170
175
  declare const schemas_updateProfileSchema: typeof updateProfileSchema;
171
176
  declare const schemas_verifySchema: typeof verifySchema;
172
177
  declare namespace schemas {
173
- export { type schemas_ChangePasswordInput as ChangePasswordInput, type schemas_LoginInput as LoginInput, type schemas_RegisterInput as RegisterInput, type schemas_ResetPasswordInput as ResetPasswordInput, schemas_changePasswordSchema as changePasswordSchema, schemas_forgotPasswordSchema as forgotPasswordSchema, schemas_loginSchema as loginSchema, schemas_mfaTokenSchema as mfaTokenSchema, schemas_refreshSchema as refreshSchema, schemas_registerSchema as registerSchema, schemas_resetPasswordSchema as resetPasswordSchema, schemas_updateEmailSchema as updateEmailSchema, schemas_updatePhoneSchema as updatePhoneSchema, schemas_updatePreferencesSchema as updatePreferencesSchema, schemas_updateProfileSchema as updateProfileSchema, schemas_verifySchema as verifySchema };
178
+ export { type schemas_ChangePasswordInput as ChangePasswordInput, type schemas_LoginInput as LoginInput, type schemas_RegisterInput as RegisterInput, type schemas_ResetPasswordInput as ResetPasswordInput, schemas_appleNativeSchema as appleNativeSchema, schemas_changePasswordSchema as changePasswordSchema, schemas_forgotPasswordSchema as forgotPasswordSchema, schemas_loginSchema as loginSchema, schemas_mfaTokenSchema as mfaTokenSchema, schemas_refreshSchema as refreshSchema, schemas_registerSchema as registerSchema, schemas_resetPasswordSchema as resetPasswordSchema, schemas_updateEmailSchema as updateEmailSchema, schemas_updatePhoneSchema as updatePhoneSchema, schemas_updatePreferencesSchema as updatePreferencesSchema, schemas_updateProfileSchema as updateProfileSchema, schemas_verifySchema as verifySchema };
174
179
  }
175
180
 
176
181
  declare function normalizeEmail(email: string): string;
package/dist/index.js CHANGED
@@ -128,6 +128,7 @@ function buildAuthAccountLimiter(route, store, config) {
128
128
  // src/schemas.ts
129
129
  var schemas_exports = {};
130
130
  __export(schemas_exports, {
131
+ appleNativeSchema: () => appleNativeSchema,
131
132
  changePasswordSchema: () => changePasswordSchema,
132
133
  forgotPasswordSchema: () => forgotPasswordSchema,
133
134
  loginSchema: () => loginSchema,
@@ -159,6 +160,12 @@ var loginSchema = z.union([
159
160
  z.object({ email, password: z.string().min(1).max(128) }),
160
161
  z.object({ phone })
161
162
  ]);
163
+ var appleNativeSchema = z.object({
164
+ identityToken: z.string().min(1).max(8192),
165
+ // Optional nonce the app bound to the native request; when present we check
166
+ // it against the token's `nonce` claim to defend against replay.
167
+ nonce: z.string().max(256).optional()
168
+ });
162
169
  var refreshSchema = z.object({ refreshToken: z.string().min(1).optional() });
163
170
  var forgotPasswordSchema = z.object({ email });
164
171
  var resetPasswordSchema = z.union([
@@ -689,7 +696,8 @@ var UserModel = class {
689
696
  `INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
690
697
  VALUES ($1, now(), $2, $3)
691
698
  ON CONFLICT (email) DO UPDATE
692
- SET provider = $2, provider_id = $3
699
+ SET provider = $2, provider_id = $3,
700
+ email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
693
701
  RETURNING id`,
694
702
  [email2, provider, providerId]
695
703
  );
@@ -2254,12 +2262,170 @@ function userController(store, config, bus) {
2254
2262
  }
2255
2263
 
2256
2264
  // src/controllers/oauth.controller.ts
2257
- import { randomBytes as randomBytes4 } from "crypto";
2265
+ import { randomBytes as randomBytes4, createPublicKey, createHash as createHash2 } from "crypto";
2266
+ import jwt2 from "jsonwebtoken";
2258
2267
  import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2 } from "@fonderie/core";
2268
+
2269
+ // src/models/consumed-token.model.ts
2270
+ var ConsumedTokenModel = class {
2271
+ constructor(store) {
2272
+ this.store = store;
2273
+ }
2274
+ store;
2275
+ // True if the token had not been consumed before (caller may proceed); false
2276
+ // if this is a replay. `expiresAt` is the token's own exp — its TTL here.
2277
+ async consumeOnce(tokenHash, expiresAt) {
2278
+ const rows = await this.store.query(
2279
+ `INSERT INTO fonderie_consumed_tokens (token_hash, expires_at)
2280
+ VALUES ($1, $2)
2281
+ ON CONFLICT (token_hash) DO NOTHING
2282
+ RETURNING token_hash`,
2283
+ [tokenHash, expiresAt]
2284
+ );
2285
+ return rows.length > 0;
2286
+ }
2287
+ // Opportunistic, fire-and-forget cleanup of expired rows (index-backed).
2288
+ // Recording history must never block or fail a login.
2289
+ sweepExpiredSafe() {
2290
+ void this.store.query(`DELETE FROM fonderie_consumed_tokens WHERE expires_at < now()`).catch(() => {
2291
+ });
2292
+ }
2293
+ };
2294
+
2295
+ // src/controllers/oauth.controller.ts
2296
+ var APPLE_AUTH_URL = "https://appleid.apple.com/auth/authorize";
2297
+ var APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
2298
+ var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
2299
+ var APPLE_ISSUER = "https://appleid.apple.com";
2300
+ function mintAppleClientSecret(apple) {
2301
+ const now = Math.floor(Date.now() / 1e3);
2302
+ return jwt2.sign(
2303
+ { iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
2304
+ apple.privateKey,
2305
+ { algorithm: "ES256", keyid: apple.keyId }
2306
+ );
2307
+ }
2308
+ var appleKeyCache = null;
2309
+ var appleKeyInflight = null;
2310
+ var appleKeyLastAttempt = 0;
2311
+ var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
2312
+ var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
2313
+ var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
2314
+ function refreshAppleKeys() {
2315
+ if (appleKeyInflight) return appleKeyInflight;
2316
+ appleKeyLastAttempt = Date.now();
2317
+ appleKeyInflight = (async () => {
2318
+ try {
2319
+ const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
2320
+ const data = await res.json();
2321
+ if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
2322
+ } catch {
2323
+ } finally {
2324
+ appleKeyInflight = null;
2325
+ }
2326
+ })();
2327
+ return appleKeyInflight;
2328
+ }
2329
+ async function getApplePublicKey(kid) {
2330
+ const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
2331
+ const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
2332
+ let jwk = find();
2333
+ if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
2334
+ await refreshAppleKeys();
2335
+ jwk = find();
2336
+ }
2337
+ if (!jwk) return null;
2338
+ try {
2339
+ return createPublicKey({ key: jwk, format: "jwk" });
2340
+ } catch {
2341
+ return null;
2342
+ }
2343
+ }
2344
+ async function verifyAppleIdToken(idToken, opts) {
2345
+ if (opts.audiences.length === 0) return null;
2346
+ const decoded = jwt2.decode(idToken, { complete: true });
2347
+ if (!decoded || typeof decoded === "string") return null;
2348
+ const kid = decoded.header.kid;
2349
+ if (!kid) return null;
2350
+ const key = await getApplePublicKey(kid);
2351
+ if (!key) return null;
2352
+ try {
2353
+ const payload = jwt2.verify(idToken, key, {
2354
+ algorithms: ["RS256"],
2355
+ issuer: APPLE_ISSUER,
2356
+ // jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
2357
+ // list and matches the token's aud against ANY entry (guarded non-empty
2358
+ // above).
2359
+ audience: opts.audiences
2360
+ });
2361
+ if (opts.nonce !== void 0 && payload.nonce !== opts.nonce) return null;
2362
+ return payload;
2363
+ } catch {
2364
+ return null;
2365
+ }
2366
+ }
2367
+ var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2259
2368
  function oauthController(store, config) {
2260
2369
  const users = new UserModel(store);
2261
2370
  const sessions = new SessionModel(store);
2262
2371
  const loginEvents = new LoginEventModel(store);
2372
+ const consumedTokens = new ConsumedTokenModel(store);
2373
+ const completeAppleLogin = async (ctx, claims) => {
2374
+ const meta = requestMeta(ctx);
2375
+ if (!claims.email) {
2376
+ return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "No email in Apple identity token");
2377
+ }
2378
+ if (!claims.emailVerified) {
2379
+ return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Apple account email is not verified");
2380
+ }
2381
+ const normalizedEmail = normalizeEmailSafe(claims.email);
2382
+ if (!normalizedEmail) {
2383
+ return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid email in Apple identity token");
2384
+ }
2385
+ const upserted = await users.upsertByProvider(normalizedEmail, "apple", claims.sub ?? "");
2386
+ if (!upserted) {
2387
+ loginEvents.recordSafe({
2388
+ userId: null,
2389
+ emailAttempted: normalizedEmail,
2390
+ method: "oauth-apple",
2391
+ outcome: "failed",
2392
+ failureReason: "provider_upsert_failed",
2393
+ ...meta
2394
+ });
2395
+ return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2396
+ }
2397
+ const fullUser = await users.findById(upserted.id);
2398
+ if (!fullUser) {
2399
+ return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2400
+ }
2401
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
2402
+ await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2403
+ loginEvents.recordSafe({
2404
+ userId: upserted.id,
2405
+ emailAttempted: normalizedEmail,
2406
+ method: "oauth-apple",
2407
+ outcome: "success",
2408
+ ...meta
2409
+ });
2410
+ return Response.json(
2411
+ {
2412
+ reason: "APPLE_AUTH_SUCCESS",
2413
+ explanation: "Apple authentication successful.",
2414
+ result: {
2415
+ tokens: { access: accessToken, refresh: refreshToken },
2416
+ user: toUserDTO(fullUser)
2417
+ }
2418
+ },
2419
+ {
2420
+ status: 200,
2421
+ headers: cookieHeaders([
2422
+ ...tokenPairCookies(accessToken, refreshToken, config),
2423
+ // One-time value — clear the (SameSite=None) state cookie.
2424
+ "oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"
2425
+ ])
2426
+ }
2427
+ );
2428
+ };
2263
2429
  return {
2264
2430
  googleInit: async (_ctx) => {
2265
2431
  const google = config.google;
@@ -2411,6 +2577,133 @@ function oauthController(store, config) {
2411
2577
  ])
2412
2578
  }
2413
2579
  );
2580
+ },
2581
+ appleInit: async (_ctx) => {
2582
+ const apple = config.apple;
2583
+ if (!apple) {
2584
+ return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
2585
+ }
2586
+ const state = randomBytes4(16).toString("hex");
2587
+ const params = new URLSearchParams({
2588
+ client_id: apple.clientId,
2589
+ redirect_uri: apple.redirectUri,
2590
+ response_type: "code",
2591
+ // form_post is REQUIRED to receive name/email; Apple then POSTs the
2592
+ // callback cross-site.
2593
+ response_mode: "form_post",
2594
+ scope: "name email",
2595
+ state
2596
+ });
2597
+ const url = `${APPLE_AUTH_URL}?${params}`;
2598
+ return Response.json(
2599
+ {
2600
+ reason: "APPLE_AUTH_URL",
2601
+ explanation: "Redirect the user to the returned URL to begin Apple OAuth.",
2602
+ result: { url }
2603
+ },
2604
+ {
2605
+ status: 200,
2606
+ headers: cookieHeaders([
2607
+ `oauth_state=${state}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=600`
2608
+ ])
2609
+ }
2610
+ );
2611
+ },
2612
+ appleCallback: async (ctx) => {
2613
+ const apple = config.apple;
2614
+ if (!apple) {
2615
+ return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
2616
+ }
2617
+ const pre = ctx.meta["body"];
2618
+ const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
2619
+ const code = form.get("code");
2620
+ if (!code) {
2621
+ return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2622
+ }
2623
+ const returnedState = form.get("state") ?? "";
2624
+ const cookieHeader = ctx.request.headers.get("cookie") ?? "";
2625
+ const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
2626
+ if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
2627
+ return setApiResponse5(
2628
+ HTTP5.BAD_REQUEST,
2629
+ "APPLE_AUTH_FAILED",
2630
+ "OAuth state mismatch \u2014 restart the sign-in flow"
2631
+ );
2632
+ }
2633
+ const tokenRes = await fetch(APPLE_TOKEN_URL, {
2634
+ method: "POST",
2635
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2636
+ body: new URLSearchParams({
2637
+ code,
2638
+ client_id: apple.clientId,
2639
+ client_secret: mintAppleClientSecret(apple),
2640
+ redirect_uri: apple.redirectUri,
2641
+ grant_type: "authorization_code"
2642
+ })
2643
+ });
2644
+ const tokenData = await tokenRes.json();
2645
+ if (!tokenData.id_token) {
2646
+ return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
2647
+ }
2648
+ const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
2649
+ if (!claims) {
2650
+ return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
2651
+ }
2652
+ return completeAppleLogin(ctx, {
2653
+ email: claims.email,
2654
+ sub: claims.sub,
2655
+ emailVerified: appleEmailVerified(claims)
2656
+ });
2657
+ },
2658
+ appleNative: async (ctx) => {
2659
+ const apple = config.apple;
2660
+ if (!apple) {
2661
+ return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
2662
+ }
2663
+ const body = ctx.meta["body"];
2664
+ const identityToken = body?.identityToken;
2665
+ if (!identityToken) {
2666
+ return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
2667
+ }
2668
+ const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
2669
+ const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
2670
+ if (!claims) {
2671
+ const meta = requestMeta(ctx);
2672
+ loginEvents.recordSafe({
2673
+ userId: null,
2674
+ emailAttempted: null,
2675
+ method: "oauth-apple",
2676
+ outcome: "failed",
2677
+ failureReason: "invalid_identity_token",
2678
+ ...meta
2679
+ });
2680
+ return setApiResponse5(HTTP5.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
2681
+ }
2682
+ const tokenHash = createHash2("sha256").update(identityToken).digest("hex");
2683
+ const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
2684
+ const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
2685
+ if (!firstUse) {
2686
+ const meta = requestMeta(ctx);
2687
+ loginEvents.recordSafe({
2688
+ userId: null,
2689
+ emailAttempted: claims.email ?? null,
2690
+ method: "oauth-apple",
2691
+ outcome: "failed",
2692
+ failureReason: "token_replayed",
2693
+ ...meta
2694
+ });
2695
+ return setApiResponse5(
2696
+ HTTP5.UNAUTHORIZED,
2697
+ "APPLE_AUTH_FAILED",
2698
+ "This Apple identity token has already been used"
2699
+ );
2700
+ }
2701
+ consumedTokens.sweepExpiredSafe();
2702
+ return completeAppleLogin(ctx, {
2703
+ email: claims.email,
2704
+ sub: claims.sub,
2705
+ emailVerified: appleEmailVerified(claims)
2706
+ });
2414
2707
  }
2415
2708
  };
2416
2709
  }
@@ -2477,7 +2770,21 @@ function buildAuthRoutes(store, config, bus) {
2477
2770
  if (config.providers.includes("google")) {
2478
2771
  routes.push(
2479
2772
  ["GET", "/auth/google", oauth.googleInit],
2480
- ["GET", "/auth/google/callback", oauth.googleCallback]
2773
+ // ipLimit: an unauthenticated caller can otherwise force one outbound
2774
+ // token-exchange to Google per request (the state check doesn't gate it).
2775
+ ["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback]
2776
+ );
2777
+ }
2778
+ if (config.providers.includes("apple")) {
2779
+ routes.push(
2780
+ // Web redirect flow. The callback is a POST (response_mode=form_post);
2781
+ // its urlencoded body is parsed inside the controller. ipLimit for the
2782
+ // same reason as Google's callback (bounds outbound token exchanges).
2783
+ ["GET", "/auth/apple", oauth.appleInit],
2784
+ ["POST", "/auth/apple/callback", ipLimit("login"), oauth.appleCallback],
2785
+ // Native (iOS) flow: the app posts the identityToken from the Apple
2786
+ // sheet. ipLimit guards token brute-forcing; validate bounds the body.
2787
+ ["POST", "/auth/apple/native", ipLimit("login"), validate(appleNativeSchema), oauth.appleNative]
2481
2788
  );
2482
2789
  }
2483
2790
  return routes;
@@ -2526,6 +2833,29 @@ function collectAuthConfigProblems(config) {
2526
2833
  });
2527
2834
  }
2528
2835
  }
2836
+ if (config.apple) {
2837
+ const a = config.apple;
2838
+ if (!a.clientId || !a.teamId || !a.keyId || !a.privateKey || !a.redirectUri) {
2839
+ problems.push({
2840
+ module: MODULE,
2841
+ severity: "error",
2842
+ message: "apple OAuth is configured but clientId, teamId, keyId, privateKey, or redirectUri is missing"
2843
+ });
2844
+ } else if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(a.privateKey)) {
2845
+ problems.push({
2846
+ module: MODULE,
2847
+ severity: "error",
2848
+ message: "apple.privateKey does not look like a PEM .p8 key (expected `-----BEGIN PRIVATE KEY-----`)"
2849
+ });
2850
+ }
2851
+ if (a.nativeClientIds?.some((id) => !id || id.trim() === "" || id.includes("*"))) {
2852
+ problems.push({
2853
+ module: MODULE,
2854
+ severity: "error",
2855
+ message: "apple.nativeClientIds must be exact bundle ids \u2014 empty or wildcard entries would accept identity tokens minted for other apps"
2856
+ });
2857
+ }
2858
+ }
2529
2859
  if (config.mfa && !config.mfaSecretKey) {
2530
2860
  problems.push({
2531
2861
  module: MODULE,