@spfn/auth 0.3.0-beta.23 → 0.3.0-beta.24
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 +165 -2
- package/dist/client-proof.js +13 -5
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +52 -1
- package/dist/client.js +40 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +82 -0
- package/dist/config.js +37 -0
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/errors.d.ts +113 -3
- package/dist/errors.js +68 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +38 -5
- package/dist/index.js +73 -2
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-CaEFq61K.d.ts → machine-principals-ZJd9anVT.d.ts} +1667 -900
- package/dist/nextjs/api.js +156 -6
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +42 -24
- package/dist/nextjs/server.js +72 -5
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +448 -525
- package/dist/server.js +1389 -653
- 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/package.json +1 -1
package/dist/nextjs/api.js
CHANGED
|
@@ -438,10 +438,113 @@ function pushCsrfCookieRemoval(setCookies) {
|
|
|
438
438
|
});
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
+
// src/nextjs/interceptors/session-binding.ts
|
|
442
|
+
import { SessionResealFailedError } from "@spfn/auth/errors";
|
|
443
|
+
|
|
444
|
+
// src/server/lib/ua-family.ts
|
|
445
|
+
var FAMILY_MARKERS = [
|
|
446
|
+
{ family: "edge", marker: /\bEdg(?:A|iOS)?\// },
|
|
447
|
+
{ family: "chrome", marker: /\b(?:Chrome|CriOS)\// },
|
|
448
|
+
{ family: "firefox", marker: /\b(?:Firefox|FxiOS)\// },
|
|
449
|
+
{ family: "safari", marker: /\bSafari\// }
|
|
450
|
+
];
|
|
451
|
+
function uaFamily(userAgent) {
|
|
452
|
+
if (!userAgent) {
|
|
453
|
+
return "other";
|
|
454
|
+
}
|
|
455
|
+
return FAMILY_MARKERS.find((entry) => entry.marker.test(userAgent))?.family ?? "other";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/nextjs/interceptors/error-envelope.ts
|
|
459
|
+
function refusalEnvelope(error, setCookies = []) {
|
|
460
|
+
const body = error.toJSON();
|
|
461
|
+
return {
|
|
462
|
+
status: error.statusCode,
|
|
463
|
+
body: {
|
|
464
|
+
...body,
|
|
465
|
+
error: {
|
|
466
|
+
code: body.__type,
|
|
467
|
+
message: body.message,
|
|
468
|
+
requestId: mintRequestId()
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
setCookies
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function mintRequestId() {
|
|
475
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
476
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/nextjs/interceptors/session-binding.ts
|
|
480
|
+
function bindingSessionFields(body, userAgent) {
|
|
481
|
+
if (body?.sessionBinding !== "passkey" || typeof body.keyExpiresAtMillis !== "number") {
|
|
482
|
+
return {};
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
binding: "passkey",
|
|
486
|
+
keyExpiresAt: body.keyExpiresAtMillis,
|
|
487
|
+
...userAgent ? { uaFamily: uaFamily(userAgent) } : {}
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
var sessionBindingInterceptor = {
|
|
491
|
+
pathPattern: "/_auth/session/binding",
|
|
492
|
+
method: "POST",
|
|
493
|
+
response: async (ctx, next) => {
|
|
494
|
+
const sessionCookie = ctx.cookies.get(COOKIE_NAMES.SESSION);
|
|
495
|
+
if (ctx.response.status !== 200 || !sessionCookie) {
|
|
496
|
+
await next();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const session = await unsealSession(sessionCookie);
|
|
501
|
+
await pushResealed(ctx.setCookies, applyBinding(session, ctx.response.body, ctx.request.headers));
|
|
502
|
+
} catch (error) {
|
|
503
|
+
authLogger.interceptor.general.error("Failed to re-seal the session after a binding change", error);
|
|
504
|
+
refuseAsUnsealable(ctx);
|
|
505
|
+
}
|
|
506
|
+
await next();
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
function refuseAsUnsealable(ctx) {
|
|
510
|
+
const refusal2 = refusalEnvelope(new SessionResealFailedError());
|
|
511
|
+
ctx.response.status = refusal2.status;
|
|
512
|
+
ctx.response.ok = false;
|
|
513
|
+
ctx.response.body = refusal2.body;
|
|
514
|
+
for (const name of [COOKIE_NAMES.SESSION, COOKIE_NAMES.SESSION_KEY_ID]) {
|
|
515
|
+
ctx.setCookies.push({ name, value: "", options: { maxAge: 0, path: "/" } });
|
|
516
|
+
}
|
|
517
|
+
pushCsrfCookieRemoval(ctx.setCookies);
|
|
518
|
+
}
|
|
519
|
+
function applyBinding(session, body, requestHeaders) {
|
|
520
|
+
const { binding, keyExpiresAt, uaFamily: sealedFamily, ...unbound } = session;
|
|
521
|
+
if (body?.mode !== "passkey") {
|
|
522
|
+
return unbound;
|
|
523
|
+
}
|
|
524
|
+
const fields = bindingSessionFields(
|
|
525
|
+
{ sessionBinding: body.mode, keyExpiresAtMillis: body.keyExpiresAtMillis },
|
|
526
|
+
requestHeaders["user-agent"]
|
|
527
|
+
);
|
|
528
|
+
return { ...unbound, ...fields, ...sealedFamily ? { uaFamily: sealedFamily } : {} };
|
|
529
|
+
}
|
|
530
|
+
async function pushResealed(setCookies, session) {
|
|
531
|
+
const ttl = getSessionTtl();
|
|
532
|
+
const options = {
|
|
533
|
+
httpOnly: true,
|
|
534
|
+
secure: cookieSecure,
|
|
535
|
+
sameSite: "lax",
|
|
536
|
+
maxAge: ttl,
|
|
537
|
+
path: "/"
|
|
538
|
+
};
|
|
539
|
+
setCookies.push({ name: COOKIE_NAMES.SESSION, value: await sealSession(session, ttl), options });
|
|
540
|
+
setCookies.push({ name: COOKIE_NAMES.SESSION_KEY_ID, value: session.keyId, options });
|
|
541
|
+
await pushCsrfCookie(setCookies, session.keyId, ttl);
|
|
542
|
+
}
|
|
543
|
+
|
|
441
544
|
// src/nextjs/interceptors/login-register.ts
|
|
442
545
|
var ROTATING_SIGN_IN_PATHS = /* @__PURE__ */ new Set(["/_auth/login", "/_auth/passkeys/login/verify"]);
|
|
443
546
|
var loginRegisterInterceptor = {
|
|
444
|
-
pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password|password\/reset\/complete|passkeys\/login\/verify)$/,
|
|
547
|
+
pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password|password\/reset\/complete|passkeys\/login\/verify|session\/renew\/verify)$/,
|
|
445
548
|
method: "POST",
|
|
446
549
|
request: async (ctx, next) => {
|
|
447
550
|
const oldKeyId = ctx.cookies.get(COOKIE_NAMES.SESSION_KEY_ID);
|
|
@@ -482,7 +585,8 @@ var loginRegisterInterceptor = {
|
|
|
482
585
|
userId: userData.userId,
|
|
483
586
|
privateKey: ctx.metadata.privateKey,
|
|
484
587
|
keyId: ctx.metadata.keyId,
|
|
485
|
-
algorithm: ctx.metadata.algorithm
|
|
588
|
+
algorithm: ctx.metadata.algorithm,
|
|
589
|
+
...bindingSessionFields(userData, ctx.request.headers["user-agent"])
|
|
486
590
|
};
|
|
487
591
|
const sealed = await sealSession(sessionData, ttl);
|
|
488
592
|
ctx.setCookies.push({
|
|
@@ -516,6 +620,12 @@ var loginRegisterInterceptor = {
|
|
|
516
620
|
}
|
|
517
621
|
};
|
|
518
622
|
|
|
623
|
+
// src/nextjs/interceptors/general-auth.ts
|
|
624
|
+
import { SessionContextChangedError, SessionRenewalRequiredError } from "@spfn/auth/errors";
|
|
625
|
+
|
|
626
|
+
// src/nextjs/interceptors/session-renew.ts
|
|
627
|
+
var SESSION_RENEW_PATH_PATTERN = /^\/_auth\/session\/renew\/(options|verify)$/;
|
|
628
|
+
|
|
519
629
|
// src/nextjs/interceptors/general-auth.ts
|
|
520
630
|
function requiresAuth(path) {
|
|
521
631
|
const publicPaths = [
|
|
@@ -530,6 +640,25 @@ function requiresAuth(path) {
|
|
|
530
640
|
];
|
|
531
641
|
return !publicPaths.some((pattern) => pattern.test(path));
|
|
532
642
|
}
|
|
643
|
+
function contextChanged(session, userAgent) {
|
|
644
|
+
return session.binding === "passkey" && Boolean(session.uaFamily) && Boolean(userAgent) && uaFamily(userAgent) !== session.uaFamily;
|
|
645
|
+
}
|
|
646
|
+
function refuseAsContextChanged(ctx) {
|
|
647
|
+
authLogger.interceptor.general.warn("Bound session presented from a different browser family", {
|
|
648
|
+
path: ctx.path,
|
|
649
|
+
sealed: ctx.metadata.sealedUaFamily,
|
|
650
|
+
presented: ctx.metadata.presentedUaFamily
|
|
651
|
+
});
|
|
652
|
+
const cleared = [
|
|
653
|
+
{ name: COOKIE_NAMES.SESSION, value: "", options: { maxAge: 0, path: "/" } },
|
|
654
|
+
{ name: COOKIE_NAMES.SESSION_KEY_ID, value: "", options: { maxAge: 0, path: "/" } },
|
|
655
|
+
{ name: COOKIE_NAMES.CSRF, value: "", options: { maxAge: 0, path: "/" } }
|
|
656
|
+
];
|
|
657
|
+
ctx.abort = refusalEnvelope(new SessionContextChangedError(), cleared);
|
|
658
|
+
}
|
|
659
|
+
function isKeyExpiredRefusal(body) {
|
|
660
|
+
return body?.__type === "KeyExpiredError";
|
|
661
|
+
}
|
|
533
662
|
var generalAuthInterceptor = {
|
|
534
663
|
pathPattern: "*",
|
|
535
664
|
// Match all paths, filter by requiresAuth()
|
|
@@ -569,6 +698,13 @@ var generalAuthInterceptor = {
|
|
|
569
698
|
if (await refuseInvalidCsrf(ctx, session.keyId)) {
|
|
570
699
|
return;
|
|
571
700
|
}
|
|
701
|
+
const presented = ctx.request.headers.get("user-agent");
|
|
702
|
+
if (contextChanged(session, presented)) {
|
|
703
|
+
ctx.metadata.sealedUaFamily = session.uaFamily;
|
|
704
|
+
ctx.metadata.presentedUaFamily = uaFamily(presented);
|
|
705
|
+
refuseAsContextChanged(ctx);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
572
708
|
const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
|
|
573
709
|
if (needsRefresh) {
|
|
574
710
|
authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
|
|
@@ -591,6 +727,7 @@ var generalAuthInterceptor = {
|
|
|
591
727
|
ctx.metadata.userId = session.userId;
|
|
592
728
|
ctx.metadata.keyId = session.keyId;
|
|
593
729
|
ctx.metadata.sessionValid = true;
|
|
730
|
+
ctx.metadata.sessionBound = session.binding === "passkey";
|
|
594
731
|
} catch (error) {
|
|
595
732
|
const err = error;
|
|
596
733
|
const msg = err.message.toLowerCase();
|
|
@@ -611,7 +748,12 @@ var generalAuthInterceptor = {
|
|
|
611
748
|
await next();
|
|
612
749
|
},
|
|
613
750
|
response: async (ctx, next) => {
|
|
614
|
-
if (ctx.response.status === 401 && ctx.metadata.sessionValid) {
|
|
751
|
+
if (ctx.response.status === 401 && ctx.metadata.sessionValid && ctx.metadata.sessionBound && isKeyExpiredRefusal(ctx.response.body)) {
|
|
752
|
+
ctx.response.body = refusalEnvelope(new SessionRenewalRequiredError()).body;
|
|
753
|
+
await next();
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (ctx.response.status === 401 && ctx.metadata.sessionValid && !SESSION_RENEW_PATH_PATTERN.test(ctx.path)) {
|
|
615
757
|
authLogger.interceptor.general.warn("Backend returned 401, clearing session");
|
|
616
758
|
ctx.setCookies.push({
|
|
617
759
|
name: COOKIE_NAMES.SESSION,
|
|
@@ -761,6 +903,11 @@ var keyRotationInterceptor = {
|
|
|
761
903
|
ctx.metadata.newKeyId = newKeyPair.keyId;
|
|
762
904
|
ctx.metadata.newAlgorithm = newKeyPair.algorithm;
|
|
763
905
|
ctx.metadata.userId = currentSession.userId;
|
|
906
|
+
ctx.metadata.bindingFields = currentSession.binding ? {
|
|
907
|
+
binding: currentSession.binding,
|
|
908
|
+
keyExpiresAt: currentSession.keyExpiresAt,
|
|
909
|
+
...currentSession.uaFamily ? { uaFamily: currentSession.uaFamily } : {}
|
|
910
|
+
} : {};
|
|
764
911
|
} catch (error) {
|
|
765
912
|
const err = error;
|
|
766
913
|
authLogger.interceptor.keyRotation.error("Failed to prepare key rotation", err);
|
|
@@ -783,7 +930,8 @@ var keyRotationInterceptor = {
|
|
|
783
930
|
userId: ctx.metadata.userId,
|
|
784
931
|
privateKey: ctx.metadata.newPrivateKey,
|
|
785
932
|
keyId: ctx.metadata.newKeyId,
|
|
786
|
-
algorithm: ctx.metadata.newAlgorithm
|
|
933
|
+
algorithm: ctx.metadata.newAlgorithm,
|
|
934
|
+
...ctx.metadata.bindingFields
|
|
787
935
|
};
|
|
788
936
|
const sealed = await sealSession(newSessionData, ttl);
|
|
789
937
|
ctx.setCookies.push({
|
|
@@ -1044,7 +1192,8 @@ var oauthFinalizeInterceptor = {
|
|
|
1044
1192
|
userId,
|
|
1045
1193
|
privateKey: pendingSession.privateKey,
|
|
1046
1194
|
keyId: pendingSession.keyId,
|
|
1047
|
-
algorithm: pendingSession.algorithm
|
|
1195
|
+
algorithm: pendingSession.algorithm,
|
|
1196
|
+
...bindingSessionFields(ctx.response.body, ctx.request.headers["user-agent"])
|
|
1048
1197
|
}, ttl);
|
|
1049
1198
|
ctx.setCookies.push({
|
|
1050
1199
|
name: COOKIE_NAMES.SESSION,
|
|
@@ -1205,7 +1354,8 @@ var authInterceptors = [
|
|
|
1205
1354
|
keyRotationInterceptor,
|
|
1206
1355
|
oauthUrlInterceptor,
|
|
1207
1356
|
oauthFinalizeInterceptor,
|
|
1208
|
-
generalAuthInterceptor
|
|
1357
|
+
generalAuthInterceptor,
|
|
1358
|
+
sessionBindingInterceptor
|
|
1209
1359
|
];
|
|
1210
1360
|
|
|
1211
1361
|
// src/nextjs/api.ts
|