@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/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([
|
|
@@ -2254,12 +2261,125 @@ function userController(store, config, bus) {
|
|
|
2254
2261
|
}
|
|
2255
2262
|
|
|
2256
2263
|
// src/controllers/oauth.controller.ts
|
|
2257
|
-
import { randomBytes as randomBytes4 } from "crypto";
|
|
2264
|
+
import { randomBytes as randomBytes4, createPublicKey } from "crypto";
|
|
2265
|
+
import jwt2 from "jsonwebtoken";
|
|
2258
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";
|
|
2259
2323
|
function oauthController(store, config) {
|
|
2260
2324
|
const users = new UserModel(store);
|
|
2261
2325
|
const sessions = new SessionModel(store);
|
|
2262
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
|
+
};
|
|
2263
2383
|
return {
|
|
2264
2384
|
googleInit: async (_ctx) => {
|
|
2265
2385
|
const google = config.google;
|
|
@@ -2411,6 +2531,113 @@ function oauthController(store, config) {
|
|
|
2411
2531
|
])
|
|
2412
2532
|
}
|
|
2413
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
|
+
});
|
|
2414
2641
|
}
|
|
2415
2642
|
};
|
|
2416
2643
|
}
|
|
@@ -2480,6 +2707,17 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2480
2707
|
["GET", "/auth/google/callback", oauth.googleCallback]
|
|
2481
2708
|
);
|
|
2482
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
|
+
}
|
|
2483
2721
|
return routes;
|
|
2484
2722
|
}
|
|
2485
2723
|
|
|
@@ -2526,6 +2764,22 @@ function collectAuthConfigProblems(config) {
|
|
|
2526
2764
|
});
|
|
2527
2765
|
}
|
|
2528
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
|
+
}
|
|
2529
2783
|
if (config.mfa && !config.mfaSecretKey) {
|
|
2530
2784
|
problems.push({
|
|
2531
2785
|
module: MODULE,
|