@spfn/auth 0.3.0-beta.2 → 0.3.0-beta.20

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.
Files changed (43) hide show
  1. package/README.md +1382 -23
  2. package/dist/client-proof.d.ts +45 -15
  3. package/dist/client-proof.js +198 -4
  4. package/dist/client-proof.js.map +1 -1
  5. package/dist/client.d.ts +92 -1
  6. package/dist/client.js +58 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/config.d.ts +302 -0
  9. package/dist/config.js +134 -4
  10. package/dist/config.js.map +1 -1
  11. package/dist/errors.d.ts +370 -3
  12. package/dist/errors.js +245 -2
  13. package/dist/errors.js.map +1 -1
  14. package/dist/index.d.ts +185 -2
  15. package/dist/index.js +256 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/machine-principals-BD4tnASp.d.ts +2739 -0
  18. package/dist/nextjs/api.js +350 -12
  19. package/dist/nextjs/api.js.map +1 -1
  20. package/dist/nextjs/client.d.ts +28 -1
  21. package/dist/nextjs/client.js +24 -3
  22. package/dist/nextjs/client.js.map +1 -1
  23. package/dist/nextjs/server.d.ts +173 -3
  24. package/dist/nextjs/server.js +372 -10
  25. package/dist/nextjs/server.js.map +1 -1
  26. package/dist/server.d.ts +3761 -414
  27. package/dist/server.js +5865 -1043
  28. package/dist/server.js.map +1 -1
  29. package/dist/{session-DTHahDQ9.d.ts → session-Dfwu5g2W.d.ts} +28 -1
  30. package/migrations/20260810112144_colorful_tomorrow_man/migration.sql +18 -0
  31. package/migrations/20260810112144_colorful_tomorrow_man/snapshot.json +3576 -0
  32. package/migrations/20260901091716_fine_arclight/migration.sql +21 -0
  33. package/migrations/20260901091716_fine_arclight/snapshot.json +3849 -0
  34. package/migrations/20260906155957_natural_moonstone/migration.sql +33 -0
  35. package/migrations/20260906155957_natural_moonstone/snapshot.json +4275 -0
  36. package/migrations/20260907020904_giant_eternals/migration.sql +21 -0
  37. package/migrations/20260907020904_giant_eternals/snapshot.json +4561 -0
  38. package/migrations/20260907044807_eminent_angel/migration.sql +2 -0
  39. package/migrations/20260907044807_eminent_angel/snapshot.json +4561 -0
  40. package/migrations/20260918083158_foamy_roughhouse/migration.sql +55 -0
  41. package/migrations/20260918083158_foamy_roughhouse/snapshot.json +5271 -0
  42. package/package.json +9 -6
  43. package/dist/authenticate-55LeXHqZ.d.ts +0 -1447
@@ -22,7 +22,8 @@ var authLogger = {
22
22
  general: rootLogger.child("@spfn/auth:interceptor:general"),
23
23
  login: rootLogger.child("@spfn/auth:interceptor:login"),
24
24
  keyRotation: rootLogger.child("@spfn/auth:interceptor:key-rotation"),
25
- oauth: rootLogger.child("@spfn/auth:interceptor:oauth")
25
+ oauth: rootLogger.child("@spfn/auth:interceptor:oauth"),
26
+ csrf: rootLogger.child("@spfn/auth:interceptor:csrf")
26
27
  },
27
28
  session: rootLogger.child("@spfn/auth:session"),
28
29
  service: rootLogger.child("@spfn/auth:service"),
@@ -89,10 +90,59 @@ async function unsealSession(jwt) {
89
90
  }
90
91
  }
91
92
 
92
- // src/server/lib/config.ts
93
+ // src/server/lib/csrf.ts
93
94
  import { env as env2 } from "@spfn/auth/config";
95
+ var CSRF_SUBKEY_LABEL = "spfn-auth-csrf-token-v1";
96
+ var MAX_CANDIDATES = 32;
97
+ function sessionSecret() {
98
+ const secret = env2.SPFN_AUTH_SESSION_SECRET;
99
+ if (!secret) {
100
+ throw new Error(
101
+ "SPFN_AUTH_SESSION_SECRET is required for CSRF protection. Set it (sessions need it anyway), or set SPFN_AUTH_CSRF=off."
102
+ );
103
+ }
104
+ return secret;
105
+ }
106
+ async function hmacSha256(key, message) {
107
+ const cryptoKey = await crypto.subtle.importKey(
108
+ "raw",
109
+ key.buffer,
110
+ { name: "HMAC", hash: "SHA-256" },
111
+ false,
112
+ ["sign"]
113
+ );
114
+ const signature = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
115
+ return new Uint8Array(signature);
116
+ }
117
+ function toHex(bytes) {
118
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
119
+ }
120
+ async function deriveCsrfToken(keyId) {
121
+ const subkey = await hmacSha256(new TextEncoder().encode(sessionSecret()), CSRF_SUBKEY_LABEL);
122
+ return toHex(await hmacSha256(subkey, keyId));
123
+ }
124
+ function timingSafeEqualString(a, b) {
125
+ if (a.length !== b.length) {
126
+ return false;
127
+ }
128
+ let difference = 0;
129
+ for (let i = 0; i < a.length; i++) {
130
+ difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
131
+ }
132
+ return difference === 0;
133
+ }
134
+ function matchesCsrfToken(expected, presented) {
135
+ if (!presented) {
136
+ return false;
137
+ }
138
+ return presented.split(",", MAX_CANDIDATES).some((candidate) => timingSafeEqualString(expected, candidate.trim()));
139
+ }
140
+
141
+ // src/server/lib/config.ts
142
+ import { env as env3 } from "@spfn/auth/config";
143
+ import { PasskeyConfigError } from "@spfn/auth/errors";
94
144
  function getCookieSuffix() {
95
- const port = process.env.PORT;
145
+ const port = process.env.SPFN_PORT;
96
146
  return port ? `_${port}` : "";
97
147
  }
98
148
  var COOKIE_NAMES = {
@@ -111,6 +161,18 @@ var COOKIE_NAMES = {
111
161
  /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
112
162
  get OAUTH_CSRF() {
113
163
  return `spfn_oauth_csrf${getCookieSuffix()}`;
164
+ },
165
+ /** Password-setup session for verified-email signup — temporary, single-purpose */
166
+ get SIGNUP_SETUP() {
167
+ return `spfn_signup_setup${getCookieSuffix()}`;
168
+ },
169
+ /** Password-setup session for a password reset — temporary, single-purpose */
170
+ get PASSWORD_RESET_SETUP() {
171
+ return `spfn_password_reset_setup${getCookieSuffix()}`;
172
+ },
173
+ /** CSRF token — the only cookie here the browser can read */
174
+ get CSRF() {
175
+ return `spfn_csrf${getCookieSuffix()}`;
114
176
  }
115
177
  };
116
178
  function parseDuration(duration) {
@@ -147,7 +209,7 @@ function getSessionTtl(override) {
147
209
  if (globalConfig.sessionTtl !== void 0) {
148
210
  return parseDuration(globalConfig.sessionTtl);
149
211
  }
150
- const envTtl = env2.SPFN_AUTH_SESSION_TTL;
212
+ const envTtl = env3.SPFN_AUTH_SESSION_TTL;
151
213
  if (envTtl) {
152
214
  return parseDuration(envTtl);
153
215
  }
@@ -155,7 +217,7 @@ function getSessionTtl(override) {
155
217
  }
156
218
 
157
219
  // src/nextjs/session-helpers.ts
158
- import { env as env3 } from "@spfn/auth/config";
220
+ import { env as env4 } from "@spfn/auth/config";
159
221
  import { logger } from "@spfn/core/logger";
160
222
  async function saveSession(data, options) {
161
223
  let maxAge;
@@ -173,6 +235,13 @@ async function saveSession(data, options) {
173
235
  path: "/",
174
236
  maxAge
175
237
  });
238
+ cookieStore.set(COOKIE_NAMES.CSRF, await deriveCsrfToken(data.keyId), {
239
+ httpOnly: false,
240
+ secure: process.env.NODE_ENV === "production",
241
+ sameSite: "lax",
242
+ path: "/",
243
+ maxAge
244
+ });
176
245
  }
177
246
  async function getSession() {
178
247
  const cookieStore = await cookies();
@@ -197,9 +266,10 @@ async function clearSession() {
197
266
  const cookieStore = await cookies();
198
267
  cookieStore.delete(COOKIE_NAMES.SESSION);
199
268
  cookieStore.delete(COOKIE_NAMES.SESSION_KEY_ID);
269
+ cookieStore.delete(COOKIE_NAMES.CSRF);
200
270
  }
201
271
  async function getPendingSessionKey() {
202
- const secret = env3.SPFN_AUTH_SESSION_SECRET;
272
+ const secret = env4.SPFN_AUTH_SESSION_SECRET;
203
273
  const encoder = new TextEncoder();
204
274
  const data = encoder.encode(`oauth-pending:${secret}`);
205
275
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
@@ -351,11 +421,47 @@ async function RequirePermission({
351
421
  return /* @__PURE__ */ jsx3(Fragment3, { children });
352
422
  }
353
423
 
424
+ // src/nextjs/cookie-names.ts
425
+ function sessionCookieNames() {
426
+ return {
427
+ session: COOKIE_NAMES.SESSION,
428
+ keyId: COOKIE_NAMES.SESSION_KEY_ID,
429
+ oauthPending: COOKIE_NAMES.OAUTH_PENDING,
430
+ csrf: COOKIE_NAMES.CSRF
431
+ };
432
+ }
433
+ function clearSessionCookies(response) {
434
+ for (const name of Object.values(sessionCookieNames())) {
435
+ response.cookies.delete({ name, path: "/" });
436
+ }
437
+ return response;
438
+ }
439
+
354
440
  // src/nextjs/oauth-handlers.ts
355
441
  import { NextResponse } from "next/server";
356
442
  import { cookies as cookies2 } from "next/headers.js";
357
- import { env as env4 } from "@spfn/core/config";
443
+ import { env as env5 } from "@spfn/core/config";
358
444
  import { logger as logger2 } from "@spfn/core/logger";
445
+
446
+ // src/lib/return-path.ts
447
+ var URL_STRIPPED_CHARACTER = /[\t\n\r]/;
448
+ function isSafeReturnPath(returnPath) {
449
+ if (!returnPath.startsWith("/")) {
450
+ return false;
451
+ }
452
+ if (returnPath.startsWith("//") || returnPath.includes("\\")) {
453
+ return false;
454
+ }
455
+ if (returnPath.includes("..") || URL_STRIPPED_CHARACTER.test(returnPath)) {
456
+ return false;
457
+ }
458
+ return !/^\/[^/?#]*:/.test(returnPath);
459
+ }
460
+
461
+ // src/nextjs/oauth-handlers.ts
462
+ function safeReturnUrl(requested, defaultRedirect) {
463
+ return requested && isSafeReturnPath(requested) ? requested : defaultRedirect;
464
+ }
359
465
  function createOAuthCallbackHandler(options) {
360
466
  const defaultRedirect = options?.defaultRedirectUrl || "/";
361
467
  const errorRedirect = options?.errorRedirectUrl || "/auth/error";
@@ -363,7 +469,7 @@ function createOAuthCallbackHandler(options) {
363
469
  const searchParams = request.nextUrl.searchParams;
364
470
  const userId = searchParams.get("userId");
365
471
  const keyId = searchParams.get("keyId");
366
- const returnUrl = searchParams.get("returnUrl") || defaultRedirect;
472
+ const returnUrl = safeReturnUrl(searchParams.get("returnUrl"), defaultRedirect);
367
473
  const error = searchParams.get("error");
368
474
  if (error) {
369
475
  const errorUrl = new URL(errorRedirect, request.url);
@@ -397,14 +503,21 @@ function createOAuthCallbackHandler(options) {
397
503
  const response = NextResponse.redirect(redirectUrl);
398
504
  response.cookies.set(COOKIE_NAMES.SESSION, sessionToken, {
399
505
  httpOnly: true,
400
- secure: env4.NODE_ENV === "production",
506
+ secure: env5.NODE_ENV === "production",
401
507
  sameSite: "lax",
402
508
  maxAge: ttl,
403
509
  path: "/"
404
510
  });
405
511
  response.cookies.set(COOKIE_NAMES.SESSION_KEY_ID, keyId, {
406
512
  httpOnly: true,
407
- secure: env4.NODE_ENV === "production",
513
+ secure: env5.NODE_ENV === "production",
514
+ sameSite: "lax",
515
+ maxAge: ttl,
516
+ path: "/"
517
+ });
518
+ response.cookies.set(COOKIE_NAMES.CSRF, await deriveCsrfToken(keyId), {
519
+ httpOnly: false,
520
+ secure: env5.NODE_ENV === "production",
408
521
  sameSite: "lax",
409
522
  maxAge: ttl,
410
523
  path: "/"
@@ -421,13 +534,260 @@ function createOAuthCallbackHandler(options) {
421
534
  }
422
535
  };
423
536
  }
537
+
538
+ // src/nextjs/oauth2-authorize-handlers.ts
539
+ import { cookies as cookies3 } from "next/headers.js";
540
+ import { NextResponse as NextResponse2 } from "next/server";
541
+ import { authApi as authApi2 } from "@spfn/auth";
542
+ import { logger as logger3 } from "@spfn/core/logger";
543
+ var AUTHORIZE_PARAMETERS = [
544
+ "client_id",
545
+ "redirect_uri",
546
+ "code_challenge",
547
+ "code_challenge_method",
548
+ "resource",
549
+ "scope",
550
+ "state"
551
+ ];
552
+ var NON_REDIRECTABLE = /* @__PURE__ */ new Set(["unknown_client", "redirect_uri_mismatch"]);
553
+ var FORM_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data"];
554
+ function escapeHtml(value) {
555
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
556
+ }
557
+ function authorizeFields(read) {
558
+ const fields = {};
559
+ for (const name of AUTHORIZE_PARAMETERS) {
560
+ const value = read(name);
561
+ if (value) {
562
+ fields[name] = value;
563
+ }
564
+ }
565
+ return fields;
566
+ }
567
+ function screen(status, body) {
568
+ return new NextResponse2(body, {
569
+ status,
570
+ headers: {
571
+ "Content-Type": "text/html; charset=utf-8",
572
+ "Content-Security-Policy": "frame-ancestors 'none'",
573
+ "Cache-Control": "no-store"
574
+ }
575
+ });
576
+ }
577
+ function refusalScreen(status, heading, message) {
578
+ return screen(status, [
579
+ "<!DOCTYPE html>",
580
+ '<html lang="en"><head><meta charset="utf-8"><title>Authorization request refused</title></head>',
581
+ `<body><h1>${heading}</h1><p>${message}</p></body></html>`
582
+ ].join("\n"));
583
+ }
584
+ function unknownClientScreen() {
585
+ return refusalScreen(
586
+ 400,
587
+ "Unrecognized application",
588
+ "The application that sent you here is not registered with this service, so the request cannot be completed. Nothing was shared."
589
+ );
590
+ }
591
+ function redirectMismatchScreen() {
592
+ return refusalScreen(
593
+ 400,
594
+ "Address not recognized",
595
+ "The application asked for the authorization to be returned to an address it never registered. Nothing was shared, and you were not sent there."
596
+ );
597
+ }
598
+ function unavailableScreen() {
599
+ return refusalScreen(
600
+ 500,
601
+ "Authorization unavailable",
602
+ "This authorization request could not be checked. Nothing was shared. Please try again."
603
+ );
604
+ }
605
+ function noSessionScreen() {
606
+ return refusalScreen(
607
+ 403,
608
+ "Sign-in required",
609
+ "This authorization request needs a signed-in session and yours is not available. Start the request again from the application."
610
+ );
611
+ }
612
+ function redirect4(url) {
613
+ return NextResponse2.redirect(url, { status: 302, headers: { "Cache-Control": "no-store" } });
614
+ }
615
+ function safeUrl(value) {
616
+ try {
617
+ return new URL(value);
618
+ } catch {
619
+ return null;
620
+ }
621
+ }
622
+ function loginRedirect(request, loginPath) {
623
+ const returnPath = `${request.nextUrl.pathname}${request.nextUrl.search}`;
624
+ if (!isSafeReturnPath(returnPath)) {
625
+ return refusalScreen(
626
+ 400,
627
+ "Malformed authorization request",
628
+ "This authorization request cannot be signed in to. Start it again from the application."
629
+ );
630
+ }
631
+ const url = new URL(loginPath, request.url);
632
+ url.searchParams.set("returnUrl", returnPath);
633
+ return redirect4(url);
634
+ }
635
+ function detailsOf(thrown) {
636
+ const error = thrown;
637
+ return error?.details ?? error?.response?.error?.details ?? error?.response?.details ?? {};
638
+ }
639
+ function statusOf(thrown) {
640
+ const error = thrown;
641
+ return Number(error?.status ?? error?.statusCode ?? 0);
642
+ }
643
+ function refusalRedirect(details) {
644
+ const { error, redirectUri, state } = details;
645
+ if (typeof error !== "string" || NON_REDIRECTABLE.has(error) || typeof redirectUri !== "string") {
646
+ return null;
647
+ }
648
+ const url = safeUrl(redirectUri);
649
+ if (!url) {
650
+ return null;
651
+ }
652
+ url.searchParams.set("error", error);
653
+ if (typeof state === "string") {
654
+ url.searchParams.set("state", state);
655
+ }
656
+ return redirect4(url);
657
+ }
658
+ function answerRefusal(thrown, onStaleSession) {
659
+ const status = statusOf(thrown);
660
+ if (status === 401) {
661
+ return onStaleSession();
662
+ }
663
+ const details = detailsOf(thrown);
664
+ if (details.error === "unknown_client") {
665
+ return unknownClientScreen();
666
+ }
667
+ if (details.error === "redirect_uri_mismatch") {
668
+ return redirectMismatchScreen();
669
+ }
670
+ const redirectable = refusalRedirect(details);
671
+ if (redirectable) {
672
+ return redirectable;
673
+ }
674
+ logger3.error("OAuth2 consent request could not be answered", { status });
675
+ return unavailableScreen();
676
+ }
677
+ function hiddenFields(fields, csrfToken) {
678
+ return [...Object.entries(fields), ["csrf", csrfToken]].map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`).join("\n ");
679
+ }
680
+ function defaultRender(view) {
681
+ const scopes = view.scopes.map((scope) => `<li><strong>${escapeHtml(scope.name)}</strong> \u2014 ${escapeHtml(scope.description)}</li>`).join("\n ");
682
+ return `<!DOCTYPE html>
683
+ <html lang="en">
684
+ <head><meta charset="utf-8"><title>Authorize ${escapeHtml(view.clientName)}</title></head>
685
+ <body>
686
+ <h1>Authorize ${escapeHtml(view.clientName)}</h1>
687
+ <p><strong>${escapeHtml(view.clientName)}</strong> is asking to act on your behalf at
688
+ <code>${escapeHtml(view.resource)}</code>. The authorization would be returned to
689
+ <code>${escapeHtml(view.redirectHost)}</code>.</p>
690
+ <h2>It is asking for</h2>
691
+ <ul>
692
+ ${scopes}
693
+ </ul>
694
+ <form method="post">
695
+ ${hiddenFields(view.fields, view.csrfToken)}
696
+ <button type="submit" name="decision" value="approve">Approve</button>
697
+ <button type="submit" name="decision" value="deny">Deny</button>
698
+ </form>
699
+ </body>
700
+ </html>`;
701
+ }
702
+ async function csrfCookie() {
703
+ const cookieStore = await cookies3();
704
+ return cookieStore.get(sessionCookieNames().csrf)?.value ?? null;
705
+ }
706
+ async function renderConsent(request, options) {
707
+ const csrfToken = await csrfCookie();
708
+ if (!csrfToken) {
709
+ return loginRedirect(request, options.loginPath);
710
+ }
711
+ const fields = authorizeFields((name) => request.nextUrl.searchParams.get(name));
712
+ const described = await authApi2.getOAuth2Authorize.call({ query: fields });
713
+ const render = options.render ?? defaultRender;
714
+ return screen(200, render({ ...described, fields, csrfToken }));
715
+ }
716
+ async function refuseUnverifiedPost(form) {
717
+ const presented = form.get("csrf");
718
+ const expected = await csrfCookie();
719
+ if (!expected || !matchesCsrfToken(expected, typeof presented === "string" ? presented : null)) {
720
+ return refusalScreen(
721
+ 403,
722
+ "Request could not be verified",
723
+ "This form did not carry a valid token for your session. Start the authorization again from the application."
724
+ );
725
+ }
726
+ return null;
727
+ }
728
+ function isFormPost(request) {
729
+ const contentType = request.headers.get("content-type") ?? "";
730
+ return FORM_CONTENT_TYPES.some((type) => contentType.startsWith(type));
731
+ }
732
+ async function recordDecision(fields, decision) {
733
+ const body = { ...fields, approve: decision === "approve" };
734
+ const issued = await authApi2.createOAuth2AuthorizationCode.call({ body });
735
+ const url = safeUrl(issued.redirectUri);
736
+ if (!url) {
737
+ return unavailableScreen();
738
+ }
739
+ url.searchParams.set("code", issued.code);
740
+ if (issued.state !== void 0) {
741
+ url.searchParams.set("state", issued.state);
742
+ }
743
+ return redirect4(url);
744
+ }
745
+ function createOAuth2AuthorizeHandlers(options) {
746
+ async function GET(request) {
747
+ if (!await getSession()) {
748
+ return loginRedirect(request, options.loginPath);
749
+ }
750
+ try {
751
+ return await renderConsent(request, options);
752
+ } catch (error) {
753
+ return answerRefusal(error, () => loginRedirect(request, options.loginPath));
754
+ }
755
+ }
756
+ async function POST(request) {
757
+ if (!await getSession()) {
758
+ return noSessionScreen();
759
+ }
760
+ if (!isFormPost(request)) {
761
+ return refusalScreen(
762
+ 415,
763
+ "Unsupported request",
764
+ "The consent form is submitted as a form. Start the authorization again from the application."
765
+ );
766
+ }
767
+ const form = await request.formData();
768
+ const refusal = await refuseUnverifiedPost(form);
769
+ if (refusal) {
770
+ return refusal;
771
+ }
772
+ try {
773
+ const fields = authorizeFields((name) => form.get(name));
774
+ return await recordDecision(fields, form.get("decision"));
775
+ } catch (error) {
776
+ return answerRefusal(error, noSessionScreen);
777
+ }
778
+ }
779
+ return { GET, POST };
780
+ }
424
781
  export {
425
782
  RequireAuth,
426
783
  RequirePermission,
427
784
  RequireRole,
428
785
  clearPendingSession,
429
786
  clearSession,
787
+ clearSessionCookies,
788
+ createOAuth2AuthorizeHandlers,
430
789
  createOAuthCallbackHandler,
790
+ escapeHtml,
431
791
  getAuthSessionData,
432
792
  getPendingSession,
433
793
  getSession,
@@ -435,8 +795,10 @@ export {
435
795
  getUserRole,
436
796
  hasAnyPermission,
437
797
  hasAnyRole,
798
+ isSafeReturnPath,
438
799
  saveSession,
439
800
  sealPendingSession,
801
+ sessionCookieNames,
440
802
  unsealPendingSession
441
803
  };
442
804
  //# sourceMappingURL=server.js.map