@kanzo-tech/auth 0.2.0
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 +103 -0
- package/dist/auth-context.d.ts +13 -0
- package/dist/auth-context.d.ts.map +1 -0
- package/dist/auth-context.js +7 -0
- package/dist/auth-context.js.map +1 -0
- package/dist/auth-fetch.d.ts +32 -0
- package/dist/auth-fetch.d.ts.map +1 -0
- package/dist/auth-fetch.js +20 -0
- package/dist/auth-fetch.js.map +1 -0
- package/dist/auth-provider.d.ts +12 -0
- package/dist/auth-provider.d.ts.map +1 -0
- package/dist/auth-provider.js +35 -0
- package/dist/auth-provider.js.map +1 -0
- package/dist/bff-auth.d.ts +32 -0
- package/dist/bff-auth.d.ts.map +1 -0
- package/dist/bff-auth.js +70 -0
- package/dist/bff-auth.js.map +1 -0
- package/dist/browser.d.ts +61 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +107 -0
- package/dist/browser.js.map +1 -0
- package/dist/can.d.ts +25 -0
- package/dist/can.d.ts.map +1 -0
- package/dist/can.js +12 -0
- package/dist/can.js.map +1 -0
- package/dist/claims.d.ts +50 -0
- package/dist/claims.d.ts.map +1 -0
- package/dist/claims.js +56 -0
- package/dist/claims.js.map +1 -0
- package/dist/cookie-session.d.ts +28 -0
- package/dist/cookie-session.d.ts.map +1 -0
- package/dist/cookie-session.js +51 -0
- package/dist/cookie-session.js.map +1 -0
- package/dist/gate.d.ts +16 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +17 -0
- package/dist/gate.js.map +1 -0
- package/dist/host.d.ts +22 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +10 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/issuer.d.ts +92 -0
- package/dist/issuer.d.ts.map +1 -0
- package/dist/issuer.js +42 -0
- package/dist/issuer.js.map +1 -0
- package/dist/next-middleware.d.ts +17 -0
- package/dist/next-middleware.d.ts.map +1 -0
- package/dist/next-middleware.js +21 -0
- package/dist/next-middleware.js.map +1 -0
- package/dist/next-routes.d.ts +19 -0
- package/dist/next-routes.d.ts.map +1 -0
- package/dist/next-routes.js +65 -0
- package/dist/next-routes.js.map +1 -0
- package/dist/next-session.d.ts +44 -0
- package/dist/next-session.d.ts.map +1 -0
- package/dist/next-session.js +11 -0
- package/dist/next-session.js.map +1 -0
- package/dist/next.d.ts +42 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +9 -0
- package/dist/next.js.map +1 -0
- package/dist/server.d.ts +53 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +157 -0
- package/dist/server.js.map +1 -0
- package/dist/single-flight.d.ts +15 -0
- package/dist/single-flight.d.ts.map +1 -0
- package/dist/single-flight.js +10 -0
- package/dist/single-flight.js.map +1 -0
- package/dist/store.d.ts +52 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +20 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +118 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/use-organization.d.ts +22 -0
- package/dist/use-organization.d.ts.map +1 -0
- package/dist/use-organization.js +20 -0
- package/dist/use-organization.js.map +1 -0
- package/dist/use-session.d.ts +13 -0
- package/dist/use-session.d.ts.map +1 -0
- package/dist/use-session.js +19 -0
- package/dist/use-session.js.map +1 -0
- package/package.json +135 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { NextResponse, NextRequest } from 'next/server';
|
|
2
|
+
export interface AuthMiddlewareConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Path prefixes that need no session — a health probe, a marketing page, a legal notice.
|
|
5
|
+
*
|
|
6
|
+
* Matched on segment boundaries, so `/health` exempts `/health` and `/health/live` and does
|
|
7
|
+
* **not** exempt `/healthcare`. A plain `startsWith` is one keystroke away from opening a route
|
|
8
|
+
* nobody meant to open.
|
|
9
|
+
*/
|
|
10
|
+
readonly public?: readonly string[];
|
|
11
|
+
/** Where `authRoutes` is mounted. Always public — it is how a person signs in. Default `/api/auth`. */
|
|
12
|
+
readonly basePath?: string;
|
|
13
|
+
/** Default `__Host-kanzo-session`. Set it only if `relyingParty` was given a different cookie. */
|
|
14
|
+
readonly cookieName?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function authMiddleware(config?: AuthMiddlewareConfig): (request: NextRequest) => NextResponse;
|
|
17
|
+
//# sourceMappingURL=next-middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next-middleware.d.ts","sourceRoot":"","sources":["../src/next-middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAiD7D,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,uGAAuG;IACvG,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,kGAAkG;IAClG,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAOD,wBAAgB,cAAc,CAC5B,MAAM,GAAE,oBAAyB,GAChC,CAAC,OAAO,EAAE,WAAW,KAAK,YAAY,CAoBxC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextResponse as s } from "next/server";
|
|
2
|
+
const l = "__Host-kanzo-session";
|
|
3
|
+
function p(e) {
|
|
4
|
+
return e.slice(e.lastIndexOf("/") + 1).includes(".");
|
|
5
|
+
}
|
|
6
|
+
function m(e = {}) {
|
|
7
|
+
const r = (e.basePath ?? "/api/auth").replace(/\/$/, ""), i = e.cookieName ?? l, c = [r, ...e.public ?? []].map((t) => t.replace(/\/$/, ""));
|
|
8
|
+
return (t) => {
|
|
9
|
+
const { pathname: n, search: u } = t.nextUrl;
|
|
10
|
+
if (p(n)) return s.next();
|
|
11
|
+
if (c.some((o) => n === o || n.startsWith(`${o}/`)))
|
|
12
|
+
return s.next();
|
|
13
|
+
if (t.cookies.has(i)) return s.next();
|
|
14
|
+
const a = new URL(`${r}/signin`, t.nextUrl);
|
|
15
|
+
return a.searchParams.set("returnTo", `${n}${u}`), s.redirect(a);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export {
|
|
19
|
+
m as authMiddleware
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=next-middleware.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next-middleware.js","sources":["../src/next-middleware.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from \"next/server\";\n\n/**\n * The edge middleware that sends an anonymous browser to the sign-in route.\n *\n * ```ts\n * // middleware.ts\n * export const middleware = authMiddleware({ public: [\"/health\"] });\n * ```\n *\n * ## It checks for presence, and nothing else\n *\n * The cookie is sealed with JWE, and this file never opens it — no key, no `jose`, no store. Two\n * reasons, and the second is the one that matters:\n *\n * 1. Middleware runs on every request, including the ones that are about to be answered from a\n * cache. Decrypting there buys a redirect decision that a route handler and `authSession` are\n * both going to make again, properly, a millisecond later.\n * 2. **A redirect is not an authorization.** Letting a request past here grants nothing: the page\n * behind it reads the session itself, and the resource server behind *that* validates an access\n * token. A forged cookie gets someone as far as a page that will find no session and say so.\n * Treating this as the check is how a middleware becomes load-bearing and then gets edited by\n * someone who does not know it is.\n *\n * So this module imports nothing from the package. That is deliberate and it has a cost: the\n * cookie's name is written here a second time, and `next-middleware.test.ts` ties the two together\n * by signing in through a fake realm and asserting the default is the name `relyingParty` actually\n * emitted. A transcribed constant with no test is how the number in a generated fixture goes stale.\n *\n * ## The matcher\n *\n * A `middleware.ts` also exports its own `config.matcher`, and keasy learned what belongs in it the\n * expensive way: a matcher that missed static files sent `/fossil/fossil_wasm_bg.wasm` to the\n * sign-in page, and the app loaded without its WebAssembly. The exemption is applied *here* as\n * well, on the path, so it holds whatever matcher a consumer writes — a fix for the class rather\n * than for the regexp. The matcher a product starts from:\n *\n * ```ts\n * export const config = { matcher: [\"/((?!_next/static|_next/image|favicon.ico|.*\\\\..*).*)\"] };\n * ```\n */\n\n/**\n * The cookie `relyingParty` issues: `sealedCookie` prefixes every name with `__Host-`.\n *\n * Held by \"defaults to the cookie name relyingParty actually issues\" in `next-middleware.test.ts`.\n */\nconst SESSION_COOKIE = \"__Host-kanzo-session\";\n\nexport interface AuthMiddlewareConfig {\n /**\n * Path prefixes that need no session — a health probe, a marketing page, a legal notice.\n *\n * Matched on segment boundaries, so `/health` exempts `/health` and `/health/live` and does\n * **not** exempt `/healthcare`. A plain `startsWith` is one keystroke away from opening a route\n * nobody meant to open.\n */\n readonly public?: readonly string[];\n /** Where `authRoutes` is mounted. Always public — it is how a person signs in. Default `/api/auth`. */\n readonly basePath?: string;\n /** Default `__Host-kanzo-session`. Set it only if `relyingParty` was given a different cookie. */\n readonly cookieName?: string;\n}\n\n/** A path whose last segment carries a dot: a static file, not a page. */\nfunction isFile(pathname: string): boolean {\n return pathname.slice(pathname.lastIndexOf(\"/\") + 1).includes(\".\");\n}\n\nexport function authMiddleware(\n config: AuthMiddlewareConfig = {},\n): (request: NextRequest) => NextResponse {\n const base = (config.basePath ?? \"/api/auth\").replace(/\\/$/, \"\");\n const cookieName = config.cookieName ?? SESSION_COOKIE;\n const open = [base, ...(config.public ?? [])].map((prefix) => prefix.replace(/\\/$/, \"\"));\n\n return (request) => {\n const { pathname, search } = request.nextUrl;\n\n if (isFile(pathname)) return NextResponse.next();\n if (open.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) {\n return NextResponse.next();\n }\n if (request.cookies.has(cookieName)) return NextResponse.next();\n\n const away = new URL(`${base}/signin`, request.nextUrl);\n // Where they were going, so the callback can put them back. `authRoutes` confines it to this\n // origin before sealing it, which is the check this line is relying on rather than repeating.\n away.searchParams.set(\"returnTo\", `${pathname}${search}`);\n return NextResponse.redirect(away);\n };\n}\n"],"names":["SESSION_COOKIE","isFile","pathname","authMiddleware","config","base","cookieName","open","prefix","request","search","NextResponse","away"],"mappings":";AA+CA,MAAMA,IAAiB;AAkBvB,SAASC,EAAOC,GAA2B;AACzC,SAAOA,EAAS,MAAMA,EAAS,YAAY,GAAG,IAAI,CAAC,EAAE,SAAS,GAAG;AACnE;AAEO,SAASC,EACdC,IAA+B,IACS;AACxC,QAAMC,KAAQD,EAAO,YAAY,aAAa,QAAQ,OAAO,EAAE,GACzDE,IAAaF,EAAO,cAAcJ,GAClCO,IAAO,CAACF,GAAM,GAAID,EAAO,UAAU,CAAA,CAAG,EAAE,IAAI,CAACI,MAAWA,EAAO,QAAQ,OAAO,EAAE,CAAC;AAEvF,SAAO,CAACC,MAAY;AAClB,UAAM,EAAE,UAAAP,GAAU,QAAAQ,EAAA,IAAWD,EAAQ;AAErC,QAAIR,EAAOC,CAAQ,EAAG,QAAOS,EAAa,KAAA;AAC1C,QAAIJ,EAAK,KAAK,CAACC,MAAWN,MAAaM,KAAUN,EAAS,WAAW,GAAGM,CAAM,GAAG,CAAC;AAChF,aAAOG,EAAa,KAAA;AAEtB,QAAIF,EAAQ,QAAQ,IAAIH,CAAU,EAAG,QAAOK,EAAa,KAAA;AAEzD,UAAMC,IAAO,IAAI,IAAI,GAAGP,CAAI,WAAWI,EAAQ,OAAO;AAGtD,WAAAG,EAAK,aAAa,IAAI,YAAY,GAAGV,CAAQ,GAAGQ,CAAM,EAAE,GACjDC,EAAa,SAASC,CAAI;AAAA,EACnC;AACF;"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AuthSessionConfig } from './next-session';
|
|
2
|
+
export interface AuthRoutesConfig extends AuthSessionConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Override the callback URL. Absent, it is derived from the incoming request: the origin it
|
|
5
|
+
* arrived at, the path this route file sits on, and `/callback`.
|
|
6
|
+
*
|
|
7
|
+
* Deriving it trusts the `Host` header, which is chosen by whoever made the request. That is a
|
|
8
|
+
* bounded trust — a forged host produces a `redirect_uri` Keycloak has not registered, and
|
|
9
|
+
* Keycloak refuses it — but a deployment behind a proxy that rewrites the host should say the
|
|
10
|
+
* URL out loud here rather than discover this.
|
|
11
|
+
*/
|
|
12
|
+
readonly redirectUri?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface AuthRouteHandlers {
|
|
15
|
+
GET(request: Request): Promise<Response>;
|
|
16
|
+
POST(request: Request): Promise<Response>;
|
|
17
|
+
}
|
|
18
|
+
export declare function authRoutes(config: AuthRoutesConfig): AuthRouteHandlers;
|
|
19
|
+
//# sourceMappingURL=next-routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next-routes.d.ts","sourceRoot":"","sources":["../src/next-routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAkCxD,MAAM,WAAW,gBAAiB,SAAQ,iBAAiB;IACzD;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC3C;AAwDD,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,iBAAiB,CA+DtE"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { relyingParty as p } from "./server.js";
|
|
2
|
+
import { AuthError as w } from "./types.js";
|
|
3
|
+
const d = { "content-type": "application/json", "cache-control": "no-store" };
|
|
4
|
+
function m(e) {
|
|
5
|
+
const t = e.lastIndexOf("/");
|
|
6
|
+
return { action: e.slice(t + 1), base: t <= 0 ? "" : e.slice(0, t) };
|
|
7
|
+
}
|
|
8
|
+
function h(e, t) {
|
|
9
|
+
if (!(e === null || e.length === 0))
|
|
10
|
+
try {
|
|
11
|
+
const s = new URL(e, t);
|
|
12
|
+
return s.origin === t ? s.href : void 0;
|
|
13
|
+
} catch {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function u(e, t) {
|
|
18
|
+
const s = new Headers({ location: e, "cache-control": "no-store" });
|
|
19
|
+
for (const a of t) s.append("set-cookie", a);
|
|
20
|
+
return new Response(null, { status: 302, headers: s });
|
|
21
|
+
}
|
|
22
|
+
function k(e) {
|
|
23
|
+
if (!(e instanceof w)) throw e;
|
|
24
|
+
return new Response(JSON.stringify({ error: e.code, message: e.message }), {
|
|
25
|
+
status: e.code === "session.absent" ? 401 : 400,
|
|
26
|
+
headers: d
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function T(e) {
|
|
30
|
+
let t;
|
|
31
|
+
const s = (r) => ((t == null ? void 0 : t.redirectUri) !== r && (t = { redirectUri: r, auth: p({ ...e, redirectUri: r }) }), t.auth), a = async (r) => {
|
|
32
|
+
const o = new URL(r.url), { action: f, base: g } = m(o.pathname), i = r.headers.get("cookie"), c = s(e.redirectUri ?? `${o.origin}${g}/callback`);
|
|
33
|
+
switch (f) {
|
|
34
|
+
case "signin": {
|
|
35
|
+
const n = await c.begin({
|
|
36
|
+
returnTo: h(o.searchParams.get("returnTo"), o.origin) ?? "/",
|
|
37
|
+
organization: o.searchParams.get("organization") ?? void 0
|
|
38
|
+
});
|
|
39
|
+
return u(n.url, n.cookies);
|
|
40
|
+
}
|
|
41
|
+
case "callback":
|
|
42
|
+
try {
|
|
43
|
+
const n = await c.complete({ url: o, cookie: i });
|
|
44
|
+
return u(n.returnTo, n.cookies);
|
|
45
|
+
} catch (n) {
|
|
46
|
+
return k(n);
|
|
47
|
+
}
|
|
48
|
+
case "signout": {
|
|
49
|
+
const n = h(o.searchParams.get("returnTo"), o.origin), l = await c.end(i, { returnTo: n });
|
|
50
|
+
return u(l.url, l.cookies);
|
|
51
|
+
}
|
|
52
|
+
case "session": {
|
|
53
|
+
const n = await c.read(i);
|
|
54
|
+
return n === null ? new Response(null, { status: 401, headers: { "cache-control": "no-store" } }) : new Response(JSON.stringify(n), { status: 200, headers: d });
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
return new Response(null, { status: 404 });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return { GET: a, POST: a };
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
T as authRoutes
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=next-routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next-routes.js","sources":["../src/next-routes.ts"],"sourcesContent":["import type { AuthSessionConfig } from \"./next-session\";\nimport { relyingParty, type RelyingParty } from \"./server\";\nimport { AuthError } from \"./types\";\n\n/**\n * The four routes a Backend For Frontend needs, as one App Router catch-all.\n *\n * ```ts\n * // app/api/auth/[...auth]/route.ts\n * export const { GET, POST } = authRoutes({ issuer, clientId, clientSecret, secret });\n * ```\n *\n * `relyingParty` already does the whole flow in strings — a URL and a `Cookie` header in, a URL and\n * `Set-Cookie` values out — so the only thing written here is the translation into `Request` and\n * `Response`, plus the two decisions that translation forces: what a route answers when there is\n * no session, and where it is willing to send a browser afterwards.\n *\n * **Nothing in this module imports `next`.** An App Router route handler is handed a standard\n * `Request` and may answer with a standard `Response`, so the framework's own types would buy\n * nothing and would make this half untestable without it. The door earns its subpath through\n * `next-session.ts` and `next-middleware.ts`, which genuinely cannot be written without `next`.\n *\n * ## The other end of the contract\n *\n * `bffAuth` in the root barrel is the browser half, and it is specific: it `GET`s\n * `${basePath}/session` and reads **401 as \"nobody is signed in\"**, not as a failure; it navigates\n * to `${basePath}/signin?returnTo=…&organization=…` and `${basePath}/signout?returnTo=…`. Those\n * four paths and that status code are the contract, and `next-routes.test.ts` drives a real\n * `bffAuth` against these handlers rather than trusting the two descriptions to agree.\n */\n\n/** The session endpoint answers about a person; no cache may ever hold that answer. */\nconst PRIVATE = { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" } as const;\n\nexport interface AuthRoutesConfig extends AuthSessionConfig {\n /**\n * Override the callback URL. Absent, it is derived from the incoming request: the origin it\n * arrived at, the path this route file sits on, and `/callback`.\n *\n * Deriving it trusts the `Host` header, which is chosen by whoever made the request. That is a\n * bounded trust — a forged host produces a `redirect_uri` Keycloak has not registered, and\n * Keycloak refuses it — but a deployment behind a proxy that rewrites the host should say the\n * URL out loud here rather than discover this.\n */\n readonly redirectUri?: string;\n}\n\nexport interface AuthRouteHandlers {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n}\n\n/** The last path segment, and everything before it. `/api/auth/signin` → `signin`, `/api/auth`. */\nfunction split(pathname: string): { readonly action: string; readonly base: string } {\n const cut = pathname.lastIndexOf(\"/\");\n return { action: pathname.slice(cut + 1), base: cut <= 0 ? \"\" : pathname.slice(0, cut) };\n}\n\n/**\n * A `returnTo` confined to this origin, or `undefined`.\n *\n * Without this the sign-in route is an open redirect: `?returnTo=https://evil.test` is carried\n * through the whole flow and spent on a browser that has just proved who it is, which is the most\n * valuable moment to hijack. The check is here, where the string arrives from a query parameter,\n * and deliberately *not* repeated on the callback — what comes back out at the callback was\n * sealed into a cookie by us, so re-checking it would only make it unclear which check is the\n * real one.\n */\nfunction sameOrigin(candidate: string | null, origin: string): string | undefined {\n if (candidate === null || candidate.length === 0) return undefined;\n try {\n const target = new URL(candidate, origin);\n return target.origin === origin ? target.href : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * A redirect that can carry cookies.\n *\n * `Response.redirect()` cannot: its headers are immutable, and a callback that cannot set a cookie\n * is a sign-in that never completes. And `append`, never `set` — the callback answers with **two**\n * `Set-Cookie` values, the session it just issued and the transaction it just spent, and `set`\n * would silently keep only the last of them.\n */\nfunction redirect(url: string, cookies: readonly string[]): Response {\n const headers = new Headers({ location: url, \"cache-control\": \"no-store\" });\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(null, { status: 302, headers });\n}\n\n/**\n * An `AuthError` as a status and a code a product can route on.\n *\n * Anything else is rethrown: a failure this module has no reading of is Next's to report, and\n * flattening it into a 400 here would hide a misconfiguration behind a message about credentials.\n */\nfunction failure(error: unknown): Response {\n if (!(error instanceof AuthError)) throw error;\n return new Response(JSON.stringify({ error: error.code, message: error.message }), {\n status: error.code === \"session.absent\" ? 401 : 400,\n headers: PRIVATE,\n });\n}\n\nexport function authRoutes(config: AuthRoutesConfig): AuthRouteHandlers {\n // One instance, rebuilt only when the callback URL it was built for changes. A `Map` keyed by\n // origin would grow without bound on a stream of forged `Host` headers; a single slot cannot,\n // and it self-heals, because the next genuine request derives its own URL again. What it costs\n // in that case is a re-discovery, which is the right price for a request that lied.\n let current: { readonly redirectUri: string; readonly auth: RelyingParty } | undefined;\n const authFor = (redirectUri: string): RelyingParty => {\n if (current?.redirectUri !== redirectUri) {\n current = { redirectUri, auth: relyingParty({ ...config, redirectUri }) };\n }\n return current.auth;\n };\n\n const handle = async (request: Request): Promise<Response> => {\n const url = new URL(request.url);\n const { action, base } = split(url.pathname);\n const cookie = request.headers.get(\"cookie\");\n const auth = authFor(config.redirectUri ?? `${url.origin}${base}/callback`);\n\n switch (action) {\n case \"signin\": {\n const started = await auth.begin({\n returnTo: sameOrigin(url.searchParams.get(\"returnTo\"), url.origin) ?? \"/\",\n organization: url.searchParams.get(\"organization\") ?? undefined,\n });\n return redirect(started.url, started.cookies);\n }\n\n case \"callback\": {\n try {\n const done = await auth.complete({ url, cookie });\n return redirect(done.returnTo, done.cookies);\n } catch (error) {\n return failure(error);\n }\n }\n\n case \"signout\": {\n const returnTo = sameOrigin(url.searchParams.get(\"returnTo\"), url.origin);\n const ended = await auth.end(cookie, { returnTo });\n return redirect(ended.url, ended.cookies);\n }\n\n case \"session\": {\n const session = await auth.read(cookie);\n // 401 and no body at all. `bffAuth` reads this status as \"nobody is signed in\" and stops;\n // a body would be parsed by something eventually, and an error shape arriving where a\n // `Session` is expected is the failure `readSession` exists to refuse.\n if (session === null) {\n return new Response(null, { status: 401, headers: { \"cache-control\": \"no-store\" } });\n }\n return new Response(JSON.stringify(session), { status: 200, headers: PRIVATE });\n }\n\n default:\n return new Response(null, { status: 404 });\n }\n };\n\n // One handler behind both verbs. Every route here is reached by navigation or by `fetch`, and\n // which verb a product uses for sign-out — a link or a form — is its choice, not ours to\n // constrain with a second table that could drift from this one.\n return { GET: handle, POST: handle };\n}\n"],"names":["PRIVATE","split","pathname","cut","sameOrigin","candidate","origin","target","redirect","url","cookies","headers","cookie","failure","error","AuthError","authRoutes","config","current","authFor","redirectUri","relyingParty","handle","request","action","base","auth","started","done","returnTo","ended","session"],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { RelyingPartyConfig } from './server';
|
|
2
|
+
import { Session } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* The session a React Server Component can read, without a request object in hand.
|
|
5
|
+
*
|
|
6
|
+
* An RSC is handed no `Request`; `next/headers` is how it reaches the one it is rendering for, and
|
|
7
|
+
* that import is what makes this module — and only this module of the three — Node-only. It is
|
|
8
|
+
* also what earns the whole door its place on a subpath: *a part belongs on a subpath only if it
|
|
9
|
+
* imports that subpath's engine.*
|
|
10
|
+
*
|
|
11
|
+
* ## Why a factory, when the call site is `await getSession()`
|
|
12
|
+
*
|
|
13
|
+
* The call site is preserved exactly; what moved is where the binding is made. A
|
|
14
|
+
* zero-argument import would have to find its secret somewhere ambient — an environment variable
|
|
15
|
+
* this package would then be naming and documenting forever — and it could never be given a
|
|
16
|
+
* {@link SessionStore}, which is an object and not a string. So the consumer binds it once, in the
|
|
17
|
+
* same module that already holds the config it hands {@link authRoutes}:
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* // auth.ts
|
|
21
|
+
* export const getSession = authSession({ issuer, clientId, clientSecret, secret });
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* and every server component writes `await getSession()`. That also makes the three names of this
|
|
25
|
+
* door one shape — `authRoutes`, `authSession`, `authMiddleware` are all factories over a config —
|
|
26
|
+
* rather than two factories and an exception.
|
|
27
|
+
*
|
|
28
|
+
* ## `cache`, and what it is for
|
|
29
|
+
*
|
|
30
|
+
* React's `cache` scopes memoization to one request, so a page that asks in a layout, in a
|
|
31
|
+
* breadcrumb and in a menu unseals the cookie once. keasy's `web/src/lib/auth-check.ts` wraps its
|
|
32
|
+
* own reader for exactly this reason. **It is dormant outside a React request scope** — `cache`
|
|
33
|
+
* with no dispatcher simply calls through — which is why the test beside this file asserts the
|
|
34
|
+
* answers and not the number of reads.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* What reading a session needs, which is strictly less than signing one in.
|
|
38
|
+
*
|
|
39
|
+
* `redirectUri` is absent because no authorization request is built here: {@link RelyingParty.read}
|
|
40
|
+
* unseals a cookie and asks the store, and neither of those has a browser to send anywhere.
|
|
41
|
+
*/
|
|
42
|
+
export type AuthSessionConfig = Omit<RelyingPartyConfig, "redirectUri">;
|
|
43
|
+
export declare function authSession(config: AuthSessionConfig): () => Promise<Session | null>;
|
|
44
|
+
//# sourceMappingURL=next-session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next-session.d.ts","sourceRoot":"","sources":["../src/next-session.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,kBAAkB,EAAE,MAAM,UAAU,CAAC;AACjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG,IAAI,CAAC,kBAAkB,EAAE,aAAa,CAAC,CAAC;AAExE,wBAAgB,WAAW,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAOpF"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { cookies as o } from "next/headers";
|
|
2
|
+
import { cache as i } from "react";
|
|
3
|
+
import { relyingParty as e } from "./server.js";
|
|
4
|
+
function m(r) {
|
|
5
|
+
const t = e({ ...r, redirectUri: "" });
|
|
6
|
+
return i(async () => t.read((await o()).toString()));
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
m as authSession
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=next-session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next-session.js","sources":["../src/next-session.ts"],"sourcesContent":["import { cookies } from \"next/headers\";\nimport { cache } from \"react\";\nimport { relyingParty, type RelyingPartyConfig } from \"./server\";\nimport type { Session } from \"./types\";\n\n/**\n * The session a React Server Component can read, without a request object in hand.\n *\n * An RSC is handed no `Request`; `next/headers` is how it reaches the one it is rendering for, and\n * that import is what makes this module — and only this module of the three — Node-only. It is\n * also what earns the whole door its place on a subpath: *a part belongs on a subpath only if it\n * imports that subpath's engine.*\n *\n * ## Why a factory, when the call site is `await getSession()`\n *\n * The call site is preserved exactly; what moved is where the binding is made. A\n * zero-argument import would have to find its secret somewhere ambient — an environment variable\n * this package would then be naming and documenting forever — and it could never be given a\n * {@link SessionStore}, which is an object and not a string. So the consumer binds it once, in the\n * same module that already holds the config it hands {@link authRoutes}:\n *\n * ```ts\n * // auth.ts\n * export const getSession = authSession({ issuer, clientId, clientSecret, secret });\n * ```\n *\n * and every server component writes `await getSession()`. That also makes the three names of this\n * door one shape — `authRoutes`, `authSession`, `authMiddleware` are all factories over a config —\n * rather than two factories and an exception.\n *\n * ## `cache`, and what it is for\n *\n * React's `cache` scopes memoization to one request, so a page that asks in a layout, in a\n * breadcrumb and in a menu unseals the cookie once. keasy's `web/src/lib/auth-check.ts` wraps its\n * own reader for exactly this reason. **It is dormant outside a React request scope** — `cache`\n * with no dispatcher simply calls through — which is why the test beside this file asserts the\n * answers and not the number of reads.\n */\n\n/**\n * What reading a session needs, which is strictly less than signing one in.\n *\n * `redirectUri` is absent because no authorization request is built here: {@link RelyingParty.read}\n * unseals a cookie and asks the store, and neither of those has a browser to send anywhere.\n */\nexport type AuthSessionConfig = Omit<RelyingPartyConfig, \"redirectUri\">;\n\nexport function authSession(config: AuthSessionConfig): () => Promise<Session | null> {\n // The redirect URI is a required field of the confidential client and an unused one on this\n // path. Naming it here rather than making it optional on `RelyingPartyConfig` keeps the type that\n // signs people in honest: a sign-in without a redirect URI is a configuration error.\n const auth = relyingParty({ ...config, redirectUri: \"\" });\n\n return cache(async () => auth.read((await cookies()).toString()));\n}\n"],"names":["authSession","config","auth","relyingParty","cache","cookies"],"mappings":";;;AA+CO,SAASA,EAAYC,GAA0D;AAIpF,QAAMC,IAAOC,EAAa,EAAE,GAAGF,GAAQ,aAAa,IAAI;AAExD,SAAOG,EAAM,YAAYF,EAAK,MAAM,MAAMG,EAAA,GAAW,SAAA,CAAU,CAAC;AAClE;"}
|
package/dist/next.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@kanzo-tech/auth/next` — the Backend For Frontend, in the three files an App Router product
|
|
3
|
+
* already has.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* // app/api/auth/[...auth]/route.ts
|
|
7
|
+
* export const { GET, POST } = authRoutes({ issuer, clientId, clientSecret, secret });
|
|
8
|
+
*
|
|
9
|
+
* // auth.ts
|
|
10
|
+
* export const getSession = authSession({ issuer, clientId, clientSecret, secret });
|
|
11
|
+
*
|
|
12
|
+
* // middleware.ts
|
|
13
|
+
* export const middleware = authMiddleware({ public: ["/health"] });
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Three names and no fourth concept — but **not one config**: `authRoutes` and `authSession` share
|
|
17
|
+
* the relying party's, while `authMiddleware` takes neither a secret nor an issuer, because an edge
|
|
18
|
+
* middleware only asks whether the cookie is *there*. Decrypting it at the edge would be the wrong
|
|
19
|
+
* place for the work and a secret in the wrong runtime. `can` and `organizationOf` on the root
|
|
20
|
+
* barrel are how a server component asks about a role, exactly as `Gate` asks in the browser.
|
|
21
|
+
* There is no `requireRole` here: it would have added no new evaluation of a role, only a *throw*,
|
|
22
|
+
* and which throw — `notFound()`, a redirect, a rendered explanation — is a product's answer and
|
|
23
|
+
* not a library's. `next.test.ts` holds the absence.
|
|
24
|
+
*
|
|
25
|
+
* ## This door must never reach React's client half
|
|
26
|
+
*
|
|
27
|
+
* The modules behind it import `./server` and `./claims` directly and never `./index`, for the
|
|
28
|
+
* reason `server.ts`'s own header gives. `next.test.ts` walks the relative imports from here, and
|
|
29
|
+
* `scripts/smoke-install.mjs` reads the built bytes.
|
|
30
|
+
*
|
|
31
|
+
* ## Why one barrel does not put `openid-client` on the edge
|
|
32
|
+
*
|
|
33
|
+
* `authMiddleware` runs in the edge runtime and must arrive with nothing behind it. It does:
|
|
34
|
+
* `next-middleware.ts` imports only `next/server`, the package is `sideEffects: false`, and the
|
|
35
|
+
* build writes one file per module (`preserveModules`), so a `middleware.ts` importing this barrel
|
|
36
|
+
* is left holding that one module. The three are kept in three files for exactly this reason
|
|
37
|
+
* rather than tidied into one.
|
|
38
|
+
*/
|
|
39
|
+
export { authMiddleware, type AuthMiddlewareConfig } from './next-middleware';
|
|
40
|
+
export { authRoutes, type AuthRouteHandlers, type AuthRoutesConfig } from './next-routes';
|
|
41
|
+
export { authSession, type AuthSessionConfig } from './next-session';
|
|
42
|
+
//# sourceMappingURL=next.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next.d.ts","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAE,cAAc,EAAE,KAAK,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAC1F,OAAO,EAAE,WAAW,EAAE,KAAK,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/next.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { authMiddleware as r } from "./next-middleware.js";
|
|
2
|
+
import { authRoutes as a } from "./next-routes.js";
|
|
3
|
+
import { authSession as f } from "./next-session.js";
|
|
4
|
+
export {
|
|
5
|
+
r as authMiddleware,
|
|
6
|
+
a as authRoutes,
|
|
7
|
+
f as authSession
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=next.js.map
|
package/dist/next.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;"}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { IssuerConfig } from './issuer';
|
|
2
|
+
import { SessionStore } from './store';
|
|
3
|
+
import { Session, SignInOptions } from './types';
|
|
4
|
+
/** What `begin` and `end` answer: where to send the browser, and what to set on the way. */
|
|
5
|
+
export interface Redirect {
|
|
6
|
+
readonly url: string;
|
|
7
|
+
readonly cookies: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
/** What `refresh` answers. */
|
|
10
|
+
export interface Renewed {
|
|
11
|
+
readonly session: Session;
|
|
12
|
+
readonly cookies: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
/** What `complete` answers: a renewal, plus where the person was going before they were asked who they are. */
|
|
15
|
+
export interface SignedIn extends Renewed {
|
|
16
|
+
readonly returnTo: string;
|
|
17
|
+
}
|
|
18
|
+
export interface RelyingPartyConfig extends IssuerConfig {
|
|
19
|
+
/** Registered at Keycloak, and where `complete` expects to be called. */
|
|
20
|
+
readonly redirectUri: string;
|
|
21
|
+
/** Seals the cookies. Any length; generate it. See `sealedCookie`. */
|
|
22
|
+
readonly secret: string | Uint8Array;
|
|
23
|
+
/** Default `openid profile email`. A multi-tenant product adds `organization:*`. */
|
|
24
|
+
readonly scope?: string;
|
|
25
|
+
/** Default {@link statelessStore}. Supply one to invalidate a session before it expires. */
|
|
26
|
+
readonly store?: SessionStore;
|
|
27
|
+
/** Session cookie lifetime in seconds. Default eight hours. */
|
|
28
|
+
readonly maxAge?: number;
|
|
29
|
+
/** Where Keycloak sends the browser after sign-out. Must be registered as a post-logout URI. */
|
|
30
|
+
readonly postLogoutRedirectUri?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface RelyingParty {
|
|
33
|
+
/** Leg one: the authorization URL, and the cookie that remembers this attempt. */
|
|
34
|
+
begin(options?: SignInOptions): Promise<Redirect>;
|
|
35
|
+
/** Leg two: the callback URL Keycloak returned to, and the `Cookie` header it arrived with. */
|
|
36
|
+
complete(request: {
|
|
37
|
+
readonly url: string | URL;
|
|
38
|
+
readonly cookie: string | null;
|
|
39
|
+
}): Promise<SignedIn>;
|
|
40
|
+
/** The session a request carries, or `null`. The read a route handler does on every request. */
|
|
41
|
+
read(cookie: string | null | undefined): Promise<Session | null>;
|
|
42
|
+
/** Spend the refresh token, take the new one, and reissue the cookie. */
|
|
43
|
+
refresh(cookie: string | null | undefined): Promise<Renewed>;
|
|
44
|
+
/** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */
|
|
45
|
+
end(cookie: string | null | undefined, options?: {
|
|
46
|
+
readonly returnTo?: string;
|
|
47
|
+
}): Promise<Redirect>;
|
|
48
|
+
}
|
|
49
|
+
export declare function relyingParty(config: RelyingPartyConfig): RelyingParty;
|
|
50
|
+
export { issuer, rewriteOrigin, type Issuer, type IssuerConfig } from './issuer';
|
|
51
|
+
export { sealedCookie, cookieValue, type SealedCookie, type SealedCookieConfig } from './cookie-session';
|
|
52
|
+
export { statelessStore, type SessionRecord, type SessionStore } from './store';
|
|
53
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAaA,OAAO,EAAU,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AACrD,OAAO,EAAsC,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AAChF,OAAO,EAAiC,KAAK,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAiG1F,4FAA4F;AAC5F,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,8BAA8B;AAC9B,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,+GAA+G;AAC/G,MAAM,WAAW,QAAS,SAAQ,OAAO;IACvC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,kBAAmB,SAAQ,YAAY;IACtD,yEAAyE;IACzE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,sEAAsE;IACtE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IACrC,oFAAoF;IACpF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,4FAA4F;IAC5F,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;IAC9B,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,gGAAgG;IAChG,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;CACzC;AAED,MAAM,WAAW,YAAY;IAC3B,kFAAkF;IAClF,KAAK,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClD,+FAA+F;IAC/F,QAAQ,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACrG,gGAAgG;IAChG,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACjE,yEAAyE;IACzE,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7D,gGAAgG;IAChG,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACrG;AAkBD,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,YAAY,CAqMrE;AAED,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,KAAK,MAAM,EAAE,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AACjF,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACzG,OAAO,EAAE,cAAc,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC"}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { buildEndSessionUrl as A, refreshTokenGrant as E, randomPKCECodeVerifier as b, randomState as p, randomNonce as x, calculatePKCECodeChallenge as C, buildAuthorizationUrl as I, authorizationCodeGrant as S } from "openid-client";
|
|
2
|
+
import { claims as O } from "./claims.js";
|
|
3
|
+
import { sealedCookie as w } from "./cookie-session.js";
|
|
4
|
+
import { cookieValue as X } from "./cookie-session.js";
|
|
5
|
+
import { issuer as U } from "./issuer.js";
|
|
6
|
+
import { rewriteOrigin as J } from "./issuer.js";
|
|
7
|
+
import { statelessStore as D } from "./store.js";
|
|
8
|
+
import { AuthError as L } from "./types.js";
|
|
9
|
+
const g = "openid profile email", N = 480 * 60, z = 600;
|
|
10
|
+
function l(t, i, r) {
|
|
11
|
+
const c = new L(t, i);
|
|
12
|
+
throw r !== void 0 && (c.cause = r), c;
|
|
13
|
+
}
|
|
14
|
+
function T(t) {
|
|
15
|
+
if (typeof t != "object" || t === null || !("code" in t)) return;
|
|
16
|
+
const i = t.code;
|
|
17
|
+
return typeof i == "string" ? i : void 0;
|
|
18
|
+
}
|
|
19
|
+
function _(t) {
|
|
20
|
+
const i = T(t);
|
|
21
|
+
if (i === "OAUTH_JWT_CLAIM_COMPARISON_FAILED") {
|
|
22
|
+
let r = t;
|
|
23
|
+
for (let c = 0; c < 4 && typeof r == "object" && r !== null; c++) {
|
|
24
|
+
if (r.claim === "nonce") return !0;
|
|
25
|
+
r = r.cause;
|
|
26
|
+
}
|
|
27
|
+
return !1;
|
|
28
|
+
}
|
|
29
|
+
return i === "OAUTH_INVALID_RESPONSE" && t instanceof Error && t.cause instanceof Error && t.cause.message.includes('"nonce"');
|
|
30
|
+
}
|
|
31
|
+
function P(t) {
|
|
32
|
+
return T(t) === "OAUTH_KEY_SELECTION_FAILED";
|
|
33
|
+
}
|
|
34
|
+
function v(t) {
|
|
35
|
+
const i = U(t), r = t.store ?? D(), c = w({
|
|
36
|
+
name: "kanzo-session",
|
|
37
|
+
secret: t.secret,
|
|
38
|
+
maxAge: t.maxAge ?? N
|
|
39
|
+
}), f = w({
|
|
40
|
+
name: "kanzo-auth",
|
|
41
|
+
secret: t.secret,
|
|
42
|
+
maxAge: z
|
|
43
|
+
}), y = async (n) => {
|
|
44
|
+
const e = await c.read(n);
|
|
45
|
+
return e === null ? null : r.get(e.ticket);
|
|
46
|
+
}, k = async (n, e) => {
|
|
47
|
+
const o = n.claims(), a = o === void 0 ? e == null ? void 0 : e.session : O(o, t);
|
|
48
|
+
a === void 0 && l("token.exchange-failed", "the token response carried no ID token, so it names nobody");
|
|
49
|
+
const s = await r.put({
|
|
50
|
+
session: a,
|
|
51
|
+
// RFC 10017 requires rotation, so the newly issued token is the only one still valid. An
|
|
52
|
+
// authorization server that did not rotate returns none, and the one we hold stays good.
|
|
53
|
+
refreshToken: n.refresh_token ?? (e == null ? void 0 : e.refreshToken),
|
|
54
|
+
idToken: n.id_token ?? (e == null ? void 0 : e.idToken)
|
|
55
|
+
});
|
|
56
|
+
return { session: a, cookies: [await c.seal({ ticket: s })] };
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
async begin(n = {}) {
|
|
60
|
+
const e = await i.configuration(), o = b(), a = p(), s = x(), d = {
|
|
61
|
+
redirect_uri: t.redirectUri,
|
|
62
|
+
// `organization:<alias>` asks Keycloak for one; a product with many asks for
|
|
63
|
+
// `organization:*` through `scope`, because plain `organization` prompts for a choice.
|
|
64
|
+
scope: n.organization === void 0 ? t.scope ?? g : `${t.scope ?? g} organization:${n.organization}`,
|
|
65
|
+
code_challenge: await C(o),
|
|
66
|
+
code_challenge_method: "S256",
|
|
67
|
+
state: a,
|
|
68
|
+
nonce: s
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
url: I(e, d).href,
|
|
72
|
+
cookies: [
|
|
73
|
+
await f.seal({ state: a, nonce: s, verifier: o, returnTo: n.returnTo ?? "/" })
|
|
74
|
+
]
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
async complete(n) {
|
|
78
|
+
const e = await f.read(n.cookie);
|
|
79
|
+
e === null && l(
|
|
80
|
+
"callback.state-mismatch",
|
|
81
|
+
"the callback arrived with no transaction cookie, so there is nothing to match its `state` against"
|
|
82
|
+
);
|
|
83
|
+
const o = new URL(n.url);
|
|
84
|
+
o.searchParams.get("state") !== e.state && l(
|
|
85
|
+
"callback.state-mismatch",
|
|
86
|
+
"the callback's `state` is not the one this browser was sent with"
|
|
87
|
+
);
|
|
88
|
+
const a = {
|
|
89
|
+
pkceCodeVerifier: e.verifier,
|
|
90
|
+
expectedState: e.state,
|
|
91
|
+
expectedNonce: e.nonce
|
|
92
|
+
}, s = (h) => S(h, o, a);
|
|
93
|
+
let d;
|
|
94
|
+
try {
|
|
95
|
+
d = await s(await i.configuration());
|
|
96
|
+
} catch (h) {
|
|
97
|
+
_(h) && l(
|
|
98
|
+
"callback.nonce-mismatch",
|
|
99
|
+
"the ID token's `nonce` is not the one this transaction sent",
|
|
100
|
+
h
|
|
101
|
+
), P(h) || l("token.exchange-failed", "the authorization code could not be exchanged", h);
|
|
102
|
+
try {
|
|
103
|
+
d = await s(await i.rediscover());
|
|
104
|
+
} catch (u) {
|
|
105
|
+
_(u) && l(
|
|
106
|
+
"callback.nonce-mismatch",
|
|
107
|
+
"the ID token's `nonce` is not the one this transaction sent",
|
|
108
|
+
u
|
|
109
|
+
), l(
|
|
110
|
+
"token.exchange-failed",
|
|
111
|
+
"the ID token did not verify, and did not verify against freshly discovered keys either",
|
|
112
|
+
u
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const m = await k(d, null);
|
|
117
|
+
return {
|
|
118
|
+
...m,
|
|
119
|
+
cookies: [...m.cookies, f.clear()],
|
|
120
|
+
returnTo: e.returnTo
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
async read(n) {
|
|
124
|
+
var e;
|
|
125
|
+
return ((e = await y(n)) == null ? void 0 : e.session) ?? null;
|
|
126
|
+
},
|
|
127
|
+
async refresh(n) {
|
|
128
|
+
const e = await c.read(n), o = e === null ? null : await r.get(e.ticket);
|
|
129
|
+
(e === null || o === null) && l("session.absent", "there is no session cookie to refresh"), o.refreshToken === void 0 && l("session.absent", "the session holds no refresh token, so it cannot be renewed");
|
|
130
|
+
let a;
|
|
131
|
+
try {
|
|
132
|
+
a = await E(await i.configuration(), o.refreshToken);
|
|
133
|
+
} catch (s) {
|
|
134
|
+
l("token.exchange-failed", "the refresh token was refused", s);
|
|
135
|
+
}
|
|
136
|
+
return await r.drop(e.ticket), k(a, o);
|
|
137
|
+
},
|
|
138
|
+
async end(n, e = {}) {
|
|
139
|
+
const o = await c.read(n), a = o === null ? null : await r.get(o.ticket);
|
|
140
|
+
o !== null && await r.drop(o.ticket);
|
|
141
|
+
const s = {}, d = e.returnTo ?? t.postLogoutRedirectUri;
|
|
142
|
+
return d !== void 0 && (s.post_logout_redirect_uri = d), (a == null ? void 0 : a.idToken) !== void 0 && (s.id_token_hint = a.idToken), {
|
|
143
|
+
url: A(await i.configuration(), s).href,
|
|
144
|
+
cookies: [c.clear()]
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export {
|
|
150
|
+
X as cookieValue,
|
|
151
|
+
U as issuer,
|
|
152
|
+
v as relyingParty,
|
|
153
|
+
J as rewriteOrigin,
|
|
154
|
+
w as sealedCookie,
|
|
155
|
+
D as statelessStore
|
|
156
|
+
};
|
|
157
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sources":["../src/server.ts"],"sourcesContent":["import {\n buildAuthorizationUrl,\n buildEndSessionUrl,\n calculatePKCECodeChallenge,\n randomNonce,\n randomPKCECodeVerifier,\n randomState,\n refreshTokenGrant,\n authorizationCodeGrant,\n type Configuration,\n} from \"openid-client\";\nimport { claims } from \"./claims\";\nimport { sealedCookie, type SealedCookie } from \"./cookie-session\";\nimport { issuer, type IssuerConfig } from \"./issuer\";\nimport { statelessStore, type SessionRecord, type SessionStore } from \"./store\";\nimport { AuthError, type AuthErrorCode, type Session, type SignInOptions } from \"./types\";\n\n/**\n * `@kanzo-tech/auth/server` — the confidential OAuth client.\n *\n * This is the server half of the Backend For Frontend, which RFC 10017 calls *\"strongly\n * recommended for business applications, sensitive applications, and applications that handle\n * personal data\"*. The tokens live here and the browser gets a cookie it cannot read.\n *\n * **Nothing in this file implements OAuth.** `openid-client` does the flow, the ID token\n * verification and the end-session URL; `jose` does the sealing. What is written here is the\n * three-line sequence a route handler needs, the cookie discipline around it, and the reading of\n * Keycloak's claims into our one `Session` — which is the only part no library could have.\n *\n * ## The one thing that must never change\n *\n * **This module must not reach React.** It imports its siblings directly — `./claims`, never\n * `./index` — because importing the root barrel would drag React into a Node process. That is not\n * a hypothetical: it is the exact defect that forced `@kanzo-tech/mosaic` out of\n * `@kanzo-tech/ui`, and `scripts/smoke-install.mjs` asserts the built bytes for it.\n *\n * ## Framework-agnostic on purpose\n *\n * Strings in, strings out: a URL and a `Cookie` header go in, a URL and `Set-Cookie` values come\n * out. `./next` is a thin wrapper over this, and so is anything else — there is no `Request` in\n * the signatures because a `Request` would make Next's flavour of it the one that fits.\n */\n\nconst DEFAULT_SCOPE = \"openid profile email\";\n/** Eight hours: a working day, after which the refresh token is the thing keeping you signed in. */\nconst DEFAULT_MAX_AGE = 8 * 60 * 60;\n/** Ten minutes is long enough to type a password and short enough that an abandoned leg expires. */\nconst TRANSACTION_MAX_AGE = 10 * 60;\n\nfunction refuse(code: AuthErrorCode, message: string, cause?: unknown): never {\n const error = new AuthError(code, message);\n if (cause !== undefined) error.cause = cause;\n throw error;\n}\n\nfunction codeOf(error: unknown): string | undefined {\n if (typeof error !== \"object\" || error === null || !(\"code\" in error)) return undefined;\n const code = (error as { code: unknown }).code;\n return typeof code === \"string\" ? code : undefined;\n}\n\n/**\n * A nonce mismatch, told apart from every other reason a grant can fail.\n *\n * `oauth4webapi` reports every failed claim comparison under one code and names the offending\n * claim on a `cause`, and `openid-client` re-wraps that in a `ClientError` — so the claim's name\n * is two `cause` hops down. Reading it is the only way to answer \"which check failed\", which is\n * the whole point of having codes rather than a 401. The walk is bounded because a cause chain is\n * data from a library, not something to trust to terminate.\n */\nfunction isNonceMismatch(error: unknown): boolean {\n const code = codeOf(error);\n\n // A *wrong* nonce is a claim comparison, and the claim's name is carried structurally.\n if (code === \"OAUTH_JWT_CLAIM_COMPARISON_FAILED\") {\n let node: unknown = error;\n for (let depth = 0; depth < 4 && typeof node === \"object\" && node !== null; depth++) {\n if ((node as { claim?: unknown }).claim === \"nonce\") return true;\n node = (node as { cause?: unknown }).cause;\n }\n return false;\n }\n\n // A *missing* nonce is reported as a malformed response instead, and the claim's name appears\n // only in the message. Matching on a library's prose is brittle, and the answer to that is the\n // test that pins it rather than a quieter code: if `oauth4webapi` rewords this, a test fails\n // here instead of production silently reclassifying a replay as a transport problem.\n return (\n code === \"OAUTH_INVALID_RESPONSE\" &&\n error instanceof Error &&\n error.cause instanceof Error &&\n error.cause.message.includes('\"nonce\"')\n );\n}\n\n/**\n * A failure that looks like the signing keys we hold are no longer the ones Keycloak signs with.\n *\n * Keycloak rotates its realm keys, and a client holding a cached JWKS sees a key id it has never\n * heard of. keasy's Rust learned this and answers it the same way: re-fetch the metadata *once, on\n * a failure*, and retry. Refreshing on a timer instead would be a request every few minutes that\n * is wrong exactly when it matters.\n *\n * **This path is only reachable with `verifySignatures`.** Without it no key material is consulted\n * during a code grant at all — the channel vouches for the ID token — so there is nothing to go\n * stale. The Rust needed the retry unconditionally because `openidconnect` verifies the signature\n * either way; that is a difference between the two libraries, not between the two designs.\n */\nfunction isStaleKeyMaterial(error: unknown): boolean {\n return codeOf(error) === \"OAUTH_KEY_SELECTION_FAILED\";\n}\n\n/** What `begin` and `end` answer: where to send the browser, and what to set on the way. */\nexport interface Redirect {\n readonly url: string;\n readonly cookies: readonly string[];\n}\n\n/** What `refresh` answers. */\nexport interface Renewed {\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/** What `complete` answers: a renewal, plus where the person was going before they were asked who they are. */\nexport interface SignedIn extends Renewed {\n readonly returnTo: string;\n}\n\nexport interface RelyingPartyConfig extends IssuerConfig {\n /** Registered at Keycloak, and where `complete` expects to be called. */\n readonly redirectUri: string;\n /** Seals the cookies. Any length; generate it. See `sealedCookie`. */\n readonly secret: string | Uint8Array;\n /** Default `openid profile email`. A multi-tenant product adds `organization:*`. */\n readonly scope?: string;\n /** Default {@link statelessStore}. Supply one to invalidate a session before it expires. */\n readonly store?: SessionStore;\n /** Session cookie lifetime in seconds. Default eight hours. */\n readonly maxAge?: number;\n /** Where Keycloak sends the browser after sign-out. Must be registered as a post-logout URI. */\n readonly postLogoutRedirectUri?: string;\n}\n\nexport interface RelyingParty {\n /** Leg one: the authorization URL, and the cookie that remembers this attempt. */\n begin(options?: SignInOptions): Promise<Redirect>;\n /** Leg two: the callback URL Keycloak returned to, and the `Cookie` header it arrived with. */\n complete(request: { readonly url: string | URL; readonly cookie: string | null }): Promise<SignedIn>;\n /** The session a request carries, or `null`. The read a route handler does on every request. */\n read(cookie: string | null | undefined): Promise<Session | null>;\n /** Spend the refresh token, take the new one, and reissue the cookie. */\n refresh(cookie: string | null | undefined): Promise<Renewed>;\n /** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */\n end(cookie: string | null | undefined, options?: { readonly returnTo?: string }): Promise<Redirect>;\n}\n\n/** Everything either grant returns: one type, because both are answers from the token endpoint. */\ntype Tokens = Awaited<ReturnType<typeof refreshTokenGrant>>;\n\n/** What the session cookie carries: a ticket into the store, and nothing a browser could use. */\ninterface SessionTicket {\n readonly ticket: string;\n}\n\n/** What the transaction cookie carries between the two legs. */\ninterface Transaction {\n readonly state: string;\n readonly nonce: string;\n readonly verifier: string;\n readonly returnTo: string;\n}\n\nexport function relyingParty(config: RelyingPartyConfig): RelyingParty {\n const provider = issuer(config);\n const store = config.store ?? statelessStore();\n\n const session: SealedCookie<SessionTicket> = sealedCookie({\n name: \"kanzo-session\",\n secret: config.secret,\n maxAge: config.maxAge ?? DEFAULT_MAX_AGE,\n });\n\n // A second cookie rather than a field on the first, because its lifetime is different by two\n // orders of magnitude and it must be gone the moment the callback has used it.\n const transaction: SealedCookie<Transaction> = sealedCookie({\n name: \"kanzo-auth\",\n secret: config.secret,\n maxAge: TRANSACTION_MAX_AGE,\n });\n\n const recordFrom = async (cookie: string | null | undefined): Promise<SessionRecord | null> => {\n const sealed = await session.read(cookie);\n if (sealed === null) return null;\n return store.get(sealed.ticket);\n };\n\n /** Everything a successful grant produces, in the one place both grants can use it. */\n const adopt = async (tokens: Tokens, previous: SessionRecord | null): Promise<Renewed> => {\n // A refresh that returns no new ID token leaves the identity as it was; only the tokens moved.\n const idClaims = tokens.claims();\n const next = idClaims === undefined ? previous?.session : claims(idClaims, config);\n if (next === undefined) {\n refuse(\"token.exchange-failed\", \"the token response carried no ID token, so it names nobody\");\n }\n\n const ticket = await store.put({\n session: next,\n // RFC 10017 requires rotation, so the newly issued token is the only one still valid. An\n // authorization server that did not rotate returns none, and the one we hold stays good.\n refreshToken: tokens.refresh_token ?? previous?.refreshToken,\n idToken: tokens.id_token ?? previous?.idToken,\n });\n\n return { session: next, cookies: [await session.seal({ ticket })] };\n };\n\n return {\n async begin(options = {}) {\n const configuration = await provider.configuration();\n\n const verifier = randomPKCECodeVerifier();\n const state = randomState();\n const nonce = randomNonce();\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirectUri,\n // `organization:<alias>` asks Keycloak for one; a product with many asks for\n // `organization:*` through `scope`, because plain `organization` prompts for a choice.\n scope:\n options.organization === undefined\n ? (config.scope ?? DEFAULT_SCOPE)\n : `${config.scope ?? DEFAULT_SCOPE} organization:${options.organization}`,\n code_challenge: await calculatePKCECodeChallenge(verifier),\n code_challenge_method: \"S256\",\n state,\n nonce,\n };\n\n return {\n url: buildAuthorizationUrl(configuration, parameters).href,\n cookies: [\n await transaction.seal({ state, nonce, verifier, returnTo: options.returnTo ?? \"/\" }),\n ],\n };\n },\n\n async complete(request) {\n const pending = await transaction.read(request.cookie);\n if (pending === null) {\n refuse(\n \"callback.state-mismatch\",\n \"the callback arrived with no transaction cookie, so there is nothing to match its `state` against\",\n );\n }\n\n const current = new URL(request.url);\n if (current.searchParams.get(\"state\") !== pending.state) {\n refuse(\n \"callback.state-mismatch\",\n \"the callback's `state` is not the one this browser was sent with\",\n );\n }\n\n const checks = {\n pkceCodeVerifier: pending.verifier,\n expectedState: pending.state,\n expectedNonce: pending.nonce,\n };\n\n const grant = (configuration: Configuration) =>\n authorizationCodeGrant(configuration, current, checks);\n\n let tokens: Tokens;\n try {\n tokens = await grant(await provider.configuration());\n } catch (error) {\n if (isNonceMismatch(error)) {\n refuse(\n \"callback.nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n error,\n );\n }\n if (!isStaleKeyMaterial(error)) {\n refuse(\"token.exchange-failed\", \"the authorization code could not be exchanged\", error);\n }\n // Keycloak rotated its signing key. One re-discovery, one retry, then give up — a loop\n // here is a self-inflicted denial of service against the identity provider.\n try {\n tokens = await grant(await provider.rediscover());\n } catch (retried) {\n if (isNonceMismatch(retried)) {\n refuse(\n \"callback.nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n retried,\n );\n }\n refuse(\n \"token.exchange-failed\",\n \"the ID token did not verify, and did not verify against freshly discovered keys either\",\n retried,\n );\n }\n }\n\n // Session fixation: the record is new, the ticket is new and the cookie is new, and any\n // session cookie this callback happened to arrive with is not read. keasy's Rust calls\n // `cycle_id()` here for the same reason — an attacker who planted a session before sign-in\n // must not find themselves holding the one that sign-in produced.\n const renewed = await adopt(tokens, null);\n\n return {\n ...renewed,\n cookies: [...renewed.cookies, transaction.clear()],\n returnTo: pending.returnTo,\n };\n },\n\n async read(cookie) {\n return (await recordFrom(cookie))?.session ?? null;\n },\n\n async refresh(cookie) {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed === null || record === null) {\n refuse(\"session.absent\", \"there is no session cookie to refresh\");\n }\n if (record.refreshToken === undefined) {\n refuse(\"session.absent\", \"the session holds no refresh token, so it cannot be renewed\");\n }\n\n let tokens: Tokens;\n try {\n tokens = await refreshTokenGrant(await provider.configuration(), record.refreshToken);\n } catch (error) {\n // Under rotation a refused refresh is often a *replayed* token rather than an expired one,\n // and the authorization server may have revoked the whole chain. Either way the session is\n // over; `single-flight.ts` exists to keep us from causing it.\n refuse(\"token.exchange-failed\", \"the refresh token was refused\", error);\n }\n\n // The superseded ticket goes first: a store that enforces one live session per person must\n // not briefly hold two, and for the stateless default this is a no-op.\n await store.drop(sealed.ticket);\n return adopt(tokens, record);\n },\n\n async end(cookie, options = {}) {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed !== null) await store.drop(sealed.ticket);\n\n const parameters: Record<string, string> = {};\n const returnTo = options.returnTo ?? config.postLogoutRedirectUri;\n if (returnTo !== undefined) parameters[\"post_logout_redirect_uri\"] = returnTo;\n // Without the hint Keycloak cannot tell which session is ending and asks the person to\n // confirm — which reads as a bug to everyone who sees it.\n if (record?.idToken !== undefined) parameters[\"id_token_hint\"] = record.idToken;\n\n // `buildEndSessionUrl` rather than a hand-built URL: the endpoint comes from discovery, and\n // the parameter names are the specification's rather than ours to remember.\n return {\n url: buildEndSessionUrl(await provider.configuration(), parameters).href,\n cookies: [session.clear()],\n };\n },\n };\n}\n\nexport { issuer, rewriteOrigin, type Issuer, type IssuerConfig } from \"./issuer\";\nexport { sealedCookie, cookieValue, type SealedCookie, type SealedCookieConfig } from \"./cookie-session\";\nexport { statelessStore, type SessionRecord, type SessionStore } from \"./store\";\n"],"names":["DEFAULT_SCOPE","DEFAULT_MAX_AGE","TRANSACTION_MAX_AGE","refuse","code","message","cause","error","AuthError","codeOf","isNonceMismatch","node","depth","isStaleKeyMaterial","relyingParty","config","provider","issuer","store","statelessStore","session","sealedCookie","transaction","recordFrom","cookie","sealed","adopt","tokens","previous","idClaims","next","claims","ticket","options","configuration","verifier","randomPKCECodeVerifier","state","randomState","nonce","randomNonce","parameters","calculatePKCECodeChallenge","buildAuthorizationUrl","request","pending","current","checks","grant","authorizationCodeGrant","retried","renewed","_a","record","refreshTokenGrant","returnTo","buildEndSessionUrl"],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One in-flight call at a time, shared by every caller that asks while it runs.
|
|
3
|
+
*
|
|
4
|
+
* This exists for exactly one failure, and it is not a performance one. RFC 10017 requires refresh
|
|
5
|
+
* tokens for browser applications to **rotate on every use**, so a refresh both mints a new token
|
|
6
|
+
* and invalidates the one it was called with. Ten requests that notice an expiring token at the
|
|
7
|
+
* same moment therefore fire ten refreshes with the same token, and nine of them are replaying a
|
|
8
|
+
* token the first already spent — the authorization server is entitled to treat that as theft and
|
|
9
|
+
* revoke the whole chain. The session does not degrade; it dies, and it dies under load, which is
|
|
10
|
+
* the worst way to find out.
|
|
11
|
+
*
|
|
12
|
+
* So the rule is a rule rather than an optimisation: **the refresh path is single-flight.**
|
|
13
|
+
*/
|
|
14
|
+
export declare function singleFlight<T>(work: () => Promise<T>): () => Promise<T>;
|
|
15
|
+
//# sourceMappingURL=single-flight.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"single-flight.d.ts","sourceRoot":"","sources":["../src/single-flight.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,MAAM,OAAO,CAAC,CAAC,CAAC,CAiBxE"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"single-flight.js","sources":["../src/single-flight.ts"],"sourcesContent":["/**\n * One in-flight call at a time, shared by every caller that asks while it runs.\n *\n * This exists for exactly one failure, and it is not a performance one. RFC 10017 requires refresh\n * tokens for browser applications to **rotate on every use**, so a refresh both mints a new token\n * and invalidates the one it was called with. Ten requests that notice an expiring token at the\n * same moment therefore fire ten refreshes with the same token, and nine of them are replaying a\n * token the first already spent — the authorization server is entitled to treat that as theft and\n * revoke the whole chain. The session does not degrade; it dies, and it dies under load, which is\n * the worst way to find out.\n *\n * So the rule is a rule rather than an optimisation: **the refresh path is single-flight.**\n */\nexport function singleFlight<T>(work: () => Promise<T>): () => Promise<T> {\n let inFlight: Promise<T> | undefined;\n\n return () => {\n // A call that arrives while one is running joins it instead of starting a second.\n if (inFlight !== undefined) return inFlight;\n\n // The slot is cleared in a `finally` so a rejection does not wedge every later call onto a\n // failure that has already been reported. The next caller gets a fresh attempt — which is what\n // you want when the failure was a dropped connection, and harmless when it was not, because\n // the caller above is the one deciding whether to retry.\n inFlight = work().finally(() => {\n inFlight = undefined;\n });\n\n return inFlight;\n };\n}\n"],"names":["singleFlight","work","inFlight"],"mappings":"AAaO,SAASA,EAAgBC,GAA0C;AACxE,MAAIC;AAEJ,SAAO,OAEDA,MAAa,WAMjBA,IAAWD,IAAO,QAAQ,MAAM;AAC9B,IAAAC,IAAW;AAAA,EACb,CAAC,IAEMA;AAEX;"}
|