@kanzo-tech/auth 0.4.0 → 0.6.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/dist/auth-fetch.d.ts +12 -0
- package/dist/auth-fetch.d.ts.map +1 -1
- package/dist/auth-fetch.js +2 -1
- package/dist/auth-fetch.js.map +1 -1
- package/dist/bff-auth.d.ts.map +1 -1
- package/dist/bff-auth.js +63 -48
- package/dist/bff-auth.js.map +1 -1
- package/dist/cookie-session.d.ts.map +1 -1
- package/dist/cookie-session.js +8 -8
- package/dist/cookie-session.js.map +1 -1
- package/dist/next-proxy.d.ts +65 -0
- package/dist/next-proxy.d.ts.map +1 -0
- package/dist/next-proxy.js +64 -0
- package/dist/next-proxy.js.map +1 -0
- package/dist/next-routes.d.ts.map +1 -1
- package/dist/next-routes.js +56 -39
- package/dist/next-routes.js.map +1 -1
- package/dist/next-token.d.ts +56 -0
- package/dist/next-token.d.ts.map +1 -0
- package/dist/next-token.js +9 -0
- package/dist/next-token.js.map +1 -0
- package/dist/next.d.ts +26 -12
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +10 -6
- package/dist/next.js.map +1 -1
- package/dist/same-site.d.ts +29 -0
- package/dist/same-site.d.ts.map +1 -0
- package/dist/same-site.js +11 -0
- package/dist/same-site.js.map +1 -0
- package/dist/server.d.ts +36 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +109 -84
- package/dist/server.js.map +1 -1
- package/dist/single-flight.d.ts +18 -0
- package/dist/single-flight.d.ts.map +1 -1
- package/dist/single-flight.js +18 -6
- package/dist/single-flight.js.map +1 -1
- package/dist/store.d.ts +102 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +33 -6
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +12 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
package/dist/next-routes.js
CHANGED
|
@@ -1,65 +1,82 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { isSameSite as m } from "./same-site.js";
|
|
2
|
+
import { relyingParty as k } from "./server.js";
|
|
3
|
+
import { AuthError as R } from "./types.js";
|
|
3
4
|
const d = { "content-type": "application/json", "cache-control": "no-store" };
|
|
4
|
-
function
|
|
5
|
-
const
|
|
6
|
-
return { action:
|
|
5
|
+
function y(t) {
|
|
6
|
+
const n = t.lastIndexOf("/");
|
|
7
|
+
return { action: t.slice(n + 1), base: n <= 0 ? "" : t.slice(0, n) };
|
|
7
8
|
}
|
|
8
|
-
function
|
|
9
|
-
if (!(
|
|
9
|
+
function g(t, n) {
|
|
10
|
+
if (!(t === null || t.length === 0))
|
|
10
11
|
try {
|
|
11
|
-
const
|
|
12
|
-
return
|
|
12
|
+
const r = new URL(t, n);
|
|
13
|
+
return r.origin === n ? r.href : void 0;
|
|
13
14
|
} catch {
|
|
14
15
|
return;
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
|
-
function
|
|
18
|
-
const
|
|
19
|
-
for (const
|
|
20
|
-
return new Response(null, { status: 302, headers:
|
|
18
|
+
function h(t, n) {
|
|
19
|
+
const r = new Headers({ location: t, "cache-control": "no-store" });
|
|
20
|
+
for (const i of n) r.append("set-cookie", i);
|
|
21
|
+
return new Response(null, { status: 302, headers: r });
|
|
21
22
|
}
|
|
22
|
-
function
|
|
23
|
-
if (!(
|
|
24
|
-
return new Response(JSON.stringify({ error:
|
|
25
|
-
status:
|
|
23
|
+
function f(t) {
|
|
24
|
+
if (!(t instanceof R)) throw t;
|
|
25
|
+
return new Response(JSON.stringify({ error: t.code, message: t.message }), {
|
|
26
|
+
status: t.code === "session.absent" ? 401 : 400,
|
|
26
27
|
headers: d
|
|
27
28
|
});
|
|
28
29
|
}
|
|
29
|
-
function
|
|
30
|
-
let
|
|
31
|
-
const
|
|
32
|
-
const o = new URL(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
function S(t) {
|
|
31
|
+
let n;
|
|
32
|
+
const r = (s) => ((n == null ? void 0 : n.redirectUri) !== s && (n = { redirectUri: s, auth: k({ ...t, redirectUri: s }) }), n.auth), i = async (s) => {
|
|
33
|
+
const o = new URL(s.url), { action: l, base: w } = y(o.pathname), u = s.headers.get("cookie"), a = r(t.redirectUri ?? `${o.origin}${w}/callback`);
|
|
34
|
+
if (l !== "callback" && l !== "signin" && !m(s))
|
|
35
|
+
return new Response(null, { status: 403, headers: { "cache-control": "no-store" } });
|
|
36
|
+
switch (l) {
|
|
37
|
+
case "signin":
|
|
38
|
+
try {
|
|
39
|
+
const e = await a.begin({
|
|
40
|
+
returnTo: g(o.searchParams.get("returnTo"), o.origin) ?? "/",
|
|
41
|
+
organization: o.searchParams.get("organization") ?? void 0
|
|
42
|
+
});
|
|
43
|
+
return h(e.url, e.cookies);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return f(e);
|
|
46
|
+
}
|
|
41
47
|
case "callback":
|
|
42
48
|
try {
|
|
43
|
-
const
|
|
44
|
-
return
|
|
45
|
-
} catch (
|
|
46
|
-
return
|
|
49
|
+
const e = await a.complete({ url: o, cookie: u });
|
|
50
|
+
return h(e.returnTo, e.cookies);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return f(e);
|
|
47
53
|
}
|
|
48
54
|
case "signout": {
|
|
49
|
-
const
|
|
50
|
-
return
|
|
55
|
+
const e = g(o.searchParams.get("returnTo"), o.origin), c = await a.end(u, { returnTo: e });
|
|
56
|
+
return h(c.url, c.cookies);
|
|
51
57
|
}
|
|
52
58
|
case "session": {
|
|
53
|
-
const
|
|
54
|
-
return
|
|
59
|
+
const e = await a.read(u);
|
|
60
|
+
return e === null ? new Response(null, { status: 401, headers: { "cache-control": "no-store" } }) : new Response(JSON.stringify(e), { status: 200, headers: d });
|
|
61
|
+
}
|
|
62
|
+
case "refresh": {
|
|
63
|
+
if (s.method !== "POST")
|
|
64
|
+
return new Response(null, { status: 405, headers: { allow: "POST" } });
|
|
65
|
+
try {
|
|
66
|
+
const e = await a.refresh(u), c = new Headers(d);
|
|
67
|
+
for (const p of e.cookies) c.append("set-cookie", p);
|
|
68
|
+
return new Response(JSON.stringify(e.session), { status: 200, headers: c });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return f(e);
|
|
71
|
+
}
|
|
55
72
|
}
|
|
56
73
|
default:
|
|
57
74
|
return new Response(null, { status: 404 });
|
|
58
75
|
}
|
|
59
76
|
};
|
|
60
|
-
return { GET:
|
|
77
|
+
return { GET: i, POST: i };
|
|
61
78
|
}
|
|
62
79
|
export {
|
|
63
|
-
|
|
80
|
+
S as authRoutes
|
|
64
81
|
};
|
|
65
82
|
//# sourceMappingURL=next-routes.js.map
|
package/dist/next-routes.js.map
CHANGED
|
@@ -1 +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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"}
|
|
1
|
+
{"version":3,"file":"next-routes.js","sources":["../src/next-routes.ts"],"sourcesContent":["import { isSameSite } from \"./same-site\";\nimport type { AuthSessionConfig } from \"./next-session\";\nimport { relyingParty, type RelyingParty } from \"./server\";\nimport { AuthError } from \"./types\";\n\n/**\n * The five 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 `POST`s\n * `${basePath}/refresh` when a request of its own comes back 401, and retries that request once if\n * the renewal worked; it navigates to `${basePath}/signin?returnTo=…&organization=…` and\n * `${basePath}/signout?returnTo=…`. Those five paths and those status codes are the contract, and\n * `next-routes.test.ts` drives a real `bffAuth` against these handlers rather than trusting the\n * two descriptions to agree.\n *\n * ## Which routes a cross-site request may reach\n *\n * `callback` and `signin` must be reachable from anywhere — one *is* a navigation from the\n * identity provider, and the other is a link somebody is allowed to put on another page. The other\n * three are not: `signout` reached cross-site is a logout anyone can cause, `refresh` is a\n * rotation anyone can cause, and `session` is a person's identity read from a page that is not\n * ours. `same-site.ts` carries the check and the reason the package rather than the product owes\n * it.\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 if (action !== \"callback\" && action !== \"signin\" && !isSameSite(request)) {\n return new Response(null, { status: 403, headers: { \"cache-control\": \"no-store\" } });\n }\n\n switch (action) {\n case \"signin\": {\n try {\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 } catch (error) {\n // `organization` arrives from a query parameter and `begin` refuses one that is not an\n // alias, because a space in it injects scopes. Without this `catch` that refusal is a\n // 500 on a link somebody typed wrong.\n return failure(error);\n }\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 case \"refresh\": {\n // `POST` only, and it is the one route here that constrains its verb. The others are\n // reached by navigation, where the verb is the browser's to choose; this one spends a\n // refresh token, and a `GET` that spends something is a link, a prefetch and a preview\n // pane away from spending it. The 405 says so rather than answering 404 for a route that\n // is plainly there.\n if (request.method !== \"POST\") {\n return new Response(null, { status: 405, headers: { allow: \"POST\" } });\n }\n try {\n const renewed = await auth.refresh(cookie);\n const headers = new Headers(PRIVATE);\n for (const value of renewed.cookies) headers.append(\"set-cookie\", value);\n return new Response(JSON.stringify(renewed.session), { status: 200, headers });\n } catch (error) {\n // A refused refresh is the end of the session, and `failure` already answers 401 for\n // `session.absent`. `token.exchange-failed` is a 400 and means the same thing to the\n // browser: there is nothing left to renew, go and sign in.\n return failure(error);\n }\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. `refresh` is the exception and\n // checks its own method, because it is the only one that spends something.\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","isSameSite","started","done","returnTo","ended","session","renewed","value"],"mappings":"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"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AuthSessionConfig } from './next-session';
|
|
2
|
+
import { Token } from './server';
|
|
3
|
+
/**
|
|
4
|
+
* The access token a request carries, renewed when it is about to expire.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // auth.ts
|
|
8
|
+
* export const getToken = authToken({ issuer, clientId, clientSecret, secret });
|
|
9
|
+
*
|
|
10
|
+
* // app/api/reports/route.ts
|
|
11
|
+
* export async function GET(request: Request) {
|
|
12
|
+
* const held = await getToken(request.headers.get("cookie"));
|
|
13
|
+
* if (held === null) return new Response(null, { status: 401 });
|
|
14
|
+
*
|
|
15
|
+
* const upstream = await fetch(REPORTS, {
|
|
16
|
+
* headers: { authorization: `Bearer ${held.accessToken}` },
|
|
17
|
+
* });
|
|
18
|
+
* const answer = new Response(upstream.body, upstream);
|
|
19
|
+
* for (const cookie of held.cookies) answer.headers.append("set-cookie", cookie);
|
|
20
|
+
* return answer;
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* This is the complement of {@link authSession}: that one answers *who*, this one answers *with
|
|
25
|
+
* what*. Both are factories over the same config, for the reason `next-session.ts` gives.
|
|
26
|
+
*
|
|
27
|
+
* ## Why this exists at all, rather than a product reaching into the cookie
|
|
28
|
+
*
|
|
29
|
+
* Without it the only way to a bearer token is to rebuild the sealed cookie by hand — the same
|
|
30
|
+
* name, the same `maxAge`, the same secret — and open it. That is this package's protocol,
|
|
31
|
+
* reimplemented in a product, held together by nothing but two constants agreeing: change the
|
|
32
|
+
* default cookie name or the default lifetime here and that product breaks in silence. A protocol
|
|
33
|
+
* a consumer must re-derive is a protocol the package failed to expose.
|
|
34
|
+
*
|
|
35
|
+
* ## The cookie is an argument, and that is the API design
|
|
36
|
+
*
|
|
37
|
+
* There is no zero-argument form, and the absence is the point. **A React Server Component cannot
|
|
38
|
+
* write cookies in Next**, and a renewal it dropped would not be a stale token — under the
|
|
39
|
+
* rotation RFC 10017 requires, the refresh token this call spent is gone and the one it minted was
|
|
40
|
+
* never persisted, so the session is over. A zero-argument form reading `next/headers` would work
|
|
41
|
+
* in an RSC exactly well enough to destroy the session on the first renewal.
|
|
42
|
+
*
|
|
43
|
+
* So the signature asks for the `Cookie` header, which a **route handler**, a **middleware** and a
|
|
44
|
+
* **server action** have and an RSC does not — and each of those can send the `Set-Cookie` that
|
|
45
|
+
* comes back. An RSC that wants to know who is signed in calls {@link authSession}, which only
|
|
46
|
+
* ever reads.
|
|
47
|
+
*
|
|
48
|
+
* ## Attaching `cookies` is not optional
|
|
49
|
+
*
|
|
50
|
+
* It is empty when nothing was renewed. When it is not empty it carries the only refresh token
|
|
51
|
+
* still valid, and dropping it ends the session at the next renewal.
|
|
52
|
+
*/
|
|
53
|
+
export declare function authToken(config: AuthSessionConfig): (cookie: string | null | undefined, options?: {
|
|
54
|
+
readonly renewWithin?: number;
|
|
55
|
+
}) => Promise<Token | null>;
|
|
56
|
+
//# sourceMappingURL=next-token.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next-token.d.ts","sourceRoot":"","sources":["../src/next-token.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAgB,KAAK,KAAK,EAAE,MAAM,UAAU,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,iBAAiB,GACxB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,KAAK,OAAO,CAAC,KAAK,GAAG,IAAI,CAAC,CAM3G"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next-token.js","sources":["../src/next-token.ts"],"sourcesContent":["import type { AuthSessionConfig } from \"./next-session\";\nimport { relyingParty, type Token } from \"./server\";\n\n/**\n * The access token a request carries, renewed when it is about to expire.\n *\n * ```ts\n * // auth.ts\n * export const getToken = authToken({ issuer, clientId, clientSecret, secret });\n *\n * // app/api/reports/route.ts\n * export async function GET(request: Request) {\n * const held = await getToken(request.headers.get(\"cookie\"));\n * if (held === null) return new Response(null, { status: 401 });\n *\n * const upstream = await fetch(REPORTS, {\n * headers: { authorization: `Bearer ${held.accessToken}` },\n * });\n * const answer = new Response(upstream.body, upstream);\n * for (const cookie of held.cookies) answer.headers.append(\"set-cookie\", cookie);\n * return answer;\n * }\n * ```\n *\n * This is the complement of {@link authSession}: that one answers *who*, this one answers *with\n * what*. Both are factories over the same config, for the reason `next-session.ts` gives.\n *\n * ## Why this exists at all, rather than a product reaching into the cookie\n *\n * Without it the only way to a bearer token is to rebuild the sealed cookie by hand — the same\n * name, the same `maxAge`, the same secret — and open it. That is this package's protocol,\n * reimplemented in a product, held together by nothing but two constants agreeing: change the\n * default cookie name or the default lifetime here and that product breaks in silence. A protocol\n * a consumer must re-derive is a protocol the package failed to expose.\n *\n * ## The cookie is an argument, and that is the API design\n *\n * There is no zero-argument form, and the absence is the point. **A React Server Component cannot\n * write cookies in Next**, and a renewal it dropped would not be a stale token — under the\n * rotation RFC 10017 requires, the refresh token this call spent is gone and the one it minted was\n * never persisted, so the session is over. A zero-argument form reading `next/headers` would work\n * in an RSC exactly well enough to destroy the session on the first renewal.\n *\n * So the signature asks for the `Cookie` header, which a **route handler**, a **middleware** and a\n * **server action** have and an RSC does not — and each of those can send the `Set-Cookie` that\n * comes back. An RSC that wants to know who is signed in calls {@link authSession}, which only\n * ever reads.\n *\n * ## Attaching `cookies` is not optional\n *\n * It is empty when nothing was renewed. When it is not empty it carries the only refresh token\n * still valid, and dropping it ends the session at the next renewal.\n */\nexport function authToken(\n config: AuthSessionConfig,\n): (cookie: string | null | undefined, options?: { readonly renewWithin?: number }) => Promise<Token | null> {\n // The redirect URI is a required field of the confidential client and an unused one on this\n // path — no authorization request is built here — for the reason `next-session.ts` spells out.\n const auth = relyingParty({ ...config, redirectUri: \"\" });\n\n return (cookie, options) => auth.token(cookie, options);\n}\n"],"names":["authToken","config","auth","relyingParty","cookie","options"],"mappings":";AAqDO,SAASA,EACdC,GAC2G;AAG3G,QAAMC,IAAOC,EAAa,EAAE,GAAGF,GAAQ,aAAa,IAAI;AAExD,SAAO,CAACG,GAAQC,MAAYH,EAAK,MAAME,GAAQC,CAAO;AACxD;"}
|
package/dist/next.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@kanzo-tech/auth/next` — the Backend For Frontend, in the
|
|
3
|
-
*
|
|
2
|
+
* `@kanzo-tech/auth/next` — the Backend For Frontend, in the files an App Router product already
|
|
3
|
+
* has.
|
|
4
4
|
*
|
|
5
5
|
* ```ts
|
|
6
6
|
* // app/api/auth/[...auth]/route.ts
|
|
@@ -8,19 +8,30 @@
|
|
|
8
8
|
*
|
|
9
9
|
* // auth.ts
|
|
10
10
|
* export const getSession = authSession({ issuer, clientId, clientSecret, secret });
|
|
11
|
+
* export const getToken = authToken({ issuer, clientId, clientSecret, secret });
|
|
11
12
|
*
|
|
12
13
|
* // middleware.ts
|
|
13
14
|
* export const middleware = authMiddleware({ public: ["/health"] });
|
|
14
15
|
* ```
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
17
|
+
* **Not one config**: `authRoutes`, `authSession`, `authToken` and `authProxy` share the relying
|
|
18
|
+
* party's, while `authMiddleware` takes neither a secret nor an issuer, because an edge middleware
|
|
19
|
+
* only asks whether the cookie is *there*. Decrypting it at the edge would be the wrong place for
|
|
20
|
+
* the work and a secret in the wrong runtime. `can` and `organizationOf` on the root barrel are
|
|
21
|
+
* how a server component asks about a role, exactly as `Gate` asks in the browser. There is no
|
|
22
|
+
* `requireRole` here: it would have added no new evaluation of a role, only a *throw*, and which
|
|
23
|
+
* throw — `notFound()`, a redirect, a rendered explanation — is a product's answer and not a
|
|
24
|
+
* library's. `next.test.ts` holds the absence.
|
|
25
|
+
*
|
|
26
|
+
* ## Who may be asked what, and why the signatures differ
|
|
27
|
+
*
|
|
28
|
+
* `authSession` reads `next/headers` and answers an identity; `authToken` is handed a `Cookie`
|
|
29
|
+
* header and answers a credential *plus the cookies to set*. The asymmetry is not an oversight —
|
|
30
|
+
* **a React Server Component cannot write cookies in Next**, and a renewal whose `Set-Cookie` is
|
|
31
|
+
* dropped ends the session rather than merely failing, because the refresh token it spent is gone.
|
|
32
|
+
* So the read-only one is the one an RSC can reach for, and the renewing one asks for something
|
|
33
|
+
* only a route handler, a middleware or a server action has. `authProxy` is that pairing already
|
|
34
|
+
* assembled: a route file that forwards to a resource server with the bearer token attached.
|
|
24
35
|
*
|
|
25
36
|
* ## This door must never reach React's client half
|
|
26
37
|
*
|
|
@@ -33,10 +44,13 @@
|
|
|
33
44
|
* `authMiddleware` runs in the edge runtime and must arrive with nothing behind it. It does:
|
|
34
45
|
* `next-middleware.ts` imports only `next/server`, the package is `sideEffects: false`, and the
|
|
35
46
|
* build writes one file per module (`preserveModules`), so a `middleware.ts` importing this barrel
|
|
36
|
-
* is left holding that one module.
|
|
37
|
-
*
|
|
47
|
+
* is left holding that one module. They are kept in one file each for exactly this reason rather
|
|
48
|
+
* than tidied together.
|
|
38
49
|
*/
|
|
39
50
|
export { authMiddleware, type AuthMiddlewareConfig } from './next-middleware';
|
|
51
|
+
export { authProxy, type AuthProxyConfig, type AuthProxyHandlers } from './next-proxy';
|
|
40
52
|
export { authRoutes, type AuthRouteHandlers, type AuthRoutesConfig } from './next-routes';
|
|
41
53
|
export { authSession, type AuthSessionConfig } from './next-session';
|
|
54
|
+
export { authToken } from './next-token';
|
|
55
|
+
export type { Token } from './server';
|
|
42
56
|
//# sourceMappingURL=next.d.ts.map
|
package/dist/next.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next.d.ts","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"next.d.ts","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,OAAO,EAAE,cAAc,EAAE,KAAK,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAAE,SAAS,EAAE,KAAK,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACvF,OAAO,EAAE,UAAU,EAAE,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAC1F,OAAO,EAAE,WAAW,EAAE,KAAK,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACrE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,YAAY,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/next.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { authMiddleware as
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { authMiddleware as t } from "./next-middleware.js";
|
|
2
|
+
import { authProxy as a } from "./next-proxy.js";
|
|
3
|
+
import { authRoutes as x } from "./next-routes.js";
|
|
4
|
+
import { authSession as h } from "./next-session.js";
|
|
5
|
+
import { authToken as p } from "./next-token.js";
|
|
4
6
|
export {
|
|
5
|
-
|
|
6
|
-
a as
|
|
7
|
-
|
|
7
|
+
t as authMiddleware,
|
|
8
|
+
a as authProxy,
|
|
9
|
+
x as authRoutes,
|
|
10
|
+
h as authSession,
|
|
11
|
+
p as authToken
|
|
8
12
|
};
|
|
9
13
|
//# sourceMappingURL=next.js.map
|
package/dist/next.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next.js","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"next.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Did this request come from us?
|
|
3
|
+
*
|
|
4
|
+
* The session cookie is `SameSite=Lax`, and that is not a preference: `Strict` withholds the
|
|
5
|
+
* cookie on the top-level navigation *back* from the identity provider, so the callback arrives
|
|
6
|
+
* without the transaction it needs and every sign-in fails. `cookie-session.ts` carries that
|
|
7
|
+
* argument in full. **`Lax` is therefore a decision this package made on a consumer's behalf, and
|
|
8
|
+
* what it costs is exactly one thing: a cross-site top-level navigation still sends the cookie.**
|
|
9
|
+
* `<img src="https://app.example.test/api/auth/signout">` on any page anywhere signs the person
|
|
10
|
+
* out; `…/refresh` fired the same way spends a refresh token on somebody else's schedule.
|
|
11
|
+
*
|
|
12
|
+
* So the compensation belongs here rather than in every product: the package that chose `Lax` is
|
|
13
|
+
* the one that owes the check for what `Lax` lets through.
|
|
14
|
+
*
|
|
15
|
+
* ## Fetch Metadata first, `Origin` second, and then allow
|
|
16
|
+
*
|
|
17
|
+
* `Sec-Fetch-Site` is set by the browser and cannot be set by script, which makes it the one
|
|
18
|
+
* header here that is worth trusting. Every browser that has shipped since 2023 sends it. When it
|
|
19
|
+
* is absent the request came from something that is not a browser — curl, a server, a crawler — or
|
|
20
|
+
* from a browser old enough not to have it, and the fallback is `Origin`, which browsers attach to
|
|
21
|
+
* every unsafe request and which the same script cannot forge either.
|
|
22
|
+
*
|
|
23
|
+
* With neither, this answers `true`. That is the honest end of the ladder rather than a hole: a
|
|
24
|
+
* request carrying no evidence of where it came from also carries no evidence that it came from a
|
|
25
|
+
* *page*, and the attack being stopped is a page. Refusing there would refuse every server-to-server
|
|
26
|
+
* call and every health probe, which is a real outage traded for no real attacker.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isSameSite(request: Request): boolean;
|
|
29
|
+
//# sourceMappingURL=same-site.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"same-site.d.ts","sourceRoot":"","sources":["../src/same-site.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAaH,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAQpD"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const t = /* @__PURE__ */ new Set(["same-origin", "none"]);
|
|
2
|
+
function r(e) {
|
|
3
|
+
const n = e.headers.get("sec-fetch-site");
|
|
4
|
+
if (n !== null) return t.has(n);
|
|
5
|
+
const i = e.headers.get("origin");
|
|
6
|
+
return i !== null ? i === new URL(e.url).origin : !0;
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
r as isSameSite
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=same-site.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"same-site.js","sources":["../src/same-site.ts"],"sourcesContent":["/**\n * Did this request come from us?\n *\n * The session cookie is `SameSite=Lax`, and that is not a preference: `Strict` withholds the\n * cookie on the top-level navigation *back* from the identity provider, so the callback arrives\n * without the transaction it needs and every sign-in fails. `cookie-session.ts` carries that\n * argument in full. **`Lax` is therefore a decision this package made on a consumer's behalf, and\n * what it costs is exactly one thing: a cross-site top-level navigation still sends the cookie.**\n * `<img src=\"https://app.example.test/api/auth/signout\">` on any page anywhere signs the person\n * out; `…/refresh` fired the same way spends a refresh token on somebody else's schedule.\n *\n * So the compensation belongs here rather than in every product: the package that chose `Lax` is\n * the one that owes the check for what `Lax` lets through.\n *\n * ## Fetch Metadata first, `Origin` second, and then allow\n *\n * `Sec-Fetch-Site` is set by the browser and cannot be set by script, which makes it the one\n * header here that is worth trusting. Every browser that has shipped since 2023 sends it. When it\n * is absent the request came from something that is not a browser — curl, a server, a crawler — or\n * from a browser old enough not to have it, and the fallback is `Origin`, which browsers attach to\n * every unsafe request and which the same script cannot forge either.\n *\n * With neither, this answers `true`. That is the honest end of the ladder rather than a hole: a\n * request carrying no evidence of where it came from also carries no evidence that it came from a\n * *page*, and the attack being stopped is a page. Refusing there would refuse every server-to-server\n * call and every health probe, which is a real outage traded for no real attacker.\n */\n\n/**\n * `same-origin` and `none` pass; `same-site` and `cross-site` do not.\n *\n * `none` is a person typing the URL or following a bookmark — user-initiated, which is the thing\n * being protected, not the thing being refused. `same-site` is a sibling subdomain, and it is\n * refused deliberately: the cookie is `__Host-` and therefore host-only, but host-only describes\n * where the cookie *lives*, not who may cause a request to it. Anything that can serve\n * `docs.example.test` could otherwise sign every reader of it out of `app.example.test`.\n */\nconst TRUSTED_SITES = new Set([\"same-origin\", \"none\"]);\n\nexport function isSameSite(request: Request): boolean {\n const site = request.headers.get(\"sec-fetch-site\");\n if (site !== null) return TRUSTED_SITES.has(site);\n\n const origin = request.headers.get(\"origin\");\n if (origin !== null) return origin === new URL(request.url).origin;\n\n return true;\n}\n"],"names":["TRUSTED_SITES","isSameSite","request","site","origin"],"mappings":"AAqCA,MAAMA,IAAgB,oBAAI,IAAI,CAAC,eAAe,MAAM,CAAC;AAE9C,SAASC,EAAWC,GAA2B;AACpD,QAAMC,IAAOD,EAAQ,QAAQ,IAAI,gBAAgB;AACjD,MAAIC,MAAS,KAAM,QAAOH,EAAc,IAAIG,CAAI;AAEhD,QAAMC,IAASF,EAAQ,QAAQ,IAAI,QAAQ;AAC3C,SAAIE,MAAW,OAAaA,MAAW,IAAI,IAAIF,EAAQ,GAAG,EAAE,SAErD;AACT;"}
|
package/dist/server.d.ts
CHANGED
|
@@ -15,6 +15,19 @@ export interface Renewed {
|
|
|
15
15
|
export interface SignedIn extends Renewed {
|
|
16
16
|
readonly returnTo: string;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* What `token` answers: the credential a resource server takes, and what to set on the way out.
|
|
20
|
+
*
|
|
21
|
+
* **`cookies` is not optional to attach.** It is empty when nothing was renewed and carries a
|
|
22
|
+
* rotated session when something was, and under the rotation RFC 10017 requires, dropping it
|
|
23
|
+
* throws away the only refresh token still valid — the session does not go stale, it ends. A
|
|
24
|
+
* caller with nowhere to put a `Set-Cookie` is a caller that must not be asking for this.
|
|
25
|
+
*/
|
|
26
|
+
export interface Token {
|
|
27
|
+
readonly accessToken: string;
|
|
28
|
+
readonly session: Session;
|
|
29
|
+
readonly cookies: readonly string[];
|
|
30
|
+
}
|
|
18
31
|
export interface RelyingPartyConfig extends IssuerConfig {
|
|
19
32
|
/** Registered at Keycloak, and where `complete` expects to be called. */
|
|
20
33
|
readonly redirectUri: string;
|
|
@@ -39,7 +52,28 @@ export interface RelyingParty {
|
|
|
39
52
|
}): Promise<SignedIn>;
|
|
40
53
|
/** The session a request carries, or `null`. The read a route handler does on every request. */
|
|
41
54
|
read(cookie: string | null | undefined): Promise<Session | null>;
|
|
42
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* The access token a request carries, renewed when it is about to expire — or `null` when there
|
|
57
|
+
* is no session at all.
|
|
58
|
+
*
|
|
59
|
+
* This is the *token-mediating backend*: the browser holds a cookie, the resource server is
|
|
60
|
+
* given a bearer token, and the two never meet. {@link read} is its sibling for identity, and
|
|
61
|
+
* the difference in the signature is the whole of the difference in what they may be called
|
|
62
|
+
* from — this one can answer with a `Set-Cookie` and therefore must be called somewhere that can
|
|
63
|
+
* send one.
|
|
64
|
+
*
|
|
65
|
+
* Renewal is single-flight per ticket, so a page that fires eight requests at an expiring token
|
|
66
|
+
* spends it once.
|
|
67
|
+
*/
|
|
68
|
+
token(cookie: string | null | undefined, options?: {
|
|
69
|
+
readonly renewWithin?: number;
|
|
70
|
+
}): Promise<Token | null>;
|
|
71
|
+
/**
|
|
72
|
+
* Spend the refresh token, take the new one, and reissue the cookie.
|
|
73
|
+
*
|
|
74
|
+
* Single-flight per ticket across the whole process: a second concurrent call joins the first
|
|
75
|
+
* rather than replaying a token it already spent. See `renewals`.
|
|
76
|
+
*/
|
|
43
77
|
refresh(cookie: string | null | undefined): Promise<Renewed>;
|
|
44
78
|
/** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */
|
|
45
79
|
end(cookie: string | null | undefined, options?: {
|
|
@@ -49,5 +83,5 @@ export interface RelyingParty {
|
|
|
49
83
|
export declare function relyingParty(config: RelyingPartyConfig): RelyingParty;
|
|
50
84
|
export { issuer, rewriteOrigin, type Issuer, type IssuerConfig } from './issuer';
|
|
51
85
|
export { sealedCookie, cookieValue, type SealedCookie, type SealedCookieConfig } from './cookie-session';
|
|
52
|
-
export { statelessStore, type SessionRecord, type SessionStore } from './store';
|
|
86
|
+
export { statelessStore, ticketStore, type SessionRecord, type SessionStore, type TicketAdapter, type TicketStoreConfig, } from './store';
|
|
53
87
|
//# sourceMappingURL=server.d.ts.map
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAaA,OAAO,EAAU,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|