javascript-solid-server 0.0.171 → 0.0.173

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.
@@ -405,7 +405,12 @@
405
405
  "Bash(ANDROID_HOME=/home/melvin/Android/Sdk ANDROID_SDK_ROOT=/home/melvin/Android/Sdk ./gradlew :app:compileDebugKotlin --no-daemon)",
406
406
  "Bash(./scripts/fetch-libnode.sh v18.20.4)",
407
407
  "Bash(ANDROID_HOME=/home/melvin/Android/Sdk ANDROID_SDK_ROOT=/home/melvin/Android/Sdk ./gradlew :app:assembleDebug --no-daemon)",
408
- "Bash(/home/melvin/Android/Sdk/platform-tools/adb devices *)"
408
+ "Bash(/home/melvin/Android/Sdk/platform-tools/adb devices *)",
409
+ "Bash(command -v chromium chromium-browser google-chrome firefox playwright)",
410
+ "Bash(/usr/bin/chromium-browser --headless --no-sandbox --disable-gpu --hide-scrollbars --window-size=480,800 --screenshot=/tmp/consent-preview.png file:///tmp/consent-preview.html)",
411
+ "Bash(cp /tmp/consent-preview.html ~/consent-preview.html)",
412
+ "Read(//home/melvin/**)",
413
+ "Bash(/usr/bin/chromium-browser --headless --no-sandbox --disable-gpu --hide-scrollbars --window-size=520,900 --screenshot=consent.png file:///home/melvin/consent-preview.html)"
409
414
  ]
410
415
  }
411
416
  }
package/bin/jss.js CHANGED
@@ -105,6 +105,11 @@ program
105
105
  .option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
106
106
  .option('--git', 'Enable Git HTTP backend (clone/push support)')
107
107
  .option('--no-git', 'Disable Git HTTP backend')
108
+ .option('--cors-proxy', 'Enable CORS proxy at /proxy?url=... for browser apps (WAC-gated)')
109
+ .option('--no-cors-proxy', 'Disable CORS proxy')
110
+ .option('--cors-proxy-max-bytes <n>', 'CORS proxy upstream response size cap (default 50MB)', parseInt)
111
+ .option('--cors-proxy-timeout-ms <ms>', 'CORS proxy upstream request timeout (default 30s)', parseInt)
112
+ .option('--cors-proxy-max-redirects <n>', 'CORS proxy max redirect hops, each re-validated (default 5)', parseInt)
108
113
  .option('--nostr', 'Enable Nostr relay')
109
114
  .option('--no-nostr', 'Disable Nostr relay')
110
115
  .option('--nostr-path <path>', 'Nostr relay WebSocket path (default: /relay)')
@@ -191,6 +196,10 @@ program
191
196
  mashlibVersion: config.mashlibVersion,
192
197
  mashlibModule: config.mashlibModule,
193
198
  git: config.git,
199
+ corsProxy: config.corsProxy,
200
+ corsProxyMaxBytes: config.corsProxyMaxBytes,
201
+ corsProxyTimeoutMs: config.corsProxyTimeoutMs,
202
+ corsProxyMaxRedirects: config.corsProxyMaxRedirects,
194
203
  nostr: config.nostr,
195
204
  nostrPath: config.nostrPath,
196
205
  nostrMaxEvents: config.nostrMaxEvents,
@@ -242,6 +251,7 @@ program
242
251
  }
243
252
  if (config.mashlibModule) console.log(` Mashlib module: ${config.mashlibModule}`);
244
253
  if (config.git) console.log(' Git: enabled (clone/push support)');
254
+ if (config.corsProxy) console.log(' CORS proxy: enabled (/proxy?url=..., WAC-gated)');
245
255
  if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
246
256
  if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
247
257
  if (config.terminal) console.log(' Terminal: enabled (/.terminal)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.171",
3
+ "version": "0.0.173",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -48,8 +48,28 @@ export function buildResourceUrl(request, urlPath) {
48
48
  * @param {object} request - Fastify request
49
49
  * @param {object} reply - Fastify reply
50
50
  * @param {object} options - Optional settings
51
- * @param {string} options.requiredMode - Override the required access mode (e.g., 'Write' for git push)
52
- * @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>}
51
+ * @param {string} [options.requiredMode] - Override the required access mode
52
+ * (e.g., 'Write' for git push). Defaults to getRequiredMode(method).
53
+ * @param {boolean} [options.skipParentForMissing] - When true, skip the
54
+ * "non-existent resource + write method → check parent container"
55
+ * fallback. Used by virtual endpoints (e.g. `/proxy` in #378) that have
56
+ * no backing storage but still want WAC checked against the URL itself.
57
+ * Without this flag, POST/PUT/PATCH on a missing resource is authorized
58
+ * against the parent (e.g. `/proxy` falls back to `/`), which is too
59
+ * permissive for endpoints whose ACL is meant to live at that path.
60
+ * @returns {Promise<{
61
+ * authorized: boolean,
62
+ * webId: string|null,
63
+ * wacAllow: string,
64
+ * authError: string|null,
65
+ * paymentRequired?: object,
66
+ * paid?: number,
67
+ * balance?: number,
68
+ * currency?: string
69
+ * }>}
70
+ * `paid` is the cost actually debited (number, not boolean) — see
71
+ * checkAccess() in src/wac/checker.js:189; callers stringify it for
72
+ * the X-Cost response header.
53
73
  */
54
74
  export async function authorize(request, reply, options = {}) {
55
75
  const urlPath = request.url.split('?')[0];
@@ -98,7 +118,7 @@ export async function authorize(request, reply, options = {}) {
98
118
  let checkUrl = resourceUrl;
99
119
  let checkIsContainer = isContainer;
100
120
 
101
- if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH')) {
121
+ if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH') && !options.skipParentForMissing) {
102
122
  // Check write permission on parent container
103
123
  const parentPath = getParentPath(storagePath);
104
124
  checkPath = parentPath;
@@ -107,6 +127,10 @@ export async function authorize(request, reply, options = {}) {
107
127
  checkUrl = buildResourceUrl(request, parentUrlPath);
108
128
  checkIsContainer = true;
109
129
  }
130
+ // skipParentForMissing: callers with virtual endpoints (e.g. /proxy in
131
+ // #378) want WAC checked against the URL path itself even when no
132
+ // backing storage exists. Without this opt-out, POST /proxy on a
133
+ // single-user pod gets authorized against /, which is too permissive.
110
134
 
111
135
  // Check WAC permissions
112
136
  const { allowed, wacAllow, paymentRequired, paid, balance, currency } = await checkAccess({
package/src/config.js CHANGED
@@ -46,6 +46,13 @@ export const defaults = {
46
46
  // Git HTTP backend
47
47
  git: false,
48
48
 
49
+ // CORS proxy (#378) — pod-hosted, WAC-gated proxy for browser apps to
50
+ // fetch arbitrary upstreams that don't return CORS headers.
51
+ corsProxy: false,
52
+ corsProxyMaxBytes: 50 * 1024 * 1024, // 50 MB ceiling on upstream response size
53
+ corsProxyTimeoutMs: 30_000, // 30 s deadline for upstream to send headers (504 if exceeded). The timeout does not apply during body streaming — body size is capped by corsProxyMaxBytes; see follow-up for streaming-phase timeout.
54
+ corsProxyMaxRedirects: 5, // each redirect re-validated for SSRF
55
+
49
56
  // Nostr relay
50
57
  nostr: false,
51
58
  nostrPath: '/relay',
@@ -147,6 +154,10 @@ const envMap = {
147
154
  JSS_MASHLIB_VERSION: 'mashlibVersion',
148
155
  JSS_MASHLIB_MODULE: 'mashlibModule',
149
156
  JSS_GIT: 'git',
157
+ JSS_CORS_PROXY: 'corsProxy',
158
+ JSS_CORS_PROXY_MAX_BYTES: 'corsProxyMaxBytes',
159
+ JSS_CORS_PROXY_TIMEOUT_MS: 'corsProxyTimeoutMs',
160
+ JSS_CORS_PROXY_MAX_REDIRECTS: 'corsProxyMaxRedirects',
150
161
  JSS_NOSTR: 'nostr',
151
162
  JSS_NOSTR_PATH: 'nostrPath',
152
163
  JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
@@ -208,6 +219,7 @@ const BOOLEAN_KEYS = new Set([
208
219
  'mashlib',
209
220
  'mashlibCdn',
210
221
  'git',
222
+ 'corsProxy',
211
223
  'nostr',
212
224
  'webrtc',
213
225
  'terminal',
@@ -243,7 +255,13 @@ function parseEnvValue(value, key) {
243
255
  }
244
256
 
245
257
  // Numeric values for known numeric keys
246
- if ((key === 'port' || key === 'nostrMaxEvents' || key === 'payCost' || key === 'payRate') && !isNaN(value)) {
258
+ if ((key === 'port' ||
259
+ key === 'nostrMaxEvents' ||
260
+ key === 'payCost' ||
261
+ key === 'payRate' ||
262
+ key === 'corsProxyMaxBytes' ||
263
+ key === 'corsProxyTimeoutMs' ||
264
+ key === 'corsProxyMaxRedirects') && !isNaN(value)) {
247
265
  return parseInt(value, 10);
248
266
  }
249
267
 
@@ -0,0 +1,423 @@
1
+ /**
2
+ * CORS proxy handler — fetches an arbitrary upstream URL on behalf of a
3
+ * browser-side caller and returns the response with CORS headers, so
4
+ * pod-hosted apps can talk to non-CORS-friendly origins (#378).
5
+ *
6
+ * URL shape: `/proxy?url=<absolute-url>` — query-param keeps the path a
7
+ * plain Solid resource for WAC purposes (acl can sit at /proxy or /.acl
8
+ * and inherit normally).
9
+ *
10
+ * Auth: WAC. The standard /proxy resource is checked by server.js's
11
+ * authorize() pipeline before this handler runs; pod owner controls
12
+ * access by writing an .acl on /proxy.
13
+ *
14
+ * SSRF: validateExternalUrl() runs on the user-supplied URL AND on every
15
+ * redirect target. Manual redirect following is non-negotiable — letting
16
+ * fetch() follow redirects automatically would let an attacker host a
17
+ * 302 to 169.254.169.254 and bypass the initial guard.
18
+ *
19
+ * Body limits: streamed, with a configurable max-bytes ceiling enforced
20
+ * during streaming. Timeout via AbortController.
21
+ */
22
+
23
+ import { validateExternalUrl } from '../utils/ssrf.js';
24
+ import { Readable, Transform } from 'stream';
25
+
26
+ // CORS headers applied to every proxy response. Same shape/source-of-truth
27
+ // pattern as GIT_CORS_HEADERS in src/handlers/git.js (#374).
28
+ //
29
+ // Allow-Headers must list every request header the proxy actually
30
+ // forwards (see FORWARD_REQUEST_HEADERS) — otherwise browsers reject
31
+ // preflight before the request reaches us. Plus:
32
+ // - Authorization, DPoP — for WAC + Solid-OIDC auth to *this* pod
33
+ // - X-Upstream-Authorization — opt-in upstream credential (renamed to
34
+ // Authorization on the way out, see pickRequestHeaders)
35
+ //
36
+ // Expose-Headers includes WAC-Allow so browser clients can render auth
37
+ // UX based on the pod's policy, plus the usual content/etag/location set.
38
+ //
39
+ // Allow-Credentials is set explicitly to 'false' to override the
40
+ // server-wide global CORS hook (which uses 'true' for the rest of the
41
+ // pod). Combining 'true' with `Allow-Origin: *` is a CORS spec
42
+ // violation that browsers reject — and an anonymous-readable proxy
43
+ // doesn't have a use case for credentialed cross-origin anyway (Cookie
44
+ // is stripped before forwarding).
45
+ export const PROXY_CORS_HEADERS = {
46
+ 'Access-Control-Allow-Origin': '*',
47
+ 'Access-Control-Allow-Credentials': 'false',
48
+ 'Access-Control-Allow-Methods': 'GET, POST, HEAD, OPTIONS',
49
+ 'Access-Control-Allow-Headers': [
50
+ 'Content-Type',
51
+ 'Authorization',
52
+ 'DPoP',
53
+ 'X-Upstream-Authorization',
54
+ 'Git-Protocol',
55
+ 'Accept',
56
+ 'Accept-Encoding',
57
+ 'Accept-Language',
58
+ 'If-Match',
59
+ 'If-None-Match',
60
+ 'If-Modified-Since',
61
+ 'Range',
62
+ 'User-Agent',
63
+ ].join(', '),
64
+ 'Access-Control-Expose-Headers': 'Content-Type, ETag, Last-Modified, Link, Location, WWW-Authenticate, WAC-Allow, X-Cost, X-Balance, X-Pay-Currency',
65
+ };
66
+
67
+ export function setProxyCorsHeaders(reply) {
68
+ for (const [k, v] of Object.entries(PROXY_CORS_HEADERS)) {
69
+ reply.header(k, v);
70
+ }
71
+ // Hardening against the proxy being used as an XSS vector: if a user
72
+ // navigates a browser to /proxy?url=https://evil/page.html, the
73
+ // upstream HTML would otherwise execute in this pod's origin and
74
+ // could exfiltrate other pod resources. CSP `sandbox` neutralizes
75
+ // scripts/plugins/navigation when rendered as a document; nosniff
76
+ // prevents MIME-sniffing tricks. fetch()-based callers are
77
+ // unaffected (they consume the raw bytes regardless of these
78
+ // response-side defenses).
79
+ reply.header('Content-Security-Policy', 'sandbox');
80
+ reply.header('X-Content-Type-Options', 'nosniff');
81
+ }
82
+
83
+ // Headers we forward from the caller to the upstream. Anything not on
84
+ // this list is dropped — origin/cookie/host/referer would either confuse
85
+ // upstream auth or leak the proxying pod's identity.
86
+ //
87
+ // Authorization is deliberately NOT forwarded by default: the browser
88
+ // uses it to authenticate to *this* pod (WAC, Solid-OIDC bearer/DPoP),
89
+ // and silently leaking that token to an arbitrary upstream is a security
90
+ // hole. Callers who genuinely need to send credentials to the upstream
91
+ // (e.g. a GitHub PAT for a private repo) opt in via the
92
+ // X-Upstream-Authorization header — pickRequestHeaders renames that to
93
+ // Authorization on the way out.
94
+ //
95
+ // DPoP is similarly not forwarded — it's bound to this pod's URL and
96
+ // would be rejected by any upstream anyway.
97
+ // Note: Content-Length is *not* forwarded. JSS registers a wildcard
98
+ // parseAs:'buffer' content parser (server.js), so request bodies arrive
99
+ // as a Buffer that we pass through to fetch unchanged — but we keep a
100
+ // defensive JSON.stringify branch downstream in case anything ever
101
+ // changes that and a parsed object lands here. Either way, the
102
+ // caller-declared length isn't reliable for the upstream call. Node's
103
+ // fetch sets Content-Length automatically based on the actual body.
104
+ // (Browsers send Content-Length on simple requests without needing it
105
+ // in Access-Control-Allow-Headers, since it's CORS-safelisted.)
106
+ const FORWARD_REQUEST_HEADERS = new Set([
107
+ 'accept',
108
+ 'accept-encoding',
109
+ 'accept-language',
110
+ 'content-type',
111
+ 'git-protocol',
112
+ 'if-match',
113
+ 'if-none-match',
114
+ 'if-modified-since',
115
+ 'range',
116
+ 'user-agent',
117
+ ]);
118
+
119
+ const UPSTREAM_AUTH_HEADER = 'x-upstream-authorization';
120
+
121
+ // Headers we strip from the upstream response before returning to the
122
+ // caller. Encoding-related ones are removed because Node's fetch already
123
+ // transparently decodes; passing through Content-Encoding would
124
+ // double-decompress in the browser.
125
+ //
126
+ // Content-Length is stripped because (a) we strip Content-Encoding so
127
+ // the body length may differ from the upstream-declared length after
128
+ // decompression, and (b) we may truncate mid-stream when maxBytes is
129
+ // exceeded. Forwarding the upstream Content-Length in either case
130
+ // causes ERR_CONTENT_LENGTH_MISMATCH or hangs in the client. Node sends
131
+ // the actual length (or chunked) automatically.
132
+ //
133
+ // WAC-Allow / X-Cost / X-Balance / X-Pay-Currency are stripped because
134
+ // they describe *this* pod's ACL decision and ledger debit; an upstream
135
+ // must not be able to spoof them. server.js sets these headers *before*
136
+ // calling handleCorsProxy (in the proxy preHandler, after authorize()),
137
+ // and stripping them here ensures copyResponseHeaders can never
138
+ // overwrite the local values when iterating upstream headers.
139
+ const STRIP_RESPONSE_HEADERS = new Set([
140
+ 'content-encoding',
141
+ 'content-length',
142
+ 'transfer-encoding',
143
+ 'connection',
144
+ 'keep-alive',
145
+ // Strip set-cookie — we never want to forward upstream cookies into
146
+ // our origin's domain (would let upstream set cookies on the pod).
147
+ 'set-cookie',
148
+ // Pod-authoritative headers: never accept upstream values for these.
149
+ 'wac-allow',
150
+ 'x-cost',
151
+ 'x-balance',
152
+ 'x-pay-currency',
153
+ // Range/206 mismatch: we may truncate the body at maxBytes, in which
154
+ // case the upstream's Content-Range no longer matches the bytes the
155
+ // client gets. Stripping both Content-Range and Accept-Ranges means
156
+ // callers don't trust a stale range — they receive whatever bytes
157
+ // actually streamed and can re-request with a smaller window if
158
+ // needed. Phase 2 could revisit if someone needs byte-accurate range
159
+ // proxying.
160
+ 'content-range',
161
+ 'accept-ranges',
162
+ ]);
163
+
164
+ const DEFAULT_MAX_REDIRECTS = 5;
165
+ const DEFAULT_TIMEOUT_MS = 30_000;
166
+ const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
167
+ const ALLOWED_METHODS = new Set(['GET', 'POST', 'HEAD', 'OPTIONS']);
168
+
169
+ function pickRequestHeaders(reqHeaders) {
170
+ const out = {};
171
+ for (const [k, v] of Object.entries(reqHeaders)) {
172
+ if (v == null) continue;
173
+ const lower = k.toLowerCase();
174
+ if (FORWARD_REQUEST_HEADERS.has(lower)) {
175
+ out[k] = v;
176
+ } else if (lower === UPSTREAM_AUTH_HEADER) {
177
+ // Opt-in upstream credential: client supplies the token under
178
+ // X-Upstream-Authorization, we relay it as Authorization upstream.
179
+ // Pod's own Authorization (used to auth to /proxy) never leaves
180
+ // the pod — see FORWARD_REQUEST_HEADERS.
181
+ out['Authorization'] = v;
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ function copyResponseHeaders(reply, fetchResponse) {
188
+ for (const [k, v] of fetchResponse.headers) {
189
+ if (STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) continue;
190
+ reply.header(k, v);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Stream the upstream body to the caller, enforcing a byte cap mid-stream.
196
+ * Uses Node 18+ Readable.fromWeb to bridge the fetch ReadableStream into
197
+ * a Node Readable, then a Transform passthrough counts bytes and ends
198
+ * the stream cleanly if maxBytes is exceeded.
199
+ *
200
+ * Note on the timeout: once status/headers are sent we can't change the
201
+ * status code (Fastify throws ERR_HTTP_HEADERS_SENT), so the deadline
202
+ * applies to the headers-received phase only. A slow-streaming upstream
203
+ * past the deadline is *not* killed mid-stream in Phase 1 — see the
204
+ * follow-up tracked in #378 / the open issue list. Mid-stream errors
205
+ * (byte cap, fetch error) terminate the response with a truncated body
206
+ * rather than a thrown error.
207
+ */
208
+ function streamUpstream(reply, fetchResponse, maxBytes, abortController) {
209
+ if (!fetchResponse.body) {
210
+ return reply.send();
211
+ }
212
+
213
+ // Byte cap with two boundary cases handled:
214
+ // - chunk smaller than remaining: pass through, keep counting
215
+ // - chunk meets or exceeds remaining: emit just enough to fill the
216
+ // cap and end the stream right then. Previously a chunk that hit
217
+ // the cap *exactly* (chunk.length === remaining) was passed
218
+ // through without ending — subsequent chunks fell into the
219
+ // "remaining <= 0" branch and were dropped silently while the
220
+ // stream stayed open, which can hang slow-streaming clients.
221
+ let bytesSeen = 0;
222
+ const counter = new Transform({
223
+ transform(chunk, _encoding, callback) {
224
+ const remaining = maxBytes - bytesSeen;
225
+ if (remaining <= 0) {
226
+ // Defensive: shouldn't happen because we end on first overflow,
227
+ // but if a chunk arrives after we've started shutting down,
228
+ // drop it.
229
+ return callback();
230
+ }
231
+ if (chunk.length < remaining) {
232
+ bytesSeen += chunk.length;
233
+ return callback(null, chunk);
234
+ }
235
+ // chunk fills or exceeds the cap — emit (up to) `remaining` bytes
236
+ // and end the stream right now.
237
+ const slice = chunk.length === remaining ? chunk : chunk.slice(0, remaining);
238
+ bytesSeen += slice.length;
239
+ this.push(slice);
240
+ this.push(null);
241
+ abortController.abort();
242
+ return callback();
243
+ }
244
+ });
245
+
246
+ Readable.fromWeb(fetchResponse.body)
247
+ .on('error', () => counter.end())
248
+ .pipe(counter);
249
+
250
+ return reply.send(counter);
251
+ }
252
+
253
+ /**
254
+ * Handle a CORS proxy request.
255
+ *
256
+ * @param {FastifyRequest} request
257
+ * @param {FastifyReply} reply
258
+ * @param {object} options
259
+ * @param {number} [options.maxBytes]
260
+ * @param {number} [options.timeoutMs]
261
+ * @param {number} [options.maxRedirects]
262
+ */
263
+ export async function handleCorsProxy(request, reply, options = {}) {
264
+ setProxyCorsHeaders(reply);
265
+
266
+ // CORS preflight — short-circuit without fetching.
267
+ if (request.method === 'OPTIONS') {
268
+ return reply.code(204).send();
269
+ }
270
+
271
+ if (!ALLOWED_METHODS.has(request.method)) {
272
+ // HTTP requires 405 to carry an Allow header listing supported methods.
273
+ reply.header('Allow', [...ALLOWED_METHODS].join(', '));
274
+ return reply.code(405).send({ error: 'Method not allowed', method: request.method });
275
+ }
276
+
277
+ const targetUrl = request.query?.url;
278
+ if (!targetUrl || typeof targetUrl !== 'string') {
279
+ return reply.code(400).send({ error: 'Missing required query parameter: url' });
280
+ }
281
+
282
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
283
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
284
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
285
+
286
+ // Validate the initial URL, then follow redirects manually re-validating
287
+ // each hop's Location header. Required because fetch's automatic redirect
288
+ // following bypasses our SSRF guard at the second hop.
289
+ let currentUrl = targetUrl;
290
+ let currentMethod = request.method;
291
+ let redirectsLeft = maxRedirects;
292
+
293
+ // Body for non-GET/HEAD methods. Read once up front; we may need it on
294
+ // the first hop and (for 307/308) on redirected hops.
295
+ let body = null;
296
+ if (currentMethod !== 'GET' && currentMethod !== 'HEAD') {
297
+ body = request.rawBody ?? request.body;
298
+ if (typeof body === 'object' && !(body instanceof Buffer) && body !== null) {
299
+ body = JSON.stringify(body);
300
+ }
301
+ }
302
+
303
+ // Forwarded headers are computed once and reused across redirect hops,
304
+ // EXCEPT Authorization. If the client opted in via X-Upstream-Authorization,
305
+ // its credential is now in forwardHeaders.Authorization. We must strip
306
+ // it on cross-origin redirects: a redirect to evil.com would otherwise
307
+ // leak the user's upstream token (e.g. a GitHub PAT) to whoever the
308
+ // attacker controls. See cross-origin redirect handling below.
309
+ const forwardHeaders = pickRequestHeaders(request.headers);
310
+ const initialOrigin = (() => {
311
+ try { return new URL(currentUrl).origin; } catch { return null; }
312
+ })();
313
+
314
+ while (true) {
315
+ const validation = await validateExternalUrl(currentUrl, {
316
+ requireHttps: false, // allow http:// — pod operator can lock down via .acl scope
317
+ blockPrivateIPs: true,
318
+ resolveDNS: true,
319
+ });
320
+ if (!validation.valid) {
321
+ return reply.code(400).send({ error: 'Invalid upstream URL', detail: validation.error });
322
+ }
323
+
324
+ const abortController = new AbortController();
325
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
326
+
327
+ let upstream;
328
+ try {
329
+ upstream = await fetch(currentUrl, {
330
+ method: currentMethod,
331
+ headers: forwardHeaders,
332
+ body: (currentMethod === 'GET' || currentMethod === 'HEAD') ? undefined : body,
333
+ redirect: 'manual',
334
+ signal: abortController.signal,
335
+ });
336
+ } catch (err) {
337
+ clearTimeout(timeoutId);
338
+ if (err.name === 'AbortError') {
339
+ return reply.code(504).send({ error: 'Upstream timeout' });
340
+ }
341
+ return reply.code(502).send({ error: 'Upstream fetch failed', detail: err.message });
342
+ }
343
+ // Headers received — clear the deadline. Streaming-phase timeouts
344
+ // are a known Phase 1 limitation: once Fastify has sent headers we
345
+ // can't change the status code, and the destroy-source-during-pipe
346
+ // pattern doesn't reliably terminate the response. Tracked as a
347
+ // follow-up.
348
+ clearTimeout(timeoutId);
349
+
350
+ // Manual redirect handling: 301/302/303 → GET (per HTTP semantics),
351
+ // 307/308 → preserve method and body.
352
+ if ([301, 302, 303, 307, 308].includes(upstream.status)) {
353
+ const location = upstream.headers.get('location');
354
+ if (!location) {
355
+ return reply.code(502).send({ error: 'Upstream redirect with no Location header' });
356
+ }
357
+ if (--redirectsLeft < 0) {
358
+ return reply.code(502).send({ error: `Exceeded ${maxRedirects} redirects` });
359
+ }
360
+ // Resolve relative redirects against the previous URL. Malformed
361
+ // Location values throw — bubble that up as a 502 instead of 500.
362
+ try {
363
+ currentUrl = new URL(location, currentUrl).toString();
364
+ } catch {
365
+ return reply.code(502).send({ error: 'Upstream redirect with malformed Location', location });
366
+ }
367
+ if ([301, 302, 303].includes(upstream.status)) {
368
+ // Per fetch / RFC 7231 redirect semantics:
369
+ // - 303: any non-GET/HEAD becomes GET with no body
370
+ // - 301/302: POST → GET with no body (legacy quirk; spec
371
+ // technically says preserve method, but every real client
372
+ // downgrades POST to GET); HEAD stays HEAD; GET stays GET
373
+ // HEAD must never be turned into GET — it'd cause an
374
+ // unexpected body to stream on what was a body-less request.
375
+ if (currentMethod !== 'GET' && currentMethod !== 'HEAD') {
376
+ currentMethod = 'GET';
377
+ body = null;
378
+ delete forwardHeaders['content-length'];
379
+ delete forwardHeaders['Content-Length'];
380
+ delete forwardHeaders['content-type'];
381
+ delete forwardHeaders['Content-Type'];
382
+ }
383
+ }
384
+ // Cross-origin redirect: strip Authorization to prevent leaking
385
+ // the X-Upstream-Authorization-derived credential to a different
386
+ // origin. Curl, browsers, and well-behaved HTTP clients all do
387
+ // this. The opt-in upstream auth is bound to the origin the
388
+ // client requested.
389
+ try {
390
+ const nextOrigin = new URL(currentUrl).origin;
391
+ if (initialOrigin && nextOrigin !== initialOrigin) {
392
+ delete forwardHeaders['authorization'];
393
+ delete forwardHeaders['Authorization'];
394
+ }
395
+ } catch {
396
+ // currentUrl was just validated above, so this should never
397
+ // throw; if it somehow does, drop Authorization to fail safe.
398
+ delete forwardHeaders['authorization'];
399
+ delete forwardHeaders['Authorization'];
400
+ }
401
+ // Drain the redirect body to free the connection; we never return it.
402
+ try { await upstream.body?.cancel(); } catch { /* ignore */ }
403
+ continue;
404
+ }
405
+
406
+ // Final response — stream it back.
407
+ reply.code(upstream.status);
408
+ copyResponseHeaders(reply, upstream);
409
+ setProxyCorsHeaders(reply); // reapply in case copyResponseHeaders set conflicting CORS values
410
+
411
+ return streamUpstream(reply, upstream, maxBytes, abortController);
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Match a request URL *path* (not full URL) against the proxy route.
417
+ * Used by server.js to decide whether the proxy preHandler should fire
418
+ * and whether the standard WAC hook should skip. Callers must pass the
419
+ * already-split path component (e.g. `request.url.split('?')[0]`).
420
+ */
421
+ export function isCorsProxyRequest(urlPath) {
422
+ return urlPath === '/proxy';
423
+ }
package/src/idp/index.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  handleLogin,
12
12
  handleConsent,
13
13
  handleAbort,
14
+ handleSwitchAccount,
14
15
  handleRegisterGet,
15
16
  handleRegisterPost,
16
17
  handlePasskeyComplete,
@@ -324,6 +325,13 @@ export async function idpPlugin(fastify, options) {
324
325
  return handleAbort(request, reply, provider);
325
326
  });
326
327
 
328
+ // POST "Sign in as a different user" (#384) — destroys the OIDC
329
+ // session and bounces back to the login prompt while preserving the
330
+ // in-flight authz request.
331
+ fastify.post('/idp/interaction/:uid/switch', async (request, reply) => {
332
+ return handleSwitchAccount(request, reply, provider);
333
+ });
334
+
327
335
  // Registration routes (disabled in single-user mode)
328
336
  if (singleUser) {
329
337
  // Single-user mode: registration disabled
@@ -297,6 +297,89 @@ export async function handleConsent(request, reply, provider) {
297
297
  }
298
298
  }
299
299
 
300
+ /**
301
+ * Handle POST /idp/interaction/:uid/switch
302
+ *
303
+ * "Sign in as a different user" from the consent page (#384). Destroys
304
+ * the current OIDC session, mutates the in-flight interaction back to
305
+ * the login prompt, and redirects the user to the same /idp/interaction
306
+ * URL — which `handleInteractionGet` will render as the login page.
307
+ *
308
+ * Re-using the same interaction uid (rather than starting a fresh
309
+ * /idp/auth flow) preserves the original authz request params so the
310
+ * caller's redirect_uri / state / nonce all flow through unchanged.
311
+ */
312
+ export async function handleSwitchAccount(request, reply, provider) {
313
+ const { uid } = request.params;
314
+
315
+ try {
316
+ const interaction = await provider.Interaction.find(uid);
317
+ if (!interaction) {
318
+ return reply.code(404).type('text/html').send(errorPage('Interaction not found', 'This interaction may have expired. Try signing in again from your app.'));
319
+ }
320
+
321
+ // The UI entrypoint is the consent page only. Refusing on other
322
+ // prompt states (login, passkey, etc.) prevents a crafted request
323
+ // from corrupting an in-flight non-consent interaction.
324
+ if (interaction.prompt?.name !== 'consent') {
325
+ return reply.code(400).type('text/html').send(errorPage('Cannot switch account here', 'Account switching is only available from the consent page.'));
326
+ }
327
+
328
+ // Destroy the bound session so the new login starts cold. The cookie
329
+ // becomes a stale reference; oidc-provider's Session.get treats a
330
+ // missing session blob as "new browser", which is the shape we want.
331
+ if (interaction.session?.uid) {
332
+ const sess = await provider.Session.findByUid(interaction.session.uid);
333
+ if (sess) await sess.destroy();
334
+ }
335
+
336
+ // Reset the interaction back to the login prompt, dropping the
337
+ // session reference and any prior `result` snapshot. `prompt`,
338
+ // `session`, and `result` are all in the oidc-provider Interaction
339
+ // IN_PAYLOAD allowlist, so the mutations persist through the
340
+ // adapter. Original `params` (client_id, redirect_uri, state, etc.)
341
+ // are untouched, so resume picks them up after login. Clearing
342
+ // `result` prevents a stale `result.login` from a previous identity
343
+ // influencing the next resume.
344
+ interaction.session = undefined;
345
+ interaction.result = undefined;
346
+ interaction.prompt = { name: 'login', reasons: ['no_session'], details: {} };
347
+ interaction.lastError = undefined;
348
+ const ttl = Math.max(1, interaction.exp - Math.floor(Date.now() / 1000));
349
+ await interaction.save(ttl);
350
+
351
+ // Clear the user-agent's session cookie too. The IdP runs with
352
+ // signed cookies (provider.js cookies.long.signed = true), so each
353
+ // session cookie has a paired `.sig`. The `.legacy` variant is
354
+ // created during identifier rotation and likewise has its own
355
+ // `.sig`. Clearing all four keeps the browser fully tidy. JSS
356
+ // doesn't register @fastify/cookie, so we emit Set-Cookie headers
357
+ // directly with an expired Expires + Max-Age=0. Server-side state
358
+ // is already gone via session.destroy() above — these expirations
359
+ // are belt-and-suspenders.
360
+ const expired = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly';
361
+ reply.header('Set-Cookie', [
362
+ `_session=; ${expired}`,
363
+ `_session.sig=; ${expired}`,
364
+ `_session.legacy=; ${expired}`,
365
+ `_session.legacy.sig=; ${expired}`,
366
+ ]);
367
+
368
+ // 303 See Other — explicitly forces the UA to issue GET on the
369
+ // Location target. 302 leaves it ambiguous (and some legacy UAs
370
+ // repeat the POST), which would re-trigger this handler in a loop.
371
+ // Status-then-URL arg order matches the rest of the codebase
372
+ // (src/server.js:637, src/tunnel/index.js:222).
373
+ return reply.redirect(303, `/idp/interaction/${uid}`);
374
+ } catch (err) {
375
+ request.log.error(err, 'Switch-account error');
376
+ // Don't surface raw err.message — adapter errors and stack-leaking
377
+ // strings on an auth endpoint are a soft info-leak. Full error is
378
+ // already in the server log via request.log.error above.
379
+ return reply.code(500).type('text/html').send(errorPage('Error', 'Something went wrong. Please try signing in again.'));
380
+ }
381
+ }
382
+
300
383
  /**
301
384
  * Handle POST /idp/interaction/:uid/abort
302
385
  * User cancelled the flow
package/src/idp/views.js CHANGED
@@ -503,7 +503,15 @@ export function consentPage(uid, client, params, account) {
503
503
  ${clientUri ? `<div class="client-uri">${escapeHtml(clientUri)}</div>` : ''}
504
504
  </div>
505
505
 
506
- ${account ? `<p>Signed in as <strong>${escapeHtml(account.email)}</strong></p>` : ''}
506
+ ${account ? `
507
+ <div style="display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; margin: 12px 0;">
508
+ <span>Signed in as <strong>${escapeHtml(account.email)}</strong></span>
509
+ <span style="color: #94a3b8;">·</span>
510
+ <form method="POST" action="/idp/interaction/${uid}/switch" style="display: inline; margin: 0;">
511
+ <button type="submit" style="background: none; border: 0; padding: 0; color: #2563eb; font: inherit; cursor: pointer; text-decoration: underline;">Sign in as a different user</button>
512
+ </form>
513
+ </div>
514
+ ` : ''}
507
515
 
508
516
  <div class="scopes">
509
517
  <label>This app is requesting access to:</label>
package/src/server.js CHANGED
@@ -12,6 +12,7 @@ import { notificationsPlugin } from './notifications/index.js';
12
12
  import { startFileWatcher } from './notifications/events.js';
13
13
  import { idpPlugin } from './idp/index.js';
14
14
  import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
15
+ import { handleCorsProxy, isCorsProxyRequest, setProxyCorsHeaders } from './handlers/cors-proxy.js';
15
16
  import { AccessMode } from './wac/parser.js';
16
17
  import { registerNostrRelay } from './nostr/relay.js';
17
18
  import { createPayHandler, isPayRequest } from './handlers/pay.js';
@@ -71,6 +72,18 @@ export function createServer(options = {}) {
71
72
  const mashlibVersion = options.mashlibVersion ?? '2.0.0';
72
73
  // Git HTTP backend is OFF by default - enables clone/push via git protocol
73
74
  const gitEnabled = options.git ?? false;
75
+ // CORS proxy (#378) — OFF by default. Numeric settings get the
76
+ // sane-default fallback if the env var or config file supplies a
77
+ // non-finite/non-positive value (e.g. JSS_CORS_PROXY_MAX_BYTES=banana
78
+ // would otherwise leave the cap as the string "banana", making
79
+ // `bytesSeen > "banana"` always false and silently disabling the
80
+ // safety limit).
81
+ const positiveInt = (v, fallback) =>
82
+ (typeof v === 'number' && Number.isFinite(v) && v > 0) ? v : fallback;
83
+ const corsProxyEnabled = options.corsProxy === true;
84
+ const corsProxyMaxBytes = positiveInt(options.corsProxyMaxBytes, 50 * 1024 * 1024);
85
+ const corsProxyTimeoutMs = positiveInt(options.corsProxyTimeoutMs, 30_000);
86
+ const corsProxyMaxRedirects = positiveInt(options.corsProxyMaxRedirects, 5);
74
87
  // Nostr relay is OFF by default
75
88
  const nostrEnabled = options.nostr ?? false;
76
89
  const nostrPath = options.nostrPath ?? '/relay';
@@ -386,7 +399,13 @@ export function createServer(options = {}) {
386
399
  return;
387
400
  }
388
401
 
389
- const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
402
+ // Only inspect the path component splitting the full URL on '/'
403
+ // would catch dot-prefixed segments inside query-string values
404
+ // (e.g. /proxy?url=https://example.com/.git/config), rejecting
405
+ // legitimate proxy requests for upstream URLs that happen to
406
+ // contain dotfile-like path segments. The dotfile guard is about
407
+ // *this* pod's filesystem, not what the URL looks like.
408
+ const segments = request.url.split('?')[0].split('/');
390
409
  const hasForbiddenDotfile = segments.some(seg =>
391
410
  seg.startsWith('.') &&
392
411
  seg.length > 1 &&
@@ -439,6 +458,92 @@ export function createServer(options = {}) {
439
458
  fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress, payToken, payRate, payChains }));
440
459
  }
441
460
 
461
+ // CORS proxy (#378) — WAC-gated. Standard authorize() path runs against
462
+ // /proxy as a virtual resource; pod owner controls access by writing an
463
+ // .acl on /proxy (or inheriting from /.acl). OPTIONS preflight returns
464
+ // 204 directly without auth so browser CORS checks succeed before sign-in.
465
+ if (corsProxyEnabled) {
466
+ fastify.addHook('preHandler', async (request, reply) => {
467
+ const urlPath = request.url.split('?')[0];
468
+ if (!isCorsProxyRequest(urlPath)) {
469
+ return;
470
+ }
471
+
472
+ // OPTIONS preflight short-circuits to the handler (which returns
473
+ // 204 + proxy CORS headers) without going through authorize() at
474
+ // all. authorize() does have its own OPTIONS short-circuit, but
475
+ // routing through here keeps the preflight off the auth/payment
476
+ // path entirely — preflights must never debit ledgers or evaluate
477
+ // PaymentConditions.
478
+ if (request.method === 'OPTIONS') {
479
+ return handleCorsProxy(request, reply, {
480
+ maxBytes: corsProxyMaxBytes,
481
+ timeoutMs: corsProxyTimeoutMs,
482
+ maxRedirects: corsProxyMaxRedirects,
483
+ });
484
+ }
485
+
486
+ // Don't override requiredMode — let authorize() derive it from the
487
+ // request method via getRequiredMode(). GET/HEAD need READ on the
488
+ // /proxy resource, POST needs APPEND/WRITE — pod owners can grant
489
+ // these separately via ACL modes (e.g. acl:Read for browse-only,
490
+ // acl:Append/Write for proxying side-effecting POSTs upstream).
491
+ //
492
+ // skipParentForMissing prevents authorize()'s "non-existent resource +
493
+ // write method → check parent container" fallback from kicking in.
494
+ // /proxy is a virtual endpoint with no backing storage, so the
495
+ // fallback would route POST authorization to / (the root) instead
496
+ // of /proxy — too permissive. With this flag, authorize() checks
497
+ // ACLs against /proxy directly regardless of storage existence.
498
+ const { authorized, webId, wacAllow, authError, paymentRequired, paid, balance, currency } =
499
+ await authorize(request, reply, { skipParentForMissing: true });
500
+ request.webId = webId;
501
+ request.wacAllow = wacAllow;
502
+
503
+ // Surface paid-access bookkeeping the same way the standard WAC
504
+ // hook does (lines 564-569 below). When a /proxy ACL uses a
505
+ // PaymentCondition and the caller has sufficient balance,
506
+ // checkAccess() returns paid (the cost), balance, and currency —
507
+ // browser-side renders charge UI off these. Without this, ledger
508
+ // debit happens silently.
509
+ if (paid !== undefined) {
510
+ reply.header('X-Cost', String(paid));
511
+ reply.header('X-Balance', String(balance));
512
+ if (currency) reply.header('X-Pay-Currency', currency);
513
+ }
514
+
515
+ // Set WAC-Allow on success too, matching the global WAC hook
516
+ // (line 562 area). Browser clients read it via Expose-Headers
517
+ // to render auth UX. Without this, only 401/403/402 responses
518
+ // carry WAC-Allow, which is inconsistent.
519
+ reply.header('WAC-Allow', wacAllow);
520
+
521
+ // ACL with a PaymentCondition surfaces as 402 here — mirrors the
522
+ // git handler at src/server.js:418 and the standard WAC hook so
523
+ // payment-gated /proxy ACLs behave consistently.
524
+ if (paymentRequired) {
525
+ setProxyCorsHeaders(reply);
526
+ reply.header('WAC-Allow', wacAllow);
527
+ return reply.code(402).send({ type: 'PaymentRequired', ...paymentRequired });
528
+ }
529
+
530
+ if (request.method !== 'OPTIONS' && !authorized) {
531
+ // Apply proxy CORS headers BEFORE handleUnauthorized so the 401/403
532
+ // is readable by browser clients (without these the browser surfaces
533
+ // the response as a generic CORS failure — same shape as #374).
534
+ setProxyCorsHeaders(reply);
535
+ reply.header('WAC-Allow', wacAllow);
536
+ return handleUnauthorized(request, reply, webId !== null, wacAllow, authError);
537
+ }
538
+
539
+ return handleCorsProxy(request, reply, {
540
+ maxBytes: corsProxyMaxBytes,
541
+ timeoutMs: corsProxyTimeoutMs,
542
+ maxRedirects: corsProxyMaxRedirects,
543
+ });
544
+ });
545
+ }
546
+
442
547
  // Authorization hook - check WAC permissions
443
548
  // Skip for pod creation endpoint (needs special handling)
444
549
  fastify.addHook('preHandler', async (request, reply) => {
@@ -460,6 +565,7 @@ export function createServer(options = {}) {
460
565
  request.url.startsWith('/.well-known/') ||
461
566
  (nostrEnabled && request.url.startsWith(nostrPath)) ||
462
567
  (gitEnabled && isGitRequest(request.url)) ||
568
+ (corsProxyEnabled && isCorsProxyRequest(request.url.split('?')[0])) ||
463
569
  (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
464
570
  isProfileAP ||
465
571
  request.url.startsWith('/storage/') ||
package/test/idp.test.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { describe, it, before, after, beforeEach } from 'node:test';
6
6
  import assert from 'node:assert';
7
+ import http from 'node:http';
7
8
  import { createServer } from '../src/server.js';
8
9
  import fs from 'fs-extra';
9
10
  import path from 'path';
@@ -182,6 +183,122 @@ describe('Identity Provider', () => {
182
183
  });
183
184
  });
184
185
 
186
+ // Regression coverage for #384 — "Sign in as a different user" on consent
187
+ describe('Switch account on consent (#384)', () => {
188
+ // The IDP's filesystem adapter stores Interaction records as JSON at
189
+ // <DATA_ROOT>/.idp/interaction/<uid>.json (model name "Interaction"
190
+ // → dir "interaction" via the adapter's modelToDir camelCase split).
191
+ // Tests write a synthetic interaction directly so we don't have to
192
+ // walk a full OIDC client flow to set up state.
193
+ const interactionDir = `${DATA_DIR}/.idp/interaction`;
194
+
195
+ function writeInteraction(uid, payload) {
196
+ const ttlSec = 3600;
197
+ const data = {
198
+ ...payload,
199
+ kind: 'Interaction',
200
+ jti: uid,
201
+ exp: Math.floor(Date.now() / 1000) + ttlSec,
202
+ iat: Math.floor(Date.now() / 1000),
203
+ _id: uid,
204
+ _expiresAt: Date.now() + ttlSec * 1000,
205
+ };
206
+ return fs.outputJson(`${interactionDir}/${uid}.json`, data, { spaces: 2 });
207
+ }
208
+
209
+ // Use node:http directly for the cookie-clearing assertion. fetch's
210
+ // Headers.getSetCookie() is only available on Node 19.7+, but the
211
+ // package declares engines.node >= 18. http.request gives us
212
+ // res.headers['set-cookie'] as a real array on every supported
213
+ // Node version, no version-gated branches needed.
214
+ function rawPost(urlString) {
215
+ return new Promise((resolve, reject) => {
216
+ const u = new URL(urlString);
217
+ const req = http.request({
218
+ method: 'POST',
219
+ hostname: u.hostname,
220
+ port: u.port,
221
+ path: u.pathname + u.search,
222
+ }, (res) => {
223
+ let body = '';
224
+ res.on('data', (c) => body += c);
225
+ res.on('end', () => resolve({
226
+ statusCode: res.statusCode,
227
+ headers: res.headers,
228
+ body,
229
+ }));
230
+ });
231
+ req.on('error', reject);
232
+ req.end();
233
+ });
234
+ }
235
+
236
+ it('redirects back to /idp/interaction/:uid and resets the prompt to login', async () => {
237
+ const uid = 'test-switch-' + Math.random().toString(36).slice(2);
238
+ await writeInteraction(uid, {
239
+ prompt: { name: 'consent', reasons: [], details: {} },
240
+ session: { uid: 'fake-session-uid', accountId: 'acct-foo' },
241
+ params: { client_id: 'test-client', redirect_uri: 'http://localhost', state: 'xyz' },
242
+ });
243
+
244
+ const res = await rawPost(`${baseUrl}/idp/interaction/${uid}/switch`);
245
+
246
+ // 303 See Other — forces UA to GET the Location target so a
247
+ // (broken) UA can't loop by re-POSTing to /switch.
248
+ assert.strictEqual(res.statusCode, 303);
249
+ assert.strictEqual(res.headers.location, `/idp/interaction/${uid}`);
250
+
251
+ // Verify the interaction was mutated as expected.
252
+ const saved = await fs.readJson(`${interactionDir}/${uid}.json`);
253
+ assert.strictEqual(saved.prompt.name, 'login');
254
+ assert.ok(saved.session === undefined || saved.session === null,
255
+ 'session should be cleared');
256
+ // Original params survive so resume can continue the authz request.
257
+ assert.strictEqual(saved.params.client_id, 'test-client');
258
+ assert.strictEqual(saved.params.state, 'xyz');
259
+
260
+ // Cookies should be cleared so the user's UA forgets the prior
261
+ // session. Node's http module gives Set-Cookie as an array on
262
+ // every supported version, so no Node 19.7+ gating needed.
263
+ const setCookies = Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : [];
264
+ assert.ok(setCookies.length >= 4, `expected at least 4 Set-Cookie headers, got ${setCookies.length}`);
265
+ // All four signed-cookie names should be cleared:
266
+ // _session + _session.sig + _session.legacy + _session.legacy.sig.
267
+ for (const name of ['_session=', '_session.sig=', '_session.legacy=', '_session.legacy.sig=']) {
268
+ assert.ok(setCookies.some(c => c.startsWith(name)),
269
+ `should clear ${name.slice(0, -1)}`);
270
+ }
271
+ assert.ok(setCookies.every(c => /Max-Age=0|Expires=Thu, 01 Jan 1970/.test(c)),
272
+ 'all Set-Cookies should be expirations');
273
+ });
274
+
275
+ it('returns 400 when the interaction is not on the consent prompt', async () => {
276
+ const uid = 'test-switch-bad-' + Math.random().toString(36).slice(2);
277
+ await writeInteraction(uid, {
278
+ prompt: { name: 'login', reasons: ['no_session'], details: {} },
279
+ params: { client_id: 'test-client' },
280
+ });
281
+
282
+ const res = await fetch(`${baseUrl}/idp/interaction/${uid}/switch`, {
283
+ method: 'POST',
284
+ redirect: 'manual',
285
+ });
286
+
287
+ assert.strictEqual(res.status, 400);
288
+ // Original interaction should be untouched.
289
+ const saved = await fs.readJson(`${interactionDir}/${uid}.json`);
290
+ assert.strictEqual(saved.prompt.name, 'login');
291
+ });
292
+
293
+ it('returns 404 for an unknown interaction uid', async () => {
294
+ const res = await fetch(`${baseUrl}/idp/interaction/does-not-exist-${Date.now()}/switch`, {
295
+ method: 'POST',
296
+ redirect: 'manual',
297
+ });
298
+ assert.strictEqual(res.status, 404);
299
+ });
300
+ });
301
+
185
302
  // Regression coverage for #286 — friendly /idp landing + /idp/auth guard.
186
303
  describe('Landing page', () => {
187
304
  it('GET /idp returns the landing HTML', async () => {