@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.
- package/README.md +58 -3
- package/dist/index.d.mts +23 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +40 -0
- package/dist/index.mjs.map +1 -0
- package/dist/{jwt.d.ts → jwt.d.mts} +97 -83
- package/dist/jwt.d.mts.map +1 -0
- package/dist/jwt.mjs +465 -0
- package/dist/jwt.mjs.map +1 -0
- package/dist/middleware.d.mts +556 -0
- package/dist/middleware.d.mts.map +1 -0
- package/dist/middleware.mjs +764 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/types.d.mts +301 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +78 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +23 -27
- package/dist/index.d.ts +0 -22
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -42
- package/dist/jwt.d.ts.map +0 -1
- package/dist/jwt.js +0 -415
- package/dist/middleware.d.ts +0 -202
- package/dist/middleware.d.ts.map +0 -1
- package/dist/middleware.js +0 -131
- package/dist/tsconfig.lib.tsbuildinfo +0 -1
- package/dist/types.d.ts +0 -215
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -26
|
@@ -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
|