@spfn/auth 0.3.0-beta.24 → 0.3.0-beta.26
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 +163 -5
- package/dist/client-proof.d.ts +10 -1
- package/dist/client-proof.js +126 -9
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +50 -1
- package/dist/client.js +25 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.js +16 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +48 -2
- package/dist/errors.js +27 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +23 -16
- package/dist/index.js +31 -1
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-ZJd9anVT.d.ts → machine-principals-CdEgxOB1.d.ts} +1397 -886
- package/dist/nextjs/api.js +194 -49
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +17 -0
- package/dist/nextjs/server.js +33 -6
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +256 -96
- package/dist/server.js +1328 -676
- package/dist/server.js.map +1 -1
- 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()}`;
|
|
@@ -457,20 +469,23 @@ function uaFamily(userAgent) {
|
|
|
457
469
|
|
|
458
470
|
// src/nextjs/interceptors/error-envelope.ts
|
|
459
471
|
function refusalEnvelope(error, setCookies = []) {
|
|
460
|
-
const body = error.toJSON();
|
|
461
472
|
return {
|
|
462
473
|
status: error.statusCode,
|
|
463
|
-
body:
|
|
464
|
-
...body,
|
|
465
|
-
error: {
|
|
466
|
-
code: body.__type,
|
|
467
|
-
message: body.message,
|
|
468
|
-
requestId: mintRequestId()
|
|
469
|
-
}
|
|
470
|
-
},
|
|
474
|
+
body: refusalBody(error),
|
|
471
475
|
setCookies
|
|
472
476
|
};
|
|
473
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
|
+
}
|
|
474
489
|
function mintRequestId() {
|
|
475
490
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
476
491
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
@@ -562,9 +577,9 @@ var loginRegisterInterceptor = {
|
|
|
562
577
|
ctx.body.oldKeyId = oldKeyId;
|
|
563
578
|
}
|
|
564
579
|
delete ctx.body.remember;
|
|
565
|
-
ctx.metadata.
|
|
566
|
-
ctx.metadata.
|
|
567
|
-
ctx.metadata.
|
|
580
|
+
ctx.metadata.newPrivateKey = keyPair.privateKey;
|
|
581
|
+
ctx.metadata.newKeyId = keyPair.keyId;
|
|
582
|
+
ctx.metadata.newAlgorithm = keyPair.algorithm;
|
|
568
583
|
ctx.metadata.remember = remember;
|
|
569
584
|
await next();
|
|
570
585
|
},
|
|
@@ -583,9 +598,9 @@ var loginRegisterInterceptor = {
|
|
|
583
598
|
const ttl = getSessionTtl(ctx.metadata.remember);
|
|
584
599
|
const sessionData = {
|
|
585
600
|
userId: userData.userId,
|
|
586
|
-
privateKey: ctx.metadata.
|
|
587
|
-
keyId: ctx.metadata.
|
|
588
|
-
algorithm: ctx.metadata.
|
|
601
|
+
privateKey: ctx.metadata.newPrivateKey,
|
|
602
|
+
keyId: ctx.metadata.newKeyId,
|
|
603
|
+
algorithm: ctx.metadata.newAlgorithm,
|
|
589
604
|
...bindingSessionFields(userData, ctx.request.headers["user-agent"])
|
|
590
605
|
};
|
|
591
606
|
const sealed = await sealSession(sessionData, ttl);
|
|
@@ -602,7 +617,7 @@ var loginRegisterInterceptor = {
|
|
|
602
617
|
});
|
|
603
618
|
ctx.setCookies.push({
|
|
604
619
|
name: COOKIE_NAMES.SESSION_KEY_ID,
|
|
605
|
-
value: ctx.metadata.
|
|
620
|
+
value: ctx.metadata.newKeyId,
|
|
606
621
|
options: {
|
|
607
622
|
httpOnly: true,
|
|
608
623
|
secure: cookieSecure,
|
|
@@ -611,7 +626,7 @@ var loginRegisterInterceptor = {
|
|
|
611
626
|
path: "/"
|
|
612
627
|
}
|
|
613
628
|
});
|
|
614
|
-
await pushCsrfCookie(ctx.setCookies, ctx.metadata.
|
|
629
|
+
await pushCsrfCookie(ctx.setCookies, ctx.metadata.newKeyId, ttl);
|
|
615
630
|
} catch (error) {
|
|
616
631
|
const err = error;
|
|
617
632
|
authLogger.interceptor.login.error("Failed to save session", err);
|
|
@@ -620,6 +635,149 @@ var loginRegisterInterceptor = {
|
|
|
620
635
|
}
|
|
621
636
|
};
|
|
622
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.newPrivateKey && ctx.metadata.newKeyId) {
|
|
697
|
+
return {
|
|
698
|
+
privateKey: ctx.metadata.newPrivateKey,
|
|
699
|
+
keyId: ctx.metadata.newKeyId,
|
|
700
|
+
algorithm: ctx.metadata.newAlgorithm
|
|
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
|
+
|
|
623
781
|
// src/nextjs/interceptors/general-auth.ts
|
|
624
782
|
import { SessionContextChangedError, SessionRenewalRequiredError } from "@spfn/auth/errors";
|
|
625
783
|
|
|
@@ -635,8 +793,16 @@ function requiresAuth(path) {
|
|
|
635
793
|
// Send verification code
|
|
636
794
|
/^\/_auth\/codes\/verify$/,
|
|
637
795
|
// Verify code
|
|
638
|
-
/^\/_auth\/exists
|
|
796
|
+
/^\/_auth\/exists$/,
|
|
639
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$/
|
|
640
806
|
];
|
|
641
807
|
return !publicPaths.some((pattern) => pattern.test(path));
|
|
642
808
|
}
|
|
@@ -659,6 +825,9 @@ function refuseAsContextChanged(ctx) {
|
|
|
659
825
|
function isKeyExpiredRefusal(body) {
|
|
660
826
|
return body?.__type === "KeyExpiredError";
|
|
661
827
|
}
|
|
828
|
+
function sessionQueued(setCookies) {
|
|
829
|
+
return setCookies.some((cookie) => cookie.name === COOKIE_NAMES.SESSION);
|
|
830
|
+
}
|
|
662
831
|
var generalAuthInterceptor = {
|
|
663
832
|
pathPattern: "*",
|
|
664
833
|
// Match all paths, filter by requiresAuth()
|
|
@@ -787,7 +956,7 @@ var generalAuthInterceptor = {
|
|
|
787
956
|
}
|
|
788
957
|
});
|
|
789
958
|
pushCsrfCookieRemoval(ctx.setCookies);
|
|
790
|
-
} else if (ctx.metadata.refreshSession && ctx.response.status === 200) {
|
|
959
|
+
} else if (ctx.metadata.refreshSession && ctx.response.status === 200 && !sessionQueued(ctx.setCookies)) {
|
|
791
960
|
try {
|
|
792
961
|
const sessionData = ctx.metadata.sessionData;
|
|
793
962
|
const ttl = getSessionTtl();
|
|
@@ -966,10 +1135,10 @@ var keyRotationInterceptor = {
|
|
|
966
1135
|
};
|
|
967
1136
|
|
|
968
1137
|
// src/server/lib/oauth/state.ts
|
|
969
|
-
import * as
|
|
970
|
-
import { env as
|
|
1138
|
+
import * as jose3 from "jose";
|
|
1139
|
+
import { env as env6 } from "@spfn/auth/config";
|
|
971
1140
|
async function getStateKey() {
|
|
972
|
-
const secret =
|
|
1141
|
+
const secret = env6.SPFN_AUTH_SESSION_SECRET;
|
|
973
1142
|
const encoder = new TextEncoder();
|
|
974
1143
|
const data = encoder.encode(`oauth-state:${secret}`);
|
|
975
1144
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
@@ -995,7 +1164,7 @@ async function createOAuthState(params) {
|
|
|
995
1164
|
algorithm: params.algorithm,
|
|
996
1165
|
metadata: params.metadata
|
|
997
1166
|
};
|
|
998
|
-
const jwe = await new
|
|
1167
|
+
const jwe = await new jose3.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
|
|
999
1168
|
return encodeURIComponent(jwe);
|
|
1000
1169
|
}
|
|
1001
1170
|
|
|
@@ -1014,31 +1183,6 @@ function isSafeReturnPath(returnPath) {
|
|
|
1014
1183
|
return !/^\/[^/?#]*:/.test(returnPath);
|
|
1015
1184
|
}
|
|
1016
1185
|
|
|
1017
|
-
// src/nextjs/session-helpers.ts
|
|
1018
|
-
import * as jose3 from "jose";
|
|
1019
|
-
import { cookies } from "next/headers.js";
|
|
1020
|
-
import { env as env5 } from "@spfn/auth/config";
|
|
1021
|
-
import { logger } from "@spfn/core/logger";
|
|
1022
|
-
async function getPendingSessionKey() {
|
|
1023
|
-
const secret = env5.SPFN_AUTH_SESSION_SECRET;
|
|
1024
|
-
const encoder = new TextEncoder();
|
|
1025
|
-
const data = encoder.encode(`oauth-pending:${secret}`);
|
|
1026
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
1027
|
-
return new Uint8Array(hashBuffer);
|
|
1028
|
-
}
|
|
1029
|
-
async function sealPendingSession(data, ttl = 600) {
|
|
1030
|
-
const key = await getPendingSessionKey();
|
|
1031
|
-
return await new jose3.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-oauth").encrypt(key);
|
|
1032
|
-
}
|
|
1033
|
-
async function unsealPendingSession(jwt2) {
|
|
1034
|
-
const key = await getPendingSessionKey();
|
|
1035
|
-
const { payload } = await jose3.jwtDecrypt(jwt2, key, {
|
|
1036
|
-
issuer: "spfn-auth",
|
|
1037
|
-
audience: "spfn-oauth"
|
|
1038
|
-
});
|
|
1039
|
-
return payload.data;
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
1186
|
// src/nextjs/interceptors/oauth.ts
|
|
1043
1187
|
var UNSAFE_RETURN_URL_MESSAGE = "returnUrl must be a relative path within the app";
|
|
1044
1188
|
function refuseUnsafeReturnUrl() {
|
|
@@ -1158,7 +1302,7 @@ var oauthFinalizeInterceptor = {
|
|
|
1158
1302
|
pathPattern: /^\/_auth\/oauth\/finalize$/,
|
|
1159
1303
|
method: "POST",
|
|
1160
1304
|
response: async (ctx, next) => {
|
|
1161
|
-
if (!ctx.response.ok) {
|
|
1305
|
+
if (!ctx.response.ok || ctx.response.status === 202) {
|
|
1162
1306
|
await next();
|
|
1163
1307
|
return;
|
|
1164
1308
|
}
|
|
@@ -1351,6 +1495,7 @@ var authInterceptors = [
|
|
|
1351
1495
|
signupLinkInterceptor,
|
|
1352
1496
|
passwordResetInterceptor,
|
|
1353
1497
|
loginRegisterInterceptor,
|
|
1498
|
+
mfaVerifyInterceptor,
|
|
1354
1499
|
keyRotationInterceptor,
|
|
1355
1500
|
oauthUrlInterceptor,
|
|
1356
1501
|
oauthFinalizeInterceptor,
|