@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 +3 -0
- package/brain/signatures.md +10 -2
- package/dist/index.cjs +254 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -3
- package/dist/index.d.ts +8 -3
- package/dist/index.js +255 -1
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js.map +1 -1
- package/dist/{session-6B77MAFn.d.cts → session-Dc5-JNoW.d.cts} +9 -1
- package/dist/{session-6B77MAFn.d.ts → session-Dc5-JNoW.d.ts} +9 -1
- package/package.json +1 -1
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` |
|
package/brain/signatures.md
CHANGED
|
@@ -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,
|