@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/server.js
CHANGED
|
@@ -1,86 +1,109 @@
|
|
|
1
|
-
import { buildEndSessionUrl as
|
|
2
|
-
import { claims as
|
|
3
|
-
import { sealedCookie as
|
|
4
|
-
import { cookieValue as
|
|
5
|
-
import { issuer as
|
|
6
|
-
import { rewriteOrigin as
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import { buildEndSessionUrl as p, randomPKCECodeVerifier as E, randomState as x, randomNonce as I, calculatePKCECodeChallenge as b, buildAuthorizationUrl as z, refreshTokenGrant as N, authorizationCodeGrant as O } from "openid-client";
|
|
2
|
+
import { claims as C } from "./claims.js";
|
|
3
|
+
import { sealedCookie as g } from "./cookie-session.js";
|
|
4
|
+
import { cookieValue as B } from "./cookie-session.js";
|
|
5
|
+
import { issuer as S } from "./issuer.js";
|
|
6
|
+
import { rewriteOrigin as q } from "./issuer.js";
|
|
7
|
+
import { keyedSingleFlight as D } from "./single-flight.js";
|
|
8
|
+
import { statelessStore as U } from "./store.js";
|
|
9
|
+
import { ticketStore as te } from "./store.js";
|
|
10
|
+
import { AuthError as v } from "./types.js";
|
|
11
|
+
const A = "openid profile email", L = 480 * 60, R = 600, F = 60;
|
|
12
|
+
function l(n, c, i) {
|
|
13
|
+
const d = new v(n, c);
|
|
14
|
+
throw i !== void 0 && (d.cause = i), d;
|
|
13
15
|
}
|
|
14
|
-
function
|
|
15
|
-
if (typeof
|
|
16
|
-
const
|
|
17
|
-
return typeof
|
|
16
|
+
function y(n) {
|
|
17
|
+
if (typeof n != "object" || n === null || !("code" in n)) return;
|
|
18
|
+
const c = n.code;
|
|
19
|
+
return typeof c == "string" ? c : void 0;
|
|
18
20
|
}
|
|
19
|
-
function _(
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
-
let
|
|
23
|
-
for (let
|
|
24
|
-
if (
|
|
25
|
-
|
|
21
|
+
function _(n) {
|
|
22
|
+
const c = y(n);
|
|
23
|
+
if (c === "OAUTH_JWT_CLAIM_COMPARISON_FAILED") {
|
|
24
|
+
let i = n;
|
|
25
|
+
for (let d = 0; d < 4 && typeof i == "object" && i !== null; d++) {
|
|
26
|
+
if (i.claim === "nonce") return !0;
|
|
27
|
+
i = i.cause;
|
|
26
28
|
}
|
|
27
29
|
return !1;
|
|
28
30
|
}
|
|
29
|
-
return
|
|
31
|
+
return c === "OAUTH_INVALID_RESPONSE" && n instanceof Error && n.cause instanceof Error && n.cause.message.includes('"nonce"');
|
|
30
32
|
}
|
|
31
|
-
function P(
|
|
32
|
-
return
|
|
33
|
+
function P(n) {
|
|
34
|
+
return y(n) === "OAUTH_KEY_SELECTION_FAILED";
|
|
33
35
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
const G = /^(\*|[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?)$/, M = D();
|
|
37
|
+
function X(n) {
|
|
38
|
+
const c = S(n), i = n.store ?? U(), d = g({
|
|
36
39
|
name: "kanzo-session",
|
|
37
|
-
secret:
|
|
38
|
-
maxAge:
|
|
39
|
-
}),
|
|
40
|
+
secret: n.secret,
|
|
41
|
+
maxAge: n.maxAge ?? L
|
|
42
|
+
}), k = g({
|
|
40
43
|
name: "kanzo-auth",
|
|
41
|
-
secret:
|
|
42
|
-
maxAge:
|
|
43
|
-
}),
|
|
44
|
-
const e = await
|
|
45
|
-
return e === null ? null :
|
|
46
|
-
},
|
|
47
|
-
const o =
|
|
44
|
+
secret: n.secret,
|
|
45
|
+
maxAge: R
|
|
46
|
+
}), m = async (t) => {
|
|
47
|
+
const e = await d.read(t);
|
|
48
|
+
return e === null ? null : i.get(e.ticket);
|
|
49
|
+
}, w = async (t, e) => {
|
|
50
|
+
const o = t.claims(), a = o === void 0 ? e == null ? void 0 : e.session : C(o, n);
|
|
48
51
|
a === void 0 && l("token.exchange-failed", "the token response carried no ID token, so it names nobody");
|
|
49
|
-
const s =
|
|
52
|
+
const s = t.expiresIn(), r = {
|
|
50
53
|
session: a,
|
|
54
|
+
accessToken: t.access_token,
|
|
55
|
+
accessTokenExpiresAt: s === void 0 ? void 0 : Date.now() + s * 1e3,
|
|
51
56
|
// RFC 10017 requires rotation, so the newly issued token is the only one still valid. An
|
|
52
57
|
// authorization server that did not rotate returns none, and the one we hold stays good.
|
|
53
|
-
refreshToken:
|
|
54
|
-
idToken:
|
|
58
|
+
refreshToken: t.refresh_token ?? (e == null ? void 0 : e.refreshToken),
|
|
59
|
+
idToken: t.id_token ?? (e == null ? void 0 : e.idToken)
|
|
60
|
+
}, f = await i.put(r);
|
|
61
|
+
return { renewed: { session: a, cookies: [await d.seal({ ticket: f })] }, record: r };
|
|
62
|
+
}, T = async (t) => {
|
|
63
|
+
const e = await d.read(t), o = e === null ? null : await i.get(e.ticket);
|
|
64
|
+
(e === null || o === null) && l("session.absent", "there is no session cookie to refresh");
|
|
65
|
+
const a = o.refreshToken;
|
|
66
|
+
return a === void 0 && l("session.absent", "the session holds no refresh token, so it cannot be renewed"), M(e.ticket, async () => {
|
|
67
|
+
let s;
|
|
68
|
+
try {
|
|
69
|
+
s = await N(await c.configuration(), a);
|
|
70
|
+
} catch (r) {
|
|
71
|
+
l("token.exchange-failed", "the refresh token was refused", r);
|
|
72
|
+
}
|
|
73
|
+
return await i.drop(e.ticket), w(s, o);
|
|
55
74
|
});
|
|
56
|
-
return { session: a, cookies: [await c.seal({ ticket: s })] };
|
|
57
75
|
};
|
|
58
76
|
return {
|
|
59
|
-
async begin(
|
|
60
|
-
const e = await
|
|
61
|
-
|
|
77
|
+
async begin(t = {}) {
|
|
78
|
+
const e = await c.configuration(), o = E(), a = x(), s = I();
|
|
79
|
+
t.organization !== void 0 && !G.test(t.organization) && l(
|
|
80
|
+
"organization.invalid",
|
|
81
|
+
`\`${t.organization}\` is not an organization alias, and a scope is a space-delimited list: see ORGANIZATION`
|
|
82
|
+
);
|
|
83
|
+
const r = {
|
|
84
|
+
redirect_uri: n.redirectUri,
|
|
62
85
|
// `organization:<alias>` asks Keycloak for one; a product with many asks for
|
|
63
86
|
// `organization:*` through `scope`, because plain `organization` prompts for a choice.
|
|
64
|
-
scope:
|
|
65
|
-
code_challenge: await
|
|
87
|
+
scope: t.organization === void 0 ? n.scope ?? A : `${n.scope ?? A} organization:${t.organization}`,
|
|
88
|
+
code_challenge: await b(o),
|
|
66
89
|
code_challenge_method: "S256",
|
|
67
90
|
state: a,
|
|
68
91
|
nonce: s
|
|
69
92
|
};
|
|
70
93
|
return {
|
|
71
|
-
url:
|
|
94
|
+
url: z(e, r).href,
|
|
72
95
|
cookies: [
|
|
73
|
-
await
|
|
96
|
+
await k.seal({ state: a, nonce: s, verifier: o, returnTo: t.returnTo ?? "/" })
|
|
74
97
|
]
|
|
75
98
|
};
|
|
76
99
|
},
|
|
77
|
-
async complete(
|
|
78
|
-
const e = await
|
|
100
|
+
async complete(t) {
|
|
101
|
+
const e = await k.read(t.cookie);
|
|
79
102
|
e === null && l(
|
|
80
103
|
"callback.state-mismatch",
|
|
81
104
|
"the callback arrived with no transaction cookie, so there is nothing to match its `state` against"
|
|
82
105
|
);
|
|
83
|
-
const o = new URL(
|
|
106
|
+
const o = new URL(t.url);
|
|
84
107
|
o.searchParams.get("state") !== e.state && l(
|
|
85
108
|
"callback.state-mismatch",
|
|
86
109
|
"the callback's `state` is not the one this browser was sent with"
|
|
@@ -89,10 +112,10 @@ function v(t) {
|
|
|
89
112
|
pkceCodeVerifier: e.verifier,
|
|
90
113
|
expectedState: e.state,
|
|
91
114
|
expectedNonce: e.nonce
|
|
92
|
-
}, s = (h) =>
|
|
93
|
-
let
|
|
115
|
+
}, s = (h) => O(h, o, a);
|
|
116
|
+
let r;
|
|
94
117
|
try {
|
|
95
|
-
|
|
118
|
+
r = await s(await c.configuration());
|
|
96
119
|
} catch (h) {
|
|
97
120
|
_(h) && l(
|
|
98
121
|
"callback.nonce-mismatch",
|
|
@@ -100,7 +123,7 @@ function v(t) {
|
|
|
100
123
|
h
|
|
101
124
|
), P(h) || l("token.exchange-failed", "the authorization code could not be exchanged", h);
|
|
102
125
|
try {
|
|
103
|
-
|
|
126
|
+
r = await s(await c.rediscover());
|
|
104
127
|
} catch (u) {
|
|
105
128
|
_(u) && l(
|
|
106
129
|
"callback.nonce-mismatch",
|
|
@@ -113,45 +136,47 @@ function v(t) {
|
|
|
113
136
|
);
|
|
114
137
|
}
|
|
115
138
|
}
|
|
116
|
-
const
|
|
139
|
+
const { renewed: f } = await w(r, null);
|
|
117
140
|
return {
|
|
118
|
-
...
|
|
119
|
-
cookies: [...
|
|
141
|
+
...f,
|
|
142
|
+
cookies: [...f.cookies, k.clear()],
|
|
120
143
|
returnTo: e.returnTo
|
|
121
144
|
};
|
|
122
145
|
},
|
|
123
|
-
async read(
|
|
146
|
+
async read(t) {
|
|
124
147
|
var e;
|
|
125
|
-
return ((e = await
|
|
148
|
+
return ((e = await m(t)) == null ? void 0 : e.session) ?? null;
|
|
126
149
|
},
|
|
127
|
-
async
|
|
128
|
-
const
|
|
129
|
-
(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
150
|
+
async token(t, e = {}) {
|
|
151
|
+
const o = await m(t);
|
|
152
|
+
if (o === null) return null;
|
|
153
|
+
const a = (e.renewWithin ?? F) * 1e3, s = o.accessToken, r = o.accessTokenExpiresAt !== void 0 && o.accessTokenExpiresAt - Date.now() <= a;
|
|
154
|
+
if (s !== void 0 && !r)
|
|
155
|
+
return { accessToken: s, session: o.session, cookies: [] };
|
|
156
|
+
const { renewed: f, record: h } = await T(t);
|
|
157
|
+
return h.accessToken === void 0 && l("token.exchange-failed", "the token response carried no access token"), { accessToken: h.accessToken, session: f.session, cookies: f.cookies };
|
|
158
|
+
},
|
|
159
|
+
async refresh(t) {
|
|
160
|
+
return (await T(t)).renewed;
|
|
137
161
|
},
|
|
138
|
-
async end(
|
|
139
|
-
const o = await
|
|
140
|
-
o !== null && await
|
|
141
|
-
const s = {},
|
|
142
|
-
return
|
|
143
|
-
url:
|
|
144
|
-
cookies: [
|
|
162
|
+
async end(t, e = {}) {
|
|
163
|
+
const o = await d.read(t), a = o === null ? null : await i.get(o.ticket);
|
|
164
|
+
o !== null && await i.drop(o.ticket);
|
|
165
|
+
const s = {}, r = e.returnTo ?? n.postLogoutRedirectUri;
|
|
166
|
+
return r !== void 0 && (s.post_logout_redirect_uri = r), (a == null ? void 0 : a.idToken) !== void 0 && (s.id_token_hint = a.idToken), {
|
|
167
|
+
url: p(await c.configuration(), s).href,
|
|
168
|
+
cookies: [d.clear()]
|
|
145
169
|
};
|
|
146
170
|
}
|
|
147
171
|
};
|
|
148
172
|
}
|
|
149
173
|
export {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
174
|
+
B as cookieValue,
|
|
175
|
+
S as issuer,
|
|
176
|
+
X as relyingParty,
|
|
177
|
+
q as rewriteOrigin,
|
|
178
|
+
g as sealedCookie,
|
|
179
|
+
U as statelessStore,
|
|
180
|
+
te as ticketStore
|
|
156
181
|
};
|
|
157
182
|
//# sourceMappingURL=server.js.map
|
package/dist/server.js.map
CHANGED
|
@@ -1 +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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"}
|
|
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 { keyedSingleFlight } from \"./single-flight\";\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/**\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 */\nconst DEFAULT_RENEW_WITHIN = 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/**\n * A Keycloak organization alias, or `*`. Anything else is not put into a scope string.\n *\n * `scope` is a **space-delimited list**, so a value with a space in it does not become one scope\n * with a space in it — it becomes two scopes, and the second one is whatever the caller wrote.\n * `?organization=x%20offline_access` reaching `begin` unchecked is an authorization request for\n * `offline_access`, which is a refresh token that outlives the browser session, asked for by\n * whoever composed the link. That is scope injection, and the place to stop it is here rather than\n * at whichever door happened to be the one taking query parameters today.\n *\n * The alphabet is Keycloak's own for an alias — it is a hostname-ish name, and the realm will not\n * mint one outside this set — plus the `*` that asks for every organization at once.\n */\nconst ORGANIZATION = /^(\\*|[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?)$/;\n\n/**\n * One renewal per ticket, for the whole process rather than per `relyingParty`.\n *\n * `singleFlight`'s own header says why a second concurrent renewal is a revoked token chain and\n * not a wasted round trip. What that header does not say is that a server builds more than one\n * relying party: `authRoutes` rebuilds its own when the derived callback URL changes, `authToken`\n * and `authProxy` each hold theirs, and an instance-level slot would let a refresh from the route\n * and a refresh from the proxy replay the same token at the same moment. The ticket names the\n * session, so the ticket is the right key, and it is the same ticket whichever instance holds it.\n *\n * **It is per process.** Two Node instances behind a load balancer can still collide, and the\n * answer to that is a `SessionStore` whose `put` is the point of coordination — not a lock here,\n * which would be a distributed one pretending to be a `Map`.\n */\nconst renewals = keyedSingleFlight<Adopted>();\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\n/**\n * What `token` answers: the credential a resource server takes, and what to set on the way out.\n *\n * **`cookies` is not optional to attach.** It is empty when nothing was renewed and carries a\n * rotated session when something was, and under the rotation RFC 10017 requires, dropping it\n * throws away the only refresh token still valid — the session does not go stale, it ends. A\n * caller with nowhere to put a `Set-Cookie` is a caller that must not be asking for this.\n */\nexport interface Token {\n readonly accessToken: string;\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/**\n * Everything a successful grant produced: what the caller is told, and the record behind it.\n *\n * The two are separate and only the first is ever returned from a public method, because a\n * `SessionRecord` holds the refresh token and a `Renewed` is the sort of thing a route handler\n * writes straight into a response body. Structural typing would have let one extra field ride\n * along unnoticed all the way to the browser.\n */\ninterface Adopted {\n readonly renewed: Renewed;\n readonly record: SessionRecord;\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 /**\n * The access token a request carries, renewed when it is about to expire — or `null` when there\n * is no session at all.\n *\n * This is the *token-mediating backend*: the browser holds a cookie, the resource server is\n * given a bearer token, and the two never meet. {@link read} is its sibling for identity, and\n * the difference in the signature is the whole of the difference in what they may be called\n * from — this one can answer with a `Set-Cookie` and therefore must be called somewhere that can\n * send one.\n *\n * Renewal is single-flight per ticket, so a page that fires eight requests at an expiring token\n * spends it once.\n */\n token(\n cookie: string | null | undefined,\n options?: { readonly renewWithin?: number },\n ): Promise<Token | null>;\n /**\n * Spend the refresh token, take the new one, and reissue the cookie.\n *\n * Single-flight per ticket across the whole process: a second concurrent call joins the first\n * rather than replaying a token it already spent. See `renewals`.\n */\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<Adopted> => {\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 // `expiresIn()` counts down from the moment the response was parsed, which is the only honest\n // reading: the token endpoint says `expires_in`, never an absolute time, because it has no\n // opinion about our clock. Absent, the expiry is unknown rather than zero — a record that\n // claimed to have expired at the epoch would be renewed on every single request.\n const lifetime = tokens.expiresIn();\n\n const record: SessionRecord = {\n session: next,\n accessToken: tokens.access_token,\n accessTokenExpiresAt: lifetime === undefined ? undefined : Date.now() + lifetime * 1000,\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 const ticket = await store.put(record);\n return { renewed: { session: next, cookies: [await session.seal({ ticket })] }, record };\n };\n\n /** The renewal both `refresh` and `token` run, with the record they each need a different half of. */\n const renew = async (cookie: string | null | undefined): Promise<Adopted> => {\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 const spent = record.refreshToken;\n if (spent === undefined) {\n refuse(\"session.absent\", \"the session holds no refresh token, so it cannot be renewed\");\n }\n\n // Everything above is a read and may run concurrently; everything below spends a token that can\n // only be spent once, so it is the half behind the slot. A caller that joins gets the cookie\n // the first one was issued, which is the cookie it would have been issued anyway.\n return renewals(sealed.ticket, async () => {\n let tokens: Tokens;\n try {\n tokens = await refreshTokenGrant(await provider.configuration(), spent);\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; the slot above 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\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 if (options.organization !== undefined && !ORGANIZATION.test(options.organization)) {\n refuse(\n \"organization.invalid\",\n `\\`${options.organization}\\` is not an organization alias, and a scope is a space-delimited list: see ORGANIZATION`,\n );\n }\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 token(cookie, options = {}) {\n const record = await recordFrom(cookie);\n if (record === null) return null;\n\n const within = (options.renewWithin ?? DEFAULT_RENEW_WITHIN) * 1000;\n const held = record.accessToken;\n // An unknown expiry is not treated as expired: a realm that omits `expires_in` would\n // otherwise be renewed on every request, which is the replay this package exists to avoid.\n const stale =\n record.accessTokenExpiresAt !== undefined &&\n record.accessTokenExpiresAt - Date.now() <= within;\n\n if (held !== undefined && !stale) {\n return { accessToken: held, session: record.session, cookies: [] };\n }\n\n const { renewed, record: fresh } = await renew(cookie);\n if (fresh.accessToken === undefined) {\n refuse(\"token.exchange-failed\", \"the token response carried no access token\");\n }\n return { accessToken: fresh.accessToken, session: renewed.session, cookies: renewed.cookies };\n },\n\n async refresh(cookie) {\n return (await renew(cookie)).renewed;\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 {\n statelessStore,\n ticketStore,\n type SessionRecord,\n type SessionStore,\n type TicketAdapter,\n type TicketStoreConfig,\n} from \"./store\";\n"],"names":["DEFAULT_SCOPE","DEFAULT_MAX_AGE","TRANSACTION_MAX_AGE","DEFAULT_RENEW_WITHIN","refuse","code","message","cause","error","AuthError","codeOf","isNonceMismatch","node","depth","isStaleKeyMaterial","ORGANIZATION","renewals","keyedSingleFlight","relyingParty","config","provider","issuer","store","statelessStore","session","sealedCookie","transaction","recordFrom","cookie","sealed","adopt","tokens","previous","idClaims","next","claims","lifetime","record","ticket","renew","spent","refreshTokenGrant","options","configuration","verifier","randomPKCECodeVerifier","state","randomState","nonce","randomNonce","parameters","calculatePKCECodeChallenge","buildAuthorizationUrl","request","pending","current","checks","grant","authorizationCodeGrant","retried","renewed","_a","within","held","stale","fresh","returnTo","buildEndSessionUrl"],"mappings":"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"}
|
package/dist/single-flight.d.ts
CHANGED
|
@@ -12,4 +12,22 @@
|
|
|
12
12
|
* So the rule is a rule rather than an optimisation: **the refresh path is single-flight.**
|
|
13
13
|
*/
|
|
14
14
|
export declare function singleFlight<T>(work: () => Promise<T>): () => Promise<T>;
|
|
15
|
+
/**
|
|
16
|
+
* The same rule, one slot per key.
|
|
17
|
+
*
|
|
18
|
+
* A browser holds one session and a server holds every session at once, so a single slot is wrong
|
|
19
|
+
* there in both directions: it would make Ada's renewal wait behind Grace's and — the part that is
|
|
20
|
+
* a defect rather than a delay — hand Ada the answer to Grace's. The key is what tells two
|
|
21
|
+
* renewals apart, and on the server that key is the **ticket**: one live session, one slot.
|
|
22
|
+
*
|
|
23
|
+
* The work is passed per call rather than at construction, which is the one shape difference from
|
|
24
|
+
* `singleFlight` and the reason for it: on the server the work closes over the request that asked,
|
|
25
|
+
* and a single function fixed at construction could not. A caller that joins a running slot gets
|
|
26
|
+
* that slot's answer and its own `work` is never invoked — which is the point, and which is why
|
|
27
|
+
* every caller for one key must be asking for the same thing.
|
|
28
|
+
*
|
|
29
|
+
* The slot is deleted rather than left holding a settled promise, so the map is bounded by what is
|
|
30
|
+
* in flight and not by how many people have ever signed in.
|
|
31
|
+
*/
|
|
32
|
+
export declare function keyedSingleFlight<T>(): (key: string, work: () => Promise<T>) => Promise<T>;
|
|
15
33
|
//# sourceMappingURL=single-flight.d.ts.map
|
|
@@ -1 +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"}
|
|
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;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAa1F"}
|
package/dist/single-flight.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
function t
|
|
2
|
-
let
|
|
3
|
-
return () => (
|
|
4
|
-
|
|
5
|
-
})),
|
|
1
|
+
function l(t) {
|
|
2
|
+
let n;
|
|
3
|
+
return () => (n !== void 0 || (n = t().finally(() => {
|
|
4
|
+
n = void 0;
|
|
5
|
+
})), n);
|
|
6
|
+
}
|
|
7
|
+
function o() {
|
|
8
|
+
const t = /* @__PURE__ */ new Map();
|
|
9
|
+
return (n, r) => {
|
|
10
|
+
const i = t.get(n);
|
|
11
|
+
if (i !== void 0) return i;
|
|
12
|
+
const e = r().finally(() => {
|
|
13
|
+
t.delete(n);
|
|
14
|
+
});
|
|
15
|
+
return t.set(n, e), e;
|
|
16
|
+
};
|
|
6
17
|
}
|
|
7
18
|
export {
|
|
8
|
-
|
|
19
|
+
o as keyedSingleFlight,
|
|
20
|
+
l as singleFlight
|
|
9
21
|
};
|
|
10
22
|
//# sourceMappingURL=single-flight.js.map
|
|
@@ -1 +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;"}
|
|
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\n/**\n * The same rule, one slot per key.\n *\n * A browser holds one session and a server holds every session at once, so a single slot is wrong\n * there in both directions: it would make Ada's renewal wait behind Grace's and — the part that is\n * a defect rather than a delay — hand Ada the answer to Grace's. The key is what tells two\n * renewals apart, and on the server that key is the **ticket**: one live session, one slot.\n *\n * The work is passed per call rather than at construction, which is the one shape difference from\n * `singleFlight` and the reason for it: on the server the work closes over the request that asked,\n * and a single function fixed at construction could not. A caller that joins a running slot gets\n * that slot's answer and its own `work` is never invoked — which is the point, and which is why\n * every caller for one key must be asking for the same thing.\n *\n * The slot is deleted rather than left holding a settled promise, so the map is bounded by what is\n * in flight and not by how many people have ever signed in.\n */\nexport function keyedSingleFlight<T>(): (key: string, work: () => Promise<T>) => Promise<T> {\n const inFlight = new Map<string, Promise<T>>();\n\n return (key, work) => {\n const running = inFlight.get(key);\n if (running !== undefined) return running;\n\n const started = work().finally(() => {\n inFlight.delete(key);\n });\n inFlight.set(key, started);\n return started;\n };\n}\n"],"names":["singleFlight","work","inFlight","keyedSingleFlight","key","running","started"],"mappings":"AAaO,SAASA,EAAgBC,GAA0C;AACxE,MAAIC;AAEJ,SAAO,OAEDA,MAAa,WAMjBA,IAAWD,IAAO,QAAQ,MAAM;AAC9B,IAAAC,IAAW;AAAA,EACb,CAAC,IAEMA;AAEX;AAmBO,SAASC,IAA4E;AAC1F,QAAMD,wBAAe,IAAA;AAErB,SAAO,CAACE,GAAKH,MAAS;AACpB,UAAMI,IAAUH,EAAS,IAAIE,CAAG;AAChC,QAAIC,MAAY,OAAW,QAAOA;AAElC,UAAMC,IAAUL,IAAO,QAAQ,MAAM;AACnC,MAAAC,EAAS,OAAOE,CAAG;AAAA,IACrB,CAAC;AACD,WAAAF,EAAS,IAAIE,GAAKE,CAAO,GAClBA;AAAA,EACT;AACF;"}
|
package/dist/store.d.ts
CHANGED
|
@@ -8,8 +8,13 @@ import { Session } from './types';
|
|
|
8
8
|
* to take effect immediately, and a self-contained cookie can do neither. Both are the same
|
|
9
9
|
* missing ability: a cookie already in someone's hands cannot be taken back.
|
|
10
10
|
*
|
|
11
|
-
* So: one interface,
|
|
12
|
-
*
|
|
11
|
+
* So: one interface, two implementations, and no catalogue. {@link statelessStore} is the cookie;
|
|
12
|
+
* {@link ticketStore} is an opaque ticket over **a key-value adapter the deployment supplies**, so
|
|
13
|
+
* plugging in Redis or a table is two functions rather than a reimplementation of the ticket. No
|
|
14
|
+
* backend ships here — a driver is a dependency and a deployment decision, and neither is this
|
|
15
|
+
* package's to make on its way past — but the *shape* does, because without it every product that
|
|
16
|
+
* wants a real sign-out writes its own sealing, and a logout that invalidates nothing is the
|
|
17
|
+
* thing they all ship instead.
|
|
13
18
|
*/
|
|
14
19
|
/**
|
|
15
20
|
* What the server holds, and the browser never sees.
|
|
@@ -20,6 +25,25 @@ import { Session } from './types';
|
|
|
20
25
|
*/
|
|
21
26
|
export interface SessionRecord {
|
|
22
27
|
readonly session: Session;
|
|
28
|
+
/**
|
|
29
|
+
* The credential for a resource server, and the reason this field exists.
|
|
30
|
+
*
|
|
31
|
+
* Without it a token-mediating backend has nothing `typ: "Bearer"` to forward, and what it
|
|
32
|
+
* reaches for instead is the ID token — which works on a realm that happens to put the same
|
|
33
|
+
* audience in both and stops working the day the resource server checks the type, as it should.
|
|
34
|
+
* An ID token says *who signed in*; it was never a key to an API. It is also what
|
|
35
|
+
* `/token/introspect` and `/revoke` take, neither of which is reachable holding the other one.
|
|
36
|
+
*/
|
|
37
|
+
readonly accessToken?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Epoch milliseconds, from the token response's `expires_in` rather than from opening the token.
|
|
40
|
+
*
|
|
41
|
+
* The access token is opaque to us by contract — it is the resource server's to read — so its
|
|
42
|
+
* lifetime comes from the envelope it arrived in. {@link Session.expiresAt} is the *ID token's*
|
|
43
|
+
* expiry and is a different number on a realm that gives the two different lifetimes; renewing
|
|
44
|
+
* against the wrong one is how a request goes out with a credential that died a minute ago.
|
|
45
|
+
*/
|
|
46
|
+
readonly accessTokenExpiresAt?: number;
|
|
23
47
|
/** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */
|
|
24
48
|
readonly refreshToken?: string;
|
|
25
49
|
/** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */
|
|
@@ -46,7 +70,82 @@ export interface SessionStore {
|
|
|
46
70
|
* out clears the cookie, which is enough for the person holding the browser and is not enough for
|
|
47
71
|
* anyone else — a copy of that cookie taken beforehand keeps working until it expires. A session
|
|
48
72
|
* lifetime is therefore a real security parameter under this store, and "sign out everywhere" is
|
|
49
|
-
* not implementable on top of it. Give `relyingParty` a
|
|
73
|
+
* not implementable on top of it. Give `relyingParty` a {@link ticketStore} when either matters.
|
|
74
|
+
*
|
|
75
|
+
* ## It does not fit a record that carries an access token, and the numbers are the argument
|
|
76
|
+
*
|
|
77
|
+
* A browser is only required to keep 4096 bytes of cookie. Sealed with the three tokens a
|
|
78
|
+
* token-mediating backend holds, a realistic Keycloak record — two organizations, the roles that
|
|
79
|
+
* come with them — measures **6407 bytes**, and it measured **4068** before the access token
|
|
80
|
+
* joined it, which is 28 bytes of margin and not a design. `store.test.ts` holds both figures.
|
|
81
|
+
*
|
|
82
|
+
* So this store is for a product that reads identity and calls no resource server. The moment
|
|
83
|
+
* there is an API to call, the cookie carries a ticket instead of the tokens — which is
|
|
84
|
+
* {@link ticketStore}, and the sealing throws with the byte count rather than letting a browser
|
|
85
|
+
* drop the cookie in silence.
|
|
50
86
|
*/
|
|
51
87
|
export declare function statelessStore(): SessionStore;
|
|
88
|
+
/**
|
|
89
|
+
* The two functions and a delete that a store needs from a deployment's own database.
|
|
90
|
+
*
|
|
91
|
+
* Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace
|
|
92
|
+
* and a file on disk — anything narrower would name one of them. The value is already serialized
|
|
93
|
+
* and it is **not encrypted**: it lives inside the deployment's own trust boundary, and a key held
|
|
94
|
+
* by the same process that reads the rows protects against a stolen dump and nothing else. Encrypt
|
|
95
|
+
* the storage, not the row.
|
|
96
|
+
*/
|
|
97
|
+
export interface TicketAdapter {
|
|
98
|
+
/** The value written under `key`, or `null` when it is unknown or has expired. */
|
|
99
|
+
read(key: string): Promise<string | null>;
|
|
100
|
+
/**
|
|
101
|
+
* Write `value` under `key`, to be forgotten after `ttl` seconds.
|
|
102
|
+
*
|
|
103
|
+
* **Honouring `ttl` is the adapter's job**, because every store that could hold this already has
|
|
104
|
+
* an expiry of its own — `EX` on Redis, a column and a sweep on SQL — and a timer here would be
|
|
105
|
+
* one that dies with the process. An adapter that ignores it leaks rows; it does not leak
|
|
106
|
+
* sessions, because the sealed cookie carrying the ticket expires on its own schedule.
|
|
107
|
+
*/
|
|
108
|
+
write(key: string, value: string, ttl: number): Promise<void>;
|
|
109
|
+
delete(key: string): Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
export interface TicketStoreConfig {
|
|
112
|
+
/**
|
|
113
|
+
* Seconds a record is kept. Default eight hours — **set it to the `maxAge` you gave
|
|
114
|
+
* `relyingParty`**, which is the lifetime of the cookie that carries the ticket.
|
|
115
|
+
*/
|
|
116
|
+
readonly ttl?: number;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A store where the cookie carries an opaque ticket and the record lives in the deployment's own
|
|
120
|
+
* database — which is what makes a sign-out a sign-out.
|
|
121
|
+
*
|
|
122
|
+
* ```ts
|
|
123
|
+
* relyingParty({
|
|
124
|
+
* …,
|
|
125
|
+
* store: ticketStore({
|
|
126
|
+
* read: (key) => redis.get(key),
|
|
127
|
+
* write: (key, value, ttl) => redis.set(key, value, { EX: ttl }),
|
|
128
|
+
* delete: (key) => redis.del(key),
|
|
129
|
+
* }),
|
|
130
|
+
* });
|
|
131
|
+
* ```
|
|
132
|
+
*
|
|
133
|
+
* ## What this buys that the cookie cannot
|
|
134
|
+
*
|
|
135
|
+
* **`drop` deletes.** Under {@link statelessStore} the ticket is the record, so a copy of the
|
|
136
|
+
* cookie taken before sign-out keeps working until it expires and *sign out everywhere* is not
|
|
137
|
+
* expressible at all. Here the cookie is a name for a row, and deleting the row ends every copy of
|
|
138
|
+
* the cookie at once, immediately.
|
|
139
|
+
*
|
|
140
|
+
* ## The key carries the subject, and that is deliberate
|
|
141
|
+
*
|
|
142
|
+
* A ticket is `<subject>:<random>`. The random half is the whole of the security — the subject is
|
|
143
|
+
* not a secret and is not trusted on the way back in, because the record it names is read from the
|
|
144
|
+
* row and never from the key. What the prefix buys is the one operation a flat random key makes
|
|
145
|
+
* impossible: *every session belonging to this person*. `SCAN sub:*` or `DELETE … WHERE key LIKE
|
|
146
|
+
* 'sub:%'` is then a query a deployment can write, and "sign out on every device" and "one live
|
|
147
|
+
* session per person" — which `put` is the place for — stop being features this package has to
|
|
148
|
+
* grow an API for.
|
|
149
|
+
*/
|
|
150
|
+
export declare function ticketStore(adapter: TicketAdapter, config?: TicketStoreConfig): SessionStore;
|
|
52
151
|
//# sourceMappingURL=store.d.ts.map
|
package/dist/store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,uFAAuF;IACvF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,oGAAoG;IACpG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,GAAG,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C,sFAAsF;IACtF,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IACnD,6FAA6F;IAC7F,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,cAAc,IAAI,YAAY,CAgB7C;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC1C;;;;;;;OAOG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAcD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,EAAE,MAAM,GAAE,iBAAsB,GAAG,YAAY,CA6BhG"}
|