@kanzo-tech/auth 0.30.2 → 0.31.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/next-auth.d.ts +1 -1
- package/dist/next-auth.d.ts.map +1 -1
- package/dist/next-auth.js.map +1 -1
- package/dist/next-bound.d.ts +22 -14
- package/dist/next-bound.d.ts.map +1 -1
- package/dist/next-bound.js +44 -38
- package/dist/next-bound.js.map +1 -1
- package/dist/next-gate.d.ts.map +1 -1
- package/dist/next-gate.js +28 -31
- package/dist/next-gate.js.map +1 -1
- package/dist/next-proxy.d.ts +12 -5
- package/dist/next-proxy.d.ts.map +1 -1
- package/dist/next-proxy.js +51 -44
- package/dist/next-proxy.js.map +1 -1
- package/dist/renew-within.d.ts +10 -0
- package/dist/renew-within.d.ts.map +1 -0
- package/dist/renew-within.js +5 -0
- package/dist/renew-within.js.map +1 -0
- package/dist/server.d.ts +44 -8
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +202 -164
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +3 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
package/dist/next-proxy.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-proxy.js","sources":["../src/next-proxy.ts"],"sourcesContent":["import { tenantRequest, type Bound } from \"./next-bound\";\nimport { outage } from \"./next-routes\";\nimport { isSameSite } from \"./same-site\";\nimport type { Ended, Token } from \"./server\";\nimport { AuthError } from \"./types\";\n\n/**\n * The BFF half of the *token-mediating backend*: the browser's request goes out again carrying a\n * bearer token, and the cookie that got it here stops at this line. `kanzoAuth().api`.\n *\n * ```ts\n * // lib/auth.ts: kanzoAuth(() => ({ …, api: { mount: \"/api/data\", target: \"https://reports.internal/v1\" } }))\n * // app/api/data/[...path]/route.ts\n * export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = auth.api;\n * ```\n *\n * ## Why this is in the package and not in the product\n *\n * Because it is the same ninety lines every time, and one of them is load-bearing in a way that\n * does not look it. **The `cookie` header must not be forwarded.** Leave it on and the resource\n * server receives a second credential — the session cookie — alongside the bearer token it asked\n * for, which is precisely the confusion the BFF pattern exists to remove: from then on a bug at\n * the far end can act as the person rather than as the token, and the token's scope and lifetime\n * stop being the boundary. Every other line here — the hop-by-hop headers, `duplex: \"half\"`,\n * `redirect: \"manual\"`, not buffering the body — is the sort of thing that is either right or\n * produces a symptom three layers away, and none of it is a product's idea of its own domain.\n *\n * ## What it answers without asking upstream\n *\n * - **401** when there is no session, or when the renewal was refused — with the cookie cleared in\n * the same answer. There is nothing to forward: a request with no credential is not the resource\n * server's to refuse. `bffAuth` answers that 401 by asking `refresh`, and signs in when that is\n * refused too.\n * - **403** when the request is not same-site. `same-site.ts` carries why the package owes this.\n * - **404** when `kanzoAuth` was given no `api`.\n *\n * ## Renewal happens here too\n *\n * The proxy renews before a page renders, and a client that stays on one page for longer than an\n * access token lives renews here: within a minute of expiry, in place, single-flight per ticket\n * with every other renewal of that session in the process.\n */\n\n/**\n * The upstream is reached with the global `fetch`, and **not** with the inherited `IssuerConfig`\n * one, which is a distinction worth a sentence because it is one field away from being invisible.\n * That `fetch` exists to reach the *identity provider* — it is the seam `internalOrigin` uses to\n * come at Keycloak from inside a cluster. A deployment that set it and found its resource-server\n * traffic going the same way would have every right to be surprised, so there is one name for one\n * transport and the other one is the platform's.\n */\n\nexport interface ApiHandlers {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n PUT(request: Request): Promise<Response>;\n PATCH(request: Request): Promise<Response>;\n DELETE(request: Request): Promise<Response>;\n HEAD(request: Request): Promise<Response>;\n OPTIONS(request: Request): Promise<Response>;\n}\n\n/**\n * Headers that describe **this** connection and not the message, per RFC 9110 §7.6.1.\n *\n * Forwarding them is how a proxy promises an upstream a connection it does not have. `te` and\n * `trailer` are here for the same reason the others are, and `upgrade` matters most: a forwarded\n * `Upgrade: websocket` invites an answer this handler has no way to complete.\n */\nconst HOP_BY_HOP = [\n \"connection\",\n \"keep-alive\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n \"transfer-encoding\",\n \"upgrade\",\n];\n\n/**\n * What is stripped from the browser's request on the way out, beyond the hop-by-hop set.\n *\n * - `cookie` — the whole point, above.\n * - `authorization` — ours is the only one that may be on this request; a caller's own would\n * otherwise decide which of the two the upstream reads.\n * - `host` — it names this server, not the upstream's, and `fetch` sets the right one.\n * - `content-length` — the body is re-streamed, and a length that survived a re-encode would be a\n * lie the transport has to discover.\n * - `accept-encoding` — the client's compression negotiation is with *us*; `fetch` runs its own\n * with the upstream and hands back a decoded body. Passing this on is how a response comes back\n * labelled `gzip` and already decompressed, which no browser recovers from.\n */\nconst NOT_FORWARDED = [...HOP_BY_HOP, \"cookie\", \"authorization\", \"host\", \"content-length\", \"accept-encoding\"];\n\n/**\n * What is stripped from the upstream's answer.\n *\n * `set-cookie` is the one worth the sentence: under this pattern the browser's cookie relationship\n * is with the BFF alone, and a resource server that could set a cookie on this origin could set\n * one named like ours. `content-encoding` and `content-length` go because `fetch` already decoded\n * the body, so both now describe a representation that no longer exists.\n */\nconst NOT_RETURNED = [...HOP_BY_HOP, \"set-cookie\", \"content-encoding\", \"content-length\"];\n\nfunction copyHeaders(from: Headers, without: readonly string[]): Headers {\n const headers = new Headers(from);\n for (const name of without) headers.delete(name);\n return headers;\n}\n\nexport function forward(instance: () => Promise<Bound>): ApiHandlers {\n const send = (...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args);\n\n const refuse = (status: number, cookies: readonly string[] = []) => {\n const headers = new Headers({ \"cache-control\": \"no-store\" });\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(null, { status, headers });\n };\n\n const handle = async (request: Request): Promise<Response> => {\n const bound = await instance();\n if (bound.api === undefined) return refuse(404);\n if (!isSameSite(request)) return refuse(403);\n\n const base = bound.api.mount;\n const target = bound.api.target(await bound.tenant(tenantRequest(request)));\n if (target === undefined) return refuse(404);\n /** `https://api.test` has pathname `/`, and a prefix of `/` would double every separator. */\n const prefix = target.pathname.replace(/\\/$/, \"\");\n\n const url = new URL(request.url);\n\n // A path outside the mount is not one this handler was mounted for, and refusing it *is* the\n // traversal check: the URL parser has already resolved every dot segment — `..` and its\n // percent-encoded spellings alike, which is the parser's job and not a thing to re-implement\n // — so a path that tried to climb has already fallen out of the prefix by the time it is read.\n if (!url.pathname.startsWith(base)) return refuse(400);\n\n // Assigning `pathname` rather than composing a string: a path beginning `//` parsed as a *URL*\n // is protocol-relative and names another host, and `//evil.test/x` is a path a browser will\n // happily send. Set as a component it cannot reach the origin at all.\n const upstream = new URL(target.href);\n upstream.pathname = `${prefix}${url.pathname.slice(base.length)}`;\n upstream.search = url.search;\n\n let held: Token | Ended;\n try {\n held = await bound.party.token(request.headers.get(\"cookie\"), { renewWithin: bound.renewWithin });\n } catch (error) {\n // A refused renewal is the end of the session and not an upstream failure. An IdP or a store\n // that did not answer is an outage, and anything else is a fault this module has no reading\n // of: hiding either behind a 401 would send a person to sign in again over something that\n // will still be there when they get back.\n if (!(error instanceof AuthError)) throw error;\n return refuse(outage(error.code) ?? 401);\n }\n if (held.ended) return refuse(401, held.cookies);\n\n const headers = copyHeaders(request.headers, NOT_FORWARDED);\n headers.set(\"authorization\", `Bearer ${held.accessToken}`);\n\n const body = request.method === \"GET\" || request.method === \"HEAD\" ? null : request.body;\n const answer = await send(upstream, {\n method: request.method,\n headers,\n body,\n // The body is a stream and is forwarded as one: an upload is not read into this server's\n // memory on its way past, and a server-sent event stream is not buffered until it ends —\n // which for an SSE endpoint means never. `duplex` is what Node requires to allow it.\n ...(body === null ? {} : { duplex: \"half\" }),\n // A 302 from the upstream is the upstream's answer and belongs to the caller. Following it\n // here would send the bearer token to whatever host the `Location` names.\n redirect: \"manual\",\n } as RequestInit);\n\n const out = copyHeaders(answer.headers, NOT_RETURNED);\n // Ours last, so a renewal is never lost to an upstream that had opinions about cookies.\n for (const cookie of held.cookies) out.append(\"set-cookie\", cookie);\n\n return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });\n };\n\n return {\n GET: handle,\n POST: handle,\n PUT: handle,\n PATCH: handle,\n DELETE: handle,\n HEAD: handle,\n OPTIONS: handle,\n };\n}\n"],"names":["HOP_BY_HOP","NOT_FORWARDED","NOT_RETURNED","copyHeaders","from","without","headers","name","forward","instance","send","args","refuse","status","cookies","cookie","handle","request","bound","isSameSite","base","target","tenantRequest","prefix","url","upstream","held","error","AuthError","outage","body","answer","out"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"next-proxy.js","sources":["../src/next-proxy.ts"],"sourcesContent":["import { tenantRequest, under, type Bound } from \"./next-bound\";\nimport { outage } from \"./next-routes\";\nimport { isSameSite } from \"./same-site\";\nimport type { Ended, Token } from \"./server\";\nimport { AuthError, type AuthErrorCode } from \"./types\";\n\n/**\n * The BFF half of the *token-mediating backend*: the browser's request goes out again carrying a\n * bearer token for that resource server and that organization alone, and the cookie that got it\n * here stops at this line. `kanzoAuth().api`.\n *\n * ```ts\n * // lib/auth.ts: kanzoAuth(() => ({ …, apis: { \"/api/data\": { audience: \"reports\", target: \"https://reports.internal/v1\" } } }))\n * // app/api/[...path]/route.ts — one route file can serve every mount under it\n * export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = auth.api;\n * ```\n *\n * ## Why this is in the package and not in the product\n *\n * Because it is the same ninety lines every time, and one of them is load-bearing in a way that\n * does not look it. **The `cookie` header must not be forwarded.** Leave it on and the resource\n * server receives a second credential — the session cookie — alongside the bearer token it asked\n * for, which is precisely the confusion the BFF pattern exists to remove: from then on a bug at\n * the far end can act as the person rather than as the token, and the token's scope and lifetime\n * stop being the boundary. Every other line here — the hop-by-hop headers, `duplex: \"half\"`,\n * `redirect: \"manual\"`, not buffering the body — is the sort of thing that is either right or\n * produces a symptom three layers away, and none of it is a product's idea of its own domain.\n *\n * ## What it answers without asking upstream\n *\n * - **401** when there is no session, or when the renewal was refused — with the cookie cleared in\n * the same answer. There is nothing to forward: a request with no credential is not the resource\n * server's to refuse. `bffAuth` answers that 401 by asking `refresh`, and signs in when that is\n * refused too.\n * - **403** when the request is not same-site — `same-site.ts` carries why the package owes this —\n * and when the realm will not issue a token for the organization the request addresses, because\n * the person is not a member of it.\n * - **404** when `kanzoAuth` was given no `apis`, or the mount's `target` names no server for the\n * organization.\n * - **400** for a path under no mount, and for an organization that is not an alias.\n * - **502** when the realm would not exchange the token at all: the application is not allowed to\n * ask for that audience, which is the deployment's to fix and not the person's.\n *\n * ## Renewal happens here too\n *\n * The proxy renews before a page renders, and a client that stays on one page for longer than an\n * access token lives renews here: within a minute of expiry, in place, single-flight per ticket\n * with every other renewal of that session in the process.\n */\n\n/**\n * The upstream is reached with the global `fetch`, and **not** with the inherited `IssuerConfig`\n * one, which is a distinction worth a sentence because it is one field away from being invisible.\n * That `fetch` exists to reach the *identity provider* — it is the seam `internalOrigin` uses to\n * come at Keycloak from inside a cluster. A deployment that set it and found its resource-server\n * traffic going the same way would have every right to be surprised, so there is one name for one\n * transport and the other one is the platform's.\n */\n\nexport interface ApiHandlers {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n PUT(request: Request): Promise<Response>;\n PATCH(request: Request): Promise<Response>;\n DELETE(request: Request): Promise<Response>;\n HEAD(request: Request): Promise<Response>;\n OPTIONS(request: Request): Promise<Response>;\n}\n\n/**\n * Headers that describe **this** connection and not the message, per RFC 9110 §7.6.1.\n *\n * Forwarding them is how a proxy promises an upstream a connection it does not have. `te` and\n * `trailer` are here for the same reason the others are, and `upgrade` matters most: a forwarded\n * `Upgrade: websocket` invites an answer this handler has no way to complete.\n */\nconst HOP_BY_HOP = [\n \"connection\",\n \"keep-alive\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n \"transfer-encoding\",\n \"upgrade\",\n];\n\n/**\n * What is stripped from the browser's request on the way out, beyond the hop-by-hop set.\n *\n * - `cookie` — the whole point, above.\n * - `authorization` — ours is the only one that may be on this request; a caller's own would\n * otherwise decide which of the two the upstream reads.\n * - `host` — it names this server, not the upstream's, and `fetch` sets the right one.\n * - `content-length` — the body is re-streamed, and a length that survived a re-encode would be a\n * lie the transport has to discover.\n * - `accept-encoding` — the client's compression negotiation is with *us*; `fetch` runs its own\n * with the upstream and hands back a decoded body. Passing this on is how a response comes back\n * labelled `gzip` and already decompressed, which no browser recovers from.\n */\nconst NOT_FORWARDED = [...HOP_BY_HOP, \"cookie\", \"authorization\", \"host\", \"content-length\", \"accept-encoding\"];\n\n/**\n * What is stripped from the upstream's answer.\n *\n * `set-cookie` is the one worth the sentence: under this pattern the browser's cookie relationship\n * is with the BFF alone, and a resource server that could set a cookie on this origin could set\n * one named like ours. `content-encoding` and `content-length` go because `fetch` already decoded\n * the body, so both now describe a representation that no longer exists.\n */\nconst NOT_RETURNED = [...HOP_BY_HOP, \"set-cookie\", \"content-encoding\", \"content-length\"];\n\n/** What an `AuthError` from getting the token answers, when it is not the IdP's or the store's outage. */\nfunction refusal(code: AuthErrorCode): number {\n if (code === \"organization/denied\") return 403;\n if (code === \"organization/invalid\") return 400;\n if (code === \"token/exchange-failed\") return 502;\n return 401;\n}\n\nfunction copyHeaders(from: Headers, without: readonly string[]): Headers {\n const headers = new Headers(from);\n for (const name of without) headers.delete(name);\n return headers;\n}\n\nexport function forward(instance: () => Promise<Bound>): ApiHandlers {\n const send = (...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args);\n\n const refuse = (status: number, cookies: readonly string[] = []) => {\n const headers = new Headers({ \"cache-control\": \"no-store\" });\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(null, { status, headers });\n };\n\n const handle = async (request: Request): Promise<Response> => {\n const bound = await instance();\n if (bound.apis.length === 0) return refuse(404);\n if (!isSameSite(request)) return refuse(403);\n\n const url = new URL(request.url);\n\n // A path under no mount is not one this handler was mounted for, and refusing it *is* the\n // traversal check: the URL parser has already resolved every dot segment — `..` and its\n // percent-encoded spellings alike, which is the parser's job and not a thing to re-implement\n // — so a path that tried to climb has already fallen out of every mount by the time it is read.\n const api = bound.apis.find((candidate) => under(url.pathname, [candidate.mount]));\n if (api === undefined) return refuse(400);\n\n const organization = await bound.tenant(tenantRequest(request));\n const target = api.target(organization);\n if (target === undefined) return refuse(404);\n /** `https://api.test` has pathname `/`, and a prefix of `/` would double every separator. */\n const prefix = target.pathname.replace(/\\/$/, \"\");\n\n // Assigning `pathname` rather than composing a string: a path beginning `//` parsed as a *URL*\n // is protocol-relative and names another host, and `//evil.test/x` is a path a browser will\n // happily send. Set as a component it cannot reach the origin at all.\n const upstream = new URL(target.href);\n upstream.pathname = `${prefix}${url.pathname.slice(api.mount.length)}`;\n upstream.search = url.search;\n\n let held: Token | Ended;\n try {\n held = await bound.party.token(request.headers.get(\"cookie\"), {\n audience: api.audience,\n organization,\n renewWithin: bound.renewWithin,\n });\n } catch (error) {\n // A refused renewal is the end of the session and not an upstream failure. An IdP or a store\n // that did not answer is an outage, and anything else is a fault this module has no reading\n // of: hiding either behind a 401 would send a person to sign in again over something that\n // will still be there when they get back.\n if (!(error instanceof AuthError)) throw error;\n return refuse(outage(error.code) ?? refusal(error.code));\n }\n if (held.ended) return refuse(401, held.cookies);\n\n const headers = copyHeaders(request.headers, NOT_FORWARDED);\n headers.set(\"authorization\", `Bearer ${held.accessToken}`);\n\n const body = request.method === \"GET\" || request.method === \"HEAD\" ? null : request.body;\n const answer = await send(upstream, {\n method: request.method,\n headers,\n body,\n // The body is a stream and is forwarded as one: an upload is not read into this server's\n // memory on its way past, and a server-sent event stream is not buffered until it ends —\n // which for an SSE endpoint means never. `duplex` is what Node requires to allow it.\n ...(body === null ? {} : { duplex: \"half\" }),\n // A 302 from the upstream is the upstream's answer and belongs to the caller. Following it\n // here would send the bearer token to whatever host the `Location` names.\n redirect: \"manual\",\n } as RequestInit);\n\n const out = copyHeaders(answer.headers, NOT_RETURNED);\n // Ours last, so a renewal is never lost to an upstream that had opinions about cookies.\n for (const cookie of held.cookies) out.append(\"set-cookie\", cookie);\n\n return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });\n };\n\n return {\n GET: handle,\n POST: handle,\n PUT: handle,\n PATCH: handle,\n DELETE: handle,\n HEAD: handle,\n OPTIONS: handle,\n };\n}\n"],"names":["HOP_BY_HOP","NOT_FORWARDED","NOT_RETURNED","refusal","code","copyHeaders","from","without","headers","name","forward","instance","send","args","refuse","status","cookies","cookie","handle","request","bound","isSameSite","url","api","candidate","under","organization","tenantRequest","target","prefix","upstream","held","error","AuthError","outage","body","answer","out"],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renew an access token with a minute left on it rather than after it dies.
|
|
3
|
+
*
|
|
4
|
+
* The window pays for two things at once: the flight time of the request we are about to send, and
|
|
5
|
+
* the clock skew between this server and the one that will validate the token. A minute covers
|
|
6
|
+
* both on every deployment anyone has run; going to zero means shipping tokens that expire in the
|
|
7
|
+
* air, and going large means renewing constantly on a realm with a five-minute token.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_RENEW_WITHIN = 60;
|
|
10
|
+
//# sourceMappingURL=renew-within.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renew-within.d.ts","sourceRoot":"","sources":["../src/renew-within.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renew-within.js","sources":["../src/renew-within.ts"],"sourcesContent":["/**\n * Renew an access token with a minute left on it rather than after it dies.\n *\n * The window pays for two things at once: the flight time of the request we are about to send, and\n * the clock skew between this server and the one that will validate the token. A minute covers\n * both on every deployment anyone has run; going to zero means shipping tokens that expire in the\n * air, and going large means renewing constantly on a realm with a five-minute token.\n */\nexport const DEFAULT_RENEW_WITHIN = 60;\n"],"names":["DEFAULT_RENEW_WITHIN"],"mappings":"AAQO,MAAMA,IAAuB;"}
|
package/dist/server.d.ts
CHANGED
|
@@ -36,10 +36,30 @@ export interface SignedIn {
|
|
|
36
36
|
readonly cookies: readonly string[];
|
|
37
37
|
readonly returnTo: string;
|
|
38
38
|
}
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* What `token` answers for a live session: the credential one resource server takes, issued for
|
|
41
|
+
* it and for one organization, and what to set.
|
|
42
|
+
*/
|
|
40
43
|
export interface Token extends Renewed {
|
|
41
44
|
readonly accessToken: string;
|
|
42
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Who a token is for: one resource server, and the organization a call is made in. RFC 9700 §2.3
|
|
48
|
+
* restricts an access token to one resource server, and Keycloak's own advice for an exchange is
|
|
49
|
+
* *"ideally use a single audience"*.
|
|
50
|
+
*/
|
|
51
|
+
export interface Audience {
|
|
52
|
+
/**
|
|
53
|
+
* The API's client id in the realm: the `aud` it validates, and the client scope that puts it
|
|
54
|
+
* there (`services/auth/modules/api`), which the application lists in its `apis`.
|
|
55
|
+
*/
|
|
56
|
+
readonly audience: string;
|
|
57
|
+
/**
|
|
58
|
+
* The organization the call is in: `organization:<alias>`, so the token names that organization
|
|
59
|
+
* alone. Absent for an API no organization owns, and the token then names none.
|
|
60
|
+
*/
|
|
61
|
+
readonly organization?: string;
|
|
62
|
+
}
|
|
43
63
|
export interface RelyingPartyConfig extends IssuerConfig {
|
|
44
64
|
/** Seals the cookies. Any length; generate it. See `sealedCookie`. */
|
|
45
65
|
readonly secret: string | Uint8Array;
|
|
@@ -77,18 +97,34 @@ export interface RelyingParty {
|
|
|
77
97
|
/** The session a request carries, or `null`. One store read; nothing is renewed. */
|
|
78
98
|
read(cookie: string | null | undefined): Promise<Session | null>;
|
|
79
99
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
100
|
+
* A token for one resource server and one organization — or {@link Ended} when there is no live
|
|
101
|
+
* session.
|
|
82
102
|
*
|
|
83
103
|
* This is the *token-mediating backend*: the browser holds a cookie, the resource server is
|
|
84
|
-
* given a bearer token, and the two never meet.
|
|
85
|
-
*
|
|
104
|
+
* given a bearer token, and the two never meet. The session's own token never leaves this
|
|
105
|
+
* server: it names every organization the person belongs to and no API, and it is exchanged
|
|
106
|
+
* (OAuth 2.0 Token Exchange, RFC 8693, as Keycloak's standard token exchange implements it) for
|
|
107
|
+
* one whose `aud` is `audience` alone and whose organization is `organization` alone. The
|
|
108
|
+
* session is renewed first when it is within `renewWithin` seconds of expiry (default 60).
|
|
109
|
+
*
|
|
110
|
+
* Exchanged tokens are kept until they are within the same window of expiry, one per session,
|
|
111
|
+
* audience and organization, and an exchange is single-flight under that key: a page that fires
|
|
112
|
+
* eight requests costs the realm one exchange, not eight.
|
|
113
|
+
*
|
|
114
|
+
* Rejects with `organization/denied` when the realm grants the token without the organization —
|
|
115
|
+
* the person is not a member of it — and with `organization/invalid` for one that is not an alias.
|
|
86
116
|
*/
|
|
87
|
-
token(cookie: string | null | undefined, options
|
|
117
|
+
token(cookie: string | null | undefined, options: Audience & {
|
|
88
118
|
readonly renewWithin?: number;
|
|
89
119
|
}): Promise<Token | Ended>;
|
|
90
|
-
/**
|
|
91
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Renew the session: spend the refresh token now or, with `renewWithin`, only when its access
|
|
122
|
+
* token is within that many seconds of expiry. Single-flight per ticket, so a page that fires
|
|
123
|
+
* eight requests at an expiring session spends the refresh token once.
|
|
124
|
+
*/
|
|
125
|
+
refresh(cookie: string | null | undefined, options?: {
|
|
126
|
+
readonly renewWithin?: number;
|
|
127
|
+
}): Promise<Renewed | Ended>;
|
|
92
128
|
/** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */
|
|
93
129
|
end(cookie: string | null | undefined, options?: {
|
|
94
130
|
readonly returnTo?: string;
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|