@spfn/auth 0.3.0-beta.24 → 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 +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 +182 -40
- 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("");
|
|
@@ -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.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
|
+
|
|
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
|
}
|
|
@@ -966,10 +1132,10 @@ var keyRotationInterceptor = {
|
|
|
966
1132
|
};
|
|
967
1133
|
|
|
968
1134
|
// src/server/lib/oauth/state.ts
|
|
969
|
-
import * as
|
|
970
|
-
import { env as
|
|
1135
|
+
import * as jose3 from "jose";
|
|
1136
|
+
import { env as env6 } from "@spfn/auth/config";
|
|
971
1137
|
async function getStateKey() {
|
|
972
|
-
const secret =
|
|
1138
|
+
const secret = env6.SPFN_AUTH_SESSION_SECRET;
|
|
973
1139
|
const encoder = new TextEncoder();
|
|
974
1140
|
const data = encoder.encode(`oauth-state:${secret}`);
|
|
975
1141
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
@@ -995,7 +1161,7 @@ async function createOAuthState(params) {
|
|
|
995
1161
|
algorithm: params.algorithm,
|
|
996
1162
|
metadata: params.metadata
|
|
997
1163
|
};
|
|
998
|
-
const jwe = await new
|
|
1164
|
+
const jwe = await new jose3.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
|
|
999
1165
|
return encodeURIComponent(jwe);
|
|
1000
1166
|
}
|
|
1001
1167
|
|
|
@@ -1014,31 +1180,6 @@ function isSafeReturnPath(returnPath) {
|
|
|
1014
1180
|
return !/^\/[^/?#]*:/.test(returnPath);
|
|
1015
1181
|
}
|
|
1016
1182
|
|
|
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
1183
|
// src/nextjs/interceptors/oauth.ts
|
|
1043
1184
|
var UNSAFE_RETURN_URL_MESSAGE = "returnUrl must be a relative path within the app";
|
|
1044
1185
|
function refuseUnsafeReturnUrl() {
|
|
@@ -1158,7 +1299,7 @@ var oauthFinalizeInterceptor = {
|
|
|
1158
1299
|
pathPattern: /^\/_auth\/oauth\/finalize$/,
|
|
1159
1300
|
method: "POST",
|
|
1160
1301
|
response: async (ctx, next) => {
|
|
1161
|
-
if (!ctx.response.ok) {
|
|
1302
|
+
if (!ctx.response.ok || ctx.response.status === 202) {
|
|
1162
1303
|
await next();
|
|
1163
1304
|
return;
|
|
1164
1305
|
}
|
|
@@ -1351,6 +1492,7 @@ var authInterceptors = [
|
|
|
1351
1492
|
signupLinkInterceptor,
|
|
1352
1493
|
passwordResetInterceptor,
|
|
1353
1494
|
loginRegisterInterceptor,
|
|
1495
|
+
mfaVerifyInterceptor,
|
|
1354
1496
|
keyRotationInterceptor,
|
|
1355
1497
|
oauthUrlInterceptor,
|
|
1356
1498
|
oauthFinalizeInterceptor,
|