@spfn/auth 0.3.0-beta.2 → 0.3.0-beta.20
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/README.md +1382 -23
- package/dist/client-proof.d.ts +45 -15
- package/dist/client-proof.js +198 -4
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +92 -1
- package/dist/client.js +58 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +302 -0
- package/dist/config.js +134 -4
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +370 -3
- package/dist/errors.js +245 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +185 -2
- package/dist/index.js +256 -2
- package/dist/index.js.map +1 -1
- package/dist/machine-principals-BD4tnASp.d.ts +2739 -0
- package/dist/nextjs/api.js +350 -12
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/client.d.ts +28 -1
- package/dist/nextjs/client.js +24 -3
- package/dist/nextjs/client.js.map +1 -1
- package/dist/nextjs/server.d.ts +173 -3
- package/dist/nextjs/server.js +372 -10
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +3761 -414
- package/dist/server.js +5865 -1043
- package/dist/server.js.map +1 -1
- package/dist/{session-DTHahDQ9.d.ts → session-Dfwu5g2W.d.ts} +28 -1
- package/migrations/20260810112144_colorful_tomorrow_man/migration.sql +18 -0
- package/migrations/20260810112144_colorful_tomorrow_man/snapshot.json +3576 -0
- package/migrations/20260901091716_fine_arclight/migration.sql +21 -0
- package/migrations/20260901091716_fine_arclight/snapshot.json +3849 -0
- package/migrations/20260906155957_natural_moonstone/migration.sql +33 -0
- package/migrations/20260906155957_natural_moonstone/snapshot.json +4275 -0
- package/migrations/20260907020904_giant_eternals/migration.sql +21 -0
- package/migrations/20260907020904_giant_eternals/snapshot.json +4561 -0
- package/migrations/20260907044807_eminent_angel/migration.sql +2 -0
- package/migrations/20260907044807_eminent_angel/snapshot.json +4561 -0
- package/migrations/20260918083158_foamy_roughhouse/migration.sql +55 -0
- package/migrations/20260918083158_foamy_roughhouse/snapshot.json +5271 -0
- package/package.json +9 -6
- package/dist/authenticate-55LeXHqZ.d.ts +0 -1447
package/dist/nextjs/api.js
CHANGED
|
@@ -96,7 +96,8 @@ var authLogger = {
|
|
|
96
96
|
general: rootLogger.child("@spfn/auth:interceptor:general"),
|
|
97
97
|
login: rootLogger.child("@spfn/auth:interceptor:login"),
|
|
98
98
|
keyRotation: rootLogger.child("@spfn/auth:interceptor:key-rotation"),
|
|
99
|
-
oauth: rootLogger.child("@spfn/auth:interceptor:oauth")
|
|
99
|
+
oauth: rootLogger.child("@spfn/auth:interceptor:oauth"),
|
|
100
|
+
csrf: rootLogger.child("@spfn/auth:interceptor:csrf")
|
|
100
101
|
},
|
|
101
102
|
session: rootLogger.child("@spfn/auth:session"),
|
|
102
103
|
service: rootLogger.child("@spfn/auth:service"),
|
|
@@ -190,8 +191,9 @@ async function shouldRefreshSession(jwt2, thresholdHours = 24) {
|
|
|
190
191
|
|
|
191
192
|
// src/server/lib/config.ts
|
|
192
193
|
import { env as env2 } from "@spfn/auth/config";
|
|
194
|
+
import { PasskeyConfigError } from "@spfn/auth/errors";
|
|
193
195
|
function getCookieSuffix() {
|
|
194
|
-
const port = process.env.
|
|
196
|
+
const port = process.env.SPFN_PORT;
|
|
195
197
|
return port ? `_${port}` : "";
|
|
196
198
|
}
|
|
197
199
|
var COOKIE_NAMES = {
|
|
@@ -210,6 +212,18 @@ var COOKIE_NAMES = {
|
|
|
210
212
|
/** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
|
|
211
213
|
get OAUTH_CSRF() {
|
|
212
214
|
return `spfn_oauth_csrf${getCookieSuffix()}`;
|
|
215
|
+
},
|
|
216
|
+
/** Password-setup session for verified-email signup — temporary, single-purpose */
|
|
217
|
+
get SIGNUP_SETUP() {
|
|
218
|
+
return `spfn_signup_setup${getCookieSuffix()}`;
|
|
219
|
+
},
|
|
220
|
+
/** Password-setup session for a password reset — temporary, single-purpose */
|
|
221
|
+
get PASSWORD_RESET_SETUP() {
|
|
222
|
+
return `spfn_password_reset_setup${getCookieSuffix()}`;
|
|
223
|
+
},
|
|
224
|
+
/** CSRF token — the only cookie here the browser can read */
|
|
225
|
+
get CSRF() {
|
|
226
|
+
return `spfn_csrf${getCookieSuffix()}`;
|
|
213
227
|
}
|
|
214
228
|
};
|
|
215
229
|
function parseDuration(duration) {
|
|
@@ -252,6 +266,33 @@ function getSessionTtl(override) {
|
|
|
252
266
|
}
|
|
253
267
|
return 7 * 24 * 60 * 60;
|
|
254
268
|
}
|
|
269
|
+
var CSRF_MODES = ["off", "warn", "enforce"];
|
|
270
|
+
var unrecognizedCsrfModeReported = false;
|
|
271
|
+
function getCsrfMode() {
|
|
272
|
+
const configured = globalConfig.csrf?.mode ?? env2.SPFN_AUTH_CSRF;
|
|
273
|
+
if (!configured) {
|
|
274
|
+
return "warn";
|
|
275
|
+
}
|
|
276
|
+
const normalized = String(configured).trim().toLowerCase();
|
|
277
|
+
if (!CSRF_MODES.includes(normalized)) {
|
|
278
|
+
if (!unrecognizedCsrfModeReported) {
|
|
279
|
+
unrecognizedCsrfModeReported = true;
|
|
280
|
+
authLogger.interceptor.csrf.error(
|
|
281
|
+
`Unrecognized CSRF mode "${configured}" \u2014 expected off | warn | enforce. Enforcing.`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return "enforce";
|
|
285
|
+
}
|
|
286
|
+
return normalized;
|
|
287
|
+
}
|
|
288
|
+
var PACKAGE_CSRF_EXEMPT_PATHS = [
|
|
289
|
+
"/_auth/oauth2/register",
|
|
290
|
+
"/_auth/oauth2/token",
|
|
291
|
+
"/_auth/oauth2/revoke"
|
|
292
|
+
];
|
|
293
|
+
function getCsrfExemptPaths() {
|
|
294
|
+
return [...PACKAGE_CSRF_EXEMPT_PATHS, ...globalConfig.csrf?.exemptPaths ?? []];
|
|
295
|
+
}
|
|
255
296
|
|
|
256
297
|
// src/nextjs/interceptors/cookie-options.ts
|
|
257
298
|
function resolveSecure() {
|
|
@@ -263,9 +304,144 @@ function resolveSecure() {
|
|
|
263
304
|
}
|
|
264
305
|
var cookieSecure = resolveSecure();
|
|
265
306
|
|
|
307
|
+
// src/server/lib/csrf.ts
|
|
308
|
+
import { env as env3 } from "@spfn/auth/config";
|
|
309
|
+
var CSRF_HEADER = "x-spfn-csrf";
|
|
310
|
+
var CSRF_SUBKEY_LABEL = "spfn-auth-csrf-token-v1";
|
|
311
|
+
var MAX_CANDIDATES = 32;
|
|
312
|
+
function sessionSecret() {
|
|
313
|
+
const secret = env3.SPFN_AUTH_SESSION_SECRET;
|
|
314
|
+
if (!secret) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
"SPFN_AUTH_SESSION_SECRET is required for CSRF protection. Set it (sessions need it anyway), or set SPFN_AUTH_CSRF=off."
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return secret;
|
|
320
|
+
}
|
|
321
|
+
async function hmacSha256(key, message) {
|
|
322
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
323
|
+
"raw",
|
|
324
|
+
key.buffer,
|
|
325
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
326
|
+
false,
|
|
327
|
+
["sign"]
|
|
328
|
+
);
|
|
329
|
+
const signature = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
|
|
330
|
+
return new Uint8Array(signature);
|
|
331
|
+
}
|
|
332
|
+
function toHex(bytes) {
|
|
333
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
334
|
+
}
|
|
335
|
+
async function deriveCsrfToken(keyId) {
|
|
336
|
+
const subkey = await hmacSha256(new TextEncoder().encode(sessionSecret()), CSRF_SUBKEY_LABEL);
|
|
337
|
+
return toHex(await hmacSha256(subkey, keyId));
|
|
338
|
+
}
|
|
339
|
+
function timingSafeEqualString(a, b) {
|
|
340
|
+
if (a.length !== b.length) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
let difference = 0;
|
|
344
|
+
for (let i = 0; i < a.length; i++) {
|
|
345
|
+
difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
346
|
+
}
|
|
347
|
+
return difference === 0;
|
|
348
|
+
}
|
|
349
|
+
function matchesCsrfToken(expected, presented) {
|
|
350
|
+
if (!presented) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
return presented.split(",", MAX_CANDIDATES).some((candidate) => timingSafeEqualString(expected, candidate.trim()));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// src/nextjs/interceptors/csrf.ts
|
|
357
|
+
var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
358
|
+
function csrfCookie(token, ttl) {
|
|
359
|
+
return {
|
|
360
|
+
name: COOKIE_NAMES.CSRF,
|
|
361
|
+
value: token,
|
|
362
|
+
options: {
|
|
363
|
+
httpOnly: false,
|
|
364
|
+
secure: cookieSecure,
|
|
365
|
+
sameSite: "lax",
|
|
366
|
+
maxAge: ttl,
|
|
367
|
+
path: "/"
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function refusal() {
|
|
372
|
+
return {
|
|
373
|
+
status: 403,
|
|
374
|
+
body: {
|
|
375
|
+
error: "Forbidden",
|
|
376
|
+
message: "CSRF token missing or invalid"
|
|
377
|
+
},
|
|
378
|
+
setCookies: []
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
async function refuseInvalidCsrf(ctx, keyId) {
|
|
382
|
+
const mode = getCsrfMode();
|
|
383
|
+
if (mode === "off" || SAFE_METHODS.has(ctx.method.toUpperCase())) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
if (getCsrfExemptPaths().includes(ctx.path)) {
|
|
387
|
+
authLogger.interceptor.csrf.debug("Path is CSRF-exempt", { path: ctx.path });
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
let expected;
|
|
391
|
+
try {
|
|
392
|
+
expected = await deriveCsrfToken(keyId);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
authLogger.interceptor.csrf.error(
|
|
395
|
+
"Cannot derive the CSRF token \u2014 refusing regardless of mode",
|
|
396
|
+
error
|
|
397
|
+
);
|
|
398
|
+
ctx.abort = refusal();
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
const presented = ctx.request.headers.get(CSRF_HEADER);
|
|
402
|
+
if (matchesCsrfToken(expected, presented)) {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
const detail = {
|
|
406
|
+
method: ctx.method,
|
|
407
|
+
path: ctx.path,
|
|
408
|
+
headerPresent: !!presented
|
|
409
|
+
};
|
|
410
|
+
if (mode === "warn") {
|
|
411
|
+
authLogger.interceptor.csrf.warn("CSRF check would refuse this request (mode=warn)", detail);
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
authLogger.interceptor.csrf.warn("CSRF check refused this request", detail);
|
|
415
|
+
ctx.abort = refusal();
|
|
416
|
+
ctx.abort.setCookies = [csrfCookie(expected, getSessionTtl())];
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
async function pushCsrfCookie(setCookies, keyId, ttl) {
|
|
420
|
+
setCookies.push(csrfCookie(await deriveCsrfToken(keyId), ttl));
|
|
421
|
+
}
|
|
422
|
+
async function pushCsrfCookieIfStale(setCookies, presented, keyId) {
|
|
423
|
+
try {
|
|
424
|
+
const token = await deriveCsrfToken(keyId);
|
|
425
|
+
if (presented && timingSafeEqualString(token, presented)) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
setCookies.push(csrfCookie(token, getSessionTtl()));
|
|
429
|
+
} catch (error) {
|
|
430
|
+
authLogger.interceptor.csrf.error("Cannot reissue the CSRF cookie", error);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function pushCsrfCookieRemoval(setCookies) {
|
|
434
|
+
setCookies.push({
|
|
435
|
+
name: COOKIE_NAMES.CSRF,
|
|
436
|
+
value: "",
|
|
437
|
+
options: { maxAge: 0, path: "/" }
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
266
441
|
// src/nextjs/interceptors/login-register.ts
|
|
442
|
+
var ROTATING_SIGN_IN_PATHS = /* @__PURE__ */ new Set(["/_auth/login", "/_auth/passkeys/login/verify"]);
|
|
267
443
|
var loginRegisterInterceptor = {
|
|
268
|
-
pathPattern: /^\/_auth\/(login|register|invitations\/accept)$/,
|
|
444
|
+
pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password|password\/reset\/complete|passkeys\/login\/verify)$/,
|
|
269
445
|
method: "POST",
|
|
270
446
|
request: async (ctx, next) => {
|
|
271
447
|
const oldKeyId = ctx.cookies.get(COOKIE_NAMES.SESSION_KEY_ID);
|
|
@@ -279,7 +455,7 @@ var loginRegisterInterceptor = {
|
|
|
279
455
|
ctx.body.fingerprint = keyPair.fingerprint;
|
|
280
456
|
ctx.body.algorithm = keyPair.algorithm;
|
|
281
457
|
ctx.body.keySize = Buffer.from(keyPair.publicKey, "base64").length;
|
|
282
|
-
if (ctx.path
|
|
458
|
+
if (ROTATING_SIGN_IN_PATHS.has(ctx.path) && oldKeyId) {
|
|
283
459
|
ctx.body.oldKeyId = oldKeyId;
|
|
284
460
|
}
|
|
285
461
|
delete ctx.body.remember;
|
|
@@ -331,6 +507,7 @@ var loginRegisterInterceptor = {
|
|
|
331
507
|
path: "/"
|
|
332
508
|
}
|
|
333
509
|
});
|
|
510
|
+
await pushCsrfCookie(ctx.setCookies, ctx.metadata.keyId, ttl);
|
|
334
511
|
} catch (error) {
|
|
335
512
|
const err = error;
|
|
336
513
|
authLogger.interceptor.login.error("Failed to save session", err);
|
|
@@ -389,6 +566,9 @@ var generalAuthInterceptor = {
|
|
|
389
566
|
userId: session.userId,
|
|
390
567
|
keyId: session.keyId
|
|
391
568
|
});
|
|
569
|
+
if (await refuseInvalidCsrf(ctx, session.keyId)) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
392
572
|
const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
|
|
393
573
|
if (needsRefresh) {
|
|
394
574
|
authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
|
|
@@ -409,6 +589,7 @@ var generalAuthInterceptor = {
|
|
|
409
589
|
ctx.headers["Authorization"] = `Bearer ${token}`;
|
|
410
590
|
ctx.headers["X-Key-Id"] = session.keyId;
|
|
411
591
|
ctx.metadata.userId = session.userId;
|
|
592
|
+
ctx.metadata.keyId = session.keyId;
|
|
412
593
|
ctx.metadata.sessionValid = true;
|
|
413
594
|
} catch (error) {
|
|
414
595
|
const err = error;
|
|
@@ -442,6 +623,7 @@ var generalAuthInterceptor = {
|
|
|
442
623
|
value: "",
|
|
443
624
|
options: { maxAge: 0, path: "/" }
|
|
444
625
|
});
|
|
626
|
+
pushCsrfCookieRemoval(ctx.setCookies);
|
|
445
627
|
await next();
|
|
446
628
|
return;
|
|
447
629
|
}
|
|
@@ -462,6 +644,7 @@ var generalAuthInterceptor = {
|
|
|
462
644
|
path: "/"
|
|
463
645
|
}
|
|
464
646
|
});
|
|
647
|
+
pushCsrfCookieRemoval(ctx.setCookies);
|
|
465
648
|
} else if (ctx.metadata.refreshSession && ctx.response.status === 200) {
|
|
466
649
|
try {
|
|
467
650
|
const sessionData = ctx.metadata.sessionData;
|
|
@@ -489,6 +672,7 @@ var generalAuthInterceptor = {
|
|
|
489
672
|
path: "/"
|
|
490
673
|
}
|
|
491
674
|
});
|
|
675
|
+
await pushCsrfCookie(ctx.setCookies, sessionData.keyId, ttl);
|
|
492
676
|
authLogger.interceptor.general.info("Session refreshed", {
|
|
493
677
|
userId: sessionData.userId,
|
|
494
678
|
sealedLength: sealed.length,
|
|
@@ -520,6 +704,15 @@ var generalAuthInterceptor = {
|
|
|
520
704
|
value: "",
|
|
521
705
|
options: { ...base, sameSite: "lax" }
|
|
522
706
|
});
|
|
707
|
+
pushCsrfCookieRemoval(ctx.setCookies);
|
|
708
|
+
}
|
|
709
|
+
const csrfQueued = ctx.setCookies.some((cookie) => cookie.name === COOKIE_NAMES.CSRF);
|
|
710
|
+
if (ctx.metadata.sessionValid && !csrfQueued) {
|
|
711
|
+
await pushCsrfCookieIfStale(
|
|
712
|
+
ctx.setCookies,
|
|
713
|
+
ctx.cookies.get(COOKIE_NAMES.CSRF),
|
|
714
|
+
ctx.metadata.keyId
|
|
715
|
+
);
|
|
523
716
|
}
|
|
524
717
|
await next();
|
|
525
718
|
}
|
|
@@ -546,10 +739,11 @@ var keyRotationInterceptor = {
|
|
|
546
739
|
ctx.body.fingerprint = newKeyPair.fingerprint;
|
|
547
740
|
ctx.body.algorithm = newKeyPair.algorithm;
|
|
548
741
|
ctx.body.keySize = Buffer.from(newKeyPair.publicKey, "base64").length;
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
742
|
+
authLogger.interceptor.keyRotation.debug("Generated a new key pair", {
|
|
743
|
+
keyId: newKeyPair.keyId,
|
|
744
|
+
fingerprint: newKeyPair.fingerprint,
|
|
745
|
+
algorithm: newKeyPair.algorithm
|
|
746
|
+
});
|
|
553
747
|
const token = generateClientToken(
|
|
554
748
|
{
|
|
555
749
|
userId: currentSession.userId,
|
|
@@ -614,6 +808,7 @@ var keyRotationInterceptor = {
|
|
|
614
808
|
path: "/"
|
|
615
809
|
}
|
|
616
810
|
});
|
|
811
|
+
await pushCsrfCookie(ctx.setCookies, ctx.metadata.newKeyId, ttl);
|
|
617
812
|
} catch (error) {
|
|
618
813
|
const err = error;
|
|
619
814
|
authLogger.interceptor.keyRotation.error("Failed to update session after rotation", err);
|
|
@@ -624,9 +819,9 @@ var keyRotationInterceptor = {
|
|
|
624
819
|
|
|
625
820
|
// src/server/lib/oauth/state.ts
|
|
626
821
|
import * as jose2 from "jose";
|
|
627
|
-
import { env as
|
|
822
|
+
import { env as env4 } from "@spfn/auth/config";
|
|
628
823
|
async function getStateKey() {
|
|
629
|
-
const secret =
|
|
824
|
+
const secret = env4.SPFN_AUTH_SESSION_SECRET;
|
|
630
825
|
const encoder = new TextEncoder();
|
|
631
826
|
const data = encoder.encode(`oauth-state:${secret}`);
|
|
632
827
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
@@ -656,13 +851,28 @@ async function createOAuthState(params) {
|
|
|
656
851
|
return encodeURIComponent(jwe);
|
|
657
852
|
}
|
|
658
853
|
|
|
854
|
+
// src/lib/return-path.ts
|
|
855
|
+
var URL_STRIPPED_CHARACTER = /[\t\n\r]/;
|
|
856
|
+
function isSafeReturnPath(returnPath) {
|
|
857
|
+
if (!returnPath.startsWith("/")) {
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
if (returnPath.startsWith("//") || returnPath.includes("\\")) {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
if (returnPath.includes("..") || URL_STRIPPED_CHARACTER.test(returnPath)) {
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
return !/^\/[^/?#]*:/.test(returnPath);
|
|
867
|
+
}
|
|
868
|
+
|
|
659
869
|
// src/nextjs/session-helpers.ts
|
|
660
870
|
import * as jose3 from "jose";
|
|
661
871
|
import { cookies } from "next/headers.js";
|
|
662
|
-
import { env as
|
|
872
|
+
import { env as env5 } from "@spfn/auth/config";
|
|
663
873
|
import { logger } from "@spfn/core/logger";
|
|
664
874
|
async function getPendingSessionKey() {
|
|
665
|
-
const secret =
|
|
875
|
+
const secret = env5.SPFN_AUTH_SESSION_SECRET;
|
|
666
876
|
const encoder = new TextEncoder();
|
|
667
877
|
const data = encoder.encode(`oauth-pending:${secret}`);
|
|
668
878
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
@@ -682,6 +892,20 @@ async function unsealPendingSession(jwt2) {
|
|
|
682
892
|
}
|
|
683
893
|
|
|
684
894
|
// src/nextjs/interceptors/oauth.ts
|
|
895
|
+
var UNSAFE_RETURN_URL_MESSAGE = "returnUrl must be a relative path within the app";
|
|
896
|
+
function refuseUnsafeReturnUrl() {
|
|
897
|
+
return {
|
|
898
|
+
status: 400,
|
|
899
|
+
body: {
|
|
900
|
+
__type: "ValidationError",
|
|
901
|
+
message: UNSAFE_RETURN_URL_MESSAGE,
|
|
902
|
+
error: {
|
|
903
|
+
code: "ValidationError",
|
|
904
|
+
message: UNSAFE_RETURN_URL_MESSAGE
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
}
|
|
685
909
|
var oauthUrlInterceptor = {
|
|
686
910
|
pathPattern: /^\/_auth\/oauth\/\w+\/url$/,
|
|
687
911
|
method: "POST",
|
|
@@ -689,6 +913,13 @@ var oauthUrlInterceptor = {
|
|
|
689
913
|
const provider = ctx.path.split("/")[3];
|
|
690
914
|
const returnUrl = ctx.body?.returnUrl || "/";
|
|
691
915
|
const metadata = ctx.body?.metadata;
|
|
916
|
+
if (typeof returnUrl !== "string" || !isSafeReturnPath(returnUrl)) {
|
|
917
|
+
authLogger.interceptor.oauth?.warn?.("OAuth start refused: returnUrl is not a path within the app", {
|
|
918
|
+
provider
|
|
919
|
+
});
|
|
920
|
+
ctx.abort = refuseUnsafeReturnUrl();
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
692
923
|
const keyPair = generateKeyPair("ES256");
|
|
693
924
|
const csrfNonce = generateOAuthNonce();
|
|
694
925
|
const state = await createOAuthState({
|
|
@@ -837,6 +1068,7 @@ var oauthFinalizeInterceptor = {
|
|
|
837
1068
|
path: "/"
|
|
838
1069
|
}
|
|
839
1070
|
});
|
|
1071
|
+
await pushCsrfCookie(ctx.setCookies, keyId, ttl);
|
|
840
1072
|
ctx.setCookies.push({
|
|
841
1073
|
name: COOKIE_NAMES.OAUTH_PENDING,
|
|
842
1074
|
value: "",
|
|
@@ -861,8 +1093,114 @@ var oauthFinalizeInterceptor = {
|
|
|
861
1093
|
}
|
|
862
1094
|
};
|
|
863
1095
|
|
|
1096
|
+
// src/nextjs/interceptors/signup-link.ts
|
|
1097
|
+
var SETUP_COOKIE_TTL_SECONDS = 60 * 60;
|
|
1098
|
+
function setupCookie(value, maxAge) {
|
|
1099
|
+
return {
|
|
1100
|
+
name: COOKIE_NAMES.SIGNUP_SETUP,
|
|
1101
|
+
value,
|
|
1102
|
+
options: {
|
|
1103
|
+
httpOnly: true,
|
|
1104
|
+
secure: cookieSecure,
|
|
1105
|
+
sameSite: "lax",
|
|
1106
|
+
maxAge,
|
|
1107
|
+
path: "/"
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
var signupLinkInterceptor = {
|
|
1112
|
+
pathPattern: /^\/_auth\/signup\/(email\/confirm|password)$/,
|
|
1113
|
+
method: "POST",
|
|
1114
|
+
request: async (ctx, next) => {
|
|
1115
|
+
if (ctx.path === "/_auth/signup/password") {
|
|
1116
|
+
const cookie = ctx.cookies.get(COOKIE_NAMES.SIGNUP_SETUP);
|
|
1117
|
+
if (cookie) {
|
|
1118
|
+
if (!ctx.body) {
|
|
1119
|
+
ctx.body = {};
|
|
1120
|
+
}
|
|
1121
|
+
ctx.body.setupSecret = cookie;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
await next();
|
|
1125
|
+
},
|
|
1126
|
+
response: async (ctx, next) => {
|
|
1127
|
+
if (!ctx.response.ok) {
|
|
1128
|
+
await next();
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (ctx.path === "/_auth/signup/email/confirm") {
|
|
1132
|
+
const secret = ctx.response.body?.setupSecret;
|
|
1133
|
+
if (!secret) {
|
|
1134
|
+
authLogger.interceptor.oauth?.error?.("Signup confirm response carried no setup secret");
|
|
1135
|
+
await next();
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
ctx.setCookies.push(setupCookie(secret, SETUP_COOKIE_TTL_SECONDS));
|
|
1139
|
+
delete ctx.response.body.setupSecret;
|
|
1140
|
+
}
|
|
1141
|
+
if (ctx.path === "/_auth/signup/password") {
|
|
1142
|
+
ctx.setCookies.push(setupCookie("", 0));
|
|
1143
|
+
}
|
|
1144
|
+
await next();
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
// src/nextjs/interceptors/password-reset.ts
|
|
1149
|
+
var SETUP_COOKIE_TTL_SECONDS2 = 60 * 60;
|
|
1150
|
+
function setupCookie2(value, maxAge) {
|
|
1151
|
+
return {
|
|
1152
|
+
name: COOKIE_NAMES.PASSWORD_RESET_SETUP,
|
|
1153
|
+
value,
|
|
1154
|
+
options: {
|
|
1155
|
+
httpOnly: true,
|
|
1156
|
+
secure: cookieSecure,
|
|
1157
|
+
sameSite: "lax",
|
|
1158
|
+
maxAge,
|
|
1159
|
+
path: "/"
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
var passwordResetInterceptor = {
|
|
1164
|
+
pathPattern: /^\/_auth\/password\/reset\/(confirm|complete)$/,
|
|
1165
|
+
method: "POST",
|
|
1166
|
+
request: async (ctx, next) => {
|
|
1167
|
+
if (ctx.path === "/_auth/password/reset/complete") {
|
|
1168
|
+
const cookie = ctx.cookies.get(COOKIE_NAMES.PASSWORD_RESET_SETUP);
|
|
1169
|
+
if (cookie) {
|
|
1170
|
+
if (!ctx.body) {
|
|
1171
|
+
ctx.body = {};
|
|
1172
|
+
}
|
|
1173
|
+
ctx.body.setupSecret = cookie;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
await next();
|
|
1177
|
+
},
|
|
1178
|
+
response: async (ctx, next) => {
|
|
1179
|
+
if (!ctx.response.ok) {
|
|
1180
|
+
await next();
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (ctx.path === "/_auth/password/reset/confirm") {
|
|
1184
|
+
const secret = ctx.response.body?.setupSecret;
|
|
1185
|
+
if (!secret) {
|
|
1186
|
+
authLogger.interceptor.oauth?.error?.("Password reset confirm response carried no setup secret");
|
|
1187
|
+
await next();
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
ctx.setCookies.push(setupCookie2(secret, SETUP_COOKIE_TTL_SECONDS2));
|
|
1191
|
+
delete ctx.response.body.setupSecret;
|
|
1192
|
+
}
|
|
1193
|
+
if (ctx.path === "/_auth/password/reset/complete") {
|
|
1194
|
+
ctx.setCookies.push(setupCookie2("", 0));
|
|
1195
|
+
}
|
|
1196
|
+
await next();
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
|
|
864
1200
|
// src/nextjs/interceptors/index.ts
|
|
865
1201
|
var authInterceptors = [
|
|
1202
|
+
signupLinkInterceptor,
|
|
1203
|
+
passwordResetInterceptor,
|
|
866
1204
|
loginRegisterInterceptor,
|
|
867
1205
|
keyRotationInterceptor,
|
|
868
1206
|
oauthUrlInterceptor,
|