@zitadel/sdk-core 1.0.0-alpha.24 → 1.0.0-alpha.28

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.
@@ -0,0 +1,764 @@
1
+ import { isJwtShaped, verifyJwt } from "./jwt.mjs";
2
+ import { joinURL } from "ufo";
3
+ //#region src/middleware.ts
4
+ /**
5
+ * Headers that must never be forwarded to an upstream service.
6
+ * These are connection-level headers that are meaningful only between
7
+ * two directly connected peers and become invalid when proxied.
8
+ */
9
+ const HOP_BY_HOP = /* @__PURE__ */ new Set([
10
+ "connection",
11
+ "content-length",
12
+ "host",
13
+ "keep-alive",
14
+ "proxy-authenticate",
15
+ "proxy-authorization",
16
+ "te",
17
+ "trailer",
18
+ "transfer-encoding",
19
+ "upgrade"
20
+ ]);
21
+ /**
22
+ * SDK-internal headers that must never be forwarded to the upstream backend.
23
+ * These headers carry session data between the middleware and server components;
24
+ * forwarding them upstream would expose internal state and allow header injection.
25
+ */
26
+ const INTERNAL_HEADERS = /* @__PURE__ */ new Set(["x-nextgen-auth-token"]);
27
+ /**
28
+ * Returns `true` when `pathname` matches at least one entry in `routes`.
29
+ *
30
+ * An entry ending with `*` matches any path that starts with the prefix
31
+ * before the `*`. All other entries match exactly, ignoring a trailing slash
32
+ * on either side: `/profile` and `/profile/` name the same route, and several
33
+ * frameworks (Qwik Router by default) serve the slashed form as canonical. An
34
+ * exact compare would let `/profile/` past a `protectedRoutes: ["/profile"]`.
35
+ *
36
+ * @param pathname - The URL pathname to test.
37
+ * @param routes - The list of route patterns to match against.
38
+ * @returns `true` if at least one pattern matches.
39
+ */
40
+ function matchesRoutes(pathname, routes) {
41
+ const withoutTrailingSlash = (path) => {
42
+ let end = path.length;
43
+ while (end > 1 && path.charCodeAt(end - 1) === 47) end--;
44
+ return path.slice(0, end);
45
+ };
46
+ const path = withoutTrailingSlash(pathname);
47
+ return routes.some((route) => {
48
+ if (route.endsWith("*")) return pathname.startsWith(route.slice(0, -1));
49
+ return path === withoutTrailingSlash(route);
50
+ });
51
+ }
52
+ /**
53
+ * The query parameter that brings a signed-out request's path to the login
54
+ * page, where `<zitadel-login>` returns after sign-in.
55
+ */
56
+ const RETURN_PATH_PARAM = "next";
57
+ /**
58
+ * Where a signed-out request for a protected route is sent: `loginPath`, with
59
+ * the requested path and query string in `next`, so `<zitadel-login>` returns
60
+ * to the page as it was asked for, filters and tabs included. The fragment
61
+ * never reaches the server, so it cannot be carried.
62
+ *
63
+ * A `next` the request carries itself is left out, so a redirect never nests
64
+ * an earlier one: should the login page be protected by mistake, each pass of
65
+ * the redirect loop would otherwise wrap the last and grow the URL. The rest
66
+ * of the query keeps the encoding it was sent with.
67
+ *
68
+ * Whatever query `loginPath` has of its own is kept, except a `next`, which
69
+ * this replaces.
70
+ *
71
+ * @param loginPath - The login page, a path starting with a single `/`.
72
+ * @param requestUrl - The full URL of the signed-out request.
73
+ * @returns The URL to redirect to, on the request's origin.
74
+ */
75
+ function loginRedirectUrl(loginPath, requestUrl) {
76
+ const requested = new URL(requestUrl);
77
+ const query = requested.search.slice(1).split("&").filter((pair) => !new URLSearchParams(pair).has(RETURN_PATH_PARAM)).join("&");
78
+ const loginUrl = new URL(loginPath, requested);
79
+ loginUrl.searchParams.set(RETURN_PATH_PARAM, query === "" ? requested.pathname : `${requested.pathname}?${query}`);
80
+ return loginUrl;
81
+ }
82
+ /**
83
+ * Whether a proxied call may carry the confidential project secret.
84
+ *
85
+ * ADR 036: the browser flow needs the secret only for the handoff exchange
86
+ * (`POST /sessions/exchange`). Stamping it onto every proxied operation would
87
+ * turn the app's public `/__nextgen/*` boundary into an open relay carrying
88
+ * project-level authority for any anonymous caller.
89
+ *
90
+ * @param method - The proxied request's HTTP method.
91
+ * @param pathname - The path after the proxy prefix, e.g. `/sessions/exchange`.
92
+ * @returns `true` only for the handoff exchange.
93
+ */
94
+ function requiresProjectSecret(method, pathname) {
95
+ return method.toUpperCase() === "POST" && pathname === "/sessions/exchange";
96
+ }
97
+ /**
98
+ * An upstream `Location`, pointed back at this app.
99
+ *
100
+ * Only the path, query and fragment survive; whatever origin the upstream
101
+ * named is discarded and the result is re-based onto the request's own URL.
102
+ * That keeps both properties the blanket strip was protecting -- an internal
103
+ * hostname can never reach the browser, and the redirect can only ever land on
104
+ * this app -- without needing to know what this app's public origin is.
105
+ *
106
+ * Comparing origins instead would have required that knowledge, and there is
107
+ * no reliable source for it: `X-Forwarded-Host` is attacker-settable on a
108
+ * direct request, and the request's own host is wrong behind a proxy that
109
+ * terminates TLS or rewrites it. Discarding the origin sidesteps the question
110
+ * -- a redirect to another origin becomes the same path on this one rather
111
+ * than being dropped, so a deployment behind such a proxy works instead of
112
+ * landing on a blank page.
113
+ *
114
+ * Nothing legitimate on this path points off-origin anyway: an outbound
115
+ * redirect to a provider travels as `step.redirect_url` in a JSON body and the
116
+ * widget performs it, never as a proxied header.
117
+ *
118
+ * A relative reference resolves against the path it came from (RFC 3986 §5),
119
+ * so `next?flow=x` answered to `/__nextgen/idp/google/callback` means
120
+ * `/__nextgen/idp/google/next?flow=x`. An unparseable value is the one case with
121
+ * nothing to forward.
122
+ */
123
+ function ontoThisApp(location, selfUrl) {
124
+ if (selfUrl === void 0 || selfUrl === "") return;
125
+ try {
126
+ const base = new URL(selfUrl);
127
+ const target = new URL(location, base);
128
+ return new URL(`${target.pathname}${target.search}${target.hash}`, base).toString();
129
+ } catch {
130
+ return;
131
+ }
132
+ }
133
+ /**
134
+ * Filters upstream response headers for proxying: strips hop-by-hop headers
135
+ * and `set-cookie` (handled separately via `getSetCookie()`), and reduces
136
+ * `location` to a redirect that can only land on this app.
137
+ *
138
+ * `location` was stripped outright until the identity-provider callback
139
+ * needed it: the provider returns the browser to `/__nextgen/idp/{slug}/callback`,
140
+ * and the engine answers `303` back to the page the sign-in started on. That
141
+ * is a top-level navigation with no JavaScript in the loop, so a dropped
142
+ * `Location` leaves the browser on an empty page. Outbound redirects to a
143
+ * provider do not travel this way -- they arrive as `step.redirect_url` in a
144
+ * JSON body and the widget navigates -- so nothing legitimate points off-origin.
145
+ *
146
+ * @param upstream - The upstream response headers.
147
+ * @param selfUrl - The app's own request URL, which is both the origin a
148
+ * redirect must land on and the base a relative one resolves against.
149
+ * Omitting it keeps the old behaviour of dropping every redirect.
150
+ * @returns A new `Headers` object with filtered headers.
151
+ */
152
+ function filterResponseHeaders(upstream, selfUrl) {
153
+ const filtered = new Headers();
154
+ upstream.forEach((value, key) => {
155
+ const name = key.toLowerCase();
156
+ if (HOP_BY_HOP.has(name) || name === "set-cookie") return;
157
+ if (name === "location") {
158
+ const onThisApp = ontoThisApp(value, selfUrl);
159
+ if (onThisApp !== void 0) filtered.set("location", onThisApp);
160
+ return;
161
+ }
162
+ filtered.set(key, value);
163
+ });
164
+ return filtered;
165
+ }
166
+ /** The cookie the server sets a session in and reads it from. */
167
+ const SERVER_SESSION_COOKIE = "__nextgen_session";
168
+ /** Every app's own session cookie starts with this. */
169
+ const PROJECT_SESSION_COOKIE_PREFIX = `${SERVER_SESSION_COOKIE}_`;
170
+ /**
171
+ * The cookie an app keeps its project's session in: the server's name with the
172
+ * project id appended (ADR 073). A browser shares one cookie jar across every
173
+ * port of a host, so under the server's name alone the Console of a local
174
+ * server and every app on `localhost` would hold their sessions in one slot and
175
+ * replace each other's.
176
+ *
177
+ * Every byte of the id outside `[A-Za-z0-9_-]` is percent-encoded, so any
178
+ * configured id makes a valid cookie name. Ids the server mints need none.
179
+ */
180
+ function sessionCookieName(projectId) {
181
+ let name = PROJECT_SESSION_COOKIE_PREFIX;
182
+ for (const byte of new TextEncoder().encode(projectId)) {
183
+ const char = String.fromCharCode(byte);
184
+ name += /[A-Za-z0-9_-]/.test(char) ? char : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
185
+ }
186
+ return name;
187
+ }
188
+ /** Whether `name` is a session cookie: the server's, or any app's own. */
189
+ function isSessionCookie(name) {
190
+ return name === "__nextgen_session" || name.startsWith(PROJECT_SESSION_COOKIE_PREFIX);
191
+ }
192
+ /**
193
+ * The `Set-Cookie` value for `cookie`, with the attributes the server gives its
194
+ * own session cookie: `Path=/`, `HttpOnly`, `SameSite=Lax`.
195
+ */
196
+ function serializeSessionCookie(cookie) {
197
+ const attributes = [`${cookie.name}=${cookie.value}`, "Path=/"];
198
+ if (cookie.maxAge !== void 0) attributes.push(`Max-Age=${cookie.maxAge}`);
199
+ attributes.push("HttpOnly", "SameSite=Lax");
200
+ if (cookie.secure) attributes.push("Secure");
201
+ return attributes.join("; ");
202
+ }
203
+ /**
204
+ * The `Cache-Control` of a response that sets a session cookie, as the server
205
+ * sends on its session answers: a shared cache must not hand the cookie, the
206
+ * session token it carries or its clearing, to the next visitor.
207
+ */
208
+ const SESSION_COOKIE_CACHE_CONTROL = "private, no-store";
209
+ /** The first entry of a forwarded header a chain of proxies appended to, if any. */
210
+ function firstForwarded(value) {
211
+ const first = value?.split(",")[0]?.trim();
212
+ return first ? first : void 0;
213
+ }
214
+ /**
215
+ * Whether a session cookie this app sets needs `Secure`, by the rule the server
216
+ * applies to its own (`cookieSecureFromContext`): always, except over plain
217
+ * http to a loopback host, where Safari would drop a `Secure` cookie. The
218
+ * scheme and host are what a proxy in front of the app forwarded (the first
219
+ * `X-Forwarded-Proto` and `X-Forwarded-Host`), else the app's request URL's,
220
+ * so an app behind a TLS proxy that talks to it over loopback keeps `Secure`.
221
+ */
222
+ function secureFor(request) {
223
+ let url;
224
+ try {
225
+ url = new URL(request.requestUrl);
226
+ } catch {
227
+ return true;
228
+ }
229
+ if ((firstForwarded(request.forwardedProto) ?? url.protocol.slice(0, -1)).toLowerCase() !== "http") return true;
230
+ let host;
231
+ try {
232
+ host = new URL(`http://${firstForwarded(request.forwardedHost) ?? url.host}`).hostname;
233
+ } catch {
234
+ return true;
235
+ }
236
+ host = host.toLowerCase();
237
+ return !(host === "localhost" || host === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(host));
238
+ }
239
+ /** Seconds from now until `epochMs`, at least one, or `undefined` when unknown. */
240
+ function secondsUntil(epochMs) {
241
+ return Number.isFinite(epochMs) ? Math.max(1, Math.floor((epochMs - Date.now()) / 1e3)) : void 0;
242
+ }
243
+ /** Clears the cookie `name`. */
244
+ function clearedCookie(name, secure) {
245
+ return {
246
+ name,
247
+ value: "",
248
+ maxAge: 0,
249
+ secure
250
+ };
251
+ }
252
+ /**
253
+ * Copies a session of this project found in the server's cookie into the
254
+ * project's own. The server's cookie keeps it: on a server without platform
255
+ * bootstrap the Console signs in to the app's project, and clearing the cookie
256
+ * would sign it out. The proxy retires the server's cookie when the app signs
257
+ * a user in or out ({@link proxySessionCookies}), so it cannot sign its user
258
+ * back in once the app's own cookie is gone.
259
+ */
260
+ function copiedToProjectCookie(token, projectId, maxAge, secure) {
261
+ return {
262
+ name: sessionCookieName(projectId),
263
+ value: token,
264
+ maxAge,
265
+ secure
266
+ };
267
+ }
268
+ /** The pieces of a `Cookie` header, each with the name it carries. */
269
+ function cookiePieces(header) {
270
+ const pieces = [];
271
+ for (const raw of (header ?? "").split(";")) {
272
+ const piece = raw.trim();
273
+ if (piece !== "") {
274
+ const index = piece.indexOf("=");
275
+ pieces.push({
276
+ name: (index === -1 ? piece : piece.slice(0, index)).trim(),
277
+ piece
278
+ });
279
+ }
280
+ }
281
+ return pieces;
282
+ }
283
+ /** The value of the first cookie named `name`, or `null` when it is absent or empty. */
284
+ function cookieValue(pieces, name) {
285
+ const found = pieces.find((candidate) => candidate.name === name);
286
+ const value = found ? found.piece.slice(found.piece.indexOf("=") + 1).trim() : "";
287
+ return value === "" ? null : value;
288
+ }
289
+ /** The name a `Set-Cookie` value sets. */
290
+ function setCookieName(setCookie) {
291
+ const index = setCookie.indexOf("=");
292
+ return index === -1 ? "" : setCookie.slice(0, index).trim();
293
+ }
294
+ /**
295
+ * The session cookies of a call the app proxies to the server (ADR 073). The
296
+ * server knows one session cookie, `__nextgen_session`; the app keeps its
297
+ * project's session in {@link sessionCookieName}, so it neither replaces nor
298
+ * reads the session of another app or of the Console on the same host.
299
+ *
300
+ * - On the way up, the app's own session cookie goes under the server's name,
301
+ * and every other session cookie stays behind. Other cookies pass unchanged.
302
+ * - On the way back, a `Set-Cookie` of the server's session cookie, which sets
303
+ * or clears it, is renamed to the app's.
304
+ * - A session kept under the server's name goes up to one call only, the
305
+ * session probe `GET /sessions/me?project_id=<project>`, which the server
306
+ * refuses for another project's session. When the answer signs a user of
307
+ * this project in, the session is copied to the app's cookie; the server's
308
+ * cookie keeps it, since the Console may share the project.
309
+ * - When the app signs a user in (`POST /sessions/exchange`) or out
310
+ * (`DELETE /sessions/me`), a session still kept under the server's name is
311
+ * retired, or it would sign its user back in once the app's own cookie is
312
+ * gone: the server is asked about it in this project, and a session of this
313
+ * project is cleared (and on sign-out also ended), a dead one cleared, and
314
+ * one of another project, the Console's on `localhost`, left alone.
315
+ * - Without a project the cookies pass as they are: nothing says which session
316
+ * is the app's.
317
+ *
318
+ * @param request - The proxied request.
319
+ * @param projectId - The project this app signs its users in to.
320
+ * @param upstream - The server the proxy forwards to, which it asks about a
321
+ * session kept under the server's name.
322
+ */
323
+ function proxySessionCookies(request, projectId, upstream) {
324
+ if (!projectId) return {
325
+ upstreamCookie: request.cookie,
326
+ toBrowser: async (upstream) => ({
327
+ setCookies: upstream.headers.getSetCookie?.() ?? [],
328
+ body: upstream.body
329
+ })
330
+ };
331
+ const ownName = sessionCookieName(projectId);
332
+ const pieces = cookiePieces(request.cookie);
333
+ const own = cookieValue(pieces, ownName);
334
+ const shared = cookieValue(pieces, SERVER_SESSION_COOKIE);
335
+ const method = request.method.toUpperCase();
336
+ const isProbe = method === "GET" && request.path === "/sessions/me" && new URLSearchParams(request.search).get("project_id") === projectId;
337
+ const signsIn = method === "POST" && request.path === "/sessions/exchange";
338
+ const signsOut = method === "DELETE" && request.path === "/sessions/me";
339
+ const probed = own === null && isProbe ? shared : null;
340
+ const secure = secureFor(request);
341
+ const sent = pieces.filter(({ name }) => !isSessionCookie(name)).map(({ piece }) => piece);
342
+ const session = own ?? probed;
343
+ if (session !== null) sent.push(`${SERVER_SESSION_COOKIE}=${session}`);
344
+ return {
345
+ upstreamCookie: sent.length > 0 ? sent.join("; ") : null,
346
+ async toBrowser(answer) {
347
+ const upstreamSetCookies = answer.headers.getSetCookie?.() ?? [];
348
+ const setCookies = upstreamSetCookies.map((setCookie) => setCookieName(setCookie) === "__nextgen_session" ? `${ownName}${setCookie.slice(setCookie.indexOf("="))}` : setCookie);
349
+ const setsSession = upstreamSetCookies.some((setCookie) => setCookieName(setCookie) === SERVER_SESSION_COOKIE);
350
+ if (shared !== null && (signsOut || signsIn && answer.ok && setsSession)) {
351
+ if (await retiresSharedSession(shared, projectId, upstream, signsOut)) setCookies.push(serializeSessionCookie(clearedCookie(SERVER_SESSION_COOKIE, secure)));
352
+ }
353
+ if (probed === null || answer.status !== 200) return {
354
+ setCookies,
355
+ body: answer.body
356
+ };
357
+ const body = await answer.text();
358
+ const signedIn = sessionAnswer(parsedJson(body), projectId);
359
+ if (signedIn.userId && !setCookies.some((setCookie) => setCookieName(setCookie) === ownName)) {
360
+ const maxAge = signedIn.expiresAt ? secondsUntil(Date.parse(signedIn.expiresAt)) : void 0;
361
+ setCookies.push(serializeSessionCookie(copiedToProjectCookie(probed, projectId, maxAge, secure)));
362
+ }
363
+ return {
364
+ setCookies,
365
+ body
366
+ };
367
+ }
368
+ };
369
+ }
370
+ /**
371
+ * Whether the session kept under the server's name goes when the app signs a
372
+ * user in or out: the server is asked about it in this project. A session of
373
+ * this project goes, and on sign-out it is ended too, so that it cannot sign
374
+ * its user back in; one the server no longer holds goes as well. One of another
375
+ * project, the Console's on `localhost`, stays, and so does one the server gave
376
+ * no verdict on, and a JWT, which the middleware judges itself.
377
+ */
378
+ async function retiresSharedSession(token, projectId, upstream, signsOut) {
379
+ if (isJwtShaped(token)) return false;
380
+ const cookie = `${SERVER_SESSION_COOKIE}=${token}`;
381
+ let res;
382
+ try {
383
+ res = await fetch(`${joinURL(upstream.url, "/sessions/me")}?project_id=${encodeURIComponent(projectId)}`, {
384
+ method: "GET",
385
+ headers: { cookie },
386
+ signal: AbortSignal.timeout(upstream.timeoutMs)
387
+ });
388
+ } catch {
389
+ return false;
390
+ }
391
+ if (res.status === 401) return true;
392
+ if (res.status === 404 || res.status === 403) return await errorCode(res) === SESSION_NOT_FOUND;
393
+ if (!res.ok) return false;
394
+ if (parsedJson(await res.text().catch(() => ""))?.project_id !== projectId) return false;
395
+ if (signsOut) await fetch(joinURL(upstream.url, "/sessions/me"), {
396
+ method: "DELETE",
397
+ headers: { cookie },
398
+ signal: AbortSignal.timeout(upstream.timeoutMs)
399
+ }).catch(() => void 0);
400
+ return true;
401
+ }
402
+ /** `text` as JSON, or `undefined` when it is not JSON. */
403
+ function parsedJson(text) {
404
+ try {
405
+ return JSON.parse(text);
406
+ } catch {
407
+ return;
408
+ }
409
+ }
410
+ /**
411
+ * The error code the backend answers a session read with when the session
412
+ * belongs to another project than the one the read names.
413
+ */
414
+ const SESSION_PROJECT_MISMATCH = "sess.project_mismatch";
415
+ /** A live session that names no user of this app's project. */
416
+ function noUser() {
417
+ return {
418
+ userId: null,
419
+ identifier: null,
420
+ identifierProperty: null,
421
+ display: null,
422
+ expiresAt: null
423
+ };
424
+ }
425
+ /**
426
+ * What a `GET /sessions/me` answer says about this app's project. A session of
427
+ * another project names no user here: a backend that predates `project_id`
428
+ * answers for any project, so the project the answer names is checked too.
429
+ */
430
+ function sessionAnswer(body, projectId) {
431
+ const answer = typeof body === "object" && body !== null ? body : {};
432
+ if (answer.project_id !== projectId) return noUser();
433
+ const text = (value) => typeof value === "string" && value !== "" ? value : null;
434
+ return {
435
+ userId: text(answer.user_id),
436
+ identifier: text(answer.user?.identifier),
437
+ identifierProperty: text(answer.user?.identifier_property),
438
+ display: text(answer.user?.display),
439
+ expiresAt: text(answer.expires_at)
440
+ };
441
+ }
442
+ /** The code the backend answers `GET /sessions/me` with when the session is gone. */
443
+ const SESSION_NOT_FOUND = "sess.not_found";
444
+ /**
445
+ * Asks the backend (`GET {issuerUrl}/sessions/me?project_id={projectId}`)
446
+ * whether an opaque session token names a live session in this app's project,
447
+ * telling a refusal apart from no verdict.
448
+ *
449
+ * A live session of another project comes back with no user, like an
450
+ * anonymous one: it signs no one in here, and its cookie is not dead. The
451
+ * backend refuses such a session itself (`403 sess.project_mismatch`); the
452
+ * project its answer names is checked as well, because a backend that predates
453
+ * the parameter answers for any project.
454
+ *
455
+ * A `404` is a refusal only with the `sess.not_found` code. Without it, the
456
+ * answer came from something in front of the backend (a proxy, a `url` with
457
+ * the wrong path), which judged nothing.
458
+ *
459
+ * When there is no verdict, it logs why: the request it was asked for then
460
+ * renders signed out, or answers `503` on a protected route, and nothing else
461
+ * would say what went wrong.
462
+ *
463
+ * @param token - The opaque session token from the app's session cookie.
464
+ * @param issuerUrl - Base URL of the auth backend.
465
+ * @param timeoutMs - How long to wait for the backend before giving up.
466
+ * @param projectId - The project this app signs its users in to. Without one,
467
+ * no session can be shown to belong here: the backend is not asked, and the
468
+ * session reads as live with no user, so its cookie is kept.
469
+ * @returns The backend's {@link OpaqueSessionCheck | verdict}.
470
+ */
471
+ async function checkOpaqueSessionToken(token, issuerUrl, timeoutMs, projectId) {
472
+ if (!projectId) return {
473
+ verdict: "live",
474
+ session: noUser()
475
+ };
476
+ const key = await otherProjectKey(token, projectId);
477
+ if (otherProjectSessions.knows(key)) return {
478
+ verdict: "live",
479
+ session: noUser()
480
+ };
481
+ const endpoint = joinURL(issuerUrl, "/sessions/me");
482
+ try {
483
+ const res = await fetch(`${endpoint}?project_id=${encodeURIComponent(projectId)}`, {
484
+ method: "GET",
485
+ headers: { cookie: `${SERVER_SESSION_COOKIE}=${token}` },
486
+ signal: AbortSignal.timeout(timeoutMs)
487
+ });
488
+ if (res.status === 401) return { verdict: "refused" };
489
+ if (res.status === 404 && await errorCode(res) === SESSION_NOT_FOUND) return { verdict: "refused" };
490
+ if (res.status === 403 && await errorCode(res) === "sess.project_mismatch") {
491
+ otherProjectSessions.remember(key);
492
+ return {
493
+ verdict: "live",
494
+ session: noUser()
495
+ };
496
+ }
497
+ if (!res.ok) return undecided(endpoint, `HTTP ${res.status}`);
498
+ const body = await res.json();
499
+ if (body?.project_id !== projectId) otherProjectSessions.remember(key);
500
+ return {
501
+ verdict: "live",
502
+ session: sessionAnswer(body, projectId)
503
+ };
504
+ } catch (err) {
505
+ return undecided(endpoint, failureCause(err, timeoutMs));
506
+ }
507
+ }
508
+ /**
509
+ * The opaque session tokens the backend placed in another project, so that a
510
+ * request signed out here does not ask again about the session another app,
511
+ * or on `localhost` the Console, keeps under the server's cookie: a session
512
+ * never changes its project. Each is known for a while, and only so many.
513
+ */
514
+ const otherProjectSessions = (() => {
515
+ const TTL_MS = 3e5;
516
+ const MAX_ENTRIES = 1024;
517
+ const known = /* @__PURE__ */ new Map();
518
+ return {
519
+ knows(key) {
520
+ const until = known.get(key);
521
+ if (until === void 0) return false;
522
+ if (until > Date.now()) return true;
523
+ known.delete(key);
524
+ return false;
525
+ },
526
+ remember(key) {
527
+ known.delete(key);
528
+ known.set(key, Date.now() + TTL_MS);
529
+ if (known.size > MAX_ENTRIES) {
530
+ const oldest = known.keys().next().value;
531
+ if (oldest !== void 0) known.delete(oldest);
532
+ }
533
+ },
534
+ clear() {
535
+ known.clear();
536
+ }
537
+ };
538
+ })();
539
+ /** The memo key of a token in a project: a hash, so that the memo holds no session token. */
540
+ async function otherProjectKey(token, projectId) {
541
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
542
+ let hex = "";
543
+ for (const byte of new Uint8Array(digest)) hex += byte.toString(16).padStart(2, "0");
544
+ return `${projectId}:${hex}`;
545
+ }
546
+ /**
547
+ * Forgets which session tokens the backend placed in another project
548
+ * ({@link checkOpaqueSessionToken} remembers them for a while). For tests that
549
+ * answer for the same token differently.
550
+ */
551
+ function clearSessionCheckCache() {
552
+ otherProjectSessions.clear();
553
+ }
554
+ /**
555
+ * Validates an opaque session token by asking the backend
556
+ * (`GET {issuerUrl}/sessions/me?project_id={projectId}`) whether the session is
557
+ * live in this app's project.
558
+ *
559
+ * `null` folds a refusal and no verdict together, so it says whether the
560
+ * request is signed in, not whether the cookie is dead: clearing the cookie on
561
+ * `null` signs users out whenever the backend fails. To decide that, use
562
+ * {@link checkOpaqueSessionToken}.
563
+ *
564
+ * @param token - The opaque session token from the app's session cookie.
565
+ * @param issuerUrl - Base URL of the auth backend.
566
+ * @param timeoutMs - How long to wait for the backend before giving up.
567
+ * @param projectId - The project this app signs its users in to.
568
+ * @returns The live session — with a `null` `userId` when it names no user of
569
+ * this project — or `null` when the backend did not confirm one: it refused
570
+ * the token, or gave no verdict.
571
+ */
572
+ async function validateOpaqueSessionToken(token, issuerUrl, timeoutMs, projectId) {
573
+ const check = await checkOpaqueSessionToken(token, issuerUrl, timeoutMs, projectId);
574
+ return check.verdict === "live" ? check.session : null;
575
+ }
576
+ /** The `code` of an error envelope, or `undefined` when the body is not one. */
577
+ async function errorCode(res) {
578
+ try {
579
+ const body = await res.json();
580
+ return typeof body.code === "string" ? body.code : void 0;
581
+ } catch {
582
+ return;
583
+ }
584
+ }
585
+ /** Why a session check failed, for the log. */
586
+ function failureCause(err, timeoutMs) {
587
+ if (err?.name === "TimeoutError") return `no answer within ${timeoutMs} ms`;
588
+ const cause = err?.cause;
589
+ return cause instanceof Error ? `${String(err)}: ${cause.message}` : String(err);
590
+ }
591
+ /** Logs why the backend gave no verdict on a session cookie, and returns that. */
592
+ function undecided(endpoint, cause) {
593
+ console.warn(`[nextgen] Could not check the session cookie against ${endpoint} (${cause}). That is not a refusal: the request counts as signed out, and the cookie is kept.`);
594
+ return { verdict: "undecided" };
595
+ }
596
+ /**
597
+ * Whether a verified JWT names `projectId` in its `aud` claim, the one place a
598
+ * self-contained token can say which project it was issued for.
599
+ */
600
+ function jwtNamesProject(payload, projectId) {
601
+ const audience = payload.aud === void 0 ? [] : [payload.aud].flat();
602
+ return projectId !== "" && audience.includes(projectId);
603
+ }
604
+ function signedOut() {
605
+ return {
606
+ isAuthenticated: false,
607
+ session: null
608
+ };
609
+ }
610
+ let warnedMissingProject = false;
611
+ /** Says once per process why no session counts: no project is configured. */
612
+ function warnMissingProject() {
613
+ if (warnedMissingProject) return;
614
+ warnedMissingProject = true;
615
+ console.error("[nextgen] No project id is configured, so no session counts as signed in. Set ZITADEL_PROJECT_ID (or the `projectId` option) to the project this app signs its users in to.");
616
+ }
617
+ /**
618
+ * The cookie a request's session is read from: the project's own
619
+ * ({@link sessionCookieName}), else the server's `__nextgen_session`, where
620
+ * this app kept its sessions before ADR 073 and where, on `localhost`, the
621
+ * Console keeps its own.
622
+ */
623
+ function storedSession(cookie, projectId) {
624
+ for (const name of [sessionCookieName(projectId), SERVER_SESSION_COOKIE]) {
625
+ const token = cookie(name);
626
+ if (token) return {
627
+ name,
628
+ token
629
+ };
630
+ }
631
+ return null;
632
+ }
633
+ /**
634
+ * Judges a JWT. It signs its `sub` in when it verifies and its `aud` names
635
+ * this project. Held in a cookie, it is dead when it fails verification or
636
+ * names no user of this project; a verified JWT of another project is live
637
+ * there, so its cookie stays.
638
+ */
639
+ async function judgeJwt(token, projectId, options) {
640
+ const payload = isJwtShaped(token) ? await verifyJwt(token, {
641
+ issuerUrl: options.url,
642
+ allowedAlgorithms: options.allowedAlgorithms,
643
+ clockSkewMs: options.clockSkewMs,
644
+ audience: options.audience,
645
+ allowedTokenTypes: options.allowedTokenTypes,
646
+ jwksTimeoutMs: options.jwksTimeoutMs
647
+ }) : null;
648
+ if (!payload) return {
649
+ auth: signedOut(),
650
+ dead: true,
651
+ maxAge: void 0
652
+ };
653
+ const namesProject = jwtNamesProject(payload, projectId);
654
+ if (!payload.sub || !namesProject) return {
655
+ auth: signedOut(),
656
+ dead: namesProject,
657
+ maxAge: void 0
658
+ };
659
+ return {
660
+ auth: {
661
+ isAuthenticated: true,
662
+ session: {
663
+ userId: payload.sub,
664
+ identifier: payload.email ?? null,
665
+ identifierProperty: null,
666
+ display: payload.name ?? null,
667
+ token
668
+ }
669
+ },
670
+ dead: false,
671
+ maxAge: payload.exp === void 0 ? void 0 : secondsUntil(payload.exp * 1e3)
672
+ };
673
+ }
674
+ /**
675
+ * Judges which user, if any, a request is signed in as in this app's project.
676
+ * The one implementation behind every server SDK's middleware, so they cannot
677
+ * drift on who counts as signed in.
678
+ *
679
+ * - A Bearer token is judged first, as a JWT. When it signs a user of this
680
+ * project in, that is the answer and the cookie is not judged; otherwise the
681
+ * cookie speaks for itself.
682
+ * - The session cookie is the project's own, else the server's. A session of
683
+ * this project found in the server's cookie is copied to the project's own.
684
+ * - A JWT-shaped session cookie signs its `sub` in when it verifies and its
685
+ * `aud` names the project. It never reaches the backend, which does not
686
+ * re-check JWT claims.
687
+ * - Any other session cookie is checked by the backend
688
+ * ({@link checkOpaqueSessionToken}) and signs its user in when the session
689
+ * is live in this project.
690
+ * - Only a cookie that held a dead session is cleared: one the backend refused,
691
+ * or a JWT that fails verification or names no user of this project. When
692
+ * the backend gives no verdict, the result says so
693
+ * ({@link ResolvedSession.undecided}) and the cookie stays.
694
+ * - Without a project nothing signs in, and no cookie is touched.
695
+ */
696
+ async function resolveSession(request, options) {
697
+ const { url, projectId, opaqueTokenTimeoutMs } = options;
698
+ if (!projectId) {
699
+ warnMissingProject();
700
+ return {
701
+ auth: signedOut(),
702
+ undecided: false,
703
+ cookies: []
704
+ };
705
+ }
706
+ if (request.bearer !== null) {
707
+ const bearer = await judgeJwt(request.bearer, projectId, options);
708
+ if (bearer.auth.isAuthenticated) return {
709
+ auth: bearer.auth,
710
+ undecided: false,
711
+ cookies: []
712
+ };
713
+ }
714
+ const stored = storedSession(request.cookie, projectId);
715
+ if (!stored) return {
716
+ auth: signedOut(),
717
+ undecided: false,
718
+ cookies: []
719
+ };
720
+ const secure = secureFor(request);
721
+ const moved = (maxAge) => stored.name === "__nextgen_session" ? [copiedToProjectCookie(stored.token, projectId, maxAge, secure)] : [];
722
+ const cleared = () => [clearedCookie(stored.name, secure)];
723
+ if (isJwtShaped(stored.token)) {
724
+ const judged = await judgeJwt(stored.token, projectId, options);
725
+ if (judged.auth.isAuthenticated) return {
726
+ auth: judged.auth,
727
+ undecided: false,
728
+ cookies: moved(judged.maxAge)
729
+ };
730
+ return {
731
+ auth: signedOut(),
732
+ undecided: false,
733
+ cookies: judged.dead ? cleared() : []
734
+ };
735
+ }
736
+ const check = await checkOpaqueSessionToken(stored.token, url, opaqueTokenTimeoutMs, projectId);
737
+ const userId = check.verdict === "live" ? check.session.userId : null;
738
+ if (check.verdict === "live" && userId) {
739
+ const { session } = check;
740
+ return {
741
+ auth: {
742
+ isAuthenticated: true,
743
+ session: {
744
+ userId,
745
+ identifier: session.identifier,
746
+ identifierProperty: session.identifierProperty,
747
+ display: session.display,
748
+ token: stored.token
749
+ }
750
+ },
751
+ undecided: false,
752
+ cookies: moved(session.expiresAt ? secondsUntil(Date.parse(session.expiresAt)) : void 0)
753
+ };
754
+ }
755
+ return {
756
+ auth: signedOut(),
757
+ undecided: check.verdict === "undecided",
758
+ cookies: check.verdict === "refused" ? cleared() : []
759
+ };
760
+ }
761
+ //#endregion
762
+ export { HOP_BY_HOP, INTERNAL_HEADERS, SERVER_SESSION_COOKIE, SESSION_COOKIE_CACHE_CONTROL, SESSION_PROJECT_MISMATCH, checkOpaqueSessionToken, clearSessionCheckCache, filterResponseHeaders, jwtNamesProject, loginRedirectUrl, matchesRoutes, proxySessionCookies, requiresProjectSecret, resolveSession, serializeSessionCookie, sessionCookieName, validateOpaqueSessionToken };
763
+
764
+ //# sourceMappingURL=middleware.mjs.map