@fonderie/auth 7.1.1 → 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([
@@ -2295,11 +2302,124 @@ function userController(store, config, bus) {
2295
2302
 
2296
2303
  // src/controllers/oauth.controller.ts
2297
2304
  var import_node_crypto7 = require("crypto");
2305
+ var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
2298
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";
2299
2363
  function oauthController(store, config) {
2300
2364
  const users = new UserModel(store);
2301
2365
  const sessions = new SessionModel(store);
2302
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
+ };
2303
2423
  return {
2304
2424
  googleInit: async (_ctx) => {
2305
2425
  const google = config.google;
@@ -2451,6 +2571,113 @@ function oauthController(store, config) {
2451
2571
  ])
2452
2572
  }
2453
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
+ });
2454
2681
  }
2455
2682
  };
2456
2683
  }
@@ -2520,6 +2747,17 @@ function buildAuthRoutes(store, config, bus) {
2520
2747
  ["GET", "/auth/google/callback", oauth.googleCallback]
2521
2748
  );
2522
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
+ }
2523
2761
  return routes;
2524
2762
  }
2525
2763
 
@@ -2566,6 +2804,22 @@ function collectAuthConfigProblems(config) {
2566
2804
  });
2567
2805
  }
2568
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
+ }
2569
2823
  if (config.mfa && !config.mfaSecretKey) {
2570
2824
  problems.push({
2571
2825
  module: MODULE,