@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.
- package/README.md +169 -4
- package/dist/config.d.ts +38 -0
- package/dist/config.js +18 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +97 -2
- package/dist/errors.js +63 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +35 -2
- package/dist/index.js +61 -1
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-B7N8gux0.d.ts → machine-principals-CaEFq61K.d.ts} +397 -5
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +107 -1
- package/dist/nextjs/server.js +182 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +521 -9
- package/dist/server.js +1801 -839
- package/dist/server.js.map +1 -1
- package/migrations/20260918162828_handy_titania/migration.sql +35 -0
- package/migrations/20260918162828_handy_titania/snapshot.json +5948 -0
- package/package.json +1 -1
package/dist/nextjs/server.d.ts
CHANGED
|
@@ -484,4 +484,110 @@ declare function escapeHtml(value: string): string;
|
|
|
484
484
|
*/
|
|
485
485
|
declare function createOAuth2AuthorizeHandlers(options: OAuth2AuthorizeHandlerOptions): OAuth2AuthorizeHandlers;
|
|
486
486
|
|
|
487
|
-
|
|
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 };
|
package/dist/nextjs/server.js
CHANGED
|
@@ -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,
|