@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/brain/outcomes.md +11 -1
- package/brain/signatures.md +10 -2
- package/dist/index.cjs +332 -2
- 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 +333 -3
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +2 -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 +2 -1
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/016_consumed_tokens.sql +13 -0
- 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
|
@@ -9,6 +9,13 @@ downloading tarballs.
|
|
|
9
9
|
|
|
10
10
|
## Database tables (after all migrations)
|
|
11
11
|
|
|
12
|
+
### `fonderie_consumed_tokens`
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
token_hash TEXT PRIMARY KEY
|
|
16
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
17
|
+
```
|
|
18
|
+
|
|
12
19
|
### `fonderie_email_verifications`
|
|
13
20
|
|
|
14
21
|
```sql
|
|
@@ -129,10 +136,13 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
|
|
|
129
136
|
|
|
130
137
|
| Method | Path | Middleware chain (auth / validation / handler) |
|
|
131
138
|
|---|---|---|
|
|
139
|
+
| GET | `/auth/apple` | `oauth.appleInit` |
|
|
140
|
+
| POST | `/auth/apple/callback` | `ipLimit('login') → oauth.appleCallback` |
|
|
141
|
+
| POST | `/auth/apple/native` | `ipLimit('login') → validate(appleNativeSchema) → oauth.appleNative` |
|
|
132
142
|
| POST | `/auth/email/forgot` | `ipLimit('forgot') → validate(forgotPasswordSchema) → acctLimit('forgot') → auth.forgotPassword` |
|
|
133
143
|
| POST | `/auth/email/reset` | `ipLimit('reset') → validate(resetPasswordSchema) → auth.resetPassword` |
|
|
134
144
|
| GET | `/auth/google` | `oauth.googleInit` |
|
|
135
|
-
| GET | `/auth/google/callback` | `oauth.googleCallback` |
|
|
145
|
+
| GET | `/auth/google/callback` | `ipLimit('login') → oauth.googleCallback` |
|
|
136
146
|
| POST | `/auth/login` | `ipLimit('login') → validate(loginSchema) → acctLimit('login') → auth.login` |
|
|
137
147
|
| GET | `/auth/login-history` | `requireAuth → user.loginHistory` |
|
|
138
148
|
| POST | `/auth/logout` | `requireAuth → validate(refreshSchema) → auth.logout` |
|
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([
|
|
@@ -729,7 +736,8 @@ var UserModel = class {
|
|
|
729
736
|
`INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
|
|
730
737
|
VALUES ($1, now(), $2, $3)
|
|
731
738
|
ON CONFLICT (email) DO UPDATE
|
|
732
|
-
SET provider = $2, provider_id = $3
|
|
739
|
+
SET provider = $2, provider_id = $3,
|
|
740
|
+
email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
|
|
733
741
|
RETURNING id`,
|
|
734
742
|
[email2, provider, providerId]
|
|
735
743
|
);
|
|
@@ -2295,11 +2303,169 @@ function userController(store, config, bus) {
|
|
|
2295
2303
|
|
|
2296
2304
|
// src/controllers/oauth.controller.ts
|
|
2297
2305
|
var import_node_crypto7 = require("crypto");
|
|
2306
|
+
var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
|
|
2298
2307
|
var import_core8 = require("@fonderie/core");
|
|
2308
|
+
|
|
2309
|
+
// src/models/consumed-token.model.ts
|
|
2310
|
+
var ConsumedTokenModel = class {
|
|
2311
|
+
constructor(store) {
|
|
2312
|
+
this.store = store;
|
|
2313
|
+
}
|
|
2314
|
+
store;
|
|
2315
|
+
// True if the token had not been consumed before (caller may proceed); false
|
|
2316
|
+
// if this is a replay. `expiresAt` is the token's own exp — its TTL here.
|
|
2317
|
+
async consumeOnce(tokenHash, expiresAt) {
|
|
2318
|
+
const rows = await this.store.query(
|
|
2319
|
+
`INSERT INTO fonderie_consumed_tokens (token_hash, expires_at)
|
|
2320
|
+
VALUES ($1, $2)
|
|
2321
|
+
ON CONFLICT (token_hash) DO NOTHING
|
|
2322
|
+
RETURNING token_hash`,
|
|
2323
|
+
[tokenHash, expiresAt]
|
|
2324
|
+
);
|
|
2325
|
+
return rows.length > 0;
|
|
2326
|
+
}
|
|
2327
|
+
// Opportunistic, fire-and-forget cleanup of expired rows (index-backed).
|
|
2328
|
+
// Recording history must never block or fail a login.
|
|
2329
|
+
sweepExpiredSafe() {
|
|
2330
|
+
void this.store.query(`DELETE FROM fonderie_consumed_tokens WHERE expires_at < now()`).catch(() => {
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
};
|
|
2334
|
+
|
|
2335
|
+
// src/controllers/oauth.controller.ts
|
|
2336
|
+
var APPLE_AUTH_URL = "https://appleid.apple.com/auth/authorize";
|
|
2337
|
+
var APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
|
|
2338
|
+
var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
|
|
2339
|
+
var APPLE_ISSUER = "https://appleid.apple.com";
|
|
2340
|
+
function mintAppleClientSecret(apple) {
|
|
2341
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2342
|
+
return import_jsonwebtoken2.default.sign(
|
|
2343
|
+
{ iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
|
|
2344
|
+
apple.privateKey,
|
|
2345
|
+
{ algorithm: "ES256", keyid: apple.keyId }
|
|
2346
|
+
);
|
|
2347
|
+
}
|
|
2348
|
+
var appleKeyCache = null;
|
|
2349
|
+
var appleKeyInflight = null;
|
|
2350
|
+
var appleKeyLastAttempt = 0;
|
|
2351
|
+
var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
|
|
2352
|
+
var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
|
|
2353
|
+
var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
|
|
2354
|
+
function refreshAppleKeys() {
|
|
2355
|
+
if (appleKeyInflight) return appleKeyInflight;
|
|
2356
|
+
appleKeyLastAttempt = Date.now();
|
|
2357
|
+
appleKeyInflight = (async () => {
|
|
2358
|
+
try {
|
|
2359
|
+
const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
|
|
2360
|
+
const data = await res.json();
|
|
2361
|
+
if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
|
|
2362
|
+
} catch {
|
|
2363
|
+
} finally {
|
|
2364
|
+
appleKeyInflight = null;
|
|
2365
|
+
}
|
|
2366
|
+
})();
|
|
2367
|
+
return appleKeyInflight;
|
|
2368
|
+
}
|
|
2369
|
+
async function getApplePublicKey(kid) {
|
|
2370
|
+
const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
|
|
2371
|
+
const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
|
|
2372
|
+
let jwk = find();
|
|
2373
|
+
if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
|
|
2374
|
+
await refreshAppleKeys();
|
|
2375
|
+
jwk = find();
|
|
2376
|
+
}
|
|
2377
|
+
if (!jwk) return null;
|
|
2378
|
+
try {
|
|
2379
|
+
return (0, import_node_crypto7.createPublicKey)({ key: jwk, format: "jwk" });
|
|
2380
|
+
} catch {
|
|
2381
|
+
return null;
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
async function verifyAppleIdToken(idToken, opts) {
|
|
2385
|
+
if (opts.audiences.length === 0) return null;
|
|
2386
|
+
const decoded = import_jsonwebtoken2.default.decode(idToken, { complete: true });
|
|
2387
|
+
if (!decoded || typeof decoded === "string") return null;
|
|
2388
|
+
const kid = decoded.header.kid;
|
|
2389
|
+
if (!kid) return null;
|
|
2390
|
+
const key = await getApplePublicKey(kid);
|
|
2391
|
+
if (!key) return null;
|
|
2392
|
+
try {
|
|
2393
|
+
const payload = import_jsonwebtoken2.default.verify(idToken, key, {
|
|
2394
|
+
algorithms: ["RS256"],
|
|
2395
|
+
issuer: APPLE_ISSUER,
|
|
2396
|
+
// jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
|
|
2397
|
+
// list and matches the token's aud against ANY entry (guarded non-empty
|
|
2398
|
+
// above).
|
|
2399
|
+
audience: opts.audiences
|
|
2400
|
+
});
|
|
2401
|
+
if (opts.nonce !== void 0 && payload.nonce !== opts.nonce) return null;
|
|
2402
|
+
return payload;
|
|
2403
|
+
} catch {
|
|
2404
|
+
return null;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
|
|
2299
2408
|
function oauthController(store, config) {
|
|
2300
2409
|
const users = new UserModel(store);
|
|
2301
2410
|
const sessions = new SessionModel(store);
|
|
2302
2411
|
const loginEvents = new LoginEventModel(store);
|
|
2412
|
+
const consumedTokens = new ConsumedTokenModel(store);
|
|
2413
|
+
const completeAppleLogin = async (ctx, claims) => {
|
|
2414
|
+
const meta = requestMeta(ctx);
|
|
2415
|
+
if (!claims.email) {
|
|
2416
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "No email in Apple identity token");
|
|
2417
|
+
}
|
|
2418
|
+
if (!claims.emailVerified) {
|
|
2419
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Apple account email is not verified");
|
|
2420
|
+
}
|
|
2421
|
+
const normalizedEmail = normalizeEmailSafe(claims.email);
|
|
2422
|
+
if (!normalizedEmail) {
|
|
2423
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid email in Apple identity token");
|
|
2424
|
+
}
|
|
2425
|
+
const upserted = await users.upsertByProvider(normalizedEmail, "apple", claims.sub ?? "");
|
|
2426
|
+
if (!upserted) {
|
|
2427
|
+
loginEvents.recordSafe({
|
|
2428
|
+
userId: null,
|
|
2429
|
+
emailAttempted: normalizedEmail,
|
|
2430
|
+
method: "oauth-apple",
|
|
2431
|
+
outcome: "failed",
|
|
2432
|
+
failureReason: "provider_upsert_failed",
|
|
2433
|
+
...meta
|
|
2434
|
+
});
|
|
2435
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
|
|
2436
|
+
}
|
|
2437
|
+
const fullUser = await users.findById(upserted.id);
|
|
2438
|
+
if (!fullUser) {
|
|
2439
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
|
|
2440
|
+
}
|
|
2441
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
|
|
2442
|
+
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
2443
|
+
loginEvents.recordSafe({
|
|
2444
|
+
userId: upserted.id,
|
|
2445
|
+
emailAttempted: normalizedEmail,
|
|
2446
|
+
method: "oauth-apple",
|
|
2447
|
+
outcome: "success",
|
|
2448
|
+
...meta
|
|
2449
|
+
});
|
|
2450
|
+
return Response.json(
|
|
2451
|
+
{
|
|
2452
|
+
reason: "APPLE_AUTH_SUCCESS",
|
|
2453
|
+
explanation: "Apple authentication successful.",
|
|
2454
|
+
result: {
|
|
2455
|
+
tokens: { access: accessToken, refresh: refreshToken },
|
|
2456
|
+
user: toUserDTO(fullUser)
|
|
2457
|
+
}
|
|
2458
|
+
},
|
|
2459
|
+
{
|
|
2460
|
+
status: 200,
|
|
2461
|
+
headers: cookieHeaders([
|
|
2462
|
+
...tokenPairCookies(accessToken, refreshToken, config),
|
|
2463
|
+
// One-time value — clear the (SameSite=None) state cookie.
|
|
2464
|
+
"oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"
|
|
2465
|
+
])
|
|
2466
|
+
}
|
|
2467
|
+
);
|
|
2468
|
+
};
|
|
2303
2469
|
return {
|
|
2304
2470
|
googleInit: async (_ctx) => {
|
|
2305
2471
|
const google = config.google;
|
|
@@ -2451,6 +2617,133 @@ function oauthController(store, config) {
|
|
|
2451
2617
|
])
|
|
2452
2618
|
}
|
|
2453
2619
|
);
|
|
2620
|
+
},
|
|
2621
|
+
appleInit: async (_ctx) => {
|
|
2622
|
+
const apple = config.apple;
|
|
2623
|
+
if (!apple) {
|
|
2624
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
|
|
2625
|
+
}
|
|
2626
|
+
const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
|
|
2627
|
+
const params = new URLSearchParams({
|
|
2628
|
+
client_id: apple.clientId,
|
|
2629
|
+
redirect_uri: apple.redirectUri,
|
|
2630
|
+
response_type: "code",
|
|
2631
|
+
// form_post is REQUIRED to receive name/email; Apple then POSTs the
|
|
2632
|
+
// callback cross-site.
|
|
2633
|
+
response_mode: "form_post",
|
|
2634
|
+
scope: "name email",
|
|
2635
|
+
state
|
|
2636
|
+
});
|
|
2637
|
+
const url = `${APPLE_AUTH_URL}?${params}`;
|
|
2638
|
+
return Response.json(
|
|
2639
|
+
{
|
|
2640
|
+
reason: "APPLE_AUTH_URL",
|
|
2641
|
+
explanation: "Redirect the user to the returned URL to begin Apple OAuth.",
|
|
2642
|
+
result: { url }
|
|
2643
|
+
},
|
|
2644
|
+
{
|
|
2645
|
+
status: 200,
|
|
2646
|
+
headers: cookieHeaders([
|
|
2647
|
+
`oauth_state=${state}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=600`
|
|
2648
|
+
])
|
|
2649
|
+
}
|
|
2650
|
+
);
|
|
2651
|
+
},
|
|
2652
|
+
appleCallback: 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 pre = ctx.meta["body"];
|
|
2658
|
+
const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
|
|
2659
|
+
const code = form.get("code");
|
|
2660
|
+
if (!code) {
|
|
2661
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
|
|
2662
|
+
}
|
|
2663
|
+
const returnedState = form.get("state") ?? "";
|
|
2664
|
+
const cookieHeader = ctx.request.headers.get("cookie") ?? "";
|
|
2665
|
+
const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
|
|
2666
|
+
if (!returnedState || !expectedState || !(0, import_core8.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
|
|
2667
|
+
return (0, import_core8.setApiResponse)(
|
|
2668
|
+
import_core8.HTTP.BAD_REQUEST,
|
|
2669
|
+
"APPLE_AUTH_FAILED",
|
|
2670
|
+
"OAuth state mismatch \u2014 restart the sign-in flow"
|
|
2671
|
+
);
|
|
2672
|
+
}
|
|
2673
|
+
const tokenRes = await fetch(APPLE_TOKEN_URL, {
|
|
2674
|
+
method: "POST",
|
|
2675
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
2676
|
+
body: new URLSearchParams({
|
|
2677
|
+
code,
|
|
2678
|
+
client_id: apple.clientId,
|
|
2679
|
+
client_secret: mintAppleClientSecret(apple),
|
|
2680
|
+
redirect_uri: apple.redirectUri,
|
|
2681
|
+
grant_type: "authorization_code"
|
|
2682
|
+
})
|
|
2683
|
+
});
|
|
2684
|
+
const tokenData = await tokenRes.json();
|
|
2685
|
+
if (!tokenData.id_token) {
|
|
2686
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
|
|
2687
|
+
}
|
|
2688
|
+
const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
|
|
2689
|
+
if (!claims) {
|
|
2690
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
2691
|
+
}
|
|
2692
|
+
return completeAppleLogin(ctx, {
|
|
2693
|
+
email: claims.email,
|
|
2694
|
+
sub: claims.sub,
|
|
2695
|
+
emailVerified: appleEmailVerified(claims)
|
|
2696
|
+
});
|
|
2697
|
+
},
|
|
2698
|
+
appleNative: async (ctx) => {
|
|
2699
|
+
const apple = config.apple;
|
|
2700
|
+
if (!apple) {
|
|
2701
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
|
|
2702
|
+
}
|
|
2703
|
+
const body = ctx.meta["body"];
|
|
2704
|
+
const identityToken = body?.identityToken;
|
|
2705
|
+
if (!identityToken) {
|
|
2706
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
|
|
2707
|
+
}
|
|
2708
|
+
const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
|
|
2709
|
+
const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
|
|
2710
|
+
if (!claims) {
|
|
2711
|
+
const meta = requestMeta(ctx);
|
|
2712
|
+
loginEvents.recordSafe({
|
|
2713
|
+
userId: null,
|
|
2714
|
+
emailAttempted: null,
|
|
2715
|
+
method: "oauth-apple",
|
|
2716
|
+
outcome: "failed",
|
|
2717
|
+
failureReason: "invalid_identity_token",
|
|
2718
|
+
...meta
|
|
2719
|
+
});
|
|
2720
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
2721
|
+
}
|
|
2722
|
+
const tokenHash = (0, import_node_crypto7.createHash)("sha256").update(identityToken).digest("hex");
|
|
2723
|
+
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
2724
|
+
const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
|
|
2725
|
+
if (!firstUse) {
|
|
2726
|
+
const meta = requestMeta(ctx);
|
|
2727
|
+
loginEvents.recordSafe({
|
|
2728
|
+
userId: null,
|
|
2729
|
+
emailAttempted: claims.email ?? null,
|
|
2730
|
+
method: "oauth-apple",
|
|
2731
|
+
outcome: "failed",
|
|
2732
|
+
failureReason: "token_replayed",
|
|
2733
|
+
...meta
|
|
2734
|
+
});
|
|
2735
|
+
return (0, import_core8.setApiResponse)(
|
|
2736
|
+
import_core8.HTTP.UNAUTHORIZED,
|
|
2737
|
+
"APPLE_AUTH_FAILED",
|
|
2738
|
+
"This Apple identity token has already been used"
|
|
2739
|
+
);
|
|
2740
|
+
}
|
|
2741
|
+
consumedTokens.sweepExpiredSafe();
|
|
2742
|
+
return completeAppleLogin(ctx, {
|
|
2743
|
+
email: claims.email,
|
|
2744
|
+
sub: claims.sub,
|
|
2745
|
+
emailVerified: appleEmailVerified(claims)
|
|
2746
|
+
});
|
|
2454
2747
|
}
|
|
2455
2748
|
};
|
|
2456
2749
|
}
|
|
@@ -2517,7 +2810,21 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2517
2810
|
if (config.providers.includes("google")) {
|
|
2518
2811
|
routes.push(
|
|
2519
2812
|
["GET", "/auth/google", oauth.googleInit],
|
|
2520
|
-
|
|
2813
|
+
// ipLimit: an unauthenticated caller can otherwise force one outbound
|
|
2814
|
+
// token-exchange to Google per request (the state check doesn't gate it).
|
|
2815
|
+
["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback]
|
|
2816
|
+
);
|
|
2817
|
+
}
|
|
2818
|
+
if (config.providers.includes("apple")) {
|
|
2819
|
+
routes.push(
|
|
2820
|
+
// Web redirect flow. The callback is a POST (response_mode=form_post);
|
|
2821
|
+
// its urlencoded body is parsed inside the controller. ipLimit for the
|
|
2822
|
+
// same reason as Google's callback (bounds outbound token exchanges).
|
|
2823
|
+
["GET", "/auth/apple", oauth.appleInit],
|
|
2824
|
+
["POST", "/auth/apple/callback", ipLimit("login"), oauth.appleCallback],
|
|
2825
|
+
// Native (iOS) flow: the app posts the identityToken from the Apple
|
|
2826
|
+
// sheet. ipLimit guards token brute-forcing; validate bounds the body.
|
|
2827
|
+
["POST", "/auth/apple/native", ipLimit("login"), (0, import_middlewares.validate)(appleNativeSchema), oauth.appleNative]
|
|
2521
2828
|
);
|
|
2522
2829
|
}
|
|
2523
2830
|
return routes;
|
|
@@ -2566,6 +2873,29 @@ function collectAuthConfigProblems(config) {
|
|
|
2566
2873
|
});
|
|
2567
2874
|
}
|
|
2568
2875
|
}
|
|
2876
|
+
if (config.apple) {
|
|
2877
|
+
const a = config.apple;
|
|
2878
|
+
if (!a.clientId || !a.teamId || !a.keyId || !a.privateKey || !a.redirectUri) {
|
|
2879
|
+
problems.push({
|
|
2880
|
+
module: MODULE,
|
|
2881
|
+
severity: "error",
|
|
2882
|
+
message: "apple OAuth is configured but clientId, teamId, keyId, privateKey, or redirectUri is missing"
|
|
2883
|
+
});
|
|
2884
|
+
} else if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(a.privateKey)) {
|
|
2885
|
+
problems.push({
|
|
2886
|
+
module: MODULE,
|
|
2887
|
+
severity: "error",
|
|
2888
|
+
message: "apple.privateKey does not look like a PEM .p8 key (expected `-----BEGIN PRIVATE KEY-----`)"
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
if (a.nativeClientIds?.some((id) => !id || id.trim() === "" || id.includes("*"))) {
|
|
2892
|
+
problems.push({
|
|
2893
|
+
module: MODULE,
|
|
2894
|
+
severity: "error",
|
|
2895
|
+
message: "apple.nativeClientIds must be exact bundle ids \u2014 empty or wildcard entries would accept identity tokens minted for other apps"
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2569
2899
|
if (config.mfa && !config.mfaSecretKey) {
|
|
2570
2900
|
problems.push({
|
|
2571
2901
|
module: MODULE,
|