@spfn/auth 0.3.0-beta.21 → 0.3.0-beta.23

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.
@@ -484,4 +484,110 @@ declare function escapeHtml(value: string): string;
484
484
  */
485
485
  declare function createOAuth2AuthorizeHandlers(options: OAuth2AuthorizeHandlerOptions): OAuth2AuthorizeHandlers;
486
486
 
487
- export { type OAuth2AuthorizeHandlerOptions, type OAuth2AuthorizeHandlers, type OAuth2ConsentScope, type OAuth2ConsentView, type OAuthCallbackOptions, type PendingSessionData, type PublicSession, RequireAuth, type RequireAuthProps, RequirePermission, type RequirePermissionProps, RequireRole, type RequireRoleProps, type SaveSessionOptions, type SessionCookieNames, SessionData, clearPendingSession, clearSession, clearSessionCookies, createOAuth2AuthorizeHandlers, createOAuthCallbackHandler, escapeHtml, getAuthSessionData, getPendingSession, getSession, getUserPermissions, getUserRole, hasAnyPermission, hasAnyRole, saveSession, sealPendingSession, sessionCookieNames, unsealPendingSession };
487
+ /**
488
+ * @spfn/auth - The sign-out-everywhere page (Next.js route handlers)
489
+ *
490
+ * The page the mailed revoke-all link opens. `GET` describes the link and draws
491
+ * the button, `POST` presses it, and neither decides anything: both forward the
492
+ * token to `/_auth/keys/revoke-all/{confirm,consume}`, which answers either what
493
+ * to draw or the same 404 it answers for every token that names nothing.
494
+ *
495
+ * Four rules shape everything below, and each of them is an attack or a mishap
496
+ * that would otherwise work:
497
+ *
498
+ * - **The token is never anywhere but a hidden field and an API body.** Not in a
499
+ * `Location`, not in a log line, not in the text of the page. It is a bearer
500
+ * capability, and the request logger records the path of every request.
501
+ * - **There is no session here, so the CSRF token cannot come from one.** The
502
+ * whole point of the link is an owner on a device they do not trust. `GET`
503
+ * mints 32 random bytes, sets them in a cookie scoped to this page's path, and
504
+ * mirrors them into the form; `POST` compares the two. A cross-site form has
505
+ * neither half.
506
+ * - **Opening the page signs nobody out.** `GET` calls `confirm`, which the API
507
+ * guarantees changes nothing, so a mail scanner that prefetches the link has
508
+ * done nothing. `consume` is only ever reached from `POST`.
509
+ * - **Every refusal reads the same.** A 404 from either endpoint means unknown,
510
+ * expired, spent, superseded or retired, and the screen says none of them:
511
+ * telling them apart would tell whoever holds a random value that it named
512
+ * something real.
513
+ */
514
+
515
+ /**
516
+ * Everything the page needs at each of its three stages, raw and unescaped.
517
+ *
518
+ * A custom `render` receives this and owns the whole body, so at `confirm` it
519
+ * must echo `fields` and `csrfToken` back as hidden inputs: the POST is refused
520
+ * without the token, and the API re-reads the link from the field rather than
521
+ * trusting what the GET was once shown.
522
+ *
523
+ * `fields` and `csrfToken` are empty at the other two stages — there is no form
524
+ * left to submit once the link has been spent or found invalid.
525
+ *
526
+ * Every string here goes through {@link escapeHtml} before it reaches the page,
527
+ * `fields.token` above all: it is whatever was in the query of a link somebody
528
+ * was sent.
529
+ */
530
+ interface RevokeAllPageView {
531
+ /** `confirm` draws the button, `done` reports the sign-out, `invalid` refuses. */
532
+ stage: 'confirm' | 'done' | 'invalid';
533
+ /** ISO instant the link stops working. `confirm` only. */
534
+ expiresAt?: string;
535
+ /** Devices the link would sign out. `confirm` only. */
536
+ activeKeyCount?: number;
537
+ /** Devices the link did sign out. `done` only. */
538
+ revokedCount?: number;
539
+ /** The form's hidden inputs — `token` at `confirm`, empty otherwise. */
540
+ fields: Record<string, string>;
541
+ /** Value the POST's `csrf` field must carry. Empty outside `confirm`. */
542
+ csrfToken: string;
543
+ }
544
+ /**
545
+ * Options for {@link createRevokeAllPageHandlers}
546
+ */
547
+ interface RevokeAllPageHandlerOptions {
548
+ /**
549
+ * Replace the default page body
550
+ *
551
+ * Status, headers, the cookie and the field set stay the handler's; this
552
+ * owns the HTML, at all three stages.
553
+ */
554
+ render?: (view: RevokeAllPageView) => string;
555
+ }
556
+ /** The pair a route file re-exports as `export const { GET, POST } = ...`. */
557
+ interface RevokeAllPageHandlers {
558
+ GET: (request: NextRequest) => Promise<NextResponse>;
559
+ POST: (request: NextRequest) => Promise<NextResponse>;
560
+ }
561
+ /**
562
+ * Create the sign-out-everywhere page's route handlers
563
+ *
564
+ * `GET` draws the page the mailed link opens and `POST` takes the form it
565
+ * submits. Mount both at `SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH` —
566
+ * `/account/revoke-all` unless that variable says otherwise — which is the path
567
+ * `createRevokeAllLink` builds its URL on.
568
+ *
569
+ * There is no session on this page and none is wanted: an owner who no longer
570
+ * trusts the device in front of them is exactly who the link is for. What stands
571
+ * in for the session is the token in the query, and what stands in for a
572
+ * session-derived CSRF token is a random value `GET` sets in a path-scoped
573
+ * cookie and mirrors into the form.
574
+ *
575
+ * Every answer carries `Cache-Control: no-store` and
576
+ * `Content-Security-Policy: frame-ancestors 'none'`: a page whose one button
577
+ * signs out every device is a page worth clickjacking, and a copy of it in a
578
+ * shared cache is a copy of the token.
579
+ *
580
+ * @param options - An optional renderer; the defaults need nothing else
581
+ * @returns `{ GET, POST }`, ready to re-export from a route file
582
+ *
583
+ * @example
584
+ * ```typescript
585
+ * // app/account/revoke-all/route.ts
586
+ * import { createRevokeAllPageHandlers } from '@spfn/auth/nextjs/server';
587
+ *
588
+ * export const { GET, POST } = createRevokeAllPageHandlers();
589
+ * ```
590
+ */
591
+ declare function createRevokeAllPageHandlers(options?: RevokeAllPageHandlerOptions): RevokeAllPageHandlers;
592
+
593
+ export { type OAuth2AuthorizeHandlerOptions, type OAuth2AuthorizeHandlers, type OAuth2ConsentScope, type OAuth2ConsentView, type OAuthCallbackOptions, type PendingSessionData, type PublicSession, RequireAuth, type RequireAuthProps, RequirePermission, type RequirePermissionProps, RequireRole, type RequireRoleProps, type RevokeAllPageHandlerOptions, type RevokeAllPageHandlers, type RevokeAllPageView, type SaveSessionOptions, type SessionCookieNames, SessionData, clearPendingSession, clearSession, clearSessionCookies, createOAuth2AuthorizeHandlers, createOAuthCallbackHandler, createRevokeAllPageHandlers, escapeHtml, getAuthSessionData, getPendingSession, getSession, getUserPermissions, getUserRole, hasAnyPermission, hasAnyRole, saveSession, sealPendingSession, sessionCookieNames, unsealPendingSession };
@@ -778,6 +778,187 @@ function createOAuth2AuthorizeHandlers(options) {
778
778
  }
779
779
  return { GET, POST };
780
780
  }
781
+
782
+ // src/nextjs/revoke-all-page-handlers.ts
783
+ import { cookies as cookies4 } from "next/headers.js";
784
+ import { NextResponse as NextResponse3 } from "next/server";
785
+ import { authApi as authApi3 } from "@spfn/auth";
786
+ import { logger as logger4 } from "@spfn/core/logger";
787
+ var CSRF_COOKIE = "spfn_revoke_all_csrf";
788
+ var CSRF_TTL_SECONDS = 15 * 60;
789
+ var FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
790
+ function screen2(status, body) {
791
+ return new NextResponse3(body, {
792
+ status,
793
+ headers: {
794
+ "Content-Type": "text/html; charset=utf-8",
795
+ "Content-Security-Policy": "frame-ancestors 'none'",
796
+ "Cache-Control": "no-store"
797
+ }
798
+ });
799
+ }
800
+ function refusalScreen2(status, heading, message) {
801
+ return screen2(status, [
802
+ "<!DOCTYPE html>",
803
+ '<html lang="en"><head><meta charset="utf-8"><title>Sign out everywhere</title></head>',
804
+ `<body><h1>${heading}</h1><p>${message}</p></body></html>`
805
+ ].join("\n"));
806
+ }
807
+ function missingTokenScreen() {
808
+ return refusalScreen2(
809
+ 400,
810
+ "Incomplete link",
811
+ "This address is missing the part that identifies the request. Open the link from your email again, in full."
812
+ );
813
+ }
814
+ function unavailableScreen2() {
815
+ return refusalScreen2(
816
+ 500,
817
+ "Sign-out unavailable",
818
+ "This link could not be checked. Nothing was changed. Please try again."
819
+ );
820
+ }
821
+ function unverifiedScreen() {
822
+ return refusalScreen2(
823
+ 403,
824
+ "Request could not be verified",
825
+ "This form did not carry the token the page set for it. Open the link from your email again and press the button on the page it opens."
826
+ );
827
+ }
828
+ function unsupportedScreen() {
829
+ return refusalScreen2(
830
+ 415,
831
+ "Unsupported request",
832
+ "This page is answered for a browser form. Open the link from your email again."
833
+ );
834
+ }
835
+ function hiddenFields2(fields, csrfToken) {
836
+ return [...Object.entries(fields), ["csrf", csrfToken]].map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`).join("\n ");
837
+ }
838
+ function confirmBody(view) {
839
+ return `<h1>Sign out everywhere</h1>
840
+ <p>This will sign out <strong>${view.activeKeyCount}</strong> signed-in device(s), including
841
+ this one. You will need to sign in again afterwards.</p>
842
+ <p>The link stops working at <time datetime="${escapeHtml(view.expiresAt ?? "")}"
843
+ >${escapeHtml(view.expiresAt ?? "")}</time>.</p>
844
+ <form method="post">
845
+ ${hiddenFields2(view.fields, view.csrfToken)}
846
+ <button type="submit">Sign out every device</button>
847
+ </form>`;
848
+ }
849
+ function stageBody(view) {
850
+ if (view.stage === "confirm") {
851
+ return confirmBody(view);
852
+ }
853
+ if (view.stage === "done") {
854
+ return `<h1>Signed out</h1>
855
+ <p>${view.revokedCount} device(s) signed out. Sign in again to carry on.</p>`;
856
+ }
857
+ return `<h1>Link no longer valid</h1>
858
+ <p>This link cannot be used. Ask for a new one, and open the most recent email you were sent.</p>`;
859
+ }
860
+ function defaultRender2(view) {
861
+ return `<!DOCTYPE html>
862
+ <html lang="en">
863
+ <head><meta charset="utf-8"><title>Sign out everywhere</title></head>
864
+ <body>
865
+ ${stageBody(view)}
866
+ </body>
867
+ </html>`;
868
+ }
869
+ function stageView(stage, revokedCount) {
870
+ return { stage, revokedCount, fields: {}, csrfToken: "" };
871
+ }
872
+ function statusOf2(thrown) {
873
+ const error = thrown;
874
+ return Number(error?.status ?? error?.statusCode ?? 0);
875
+ }
876
+ function answerRefusal2(thrown, render) {
877
+ if (statusOf2(thrown) === 404) {
878
+ return screen2(404, render(stageView("invalid")));
879
+ }
880
+ logger4.error("Revoke-all link could not be answered", { status: statusOf2(thrown) });
881
+ return unavailableScreen2();
882
+ }
883
+ function mintCsrfToken() {
884
+ return Array.from(crypto.getRandomValues(new Uint8Array(32))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
885
+ }
886
+ function setCsrfCookie(response, csrfToken, path) {
887
+ response.cookies.set(CSRF_COOKIE, csrfToken, {
888
+ httpOnly: true,
889
+ secure: process.env.NODE_ENV === "production",
890
+ sameSite: "strict",
891
+ path,
892
+ maxAge: CSRF_TTL_SECONDS
893
+ });
894
+ return response;
895
+ }
896
+ function clearCsrfCookie(response, path) {
897
+ response.cookies.delete({ name: CSRF_COOKIE, path });
898
+ return response;
899
+ }
900
+ async function renderConfirm(request, token, render) {
901
+ const described = await authApi3.confirmRevokeAllLink.call({ body: { token } });
902
+ const csrfToken = mintCsrfToken();
903
+ const response = screen2(200, render({
904
+ stage: "confirm",
905
+ expiresAt: described.expiresAt,
906
+ activeKeyCount: described.activeKeyCount,
907
+ fields: { token },
908
+ csrfToken
909
+ }));
910
+ return setCsrfCookie(response, csrfToken, request.nextUrl.pathname);
911
+ }
912
+ async function refuseUnverifiedPost2(form) {
913
+ const presented = form.get("csrf");
914
+ const expected = (await cookies4()).get(CSRF_COOKIE)?.value;
915
+ if (!expected || !matchesCsrfToken(expected, typeof presented === "string" ? presented : null)) {
916
+ return unverifiedScreen();
917
+ }
918
+ return null;
919
+ }
920
+ function isFormPost2(request) {
921
+ return (request.headers.get("content-type") ?? "").startsWith(FORM_CONTENT_TYPE);
922
+ }
923
+ async function consume(token, render) {
924
+ const { revokedCount } = await authApi3.consumeRevokeAllLink.call({ body: { token } });
925
+ return screen2(200, render(stageView("done", revokedCount)));
926
+ }
927
+ function createRevokeAllPageHandlers(options = {}) {
928
+ const render = options.render ?? defaultRender2;
929
+ async function GET(request) {
930
+ const token = request.nextUrl.searchParams.get("token");
931
+ if (!token) {
932
+ return missingTokenScreen();
933
+ }
934
+ try {
935
+ return await renderConfirm(request, token, render);
936
+ } catch (error) {
937
+ return answerRefusal2(error, render);
938
+ }
939
+ }
940
+ async function POST(request) {
941
+ if (!isFormPost2(request)) {
942
+ return unsupportedScreen();
943
+ }
944
+ const form = await request.formData();
945
+ const refusal = await refuseUnverifiedPost2(form);
946
+ if (refusal) {
947
+ return refusal;
948
+ }
949
+ const path = request.nextUrl.pathname;
950
+ const token = form.get("token");
951
+ if (typeof token !== "string" || !token) {
952
+ return clearCsrfCookie(missingTokenScreen(), path);
953
+ }
954
+ try {
955
+ return clearCsrfCookie(await consume(token, render), path);
956
+ } catch (error) {
957
+ return clearCsrfCookie(answerRefusal2(error, render), path);
958
+ }
959
+ }
960
+ return { GET, POST };
961
+ }
781
962
  export {
782
963
  RequireAuth,
783
964
  RequirePermission,
@@ -787,6 +968,7 @@ export {
787
968
  clearSessionCookies,
788
969
  createOAuth2AuthorizeHandlers,
789
970
  createOAuthCallbackHandler,
971
+ createRevokeAllPageHandlers,
790
972
  escapeHtml,
791
973
  getAuthSessionData,
792
974
  getPendingSession,