@spfn/auth 0.3.0-beta.7 → 0.3.0-beta.8
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 +378 -0
- package/dist/client-proof.d.ts +23 -12
- package/dist/client-proof.js +143 -4
- package/dist/client-proof.js.map +1 -1
- package/dist/config.d.ts +20 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +76 -3
- package/dist/errors.js +45 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +46 -6
- package/dist/index.js +75 -0
- package/dist/index.js.map +1 -1
- package/dist/{authenticate-Mg9D7Nys.d.ts → machine-principals-Bd5hp76H.d.ts} +343 -6
- package/dist/nextjs/api.js +190 -9
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.js +59 -8
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +719 -89
- package/dist/server.js +1383 -440
- package/dist/server.js.map +1 -1
- package/migrations/20260901091716_fine_arclight/migration.sql +21 -0
- package/migrations/20260901091716_fine_arclight/snapshot.json +3849 -0
- package/package.json +2 -2
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"),
|
|
@@ -214,6 +215,10 @@ var COOKIE_NAMES = {
|
|
|
214
215
|
/** Password-setup session for verified-email signup — temporary, single-purpose */
|
|
215
216
|
get SIGNUP_SETUP() {
|
|
216
217
|
return `spfn_signup_setup${getCookieSuffix()}`;
|
|
218
|
+
},
|
|
219
|
+
/** CSRF token — the only cookie here the browser can read */
|
|
220
|
+
get CSRF() {
|
|
221
|
+
return `spfn_csrf${getCookieSuffix()}`;
|
|
217
222
|
}
|
|
218
223
|
};
|
|
219
224
|
function parseDuration(duration) {
|
|
@@ -256,6 +261,28 @@ function getSessionTtl(override) {
|
|
|
256
261
|
}
|
|
257
262
|
return 7 * 24 * 60 * 60;
|
|
258
263
|
}
|
|
264
|
+
var CSRF_MODES = ["off", "warn", "enforce"];
|
|
265
|
+
var unrecognizedCsrfModeReported = false;
|
|
266
|
+
function getCsrfMode() {
|
|
267
|
+
const configured = globalConfig.csrf?.mode ?? env2.SPFN_AUTH_CSRF;
|
|
268
|
+
if (!configured) {
|
|
269
|
+
return "warn";
|
|
270
|
+
}
|
|
271
|
+
const normalized = String(configured).trim().toLowerCase();
|
|
272
|
+
if (!CSRF_MODES.includes(normalized)) {
|
|
273
|
+
if (!unrecognizedCsrfModeReported) {
|
|
274
|
+
unrecognizedCsrfModeReported = true;
|
|
275
|
+
authLogger.interceptor.csrf.error(
|
|
276
|
+
`Unrecognized CSRF mode "${configured}" \u2014 expected off | warn | enforce. Enforcing.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return "enforce";
|
|
280
|
+
}
|
|
281
|
+
return normalized;
|
|
282
|
+
}
|
|
283
|
+
function getCsrfExemptPaths() {
|
|
284
|
+
return globalConfig.csrf?.exemptPaths ?? [];
|
|
285
|
+
}
|
|
259
286
|
|
|
260
287
|
// src/nextjs/interceptors/cookie-options.ts
|
|
261
288
|
function resolveSecure() {
|
|
@@ -267,6 +294,140 @@ function resolveSecure() {
|
|
|
267
294
|
}
|
|
268
295
|
var cookieSecure = resolveSecure();
|
|
269
296
|
|
|
297
|
+
// src/server/lib/csrf.ts
|
|
298
|
+
import { env as env3 } from "@spfn/auth/config";
|
|
299
|
+
var CSRF_HEADER = "x-spfn-csrf";
|
|
300
|
+
var CSRF_SUBKEY_LABEL = "spfn-auth-csrf-token-v1";
|
|
301
|
+
var MAX_CANDIDATES = 32;
|
|
302
|
+
function sessionSecret() {
|
|
303
|
+
const secret = env3.SPFN_AUTH_SESSION_SECRET;
|
|
304
|
+
if (!secret) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
"SPFN_AUTH_SESSION_SECRET is required for CSRF protection. Set it (sessions need it anyway), or set SPFN_AUTH_CSRF=off."
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return secret;
|
|
310
|
+
}
|
|
311
|
+
async function hmacSha256(key, message) {
|
|
312
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
313
|
+
"raw",
|
|
314
|
+
key.buffer,
|
|
315
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
316
|
+
false,
|
|
317
|
+
["sign"]
|
|
318
|
+
);
|
|
319
|
+
const signature = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
|
|
320
|
+
return new Uint8Array(signature);
|
|
321
|
+
}
|
|
322
|
+
function toHex(bytes) {
|
|
323
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
324
|
+
}
|
|
325
|
+
async function deriveCsrfToken(keyId) {
|
|
326
|
+
const subkey = await hmacSha256(new TextEncoder().encode(sessionSecret()), CSRF_SUBKEY_LABEL);
|
|
327
|
+
return toHex(await hmacSha256(subkey, keyId));
|
|
328
|
+
}
|
|
329
|
+
function timingSafeEqualString(a, b) {
|
|
330
|
+
if (a.length !== b.length) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
let difference = 0;
|
|
334
|
+
for (let i = 0; i < a.length; i++) {
|
|
335
|
+
difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
336
|
+
}
|
|
337
|
+
return difference === 0;
|
|
338
|
+
}
|
|
339
|
+
function matchesCsrfToken(expected, presented) {
|
|
340
|
+
if (!presented) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
return presented.split(",", MAX_CANDIDATES).some((candidate) => timingSafeEqualString(expected, candidate.trim()));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/nextjs/interceptors/csrf.ts
|
|
347
|
+
var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
348
|
+
function csrfCookie(token, ttl) {
|
|
349
|
+
return {
|
|
350
|
+
name: COOKIE_NAMES.CSRF,
|
|
351
|
+
value: token,
|
|
352
|
+
options: {
|
|
353
|
+
httpOnly: false,
|
|
354
|
+
secure: cookieSecure,
|
|
355
|
+
sameSite: "lax",
|
|
356
|
+
maxAge: ttl,
|
|
357
|
+
path: "/"
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function refusal() {
|
|
362
|
+
return {
|
|
363
|
+
status: 403,
|
|
364
|
+
body: {
|
|
365
|
+
error: "Forbidden",
|
|
366
|
+
message: "CSRF token missing or invalid"
|
|
367
|
+
},
|
|
368
|
+
setCookies: []
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
async function refuseInvalidCsrf(ctx, keyId) {
|
|
372
|
+
const mode = getCsrfMode();
|
|
373
|
+
if (mode === "off" || SAFE_METHODS.has(ctx.method.toUpperCase())) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
if (getCsrfExemptPaths().includes(ctx.path)) {
|
|
377
|
+
authLogger.interceptor.csrf.debug("Path is CSRF-exempt", { path: ctx.path });
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
let expected;
|
|
381
|
+
try {
|
|
382
|
+
expected = await deriveCsrfToken(keyId);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
authLogger.interceptor.csrf.error(
|
|
385
|
+
"Cannot derive the CSRF token \u2014 refusing regardless of mode",
|
|
386
|
+
error
|
|
387
|
+
);
|
|
388
|
+
ctx.abort = refusal();
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
const presented = ctx.request.headers.get(CSRF_HEADER);
|
|
392
|
+
if (matchesCsrfToken(expected, presented)) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
const detail = {
|
|
396
|
+
method: ctx.method,
|
|
397
|
+
path: ctx.path,
|
|
398
|
+
headerPresent: !!presented
|
|
399
|
+
};
|
|
400
|
+
if (mode === "warn") {
|
|
401
|
+
authLogger.interceptor.csrf.warn("CSRF check would refuse this request (mode=warn)", detail);
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
authLogger.interceptor.csrf.warn("CSRF check refused this request", detail);
|
|
405
|
+
ctx.abort = refusal();
|
|
406
|
+
ctx.abort.setCookies = [csrfCookie(expected, getSessionTtl())];
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
async function pushCsrfCookie(setCookies, keyId, ttl) {
|
|
410
|
+
setCookies.push(csrfCookie(await deriveCsrfToken(keyId), ttl));
|
|
411
|
+
}
|
|
412
|
+
async function pushCsrfCookieIfStale(setCookies, presented, keyId) {
|
|
413
|
+
try {
|
|
414
|
+
const token = await deriveCsrfToken(keyId);
|
|
415
|
+
if (presented && timingSafeEqualString(token, presented)) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
setCookies.push(csrfCookie(token, getSessionTtl()));
|
|
419
|
+
} catch (error) {
|
|
420
|
+
authLogger.interceptor.csrf.error("Cannot reissue the CSRF cookie", error);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function pushCsrfCookieRemoval(setCookies) {
|
|
424
|
+
setCookies.push({
|
|
425
|
+
name: COOKIE_NAMES.CSRF,
|
|
426
|
+
value: "",
|
|
427
|
+
options: { maxAge: 0, path: "/" }
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
270
431
|
// src/nextjs/interceptors/login-register.ts
|
|
271
432
|
var loginRegisterInterceptor = {
|
|
272
433
|
pathPattern: /^\/_auth\/(login|register|invitations\/accept|signup\/password)$/,
|
|
@@ -335,6 +496,7 @@ var loginRegisterInterceptor = {
|
|
|
335
496
|
path: "/"
|
|
336
497
|
}
|
|
337
498
|
});
|
|
499
|
+
await pushCsrfCookie(ctx.setCookies, ctx.metadata.keyId, ttl);
|
|
338
500
|
} catch (error) {
|
|
339
501
|
const err = error;
|
|
340
502
|
authLogger.interceptor.login.error("Failed to save session", err);
|
|
@@ -393,6 +555,9 @@ var generalAuthInterceptor = {
|
|
|
393
555
|
userId: session.userId,
|
|
394
556
|
keyId: session.keyId
|
|
395
557
|
});
|
|
558
|
+
if (await refuseInvalidCsrf(ctx, session.keyId)) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
396
561
|
const needsRefresh = await shouldRefreshSession(sessionCookie, 24);
|
|
397
562
|
if (needsRefresh) {
|
|
398
563
|
authLogger.interceptor.general.debug("Session needs refresh (within 24h of expiry)");
|
|
@@ -413,6 +578,7 @@ var generalAuthInterceptor = {
|
|
|
413
578
|
ctx.headers["Authorization"] = `Bearer ${token}`;
|
|
414
579
|
ctx.headers["X-Key-Id"] = session.keyId;
|
|
415
580
|
ctx.metadata.userId = session.userId;
|
|
581
|
+
ctx.metadata.keyId = session.keyId;
|
|
416
582
|
ctx.metadata.sessionValid = true;
|
|
417
583
|
} catch (error) {
|
|
418
584
|
const err = error;
|
|
@@ -446,6 +612,7 @@ var generalAuthInterceptor = {
|
|
|
446
612
|
value: "",
|
|
447
613
|
options: { maxAge: 0, path: "/" }
|
|
448
614
|
});
|
|
615
|
+
pushCsrfCookieRemoval(ctx.setCookies);
|
|
449
616
|
await next();
|
|
450
617
|
return;
|
|
451
618
|
}
|
|
@@ -466,6 +633,7 @@ var generalAuthInterceptor = {
|
|
|
466
633
|
path: "/"
|
|
467
634
|
}
|
|
468
635
|
});
|
|
636
|
+
pushCsrfCookieRemoval(ctx.setCookies);
|
|
469
637
|
} else if (ctx.metadata.refreshSession && ctx.response.status === 200) {
|
|
470
638
|
try {
|
|
471
639
|
const sessionData = ctx.metadata.sessionData;
|
|
@@ -493,6 +661,7 @@ var generalAuthInterceptor = {
|
|
|
493
661
|
path: "/"
|
|
494
662
|
}
|
|
495
663
|
});
|
|
664
|
+
await pushCsrfCookie(ctx.setCookies, sessionData.keyId, ttl);
|
|
496
665
|
authLogger.interceptor.general.info("Session refreshed", {
|
|
497
666
|
userId: sessionData.userId,
|
|
498
667
|
sealedLength: sealed.length,
|
|
@@ -524,6 +693,15 @@ var generalAuthInterceptor = {
|
|
|
524
693
|
value: "",
|
|
525
694
|
options: { ...base, sameSite: "lax" }
|
|
526
695
|
});
|
|
696
|
+
pushCsrfCookieRemoval(ctx.setCookies);
|
|
697
|
+
}
|
|
698
|
+
const csrfQueued = ctx.setCookies.some((cookie) => cookie.name === COOKIE_NAMES.CSRF);
|
|
699
|
+
if (ctx.metadata.sessionValid && !csrfQueued) {
|
|
700
|
+
await pushCsrfCookieIfStale(
|
|
701
|
+
ctx.setCookies,
|
|
702
|
+
ctx.cookies.get(COOKIE_NAMES.CSRF),
|
|
703
|
+
ctx.metadata.keyId
|
|
704
|
+
);
|
|
527
705
|
}
|
|
528
706
|
await next();
|
|
529
707
|
}
|
|
@@ -550,10 +728,11 @@ var keyRotationInterceptor = {
|
|
|
550
728
|
ctx.body.fingerprint = newKeyPair.fingerprint;
|
|
551
729
|
ctx.body.algorithm = newKeyPair.algorithm;
|
|
552
730
|
ctx.body.keySize = Buffer.from(newKeyPair.publicKey, "base64").length;
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
731
|
+
authLogger.interceptor.keyRotation.debug("Generated a new key pair", {
|
|
732
|
+
keyId: newKeyPair.keyId,
|
|
733
|
+
fingerprint: newKeyPair.fingerprint,
|
|
734
|
+
algorithm: newKeyPair.algorithm
|
|
735
|
+
});
|
|
557
736
|
const token = generateClientToken(
|
|
558
737
|
{
|
|
559
738
|
userId: currentSession.userId,
|
|
@@ -618,6 +797,7 @@ var keyRotationInterceptor = {
|
|
|
618
797
|
path: "/"
|
|
619
798
|
}
|
|
620
799
|
});
|
|
800
|
+
await pushCsrfCookie(ctx.setCookies, ctx.metadata.newKeyId, ttl);
|
|
621
801
|
} catch (error) {
|
|
622
802
|
const err = error;
|
|
623
803
|
authLogger.interceptor.keyRotation.error("Failed to update session after rotation", err);
|
|
@@ -628,9 +808,9 @@ var keyRotationInterceptor = {
|
|
|
628
808
|
|
|
629
809
|
// src/server/lib/oauth/state.ts
|
|
630
810
|
import * as jose2 from "jose";
|
|
631
|
-
import { env as
|
|
811
|
+
import { env as env4 } from "@spfn/auth/config";
|
|
632
812
|
async function getStateKey() {
|
|
633
|
-
const secret =
|
|
813
|
+
const secret = env4.SPFN_AUTH_SESSION_SECRET;
|
|
634
814
|
const encoder = new TextEncoder();
|
|
635
815
|
const data = encoder.encode(`oauth-state:${secret}`);
|
|
636
816
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
@@ -663,10 +843,10 @@ async function createOAuthState(params) {
|
|
|
663
843
|
// src/nextjs/session-helpers.ts
|
|
664
844
|
import * as jose3 from "jose";
|
|
665
845
|
import { cookies } from "next/headers.js";
|
|
666
|
-
import { env as
|
|
846
|
+
import { env as env5 } from "@spfn/auth/config";
|
|
667
847
|
import { logger } from "@spfn/core/logger";
|
|
668
848
|
async function getPendingSessionKey() {
|
|
669
|
-
const secret =
|
|
849
|
+
const secret = env5.SPFN_AUTH_SESSION_SECRET;
|
|
670
850
|
const encoder = new TextEncoder();
|
|
671
851
|
const data = encoder.encode(`oauth-pending:${secret}`);
|
|
672
852
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
@@ -841,6 +1021,7 @@ var oauthFinalizeInterceptor = {
|
|
|
841
1021
|
path: "/"
|
|
842
1022
|
}
|
|
843
1023
|
});
|
|
1024
|
+
await pushCsrfCookie(ctx.setCookies, keyId, ttl);
|
|
844
1025
|
ctx.setCookies.push({
|
|
845
1026
|
name: COOKIE_NAMES.OAUTH_PENDING,
|
|
846
1027
|
value: "",
|