@fonderie/auth 7.1.0 → 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 +272 -16
- 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 +260 -4
- 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([
|
|
@@ -1149,7 +1156,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
1149
1156
|
}
|
|
1150
1157
|
|
|
1151
1158
|
// src/controllers/auth.controller.ts
|
|
1152
|
-
var
|
|
1159
|
+
var import_node_crypto5 = require("crypto");
|
|
1153
1160
|
var import_events2 = require("@fonderie/events");
|
|
1154
1161
|
var import_core5 = require("@fonderie/core");
|
|
1155
1162
|
|
|
@@ -1196,6 +1203,8 @@ function normalizeEmailSafe(email2) {
|
|
|
1196
1203
|
}
|
|
1197
1204
|
|
|
1198
1205
|
// src/models/password-reset.model.ts
|
|
1206
|
+
var import_node_crypto4 = require("crypto");
|
|
1207
|
+
var hashSecret = (value) => (0, import_node_crypto4.createHash)("sha256").update(value).digest("hex");
|
|
1199
1208
|
var PasswordResetModel = class {
|
|
1200
1209
|
constructor(store) {
|
|
1201
1210
|
this.store = store;
|
|
@@ -1207,7 +1216,7 @@ var PasswordResetModel = class {
|
|
|
1207
1216
|
VALUES ($1, $2, $3, $4, now())
|
|
1208
1217
|
ON CONFLICT (user_id) DO UPDATE
|
|
1209
1218
|
SET pin = $2, token = $3, expires_at = $4, created_at = now()`,
|
|
1210
|
-
[userId, pin, token, expiresAt]
|
|
1219
|
+
[userId, hashSecret(pin), hashSecret(token), expiresAt]
|
|
1211
1220
|
);
|
|
1212
1221
|
}
|
|
1213
1222
|
async findLastSentAt(userId) {
|
|
@@ -1221,7 +1230,7 @@ var PasswordResetModel = class {
|
|
|
1221
1230
|
async findByPin(pin) {
|
|
1222
1231
|
const [row] = await this.store.query(
|
|
1223
1232
|
`SELECT user_id, expires_at FROM fonderie_password_resets WHERE pin = $1`,
|
|
1224
|
-
[pin]
|
|
1233
|
+
[hashSecret(pin)]
|
|
1225
1234
|
);
|
|
1226
1235
|
if (!row) return null;
|
|
1227
1236
|
return { userId: row.user_id, expiresAt: new Date(row.expires_at) };
|
|
@@ -1234,7 +1243,7 @@ var PasswordResetModel = class {
|
|
|
1234
1243
|
if (typeof token !== "string" || token.length < 32) return null;
|
|
1235
1244
|
const [row] = await this.store.query(
|
|
1236
1245
|
`SELECT user_id, expires_at FROM fonderie_password_resets WHERE token = $1`,
|
|
1237
|
-
[token]
|
|
1246
|
+
[hashSecret(token)]
|
|
1238
1247
|
);
|
|
1239
1248
|
if (!row) return null;
|
|
1240
1249
|
return { userId: row.user_id, expiresAt: new Date(row.expires_at) };
|
|
@@ -1387,7 +1396,7 @@ function authController(store, config, bus) {
|
|
|
1387
1396
|
if (!row) {
|
|
1388
1397
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
|
|
1389
1398
|
}
|
|
1390
|
-
const pin = (0,
|
|
1399
|
+
const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1391
1400
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
1392
1401
|
await emailVerif.create(row.id, pin, expiresAt);
|
|
1393
1402
|
const user = await users.findById(row.id);
|
|
@@ -1451,7 +1460,7 @@ function authController(store, config, bus) {
|
|
|
1451
1460
|
firstName ?? null,
|
|
1452
1461
|
lastName ?? null
|
|
1453
1462
|
);
|
|
1454
|
-
const otp = (0,
|
|
1463
|
+
const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1455
1464
|
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
|
|
1456
1465
|
await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
|
|
1457
1466
|
const user = await users.findById(id);
|
|
@@ -1597,7 +1606,7 @@ function authController(store, config, bus) {
|
|
|
1597
1606
|
"Account suspended. Please contact support."
|
|
1598
1607
|
);
|
|
1599
1608
|
}
|
|
1600
|
-
const otp = (0,
|
|
1609
|
+
const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1601
1610
|
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
|
|
1602
1611
|
await phoneVerif.upsert(user.id, normalizePhone(phone2), otp, expiresAt);
|
|
1603
1612
|
bus?.emit(import_events2.NOTIFICATION_EVENT, {
|
|
@@ -1707,8 +1716,8 @@ function authController(store, config, bus) {
|
|
|
1707
1716
|
"Password reset email sent (if account exists)."
|
|
1708
1717
|
);
|
|
1709
1718
|
}
|
|
1710
|
-
const pin = (0,
|
|
1711
|
-
const token = (0,
|
|
1719
|
+
const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1720
|
+
const token = (0, import_node_crypto5.randomBytes)(32).toString("hex");
|
|
1712
1721
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60);
|
|
1713
1722
|
await passwordReset.create(user.id, pin, token, expiresAt);
|
|
1714
1723
|
const base = resolved.passwordResetUrl;
|
|
@@ -1891,7 +1900,7 @@ function authController(store, config, bus) {
|
|
|
1891
1900
|
}
|
|
1892
1901
|
);
|
|
1893
1902
|
}
|
|
1894
|
-
const otp = (0,
|
|
1903
|
+
const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1895
1904
|
const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
|
|
1896
1905
|
await phoneVerif.upsert(ctx.user.id, phone2, otp, expiresAt2);
|
|
1897
1906
|
bus?.emit(import_events2.NOTIFICATION_EVENT, {
|
|
@@ -1931,7 +1940,7 @@ function authController(store, config, bus) {
|
|
|
1931
1940
|
}
|
|
1932
1941
|
);
|
|
1933
1942
|
}
|
|
1934
|
-
const pin = (0,
|
|
1943
|
+
const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1935
1944
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
1936
1945
|
await emailVerif.replace(ctx.user.id, pin, expiresAt);
|
|
1937
1946
|
bus?.emit(import_events2.NOTIFICATION_EVENT, {
|
|
@@ -1949,7 +1958,7 @@ function authController(store, config, bus) {
|
|
|
1949
1958
|
}
|
|
1950
1959
|
|
|
1951
1960
|
// src/controllers/user.controller.ts
|
|
1952
|
-
var
|
|
1961
|
+
var import_node_crypto6 = require("crypto");
|
|
1953
1962
|
var import_core7 = require("@fonderie/core");
|
|
1954
1963
|
var import_events3 = require("@fonderie/events");
|
|
1955
1964
|
|
|
@@ -2139,7 +2148,7 @@ function userController(store, config, bus) {
|
|
|
2139
2148
|
if (existing) {
|
|
2140
2149
|
return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
|
|
2141
2150
|
}
|
|
2142
|
-
const pin = (0,
|
|
2151
|
+
const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
|
|
2143
2152
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
2144
2153
|
await emailVerif.replace(ctx.user.id, pin, expiresAt);
|
|
2145
2154
|
await users.updateEmail(ctx.user.id, normalised);
|
|
@@ -2183,7 +2192,7 @@ function userController(store, config, bus) {
|
|
|
2183
2192
|
if (existing) {
|
|
2184
2193
|
return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
|
|
2185
2194
|
}
|
|
2186
|
-
const otp = (0,
|
|
2195
|
+
const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
|
|
2187
2196
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
|
|
2188
2197
|
await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
|
|
2189
2198
|
await users.updatePhone(ctx.user.id, normalised);
|
|
@@ -2292,12 +2301,125 @@ function userController(store, config, bus) {
|
|
|
2292
2301
|
}
|
|
2293
2302
|
|
|
2294
2303
|
// src/controllers/oauth.controller.ts
|
|
2295
|
-
var
|
|
2304
|
+
var import_node_crypto7 = require("crypto");
|
|
2305
|
+
var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
|
|
2296
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";
|
|
2297
2363
|
function oauthController(store, config) {
|
|
2298
2364
|
const users = new UserModel(store);
|
|
2299
2365
|
const sessions = new SessionModel(store);
|
|
2300
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
|
+
};
|
|
2301
2423
|
return {
|
|
2302
2424
|
googleInit: async (_ctx) => {
|
|
2303
2425
|
const google = config.google;
|
|
@@ -2308,7 +2430,7 @@ function oauthController(store, config) {
|
|
|
2308
2430
|
"Google OAuth not configured"
|
|
2309
2431
|
);
|
|
2310
2432
|
}
|
|
2311
|
-
const state = (0,
|
|
2433
|
+
const state = (0, import_node_crypto7.randomBytes)(16).toString("hex");
|
|
2312
2434
|
const params = new URLSearchParams({
|
|
2313
2435
|
client_id: google.clientId,
|
|
2314
2436
|
redirect_uri: google.redirectUri,
|
|
@@ -2449,6 +2571,113 @@ function oauthController(store, config) {
|
|
|
2449
2571
|
])
|
|
2450
2572
|
}
|
|
2451
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
|
+
});
|
|
2452
2681
|
}
|
|
2453
2682
|
};
|
|
2454
2683
|
}
|
|
@@ -2518,6 +2747,17 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2518
2747
|
["GET", "/auth/google/callback", oauth.googleCallback]
|
|
2519
2748
|
);
|
|
2520
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
|
+
}
|
|
2521
2761
|
return routes;
|
|
2522
2762
|
}
|
|
2523
2763
|
|
|
@@ -2564,6 +2804,22 @@ function collectAuthConfigProblems(config) {
|
|
|
2564
2804
|
});
|
|
2565
2805
|
}
|
|
2566
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
|
+
}
|
|
2567
2823
|
if (config.mfa && !config.mfaSecretKey) {
|
|
2568
2824
|
problems.push({
|
|
2569
2825
|
module: MODULE,
|