@fonderie/auth 7.1.0 → 7.2.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
@@ -129,6 +129,9 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
129
129
 
130
130
  | Method | Path | Middleware chain (auth / validation / handler) |
131
131
  |---|---|---|
132
+ | GET | `/auth/apple` | `oauth.appleInit` |
133
+ | POST | `/auth/apple/callback` | `oauth.appleCallback` |
134
+ | POST | `/auth/apple/native` | `ipLimit('login') → validate(appleNativeSchema) → oauth.appleNative` |
132
135
  | POST | `/auth/email/forgot` | `ipLimit('forgot') → validate(forgotPasswordSchema) → acctLimit('forgot') → auth.forgotPassword` |
133
136
  | POST | `/auth/email/reset` | `ipLimit('reset') → validate(resetPasswordSchema) → auth.resetPassword` |
134
137
  | GET | `/auth/google` | `oauth.googleInit` |
@@ -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([
@@ -1149,7 +1156,7 @@ function mfaController(store, config, issuer, bus) {
1149
1156
  }
1150
1157
 
1151
1158
  // src/controllers/auth.controller.ts
1152
- var import_node_crypto4 = require("crypto");
1159
+ var import_node_crypto5 = require("crypto");
1153
1160
  var import_events2 = require("@fonderie/events");
1154
1161
  var import_core5 = require("@fonderie/core");
1155
1162
 
@@ -1196,6 +1203,8 @@ function normalizeEmailSafe(email2) {
1196
1203
  }
1197
1204
 
1198
1205
  // src/models/password-reset.model.ts
1206
+ var import_node_crypto4 = require("crypto");
1207
+ var hashSecret = (value) => (0, import_node_crypto4.createHash)("sha256").update(value).digest("hex");
1199
1208
  var PasswordResetModel = class {
1200
1209
  constructor(store) {
1201
1210
  this.store = store;
@@ -1207,7 +1216,7 @@ var PasswordResetModel = class {
1207
1216
  VALUES ($1, $2, $3, $4, now())
1208
1217
  ON CONFLICT (user_id) DO UPDATE
1209
1218
  SET pin = $2, token = $3, expires_at = $4, created_at = now()`,
1210
- [userId, pin, token, expiresAt]
1219
+ [userId, hashSecret(pin), hashSecret(token), expiresAt]
1211
1220
  );
1212
1221
  }
1213
1222
  async findLastSentAt(userId) {
@@ -1221,7 +1230,7 @@ var PasswordResetModel = class {
1221
1230
  async findByPin(pin) {
1222
1231
  const [row] = await this.store.query(
1223
1232
  `SELECT user_id, expires_at FROM fonderie_password_resets WHERE pin = $1`,
1224
- [pin]
1233
+ [hashSecret(pin)]
1225
1234
  );
1226
1235
  if (!row) return null;
1227
1236
  return { userId: row.user_id, expiresAt: new Date(row.expires_at) };
@@ -1234,7 +1243,7 @@ var PasswordResetModel = class {
1234
1243
  if (typeof token !== "string" || token.length < 32) return null;
1235
1244
  const [row] = await this.store.query(
1236
1245
  `SELECT user_id, expires_at FROM fonderie_password_resets WHERE token = $1`,
1237
- [token]
1246
+ [hashSecret(token)]
1238
1247
  );
1239
1248
  if (!row) return null;
1240
1249
  return { userId: row.user_id, expiresAt: new Date(row.expires_at) };
@@ -1387,7 +1396,7 @@ function authController(store, config, bus) {
1387
1396
  if (!row) {
1388
1397
  return (0, import_core5.setApiResponse)(import_core5.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1389
1398
  }
1390
- const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1399
+ const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1391
1400
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1392
1401
  await emailVerif.create(row.id, pin, expiresAt);
1393
1402
  const user = await users.findById(row.id);
@@ -1451,7 +1460,7 @@ function authController(store, config, bus) {
1451
1460
  firstName ?? null,
1452
1461
  lastName ?? null
1453
1462
  );
1454
- const otp = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1463
+ const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1455
1464
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1456
1465
  await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
1457
1466
  const user = await users.findById(id);
@@ -1597,7 +1606,7 @@ function authController(store, config, bus) {
1597
1606
  "Account suspended. Please contact support."
1598
1607
  );
1599
1608
  }
1600
- const otp = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1609
+ const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1601
1610
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
1602
1611
  await phoneVerif.upsert(user.id, normalizePhone(phone2), otp, expiresAt);
1603
1612
  bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -1707,8 +1716,8 @@ function authController(store, config, bus) {
1707
1716
  "Password reset email sent (if account exists)."
1708
1717
  );
1709
1718
  }
1710
- const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1711
- const token = (0, import_node_crypto4.randomBytes)(32).toString("hex");
1719
+ const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1720
+ const token = (0, import_node_crypto5.randomBytes)(32).toString("hex");
1712
1721
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60);
1713
1722
  await passwordReset.create(user.id, pin, token, expiresAt);
1714
1723
  const base = resolved.passwordResetUrl;
@@ -1891,7 +1900,7 @@ function authController(store, config, bus) {
1891
1900
  }
1892
1901
  );
1893
1902
  }
1894
- const otp = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1903
+ const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1895
1904
  const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
1896
1905
  await phoneVerif.upsert(ctx.user.id, phone2, otp, expiresAt2);
1897
1906
  bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -1931,7 +1940,7 @@ function authController(store, config, bus) {
1931
1940
  }
1932
1941
  );
1933
1942
  }
1934
- const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1943
+ const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1935
1944
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1936
1945
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
1937
1946
  bus?.emit(import_events2.NOTIFICATION_EVENT, {
@@ -1949,7 +1958,7 @@ function authController(store, config, bus) {
1949
1958
  }
1950
1959
 
1951
1960
  // src/controllers/user.controller.ts
1952
- var import_node_crypto5 = require("crypto");
1961
+ var import_node_crypto6 = require("crypto");
1953
1962
  var import_core7 = require("@fonderie/core");
1954
1963
  var import_events3 = require("@fonderie/events");
1955
1964
 
@@ -2139,7 +2148,7 @@ function userController(store, config, bus) {
2139
2148
  if (existing) {
2140
2149
  return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2141
2150
  }
2142
- const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
2151
+ const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2143
2152
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2144
2153
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2145
2154
  await users.updateEmail(ctx.user.id, normalised);
@@ -2183,7 +2192,7 @@ function userController(store, config, bus) {
2183
2192
  if (existing) {
2184
2193
  return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2185
2194
  }
2186
- const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
2195
+ const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2187
2196
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
2188
2197
  await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
2189
2198
  await users.updatePhone(ctx.user.id, normalised);
@@ -2292,12 +2301,125 @@ function userController(store, config, bus) {
2292
2301
  }
2293
2302
 
2294
2303
  // src/controllers/oauth.controller.ts
2295
- var import_node_crypto6 = require("crypto");
2304
+ var import_node_crypto7 = require("crypto");
2305
+ var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
2296
2306
  var import_core8 = require("@fonderie/core");
2307
+ var APPLE_AUTH_URL = "https://appleid.apple.com/auth/authorize";
2308
+ var APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
2309
+ var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
2310
+ var APPLE_ISSUER = "https://appleid.apple.com";
2311
+ function mintAppleClientSecret(apple) {
2312
+ const now = Math.floor(Date.now() / 1e3);
2313
+ return import_jsonwebtoken2.default.sign(
2314
+ { iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
2315
+ apple.privateKey,
2316
+ { algorithm: "ES256", keyid: apple.keyId }
2317
+ );
2318
+ }
2319
+ var appleKeyCache = null;
2320
+ var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
2321
+ async function getApplePublicKey(kid) {
2322
+ const fresh = appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
2323
+ if (!fresh || !appleKeyCache.keys.some((k) => k.kid === kid)) {
2324
+ try {
2325
+ const res = await fetch(APPLE_KEYS_URL);
2326
+ const data = await res.json();
2327
+ if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
2328
+ } catch {
2329
+ }
2330
+ }
2331
+ const jwk = appleKeyCache?.keys.find((k) => k.kid === kid);
2332
+ if (!jwk) return null;
2333
+ try {
2334
+ return (0, import_node_crypto7.createPublicKey)({ key: jwk, format: "jwk" });
2335
+ } catch {
2336
+ return null;
2337
+ }
2338
+ }
2339
+ async function verifyAppleIdToken(idToken, opts) {
2340
+ if (opts.audiences.length === 0) return null;
2341
+ const decoded = import_jsonwebtoken2.default.decode(idToken, { complete: true });
2342
+ if (!decoded || typeof decoded === "string") return null;
2343
+ const kid = decoded.header.kid;
2344
+ if (!kid) return null;
2345
+ const key = await getApplePublicKey(kid);
2346
+ if (!key) return null;
2347
+ try {
2348
+ const payload = import_jsonwebtoken2.default.verify(idToken, key, {
2349
+ algorithms: ["RS256"],
2350
+ issuer: APPLE_ISSUER,
2351
+ // jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
2352
+ // list and matches the token's aud against ANY entry (guarded non-empty
2353
+ // above).
2354
+ audience: opts.audiences
2355
+ });
2356
+ if (opts.nonce !== void 0 && payload.nonce !== opts.nonce) return null;
2357
+ return payload;
2358
+ } catch {
2359
+ return null;
2360
+ }
2361
+ }
2362
+ var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2297
2363
  function oauthController(store, config) {
2298
2364
  const users = new UserModel(store);
2299
2365
  const sessions = new SessionModel(store);
2300
2366
  const loginEvents = new LoginEventModel(store);
2367
+ const completeAppleLogin = async (ctx, claims) => {
2368
+ const meta = requestMeta(ctx);
2369
+ if (!claims.email) {
2370
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "No email in Apple identity token");
2371
+ }
2372
+ if (!claims.emailVerified) {
2373
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Apple account email is not verified");
2374
+ }
2375
+ const normalizedEmail = normalizeEmailSafe(claims.email);
2376
+ if (!normalizedEmail) {
2377
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid email in Apple identity token");
2378
+ }
2379
+ const upserted = await users.upsertByProvider(normalizedEmail, "apple", claims.sub ?? "");
2380
+ if (!upserted) {
2381
+ loginEvents.recordSafe({
2382
+ userId: null,
2383
+ emailAttempted: normalizedEmail,
2384
+ method: "oauth-apple",
2385
+ outcome: "failed",
2386
+ failureReason: "provider_upsert_failed",
2387
+ ...meta
2388
+ });
2389
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2390
+ }
2391
+ const fullUser = await users.findById(upserted.id);
2392
+ if (!fullUser) {
2393
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2394
+ }
2395
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
2396
+ await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2397
+ loginEvents.recordSafe({
2398
+ userId: upserted.id,
2399
+ emailAttempted: normalizedEmail,
2400
+ method: "oauth-apple",
2401
+ outcome: "success",
2402
+ ...meta
2403
+ });
2404
+ return Response.json(
2405
+ {
2406
+ reason: "APPLE_AUTH_SUCCESS",
2407
+ explanation: "Apple authentication successful.",
2408
+ result: {
2409
+ tokens: { access: accessToken, refresh: refreshToken },
2410
+ user: toUserDTO(fullUser)
2411
+ }
2412
+ },
2413
+ {
2414
+ status: 200,
2415
+ headers: cookieHeaders([
2416
+ ...tokenPairCookies(accessToken, refreshToken, config),
2417
+ // One-time value — clear the (SameSite=None) state cookie.
2418
+ "oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"
2419
+ ])
2420
+ }
2421
+ );
2422
+ };
2301
2423
  return {
2302
2424
  googleInit: async (_ctx) => {
2303
2425
  const google = config.google;
@@ -2308,7 +2430,7 @@ function oauthController(store, config) {
2308
2430
  "Google OAuth not configured"
2309
2431
  );
2310
2432
  }
2311
- const state = (0, import_node_crypto6.randomBytes)(16).toString("hex");
2433
+ const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
2312
2434
  const params = new URLSearchParams({
2313
2435
  client_id: google.clientId,
2314
2436
  redirect_uri: google.redirectUri,
@@ -2449,6 +2571,113 @@ function oauthController(store, config) {
2449
2571
  ])
2450
2572
  }
2451
2573
  );
2574
+ },
2575
+ appleInit: async (_ctx) => {
2576
+ const apple = config.apple;
2577
+ if (!apple) {
2578
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
2579
+ }
2580
+ const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
2581
+ const params = new URLSearchParams({
2582
+ client_id: apple.clientId,
2583
+ redirect_uri: apple.redirectUri,
2584
+ response_type: "code",
2585
+ // form_post is REQUIRED to receive name/email; Apple then POSTs the
2586
+ // callback cross-site.
2587
+ response_mode: "form_post",
2588
+ scope: "name email",
2589
+ state
2590
+ });
2591
+ const url = `${APPLE_AUTH_URL}?${params}`;
2592
+ return Response.json(
2593
+ {
2594
+ reason: "APPLE_AUTH_URL",
2595
+ explanation: "Redirect the user to the returned URL to begin Apple OAuth.",
2596
+ result: { url }
2597
+ },
2598
+ {
2599
+ status: 200,
2600
+ headers: cookieHeaders([
2601
+ `oauth_state=${state}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=600`
2602
+ ])
2603
+ }
2604
+ );
2605
+ },
2606
+ appleCallback: async (ctx) => {
2607
+ const apple = config.apple;
2608
+ if (!apple) {
2609
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
2610
+ }
2611
+ const pre = ctx.meta["body"];
2612
+ const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
2613
+ const code = form.get("code");
2614
+ if (!code) {
2615
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2616
+ }
2617
+ const returnedState = form.get("state") ?? "";
2618
+ const cookieHeader = ctx.request.headers.get("cookie") ?? "";
2619
+ const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
2620
+ if (!returnedState || !expectedState || !(0, import_core8.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
2621
+ return (0, import_core8.setApiResponse)(
2622
+ import_core8.HTTP.BAD_REQUEST,
2623
+ "APPLE_AUTH_FAILED",
2624
+ "OAuth state mismatch \u2014 restart the sign-in flow"
2625
+ );
2626
+ }
2627
+ const tokenRes = await fetch(APPLE_TOKEN_URL, {
2628
+ method: "POST",
2629
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2630
+ body: new URLSearchParams({
2631
+ code,
2632
+ client_id: apple.clientId,
2633
+ client_secret: mintAppleClientSecret(apple),
2634
+ redirect_uri: apple.redirectUri,
2635
+ grant_type: "authorization_code"
2636
+ })
2637
+ });
2638
+ const tokenData = await tokenRes.json();
2639
+ if (!tokenData.id_token) {
2640
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
2641
+ }
2642
+ const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
2643
+ if (!claims) {
2644
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
2645
+ }
2646
+ return completeAppleLogin(ctx, {
2647
+ email: claims.email,
2648
+ sub: claims.sub,
2649
+ emailVerified: appleEmailVerified(claims)
2650
+ });
2651
+ },
2652
+ appleNative: 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 body = ctx.meta["body"];
2658
+ const identityToken = body?.identityToken;
2659
+ if (!identityToken) {
2660
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
2661
+ }
2662
+ const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
2663
+ const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
2664
+ if (!claims) {
2665
+ const meta = requestMeta(ctx);
2666
+ loginEvents.recordSafe({
2667
+ userId: null,
2668
+ emailAttempted: null,
2669
+ method: "oauth-apple",
2670
+ outcome: "failed",
2671
+ failureReason: "invalid_identity_token",
2672
+ ...meta
2673
+ });
2674
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
2675
+ }
2676
+ return completeAppleLogin(ctx, {
2677
+ email: claims.email,
2678
+ sub: claims.sub,
2679
+ emailVerified: appleEmailVerified(claims)
2680
+ });
2452
2681
  }
2453
2682
  };
2454
2683
  }
@@ -2518,6 +2747,17 @@ function buildAuthRoutes(store, config, bus) {
2518
2747
  ["GET", "/auth/google/callback", oauth.googleCallback]
2519
2748
  );
2520
2749
  }
2750
+ if (config.providers.includes("apple")) {
2751
+ routes.push(
2752
+ // Web redirect flow. The callback is a POST (response_mode=form_post);
2753
+ // its urlencoded body is parsed inside the controller.
2754
+ ["GET", "/auth/apple", oauth.appleInit],
2755
+ ["POST", "/auth/apple/callback", oauth.appleCallback],
2756
+ // Native (iOS) flow: the app posts the identityToken from the Apple
2757
+ // sheet. ipLimit guards token brute-forcing; validate bounds the body.
2758
+ ["POST", "/auth/apple/native", ipLimit("login"), (0, import_middlewares.validate)(appleNativeSchema), oauth.appleNative]
2759
+ );
2760
+ }
2521
2761
  return routes;
2522
2762
  }
2523
2763
 
@@ -2564,6 +2804,22 @@ function collectAuthConfigProblems(config) {
2564
2804
  });
2565
2805
  }
2566
2806
  }
2807
+ if (config.apple) {
2808
+ const a = config.apple;
2809
+ if (!a.clientId || !a.teamId || !a.keyId || !a.privateKey || !a.redirectUri) {
2810
+ problems.push({
2811
+ module: MODULE,
2812
+ severity: "error",
2813
+ message: "apple OAuth is configured but clientId, teamId, keyId, privateKey, or redirectUri is missing"
2814
+ });
2815
+ } else if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(a.privateKey)) {
2816
+ problems.push({
2817
+ module: MODULE,
2818
+ severity: "error",
2819
+ message: "apple.privateKey does not look like a PEM .p8 key (expected `-----BEGIN PRIVATE KEY-----`)"
2820
+ });
2821
+ }
2822
+ }
2567
2823
  if (config.mfa && !config.mfaSecretKey) {
2568
2824
  problems.push({
2569
2825
  module: MODULE,