@spfn/auth 0.3.0-beta.23 → 0.3.0-beta.25
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 +328 -7
- package/dist/client-proof.d.ts +10 -1
- package/dist/client-proof.js +136 -11
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +101 -1
- package/dist/client.js +65 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +122 -0
- package/dist/config.js +53 -0
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/errors.d.ts +159 -3
- package/dist/errors.js +95 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +52 -12
- package/dist/index.js +104 -3
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-CaEFq61K.d.ts → machine-principals-CdEgxOB1.d.ts} +2049 -771
- package/dist/nextjs/api.js +329 -37
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +59 -24
- package/dist/nextjs/server.js +105 -11
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +415 -332
- package/dist/server.js +2933 -1545
- package/dist/server.js.map +1 -1
- package/dist/{session-Dfwu5g2W.d.ts → session-BbhAGZtA.d.ts} +57 -1
- package/dist/{types-DYyhze28.d.ts → types-CTdoTOxM.d.ts} +24 -1
- package/migrations/20260918184037_happy_mordo/migration.sql +4 -0
- package/migrations/20260918184037_happy_mordo/snapshot.json +6000 -0
- package/migrations/20260918184152_dear_rictor/migration.sql +3 -0
- package/migrations/20260918184152_dear_rictor/snapshot.json +6039 -0
- package/migrations/20260919023107_even_mikhail_rasputin/migration.sql +20 -0
- package/migrations/20260919023107_even_mikhail_rasputin/snapshot.json +6300 -0
- package/package.json +1 -1
package/dist/nextjs/api.js
CHANGED
|
@@ -209,6 +209,18 @@ var COOKIE_NAMES = {
|
|
|
209
209
|
get OAUTH_PENDING() {
|
|
210
210
|
return `spfn_oauth_pending${getCookieSuffix()}`;
|
|
211
211
|
},
|
|
212
|
+
/**
|
|
213
|
+
* Pending second-factor session (privateKey, keyId, challengeHash) (#95)
|
|
214
|
+
*
|
|
215
|
+
* Its own name and its own audience, separate from OAUTH_PENDING. The two
|
|
216
|
+
* coexist: a person who starts a social login in one tab while a password
|
|
217
|
+
* step-up is outstanding in another has both flows live, and one name would
|
|
218
|
+
* mean the second overwrote the first — sealing a session with a private key
|
|
219
|
+
* that does not match the key being activated.
|
|
220
|
+
*/
|
|
221
|
+
get MFA_PENDING() {
|
|
222
|
+
return `spfn_mfa_pending${getCookieSuffix()}`;
|
|
223
|
+
},
|
|
212
224
|
/** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
|
|
213
225
|
get OAUTH_CSRF() {
|
|
214
226
|
return `spfn_oauth_csrf${getCookieSuffix()}`;
|
|
@@ -438,10 +450,116 @@ function pushCsrfCookieRemoval(setCookies) {
|
|
|
438
450
|
});
|
|
439
451
|
}
|
|
440
452
|
|
|
453
|
+
// src/nextjs/interceptors/session-binding.ts
|
|
454
|
+
import { SessionResealFailedError } from "@spfn/auth/errors";
|
|
455
|
+
|
|
456
|
+
// src/server/lib/ua-family.ts
|
|
457
|
+
var FAMILY_MARKERS = [
|
|
458
|
+
{ family: "edge", marker: /\bEdg(?:A|iOS)?\// },
|
|
459
|
+
{ family: "chrome", marker: /\b(?:Chrome|CriOS)\// },
|
|
460
|
+
{ family: "firefox", marker: /\b(?:Firefox|FxiOS)\// },
|
|
461
|
+
{ family: "safari", marker: /\bSafari\// }
|
|
462
|
+
];
|
|
463
|
+
function uaFamily(userAgent) {
|
|
464
|
+
if (!userAgent) {
|
|
465
|
+
return "other";
|
|
466
|
+
}
|
|
467
|
+
return FAMILY_MARKERS.find((entry) => entry.marker.test(userAgent))?.family ?? "other";
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/nextjs/interceptors/error-envelope.ts
|
|
471
|
+
function refusalEnvelope(error, setCookies = []) {
|
|
472
|
+
return {
|
|
473
|
+
status: error.statusCode,
|
|
474
|
+
body: refusalBody(error),
|
|
475
|
+
setCookies
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function refusalBody(error) {
|
|
479
|
+
const body = error.toJSON();
|
|
480
|
+
return {
|
|
481
|
+
...body,
|
|
482
|
+
error: {
|
|
483
|
+
code: body.__type,
|
|
484
|
+
message: body.message,
|
|
485
|
+
requestId: mintRequestId()
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function mintRequestId() {
|
|
490
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
491
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/nextjs/interceptors/session-binding.ts
|
|
495
|
+
function bindingSessionFields(body, userAgent) {
|
|
496
|
+
if (body?.sessionBinding !== "passkey" || typeof body.keyExpiresAtMillis !== "number") {
|
|
497
|
+
return {};
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
binding: "passkey",
|
|
501
|
+
keyExpiresAt: body.keyExpiresAtMillis,
|
|
502
|
+
...userAgent ? { uaFamily: uaFamily(userAgent) } : {}
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
var sessionBindingInterceptor = {
|
|
506
|
+
pathPattern: "/_auth/session/binding",
|
|
507
|
+
method: "POST",
|
|
508
|
+
response: async (ctx, next) => {
|
|
509
|
+
const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
|
|
510
|
+
if (ctx.response.status !== 200 || !sessionCookie) {
|
|
511
|
+
await next();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
const session = await unsealSession(sessionCookie);
|
|
516
|
+
await pushResealed(ctx.setCookies, applyBinding(session, ctx.response.body, ctx.request.headers));
|
|
517
|
+
} catch (error) {
|
|
518
|
+
authLogger.interceptor.general.error("Failed to re-seal the session after a binding change", error);
|
|
519
|
+
refuseAsUnsealable(ctx);
|
|
520
|
+
}
|
|
521
|
+
await next();
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
function refuseAsUnsealable(ctx) {
|
|
525
|
+
const refusal2 = refusalEnvelope(new SessionResealFailedError());
|
|
526
|
+
ctx.response.status = refusal2.status;
|
|
527
|
+
ctx.response.ok = false;
|
|
528
|
+
ctx.response.body = refusal2.body;
|
|
529
|
+
for (const name of [COOKIE_NAMES.SESSION, COOKIE_NAMES.SESSION_KEY_ID]) {
|
|
530
|
+
ctx.setCookies.push({ name, value: "", options: { maxAge: 0, path: "/" } });
|
|
531
|
+
}
|
|
532
|
+
pushCsrfCookieRemoval(ctx.setCookies);
|
|
533
|
+
}
|
|
534
|
+
function applyBinding(session, body, requestHeaders) {
|
|
535
|
+
const { binding, keyExpiresAt, uaFamily: sealedFamily, ...unbound } = session;
|
|
536
|
+
if (body?.mode !== "passkey") {
|
|
537
|
+
return unbound;
|
|
538
|
+
}
|
|
539
|
+
const fields = bindingSessionFields(
|
|
540
|
+
{ sessionBinding: body.mode, keyExpiresAtMillis: body.keyExpiresAtMillis },
|
|
541
|
+
requestHeaders["user-agent"]
|
|
542
|
+
);
|
|
543
|
+
return { ...unbound, ...fields, ...sealedFamily ? { uaFamily: sealedFamily } : {} };
|
|
544
|
+
}
|
|
545
|
+
async function pushResealed(setCookies, session) {
|
|
546
|
+
const ttl = getSessionTtl();
|
|
547
|
+
const options = {
|
|
548
|
+
httpOnly: true,
|
|
549
|
+
secure: cookieSecure,
|
|
550
|
+
sameSite: "lax",
|
|
551
|
+
maxAge: ttl,
|
|
552
|
+
path: "/"
|
|
553
|
+
};
|
|
554
|
+
setCookies.push({ name: COOKIE_NAMES.SESSION, value: await sealSession(session, ttl), options });
|
|
555
|
+
setCookies.push({ name: COOKIE_NAMES.SESSION_KEY_ID, value: session.keyId, options });
|
|
556
|
+
await pushCsrfCookie(setCookies, session.keyId, ttl);
|
|
557
|
+
}
|
|
558
|
+
|
|
441
559
|
// src/nextjs/interceptors/login-register.ts
|
|
442
560
|
var ROTATING_SIGN_IN_PATHS = /* @__PURE__ */ new Set(["/_auth/login", "/_auth/passkeys/login/verify"]);
|
|
443
561
|
var loginRegisterInterceptor = {
|
|
444
|
-
pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password|password\/reset\/complete|passkeys\/login\/verify)$/,
|
|
562
|
+
pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password|password\/reset\/complete|passkeys\/login\/verify|session\/renew\/verify)$/,
|
|
445
563
|
method: "POST",
|
|
446
564
|
request: async (ctx, next) => {
|
|
447
565
|
const oldKeyId = ctx.cookies.get(COOKIE_NAMES.SESSION_KEY_ID);
|
|
@@ -482,7 +600,8 @@ var loginRegisterInterceptor = {
|
|
|
482
600
|
userId: userData.userId,
|
|
483
601
|
privateKey: ctx.metadata.privateKey,
|
|
484
602
|
keyId: ctx.metadata.keyId,
|
|
485
|
-
algorithm: ctx.metadata.algorithm
|
|
603
|
+
algorithm: ctx.metadata.algorithm,
|
|
604
|
+
...bindingSessionFields(userData, ctx.request.headers["user-agent"])
|
|
486
605
|
};
|
|
487
606
|
const sealed = await sealSession(sessionData, ttl);
|
|
488
607
|
ctx.setCookies.push({
|
|
@@ -516,6 +635,155 @@ var loginRegisterInterceptor = {
|
|
|
516
635
|
}
|
|
517
636
|
};
|
|
518
637
|
|
|
638
|
+
// src/nextjs/interceptors/mfa-verify.ts
|
|
639
|
+
import { SessionPendingExpiredError, SessionPendingMismatchError } from "@spfn/auth/errors";
|
|
640
|
+
|
|
641
|
+
// src/server/lib/link-credentials.ts
|
|
642
|
+
import crypto3 from "crypto";
|
|
643
|
+
import { env as env4 } from "@spfn/auth/config";
|
|
644
|
+
function hashCredential(secret) {
|
|
645
|
+
return crypto3.createHash("sha256").update(secret).digest("base64url");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/nextjs/session-helpers.ts
|
|
649
|
+
import * as jose2 from "jose";
|
|
650
|
+
import { cookies } from "next/headers.js";
|
|
651
|
+
import { env as env5 } from "@spfn/auth/config";
|
|
652
|
+
import { logger } from "@spfn/core/logger";
|
|
653
|
+
async function getPendingSessionKey(purpose) {
|
|
654
|
+
const secret = env5.SPFN_AUTH_SESSION_SECRET;
|
|
655
|
+
const encoder = new TextEncoder();
|
|
656
|
+
const data = encoder.encode(purpose === "oauth" ? `oauth-pending:${secret}` : `mfa-pending:${secret}`);
|
|
657
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
658
|
+
return new Uint8Array(hashBuffer);
|
|
659
|
+
}
|
|
660
|
+
async function sealPendingSession(data, ttl = 600) {
|
|
661
|
+
return await sealFor("oauth", data, ttl);
|
|
662
|
+
}
|
|
663
|
+
async function sealPendingMfaSession(data, ttl = 600) {
|
|
664
|
+
return await sealFor("mfa", data, ttl);
|
|
665
|
+
}
|
|
666
|
+
async function sealFor(purpose, data, ttl) {
|
|
667
|
+
return await new jose2.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience(purpose === "oauth" ? "spfn-oauth" : "spfn-mfa").encrypt(await getPendingSessionKey(purpose));
|
|
668
|
+
}
|
|
669
|
+
async function unsealPendingSession(jwt2) {
|
|
670
|
+
const { payload } = await jose2.jwtDecrypt(jwt2, await getPendingSessionKey("oauth"), {
|
|
671
|
+
issuer: "spfn-auth",
|
|
672
|
+
audience: "spfn-oauth"
|
|
673
|
+
});
|
|
674
|
+
return payload.data;
|
|
675
|
+
}
|
|
676
|
+
async function unsealPendingMfaSession(jwt2) {
|
|
677
|
+
const { payload } = await jose2.jwtDecrypt(jwt2, await getPendingSessionKey("mfa"), {
|
|
678
|
+
issuer: "spfn-auth",
|
|
679
|
+
audience: "spfn-mfa"
|
|
680
|
+
});
|
|
681
|
+
return payload.data;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// src/nextjs/interceptors/mfa-verify.ts
|
|
685
|
+
var MFA_PATH_PATTERN = /^\/_auth\/(login|password\/reset\/complete|oauth\/[\w-]+\/native|oauth\/finalize|mfa\/verify)$/;
|
|
686
|
+
var PENDING_TTL_SECONDS = 600;
|
|
687
|
+
function challengeSecretOf(body) {
|
|
688
|
+
const challenge = body?.challenge;
|
|
689
|
+
if (typeof challenge === "string") {
|
|
690
|
+
return challenge;
|
|
691
|
+
}
|
|
692
|
+
const secret = challenge?.secret;
|
|
693
|
+
return typeof secret === "string" ? secret : void 0;
|
|
694
|
+
}
|
|
695
|
+
async function pendingKeyFor(ctx) {
|
|
696
|
+
if (ctx.metadata.privateKey && ctx.metadata.keyId) {
|
|
697
|
+
return {
|
|
698
|
+
privateKey: ctx.metadata.privateKey,
|
|
699
|
+
keyId: ctx.metadata.keyId,
|
|
700
|
+
algorithm: ctx.metadata.algorithm
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const oauthPending = ctx.cookies.get(COOKIE_NAMES.OAUTH_PENDING);
|
|
704
|
+
return oauthPending ? await unsealPendingSession(oauthPending) : null;
|
|
705
|
+
}
|
|
706
|
+
async function bakePendingCookie(ctx) {
|
|
707
|
+
const secret = challengeSecretOf(ctx.response.body);
|
|
708
|
+
const pending = secret ? await pendingKeyFor(ctx) : null;
|
|
709
|
+
if (!secret || !pending) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
ctx.setCookies.push({
|
|
713
|
+
name: COOKIE_NAMES.MFA_PENDING,
|
|
714
|
+
value: await sealPendingMfaSession({ ...pending, challengeHash: hashCredential(secret) }, PENDING_TTL_SECONDS),
|
|
715
|
+
options: {
|
|
716
|
+
httpOnly: true,
|
|
717
|
+
secure: cookieSecure,
|
|
718
|
+
sameSite: "lax",
|
|
719
|
+
maxAge: PENDING_TTL_SECONDS,
|
|
720
|
+
path: "/"
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
authLogger.interceptor.login.debug("Second-factor pending cookie set", { keyId: pending.keyId });
|
|
724
|
+
}
|
|
725
|
+
function refuse(ctx, error) {
|
|
726
|
+
authLogger.interceptor.login.warn("Second-factor session not sealed", { reason: error.name });
|
|
727
|
+
ctx.response.ok = false;
|
|
728
|
+
ctx.response.status = error.statusCode;
|
|
729
|
+
ctx.response.statusText = "Unauthorized";
|
|
730
|
+
ctx.response.body = refusalBody(error);
|
|
731
|
+
}
|
|
732
|
+
async function sealVerifiedSession(ctx) {
|
|
733
|
+
const cookie = ctx.cookies.get(COOKIE_NAMES.MFA_PENDING);
|
|
734
|
+
if (!cookie) {
|
|
735
|
+
refuse(ctx, new SessionPendingExpiredError());
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const pending = await unsealPendingMfaSession(cookie);
|
|
739
|
+
const { userId, keyId, challengeHash } = ctx.response.body || {};
|
|
740
|
+
if (pending.challengeHash !== challengeHash || pending.keyId !== keyId) {
|
|
741
|
+
refuse(ctx, new SessionPendingMismatchError());
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const ttl = getSessionTtl();
|
|
745
|
+
const sealed = await sealSession({
|
|
746
|
+
userId,
|
|
747
|
+
privateKey: pending.privateKey,
|
|
748
|
+
keyId: pending.keyId,
|
|
749
|
+
algorithm: pending.algorithm,
|
|
750
|
+
...bindingSessionFields(ctx.response.body, ctx.request.headers["user-agent"])
|
|
751
|
+
}, ttl);
|
|
752
|
+
pushSessionCookies(ctx, sealed, pending.keyId, ttl);
|
|
753
|
+
await pushCsrfCookie(ctx.setCookies, pending.keyId, ttl);
|
|
754
|
+
}
|
|
755
|
+
function pushSessionCookies(ctx, sealed, keyId, ttl) {
|
|
756
|
+
const options = { httpOnly: true, secure: cookieSecure, sameSite: "lax", path: "/" };
|
|
757
|
+
ctx.setCookies.push({ name: COOKIE_NAMES.SESSION, value: sealed, options: { ...options, maxAge: ttl } });
|
|
758
|
+
ctx.setCookies.push({ name: COOKIE_NAMES.SESSION_KEY_ID, value: keyId, options: { ...options, maxAge: ttl } });
|
|
759
|
+
ctx.setCookies.push({ name: COOKIE_NAMES.MFA_PENDING, value: "", options: { ...options, maxAge: 0 } });
|
|
760
|
+
}
|
|
761
|
+
var mfaVerifyInterceptor = {
|
|
762
|
+
pathPattern: MFA_PATH_PATTERN,
|
|
763
|
+
method: "POST",
|
|
764
|
+
response: async (ctx, next) => {
|
|
765
|
+
try {
|
|
766
|
+
if (ctx.response.status === 202) {
|
|
767
|
+
await bakePendingCookie(ctx);
|
|
768
|
+
} else if (ctx.response.status === 200 && ctx.path === "/_auth/mfa/verify") {
|
|
769
|
+
await sealVerifiedSession(ctx);
|
|
770
|
+
}
|
|
771
|
+
} catch (error) {
|
|
772
|
+
authLogger.interceptor.login.error("Second-factor session handling failed", error);
|
|
773
|
+
if (ctx.path === "/_auth/mfa/verify") {
|
|
774
|
+
refuse(ctx, new SessionPendingExpiredError());
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
await next();
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
// src/nextjs/interceptors/general-auth.ts
|
|
782
|
+
import { SessionContextChangedError, SessionRenewalRequiredError } from "@spfn/auth/errors";
|
|
783
|
+
|
|
784
|
+
// src/nextjs/interceptors/session-renew.ts
|
|
785
|
+
var SESSION_RENEW_PATH_PATTERN = /^\/_auth\/session\/renew\/(options|verify)$/;
|
|
786
|
+
|
|
519
787
|
// src/nextjs/interceptors/general-auth.ts
|
|
520
788
|
function requiresAuth(path) {
|
|
521
789
|
const publicPaths = [
|
|
@@ -525,11 +793,38 @@ function requiresAuth(path) {
|
|
|
525
793
|
// Send verification code
|
|
526
794
|
/^\/_auth\/codes\/verify$/,
|
|
527
795
|
// Verify code
|
|
528
|
-
/^\/_auth\/exists
|
|
796
|
+
/^\/_auth\/exists$/,
|
|
529
797
|
// Check account exists
|
|
798
|
+
// The two halves of a second-factor step-up (#95). Public for the same
|
|
799
|
+
// reason `login` is — the key they activate is inactive until they
|
|
800
|
+
// succeed, so there is nothing to sign them with — and public *here* for
|
|
801
|
+
// one more: a browser holding a stale session cookie would otherwise have
|
|
802
|
+
// this rule refresh or clear that session on the way out, over the fresh
|
|
803
|
+
// one `mfaVerifyInterceptor` just sealed.
|
|
804
|
+
/^\/_auth\/mfa\/verify$/,
|
|
805
|
+
/^\/_auth\/mfa\/verify\/options$/
|
|
530
806
|
];
|
|
531
807
|
return !publicPaths.some((pattern) => pattern.test(path));
|
|
532
808
|
}
|
|
809
|
+
function contextChanged(session, userAgent) {
|
|
810
|
+
return session.binding === "passkey" && Boolean(session.uaFamily) && Boolean(userAgent) && uaFamily(userAgent) !== session.uaFamily;
|
|
811
|
+
}
|
|
812
|
+
function refuseAsContextChanged(ctx) {
|
|
813
|
+
authLogger.interceptor.general.warn("Bound session presented from a different browser family", {
|
|
814
|
+
path: ctx.path,
|
|
815
|
+
sealed: ctx.metadata.sealedUaFamily,
|
|
816
|
+
presented: ctx.metadata.presentedUaFamily
|
|
817
|
+
});
|
|
818
|
+
const cleared = [
|
|
819
|
+
{ name: COOKIE_NAMES.SESSION, value: "", options: { maxAge: 0, path: "/" } },
|
|
820
|
+
{ name: COOKIE_NAMES.SESSION_KEY_ID, value: "", options: { maxAge: 0, path: "/" } },
|
|
821
|
+
{ name: COOKIE_NAMES.CSRF, value: "", options: { maxAge: 0, path: "/" } }
|
|
822
|
+
];
|
|
823
|
+
ctx.abort = refusalEnvelope(new SessionContextChangedError(), cleared);
|
|
824
|
+
}
|
|
825
|
+
function isKeyExpiredRefusal(body) {
|
|
826
|
+
return body?.__type === "KeyExpiredError";
|
|
827
|
+
}
|
|
533
828
|
var generalAuthInterceptor = {
|
|
534
829
|
pathPattern: "*",
|
|
535
830
|
// Match all paths, filter by requiresAuth()
|
|
@@ -569,6 +864,13 @@ var generalAuthInterceptor = {
|
|
|
569
864
|
if (await refuseInvalidCsrf(ctx, session.keyId)) {
|
|
570
865
|
return;
|
|
571
866
|
}
|
|
867
|
+
const presented = ctx.request.headers.get("user-agent");
|
|
868
|
+
if (contextChanged(session, presented)) {
|
|
869
|
+
ctx.metadata.sealedUaFamily = session.uaFamily;
|
|
870
|
+
ctx.metadata.presentedUaFamily = uaFamily(presented);
|
|
871
|
+
refuseAsContextChanged(ctx);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
572
874
|
const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
|
|
573
875
|
if (needsRefresh) {
|
|
574
876
|
authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
|
|
@@ -591,6 +893,7 @@ var generalAuthInterceptor = {
|
|
|
591
893
|
ctx.metadata.userId = session.userId;
|
|
592
894
|
ctx.metadata.keyId = session.keyId;
|
|
593
895
|
ctx.metadata.sessionValid = true;
|
|
896
|
+
ctx.metadata.sessionBound = session.binding === "passkey";
|
|
594
897
|
} catch (error) {
|
|
595
898
|
const err = error;
|
|
596
899
|
const msg = err.message.toLowerCase();
|
|
@@ -611,7 +914,12 @@ var generalAuthInterceptor = {
|
|
|
611
914
|
await next();
|
|
612
915
|
},
|
|
613
916
|
response: async (ctx, next) => {
|
|
614
|
-
if (ctx.response.status === 401 && ctx.metadata.sessionValid) {
|
|
917
|
+
if (ctx.response.status === 401 && ctx.metadata.sessionValid && ctx.metadata.sessionBound && isKeyExpiredRefusal(ctx.response.body)) {
|
|
918
|
+
ctx.response.body = refusalEnvelope(new SessionRenewalRequiredError()).body;
|
|
919
|
+
await next();
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (ctx.response.status === 401 && ctx.metadata.sessionValid && !SESSION_RENEW_PATH_PATTERN.test(ctx.path)) {
|
|
615
923
|
authLogger.interceptor.general.warn("Backend returned 401, clearing session");
|
|
616
924
|
ctx.setCookies.push({
|
|
617
925
|
name: COOKIE_NAMES.SESSION,
|
|
@@ -761,6 +1069,11 @@ var keyRotationInterceptor = {
|
|
|
761
1069
|
ctx.metadata.newKeyId = newKeyPair.keyId;
|
|
762
1070
|
ctx.metadata.newAlgorithm = newKeyPair.algorithm;
|
|
763
1071
|
ctx.metadata.userId = currentSession.userId;
|
|
1072
|
+
ctx.metadata.bindingFields = currentSession.binding ? {
|
|
1073
|
+
binding: currentSession.binding,
|
|
1074
|
+
keyExpiresAt: currentSession.keyExpiresAt,
|
|
1075
|
+
...currentSession.uaFamily ? { uaFamily: currentSession.uaFamily } : {}
|
|
1076
|
+
} : {};
|
|
764
1077
|
} catch (error) {
|
|
765
1078
|
const err = error;
|
|
766
1079
|
authLogger.interceptor.keyRotation.error("Failed to prepare key rotation", err);
|
|
@@ -783,7 +1096,8 @@ var keyRotationInterceptor = {
|
|
|
783
1096
|
userId: ctx.metadata.userId,
|
|
784
1097
|
privateKey: ctx.metadata.newPrivateKey,
|
|
785
1098
|
keyId: ctx.metadata.newKeyId,
|
|
786
|
-
algorithm: ctx.metadata.newAlgorithm
|
|
1099
|
+
algorithm: ctx.metadata.newAlgorithm,
|
|
1100
|
+
...ctx.metadata.bindingFields
|
|
787
1101
|
};
|
|
788
1102
|
const sealed = await sealSession(newSessionData, ttl);
|
|
789
1103
|
ctx.setCookies.push({
|
|
@@ -818,10 +1132,10 @@ var keyRotationInterceptor = {
|
|
|
818
1132
|
};
|
|
819
1133
|
|
|
820
1134
|
// src/server/lib/oauth/state.ts
|
|
821
|
-
import * as
|
|
822
|
-
import { env as
|
|
1135
|
+
import * as jose3 from "jose";
|
|
1136
|
+
import { env as env6 } from "@spfn/auth/config";
|
|
823
1137
|
async function getStateKey() {
|
|
824
|
-
const secret =
|
|
1138
|
+
const secret = env6.SPFN_AUTH_SESSION_SECRET;
|
|
825
1139
|
const encoder = new TextEncoder();
|
|
826
1140
|
const data = encoder.encode(`oauth-state:${secret}`);
|
|
827
1141
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
@@ -847,7 +1161,7 @@ async function createOAuthState(params) {
|
|
|
847
1161
|
algorithm: params.algorithm,
|
|
848
1162
|
metadata: params.metadata
|
|
849
1163
|
};
|
|
850
|
-
const jwe = await new
|
|
1164
|
+
const jwe = await new jose3.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
|
|
851
1165
|
return encodeURIComponent(jwe);
|
|
852
1166
|
}
|
|
853
1167
|
|
|
@@ -866,31 +1180,6 @@ function isSafeReturnPath(returnPath) {
|
|
|
866
1180
|
return !/^\/[^/?#]*:/.test(returnPath);
|
|
867
1181
|
}
|
|
868
1182
|
|
|
869
|
-
// src/nextjs/session-helpers.ts
|
|
870
|
-
import * as jose3 from "jose";
|
|
871
|
-
import { cookies } from "next/headers.js";
|
|
872
|
-
import { env as env5 } from "@spfn/auth/config";
|
|
873
|
-
import { logger } from "@spfn/core/logger";
|
|
874
|
-
async function getPendingSessionKey() {
|
|
875
|
-
const secret = env5.SPFN_AUTH_SESSION_SECRET;
|
|
876
|
-
const encoder = new TextEncoder();
|
|
877
|
-
const data = encoder.encode(`oauth-pending:${secret}`);
|
|
878
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
879
|
-
return new Uint8Array(hashBuffer);
|
|
880
|
-
}
|
|
881
|
-
async function sealPendingSession(data, ttl = 600) {
|
|
882
|
-
const key = await getPendingSessionKey();
|
|
883
|
-
return await new jose3.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
|
|
884
|
-
}
|
|
885
|
-
async function unsealPendingSession(jwt2) {
|
|
886
|
-
const key = await getPendingSessionKey();
|
|
887
|
-
const { payload } = await jose3.jwtDecrypt(jwt2, key, {
|
|
888
|
-
issuer: "spfn-auth",
|
|
889
|
-
audience: "spfn-oauth"
|
|
890
|
-
});
|
|
891
|
-
return payload.data;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
1183
|
// src/nextjs/interceptors/oauth.ts
|
|
895
1184
|
var UNSAFE_RETURN_URL_MESSAGE = "returnUrl must be a relative path within the app";
|
|
896
1185
|
function refuseUnsafeReturnUrl() {
|
|
@@ -1010,7 +1299,7 @@ var oauthFinalizeInterceptor = {
|
|
|
1010
1299
|
pathPattern: /^\/_auth\/oauth\/finalize$/,
|
|
1011
1300
|
method: "POST",
|
|
1012
1301
|
response: async (ctx, next) => {
|
|
1013
|
-
if (!ctx.response.ok) {
|
|
1302
|
+
if (!ctx.response.ok || ctx.response.status === 202) {
|
|
1014
1303
|
await next();
|
|
1015
1304
|
return;
|
|
1016
1305
|
}
|
|
@@ -1044,7 +1333,8 @@ var oauthFinalizeInterceptor = {
|
|
|
1044
1333
|
userId,
|
|
1045
1334
|
privateKey: pendingSession.privateKey,
|
|
1046
1335
|
keyId: pendingSession.keyId,
|
|
1047
|
-
algorithm: pendingSession.algorithm
|
|
1336
|
+
algorithm: pendingSession.algorithm,
|
|
1337
|
+
...bindingSessionFields(ctx.response.body, ctx.request.headers["user-agent"])
|
|
1048
1338
|
}, ttl);
|
|
1049
1339
|
ctx.setCookies.push({
|
|
1050
1340
|
name: COOKIE_NAMES.SESSION,
|
|
@@ -1202,10 +1492,12 @@ var authInterceptors = [
|
|
|
1202
1492
|
signupLinkInterceptor,
|
|
1203
1493
|
passwordResetInterceptor,
|
|
1204
1494
|
loginRegisterInterceptor,
|
|
1495
|
+
mfaVerifyInterceptor,
|
|
1205
1496
|
keyRotationInterceptor,
|
|
1206
1497
|
oauthUrlInterceptor,
|
|
1207
1498
|
oauthFinalizeInterceptor,
|
|
1208
|
-
generalAuthInterceptor
|
|
1499
|
+
generalAuthInterceptor,
|
|
1500
|
+
sessionBindingInterceptor
|
|
1209
1501
|
];
|
|
1210
1502
|
|
|
1211
1503
|
// src/nextjs/api.ts
|