@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/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-
|
|
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-
|
|
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-
|
|
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-
|
|
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([
|
|
@@ -689,7 +696,8 @@ var UserModel = class {
|
|
|
689
696
|
`INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
|
|
690
697
|
VALUES ($1, now(), $2, $3)
|
|
691
698
|
ON CONFLICT (email) DO UPDATE
|
|
692
|
-
SET provider = $2, provider_id = $3
|
|
699
|
+
SET provider = $2, provider_id = $3,
|
|
700
|
+
email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
|
|
693
701
|
RETURNING id`,
|
|
694
702
|
[email2, provider, providerId]
|
|
695
703
|
);
|
|
@@ -2254,12 +2262,170 @@ function userController(store, config, bus) {
|
|
|
2254
2262
|
}
|
|
2255
2263
|
|
|
2256
2264
|
// src/controllers/oauth.controller.ts
|
|
2257
|
-
import { randomBytes as randomBytes4 } from "crypto";
|
|
2265
|
+
import { randomBytes as randomBytes4, createPublicKey, createHash as createHash2 } from "crypto";
|
|
2266
|
+
import jwt2 from "jsonwebtoken";
|
|
2258
2267
|
import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2 } from "@fonderie/core";
|
|
2268
|
+
|
|
2269
|
+
// src/models/consumed-token.model.ts
|
|
2270
|
+
var ConsumedTokenModel = class {
|
|
2271
|
+
constructor(store) {
|
|
2272
|
+
this.store = store;
|
|
2273
|
+
}
|
|
2274
|
+
store;
|
|
2275
|
+
// True if the token had not been consumed before (caller may proceed); false
|
|
2276
|
+
// if this is a replay. `expiresAt` is the token's own exp — its TTL here.
|
|
2277
|
+
async consumeOnce(tokenHash, expiresAt) {
|
|
2278
|
+
const rows = await this.store.query(
|
|
2279
|
+
`INSERT INTO fonderie_consumed_tokens (token_hash, expires_at)
|
|
2280
|
+
VALUES ($1, $2)
|
|
2281
|
+
ON CONFLICT (token_hash) DO NOTHING
|
|
2282
|
+
RETURNING token_hash`,
|
|
2283
|
+
[tokenHash, expiresAt]
|
|
2284
|
+
);
|
|
2285
|
+
return rows.length > 0;
|
|
2286
|
+
}
|
|
2287
|
+
// Opportunistic, fire-and-forget cleanup of expired rows (index-backed).
|
|
2288
|
+
// Recording history must never block or fail a login.
|
|
2289
|
+
sweepExpiredSafe() {
|
|
2290
|
+
void this.store.query(`DELETE FROM fonderie_consumed_tokens WHERE expires_at < now()`).catch(() => {
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
|
|
2295
|
+
// src/controllers/oauth.controller.ts
|
|
2296
|
+
var APPLE_AUTH_URL = "https://appleid.apple.com/auth/authorize";
|
|
2297
|
+
var APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
|
|
2298
|
+
var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
|
|
2299
|
+
var APPLE_ISSUER = "https://appleid.apple.com";
|
|
2300
|
+
function mintAppleClientSecret(apple) {
|
|
2301
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2302
|
+
return jwt2.sign(
|
|
2303
|
+
{ iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
|
|
2304
|
+
apple.privateKey,
|
|
2305
|
+
{ algorithm: "ES256", keyid: apple.keyId }
|
|
2306
|
+
);
|
|
2307
|
+
}
|
|
2308
|
+
var appleKeyCache = null;
|
|
2309
|
+
var appleKeyInflight = null;
|
|
2310
|
+
var appleKeyLastAttempt = 0;
|
|
2311
|
+
var APPLE_KEYS_TTL_MS = 60 * 60 * 1e3;
|
|
2312
|
+
var APPLE_KEYS_MIN_REFETCH_MS = 60 * 1e3;
|
|
2313
|
+
var APPLE_KEYS_FETCH_TIMEOUT_MS = 5e3;
|
|
2314
|
+
function refreshAppleKeys() {
|
|
2315
|
+
if (appleKeyInflight) return appleKeyInflight;
|
|
2316
|
+
appleKeyLastAttempt = Date.now();
|
|
2317
|
+
appleKeyInflight = (async () => {
|
|
2318
|
+
try {
|
|
2319
|
+
const res = await fetch(APPLE_KEYS_URL, { signal: AbortSignal.timeout(APPLE_KEYS_FETCH_TIMEOUT_MS) });
|
|
2320
|
+
const data = await res.json();
|
|
2321
|
+
if (Array.isArray(data.keys)) appleKeyCache = { keys: data.keys, fetchedAt: Date.now() };
|
|
2322
|
+
} catch {
|
|
2323
|
+
} finally {
|
|
2324
|
+
appleKeyInflight = null;
|
|
2325
|
+
}
|
|
2326
|
+
})();
|
|
2327
|
+
return appleKeyInflight;
|
|
2328
|
+
}
|
|
2329
|
+
async function getApplePublicKey(kid) {
|
|
2330
|
+
const find = () => appleKeyCache?.keys.find((k) => k.kid === kid) ?? null;
|
|
2331
|
+
const cacheFresh = () => !!appleKeyCache && Date.now() - appleKeyCache.fetchedAt < APPLE_KEYS_TTL_MS;
|
|
2332
|
+
let jwk = find();
|
|
2333
|
+
if ((!jwk || !cacheFresh()) && Date.now() - appleKeyLastAttempt >= APPLE_KEYS_MIN_REFETCH_MS) {
|
|
2334
|
+
await refreshAppleKeys();
|
|
2335
|
+
jwk = find();
|
|
2336
|
+
}
|
|
2337
|
+
if (!jwk) return null;
|
|
2338
|
+
try {
|
|
2339
|
+
return createPublicKey({ key: jwk, format: "jwk" });
|
|
2340
|
+
} catch {
|
|
2341
|
+
return null;
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
async function verifyAppleIdToken(idToken, opts) {
|
|
2345
|
+
if (opts.audiences.length === 0) return null;
|
|
2346
|
+
const decoded = jwt2.decode(idToken, { complete: true });
|
|
2347
|
+
if (!decoded || typeof decoded === "string") return null;
|
|
2348
|
+
const kid = decoded.header.kid;
|
|
2349
|
+
if (!kid) return null;
|
|
2350
|
+
const key = await getApplePublicKey(kid);
|
|
2351
|
+
if (!key) return null;
|
|
2352
|
+
try {
|
|
2353
|
+
const payload = jwt2.verify(idToken, key, {
|
|
2354
|
+
algorithms: ["RS256"],
|
|
2355
|
+
issuer: APPLE_ISSUER,
|
|
2356
|
+
// jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
|
|
2357
|
+
// list and matches the token's aud against ANY entry (guarded non-empty
|
|
2358
|
+
// above).
|
|
2359
|
+
audience: opts.audiences
|
|
2360
|
+
});
|
|
2361
|
+
if (opts.nonce !== void 0 && payload.nonce !== opts.nonce) return null;
|
|
2362
|
+
return payload;
|
|
2363
|
+
} catch {
|
|
2364
|
+
return null;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
|
|
2259
2368
|
function oauthController(store, config) {
|
|
2260
2369
|
const users = new UserModel(store);
|
|
2261
2370
|
const sessions = new SessionModel(store);
|
|
2262
2371
|
const loginEvents = new LoginEventModel(store);
|
|
2372
|
+
const consumedTokens = new ConsumedTokenModel(store);
|
|
2373
|
+
const completeAppleLogin = async (ctx, claims) => {
|
|
2374
|
+
const meta = requestMeta(ctx);
|
|
2375
|
+
if (!claims.email) {
|
|
2376
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "No email in Apple identity token");
|
|
2377
|
+
}
|
|
2378
|
+
if (!claims.emailVerified) {
|
|
2379
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Apple account email is not verified");
|
|
2380
|
+
}
|
|
2381
|
+
const normalizedEmail = normalizeEmailSafe(claims.email);
|
|
2382
|
+
if (!normalizedEmail) {
|
|
2383
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid email in Apple identity token");
|
|
2384
|
+
}
|
|
2385
|
+
const upserted = await users.upsertByProvider(normalizedEmail, "apple", claims.sub ?? "");
|
|
2386
|
+
if (!upserted) {
|
|
2387
|
+
loginEvents.recordSafe({
|
|
2388
|
+
userId: null,
|
|
2389
|
+
emailAttempted: normalizedEmail,
|
|
2390
|
+
method: "oauth-apple",
|
|
2391
|
+
outcome: "failed",
|
|
2392
|
+
failureReason: "provider_upsert_failed",
|
|
2393
|
+
...meta
|
|
2394
|
+
});
|
|
2395
|
+
return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
|
|
2396
|
+
}
|
|
2397
|
+
const fullUser = await users.findById(upserted.id);
|
|
2398
|
+
if (!fullUser) {
|
|
2399
|
+
return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
|
|
2400
|
+
}
|
|
2401
|
+
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
|
|
2402
|
+
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
2403
|
+
loginEvents.recordSafe({
|
|
2404
|
+
userId: upserted.id,
|
|
2405
|
+
emailAttempted: normalizedEmail,
|
|
2406
|
+
method: "oauth-apple",
|
|
2407
|
+
outcome: "success",
|
|
2408
|
+
...meta
|
|
2409
|
+
});
|
|
2410
|
+
return Response.json(
|
|
2411
|
+
{
|
|
2412
|
+
reason: "APPLE_AUTH_SUCCESS",
|
|
2413
|
+
explanation: "Apple authentication successful.",
|
|
2414
|
+
result: {
|
|
2415
|
+
tokens: { access: accessToken, refresh: refreshToken },
|
|
2416
|
+
user: toUserDTO(fullUser)
|
|
2417
|
+
}
|
|
2418
|
+
},
|
|
2419
|
+
{
|
|
2420
|
+
status: 200,
|
|
2421
|
+
headers: cookieHeaders([
|
|
2422
|
+
...tokenPairCookies(accessToken, refreshToken, config),
|
|
2423
|
+
// One-time value — clear the (SameSite=None) state cookie.
|
|
2424
|
+
"oauth_state=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"
|
|
2425
|
+
])
|
|
2426
|
+
}
|
|
2427
|
+
);
|
|
2428
|
+
};
|
|
2263
2429
|
return {
|
|
2264
2430
|
googleInit: async (_ctx) => {
|
|
2265
2431
|
const google = config.google;
|
|
@@ -2411,6 +2577,133 @@ function oauthController(store, config) {
|
|
|
2411
2577
|
])
|
|
2412
2578
|
}
|
|
2413
2579
|
);
|
|
2580
|
+
},
|
|
2581
|
+
appleInit: async (_ctx) => {
|
|
2582
|
+
const apple = config.apple;
|
|
2583
|
+
if (!apple) {
|
|
2584
|
+
return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
|
|
2585
|
+
}
|
|
2586
|
+
const state = randomBytes4(16).toString("hex");
|
|
2587
|
+
const params = new URLSearchParams({
|
|
2588
|
+
client_id: apple.clientId,
|
|
2589
|
+
redirect_uri: apple.redirectUri,
|
|
2590
|
+
response_type: "code",
|
|
2591
|
+
// form_post is REQUIRED to receive name/email; Apple then POSTs the
|
|
2592
|
+
// callback cross-site.
|
|
2593
|
+
response_mode: "form_post",
|
|
2594
|
+
scope: "name email",
|
|
2595
|
+
state
|
|
2596
|
+
});
|
|
2597
|
+
const url = `${APPLE_AUTH_URL}?${params}`;
|
|
2598
|
+
return Response.json(
|
|
2599
|
+
{
|
|
2600
|
+
reason: "APPLE_AUTH_URL",
|
|
2601
|
+
explanation: "Redirect the user to the returned URL to begin Apple OAuth.",
|
|
2602
|
+
result: { url }
|
|
2603
|
+
},
|
|
2604
|
+
{
|
|
2605
|
+
status: 200,
|
|
2606
|
+
headers: cookieHeaders([
|
|
2607
|
+
`oauth_state=${state}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=600`
|
|
2608
|
+
])
|
|
2609
|
+
}
|
|
2610
|
+
);
|
|
2611
|
+
},
|
|
2612
|
+
appleCallback: 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 pre = ctx.meta["body"];
|
|
2618
|
+
const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
|
|
2619
|
+
const code = form.get("code");
|
|
2620
|
+
if (!code) {
|
|
2621
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
|
|
2622
|
+
}
|
|
2623
|
+
const returnedState = form.get("state") ?? "";
|
|
2624
|
+
const cookieHeader = ctx.request.headers.get("cookie") ?? "";
|
|
2625
|
+
const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
|
|
2626
|
+
if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
|
|
2627
|
+
return setApiResponse5(
|
|
2628
|
+
HTTP5.BAD_REQUEST,
|
|
2629
|
+
"APPLE_AUTH_FAILED",
|
|
2630
|
+
"OAuth state mismatch \u2014 restart the sign-in flow"
|
|
2631
|
+
);
|
|
2632
|
+
}
|
|
2633
|
+
const tokenRes = await fetch(APPLE_TOKEN_URL, {
|
|
2634
|
+
method: "POST",
|
|
2635
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
2636
|
+
body: new URLSearchParams({
|
|
2637
|
+
code,
|
|
2638
|
+
client_id: apple.clientId,
|
|
2639
|
+
client_secret: mintAppleClientSecret(apple),
|
|
2640
|
+
redirect_uri: apple.redirectUri,
|
|
2641
|
+
grant_type: "authorization_code"
|
|
2642
|
+
})
|
|
2643
|
+
});
|
|
2644
|
+
const tokenData = await tokenRes.json();
|
|
2645
|
+
if (!tokenData.id_token) {
|
|
2646
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
|
|
2647
|
+
}
|
|
2648
|
+
const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
|
|
2649
|
+
if (!claims) {
|
|
2650
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
2651
|
+
}
|
|
2652
|
+
return completeAppleLogin(ctx, {
|
|
2653
|
+
email: claims.email,
|
|
2654
|
+
sub: claims.sub,
|
|
2655
|
+
emailVerified: appleEmailVerified(claims)
|
|
2656
|
+
});
|
|
2657
|
+
},
|
|
2658
|
+
appleNative: async (ctx) => {
|
|
2659
|
+
const apple = config.apple;
|
|
2660
|
+
if (!apple) {
|
|
2661
|
+
return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
|
|
2662
|
+
}
|
|
2663
|
+
const body = ctx.meta["body"];
|
|
2664
|
+
const identityToken = body?.identityToken;
|
|
2665
|
+
if (!identityToken) {
|
|
2666
|
+
return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
|
|
2667
|
+
}
|
|
2668
|
+
const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
|
|
2669
|
+
const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
|
|
2670
|
+
if (!claims) {
|
|
2671
|
+
const meta = requestMeta(ctx);
|
|
2672
|
+
loginEvents.recordSafe({
|
|
2673
|
+
userId: null,
|
|
2674
|
+
emailAttempted: null,
|
|
2675
|
+
method: "oauth-apple",
|
|
2676
|
+
outcome: "failed",
|
|
2677
|
+
failureReason: "invalid_identity_token",
|
|
2678
|
+
...meta
|
|
2679
|
+
});
|
|
2680
|
+
return setApiResponse5(HTTP5.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
|
|
2681
|
+
}
|
|
2682
|
+
const tokenHash = createHash2("sha256").update(identityToken).digest("hex");
|
|
2683
|
+
const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
|
|
2684
|
+
const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
|
|
2685
|
+
if (!firstUse) {
|
|
2686
|
+
const meta = requestMeta(ctx);
|
|
2687
|
+
loginEvents.recordSafe({
|
|
2688
|
+
userId: null,
|
|
2689
|
+
emailAttempted: claims.email ?? null,
|
|
2690
|
+
method: "oauth-apple",
|
|
2691
|
+
outcome: "failed",
|
|
2692
|
+
failureReason: "token_replayed",
|
|
2693
|
+
...meta
|
|
2694
|
+
});
|
|
2695
|
+
return setApiResponse5(
|
|
2696
|
+
HTTP5.UNAUTHORIZED,
|
|
2697
|
+
"APPLE_AUTH_FAILED",
|
|
2698
|
+
"This Apple identity token has already been used"
|
|
2699
|
+
);
|
|
2700
|
+
}
|
|
2701
|
+
consumedTokens.sweepExpiredSafe();
|
|
2702
|
+
return completeAppleLogin(ctx, {
|
|
2703
|
+
email: claims.email,
|
|
2704
|
+
sub: claims.sub,
|
|
2705
|
+
emailVerified: appleEmailVerified(claims)
|
|
2706
|
+
});
|
|
2414
2707
|
}
|
|
2415
2708
|
};
|
|
2416
2709
|
}
|
|
@@ -2477,7 +2770,21 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2477
2770
|
if (config.providers.includes("google")) {
|
|
2478
2771
|
routes.push(
|
|
2479
2772
|
["GET", "/auth/google", oauth.googleInit],
|
|
2480
|
-
|
|
2773
|
+
// ipLimit: an unauthenticated caller can otherwise force one outbound
|
|
2774
|
+
// token-exchange to Google per request (the state check doesn't gate it).
|
|
2775
|
+
["GET", "/auth/google/callback", ipLimit("login"), oauth.googleCallback]
|
|
2776
|
+
);
|
|
2777
|
+
}
|
|
2778
|
+
if (config.providers.includes("apple")) {
|
|
2779
|
+
routes.push(
|
|
2780
|
+
// Web redirect flow. The callback is a POST (response_mode=form_post);
|
|
2781
|
+
// its urlencoded body is parsed inside the controller. ipLimit for the
|
|
2782
|
+
// same reason as Google's callback (bounds outbound token exchanges).
|
|
2783
|
+
["GET", "/auth/apple", oauth.appleInit],
|
|
2784
|
+
["POST", "/auth/apple/callback", ipLimit("login"), oauth.appleCallback],
|
|
2785
|
+
// Native (iOS) flow: the app posts the identityToken from the Apple
|
|
2786
|
+
// sheet. ipLimit guards token brute-forcing; validate bounds the body.
|
|
2787
|
+
["POST", "/auth/apple/native", ipLimit("login"), validate(appleNativeSchema), oauth.appleNative]
|
|
2481
2788
|
);
|
|
2482
2789
|
}
|
|
2483
2790
|
return routes;
|
|
@@ -2526,6 +2833,29 @@ function collectAuthConfigProblems(config) {
|
|
|
2526
2833
|
});
|
|
2527
2834
|
}
|
|
2528
2835
|
}
|
|
2836
|
+
if (config.apple) {
|
|
2837
|
+
const a = config.apple;
|
|
2838
|
+
if (!a.clientId || !a.teamId || !a.keyId || !a.privateKey || !a.redirectUri) {
|
|
2839
|
+
problems.push({
|
|
2840
|
+
module: MODULE,
|
|
2841
|
+
severity: "error",
|
|
2842
|
+
message: "apple OAuth is configured but clientId, teamId, keyId, privateKey, or redirectUri is missing"
|
|
2843
|
+
});
|
|
2844
|
+
} else if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(a.privateKey)) {
|
|
2845
|
+
problems.push({
|
|
2846
|
+
module: MODULE,
|
|
2847
|
+
severity: "error",
|
|
2848
|
+
message: "apple.privateKey does not look like a PEM .p8 key (expected `-----BEGIN PRIVATE KEY-----`)"
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
if (a.nativeClientIds?.some((id) => !id || id.trim() === "" || id.includes("*"))) {
|
|
2852
|
+
problems.push({
|
|
2853
|
+
module: MODULE,
|
|
2854
|
+
severity: "error",
|
|
2855
|
+
message: "apple.nativeClientIds must be exact bundle ids \u2014 empty or wildcard entries would accept identity tokens minted for other apps"
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2529
2859
|
if (config.mfa && !config.mfaSecretKey) {
|
|
2530
2860
|
problems.push({
|
|
2531
2861
|
module: MODULE,
|