@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/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([
@@ -1156,6 +1163,8 @@ function normalizeEmailSafe(email2) {
1156
1163
  }
1157
1164
 
1158
1165
  // src/models/password-reset.model.ts
1166
+ import { createHash } from "crypto";
1167
+ var hashSecret = (value) => createHash("sha256").update(value).digest("hex");
1159
1168
  var PasswordResetModel = class {
1160
1169
  constructor(store) {
1161
1170
  this.store = store;
@@ -1167,7 +1176,7 @@ var PasswordResetModel = class {
1167
1176
  VALUES ($1, $2, $3, $4, now())
1168
1177
  ON CONFLICT (user_id) DO UPDATE
1169
1178
  SET pin = $2, token = $3, expires_at = $4, created_at = now()`,
1170
- [userId, pin, token, expiresAt]
1179
+ [userId, hashSecret(pin), hashSecret(token), expiresAt]
1171
1180
  );
1172
1181
  }
1173
1182
  async findLastSentAt(userId) {
@@ -1181,7 +1190,7 @@ var PasswordResetModel = class {
1181
1190
  async findByPin(pin) {
1182
1191
  const [row] = await this.store.query(
1183
1192
  `SELECT user_id, expires_at FROM fonderie_password_resets WHERE pin = $1`,
1184
- [pin]
1193
+ [hashSecret(pin)]
1185
1194
  );
1186
1195
  if (!row) return null;
1187
1196
  return { userId: row.user_id, expiresAt: new Date(row.expires_at) };
@@ -1194,7 +1203,7 @@ var PasswordResetModel = class {
1194
1203
  if (typeof token !== "string" || token.length < 32) return null;
1195
1204
  const [row] = await this.store.query(
1196
1205
  `SELECT user_id, expires_at FROM fonderie_password_resets WHERE token = $1`,
1197
- [token]
1206
+ [hashSecret(token)]
1198
1207
  );
1199
1208
  if (!row) return null;
1200
1209
  return { userId: row.user_id, expiresAt: new Date(row.expires_at) };
@@ -2252,12 +2261,125 @@ function userController(store, config, bus) {
2252
2261
  }
2253
2262
 
2254
2263
  // src/controllers/oauth.controller.ts
2255
- import { randomBytes as randomBytes4 } from "crypto";
2264
+ import { randomBytes as randomBytes4, createPublicKey } from "crypto";
2265
+ import jwt2 from "jsonwebtoken";
2256
2266
  import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2 } from "@fonderie/core";
2267
+ var APPLE_AUTH_URL = "https://appleid.apple.com/auth/authorize";
2268
+ var APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
2269
+ var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
2270
+ var APPLE_ISSUER = "https://appleid.apple.com";
2271
+ function mintAppleClientSecret(apple) {
2272
+ const now = Math.floor(Date.now() / 1e3);
2273
+ return jwt2.sign(
2274
+ { iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
2275
+ apple.privateKey,
2276
+ { algorithm: "ES256", keyid: apple.keyId }
2277
+ );
2278
+ }
2279
+ var appleKeyCache = null;
2280
+ var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
2281
+ async function getApplePublicKey(kid) {
2282
+ const fresh = appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
2283
+ if (!fresh || !appleKeyCache.keys.some((k) => k.kid === kid)) {
2284
+ try {
2285
+ const res = await fetch(APPLE_KEYS_URL);
2286
+ const data = await res.json();
2287
+ if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
2288
+ } catch {
2289
+ }
2290
+ }
2291
+ const jwk = appleKeyCache?.keys.find((k) => k.kid === kid);
2292
+ if (!jwk) return null;
2293
+ try {
2294
+ return createPublicKey({ key: jwk, format: "jwk" });
2295
+ } catch {
2296
+ return null;
2297
+ }
2298
+ }
2299
+ async function verifyAppleIdToken(idToken, opts) {
2300
+ if (opts.audiences.length === 0) return null;
2301
+ const decoded = jwt2.decode(idToken, { complete: true });
2302
+ if (!decoded || typeof decoded === "string") return null;
2303
+ const kid = decoded.header.kid;
2304
+ if (!kid) return null;
2305
+ const key = await getApplePublicKey(kid);
2306
+ if (!key) return null;
2307
+ try {
2308
+ const payload = jwt2.verify(idToken, key, {
2309
+ algorithms: ["RS256"],
2310
+ issuer: APPLE_ISSUER,
2311
+ // jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
2312
+ // list and matches the token's aud against ANY entry (guarded non-empty
2313
+ // above).
2314
+ audience: opts.audiences
2315
+ });
2316
+ if (opts.nonce !== void 0 && payload.nonce !== opts.nonce) return null;
2317
+ return payload;
2318
+ } catch {
2319
+ return null;
2320
+ }
2321
+ }
2322
+ var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2257
2323
  function oauthController(store, config) {
2258
2324
  const users = new UserModel(store);
2259
2325
  const sessions = new SessionModel(store);
2260
2326
  const loginEvents = new LoginEventModel(store);
2327
+ const completeAppleLogin = async (ctx, claims) => {
2328
+ const meta = requestMeta(ctx);
2329
+ if (!claims.email) {
2330
+ return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "No email in Apple identity token");
2331
+ }
2332
+ if (!claims.emailVerified) {
2333
+ return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Apple account email is not verified");
2334
+ }
2335
+ const normalizedEmail = normalizeEmailSafe(claims.email);
2336
+ if (!normalizedEmail) {
2337
+ return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid email in Apple identity token");
2338
+ }
2339
+ const upserted = await users.upsertByProvider(normalizedEmail, "apple", claims.sub ?? "");
2340
+ if (!upserted) {
2341
+ loginEvents.recordSafe({
2342
+ userId: null,
2343
+ emailAttempted: normalizedEmail,
2344
+ method: "oauth-apple",
2345
+ outcome: "failed",
2346
+ failureReason: "provider_upsert_failed",
2347
+ ...meta
2348
+ });
2349
+ return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2350
+ }
2351
+ const fullUser = await users.findById(upserted.id);
2352
+ if (!fullUser) {
2353
+ return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2354
+ }
2355
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
2356
+ await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2357
+ loginEvents.recordSafe({
2358
+ userId: upserted.id,
2359
+ emailAttempted: normalizedEmail,
2360
+ method: "oauth-apple",
2361
+ outcome: "success",
2362
+ ...meta
2363
+ });
2364
+ return Response.json(
2365
+ {
2366
+ reason: "APPLE_AUTH_SUCCESS",
2367
+ explanation: "Apple authentication successful.",
2368
+ result: {
2369
+ tokens: { access: accessToken, refresh: refreshToken },
2370
+ user: toUserDTO(fullUser)
2371
+ }
2372
+ },
2373
+ {
2374
+ status: 200,
2375
+ headers: cookieHeaders([
2376
+ ...tokenPairCookies(accessToken, refreshToken, config),
2377
+ // One-time value — clear the (SameSite=None) state cookie.
2378
+ "oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"
2379
+ ])
2380
+ }
2381
+ );
2382
+ };
2261
2383
  return {
2262
2384
  googleInit: async (_ctx) => {
2263
2385
  const google = config.google;
@@ -2409,6 +2531,113 @@ function oauthController(store, config) {
2409
2531
  ])
2410
2532
  }
2411
2533
  );
2534
+ },
2535
+ appleInit: async (_ctx) => {
2536
+ const apple = config.apple;
2537
+ if (!apple) {
2538
+ return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
2539
+ }
2540
+ const state = randomBytes4(16).toString("hex");
2541
+ const params = new URLSearchParams({
2542
+ client_id: apple.clientId,
2543
+ redirect_uri: apple.redirectUri,
2544
+ response_type: "code",
2545
+ // form_post is REQUIRED to receive name/email; Apple then POSTs the
2546
+ // callback cross-site.
2547
+ response_mode: "form_post",
2548
+ scope: "name email",
2549
+ state
2550
+ });
2551
+ const url = `${APPLE_AUTH_URL}?${params}`;
2552
+ return Response.json(
2553
+ {
2554
+ reason: "APPLE_AUTH_URL",
2555
+ explanation: "Redirect the user to the returned URL to begin Apple OAuth.",
2556
+ result: { url }
2557
+ },
2558
+ {
2559
+ status: 200,
2560
+ headers: cookieHeaders([
2561
+ `oauth_state=${state}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=600`
2562
+ ])
2563
+ }
2564
+ );
2565
+ },
2566
+ appleCallback: async (ctx) => {
2567
+ const apple = config.apple;
2568
+ if (!apple) {
2569
+ return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
2570
+ }
2571
+ const pre = ctx.meta["body"];
2572
+ const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
2573
+ const code = form.get("code");
2574
+ if (!code) {
2575
+ return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2576
+ }
2577
+ const returnedState = form.get("state") ?? "";
2578
+ const cookieHeader = ctx.request.headers.get("cookie") ?? "";
2579
+ const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
2580
+ if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
2581
+ return setApiResponse5(
2582
+ HTTP5.BAD_REQUEST,
2583
+ "APPLE_AUTH_FAILED",
2584
+ "OAuth state mismatch \u2014 restart the sign-in flow"
2585
+ );
2586
+ }
2587
+ const tokenRes = await fetch(APPLE_TOKEN_URL, {
2588
+ method: "POST",
2589
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2590
+ body: new URLSearchParams({
2591
+ code,
2592
+ client_id: apple.clientId,
2593
+ client_secret: mintAppleClientSecret(apple),
2594
+ redirect_uri: apple.redirectUri,
2595
+ grant_type: "authorization_code"
2596
+ })
2597
+ });
2598
+ const tokenData = await tokenRes.json();
2599
+ if (!tokenData.id_token) {
2600
+ return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
2601
+ }
2602
+ const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
2603
+ if (!claims) {
2604
+ return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
2605
+ }
2606
+ return completeAppleLogin(ctx, {
2607
+ email: claims.email,
2608
+ sub: claims.sub,
2609
+ emailVerified: appleEmailVerified(claims)
2610
+ });
2611
+ },
2612
+ appleNative: 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 body = ctx.meta["body"];
2618
+ const identityToken = body?.identityToken;
2619
+ if (!identityToken) {
2620
+ return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
2621
+ }
2622
+ const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
2623
+ const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
2624
+ if (!claims) {
2625
+ const meta = requestMeta(ctx);
2626
+ loginEvents.recordSafe({
2627
+ userId: null,
2628
+ emailAttempted: null,
2629
+ method: "oauth-apple",
2630
+ outcome: "failed",
2631
+ failureReason: "invalid_identity_token",
2632
+ ...meta
2633
+ });
2634
+ return setApiResponse5(HTTP5.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
2635
+ }
2636
+ return completeAppleLogin(ctx, {
2637
+ email: claims.email,
2638
+ sub: claims.sub,
2639
+ emailVerified: appleEmailVerified(claims)
2640
+ });
2412
2641
  }
2413
2642
  };
2414
2643
  }
@@ -2478,6 +2707,17 @@ function buildAuthRoutes(store, config, bus) {
2478
2707
  ["GET", "/auth/google/callback", oauth.googleCallback]
2479
2708
  );
2480
2709
  }
2710
+ if (config.providers.includes("apple")) {
2711
+ routes.push(
2712
+ // Web redirect flow. The callback is a POST (response_mode=form_post);
2713
+ // its urlencoded body is parsed inside the controller.
2714
+ ["GET", "/auth/apple", oauth.appleInit],
2715
+ ["POST", "/auth/apple/callback", oauth.appleCallback],
2716
+ // Native (iOS) flow: the app posts the identityToken from the Apple
2717
+ // sheet. ipLimit guards token brute-forcing; validate bounds the body.
2718
+ ["POST", "/auth/apple/native", ipLimit("login"), validate(appleNativeSchema), oauth.appleNative]
2719
+ );
2720
+ }
2481
2721
  return routes;
2482
2722
  }
2483
2723
 
@@ -2524,6 +2764,22 @@ function collectAuthConfigProblems(config) {
2524
2764
  });
2525
2765
  }
2526
2766
  }
2767
+ if (config.apple) {
2768
+ const a = config.apple;
2769
+ if (!a.clientId || !a.teamId || !a.keyId || !a.privateKey || !a.redirectUri) {
2770
+ problems.push({
2771
+ module: MODULE,
2772
+ severity: "error",
2773
+ message: "apple OAuth is configured but clientId, teamId, keyId, privateKey, or redirectUri is missing"
2774
+ });
2775
+ } else if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(a.privateKey)) {
2776
+ problems.push({
2777
+ module: MODULE,
2778
+ severity: "error",
2779
+ message: "apple.privateKey does not look like a PEM .p8 key (expected `-----BEGIN PRIVATE KEY-----`)"
2780
+ });
2781
+ }
2782
+ }
2527
2783
  if (config.mfa && !config.mfaSecretKey) {
2528
2784
  problems.push({
2529
2785
  module: MODULE,