kczx-user-management 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/proxy.js ADDED
@@ -0,0 +1,474 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Reverse proxy from the gateway's HTTPS listener to the loopback upstream
5
+ * (the dsh webserver): plain HTTP requests and WebSocket upgrades.
6
+ *
7
+ * Ported from dsh-gateway (clarknu/dsh-gateway) lib/proxy.js, adapted to
8
+ * CommonJS. Dependency-free on purpose — node:http request + node:net
9
+ * upgrade socket, streaming both ways so SSE and long responses are never
10
+ * buffered.
11
+ *
12
+ * The crucial twist is loopback-masquerade: the client's Host/Origin are
13
+ * rewritten to the upstream loopback authority so the dsh `/api` browser-trust
14
+ * fence (which accepts loopback hosts and requires Origin.host === Host.host)
15
+ * passes for proxied traffic.
16
+ *
17
+ * dsh 0.1.2-rc.1+ note: dsh-client-connection now gates the `/` index.html
18
+ * behind a launchToken + authority-bound signed cookie (BrowserAuth). The
19
+ * gateway already authenticates the human at its own layer, so for index
20
+ * requests we mint a loopback browser cookie via the host's PUBLIC
21
+ * `connection.authenticatedUrl(baseUrl)` builder (the sanctioned way to obtain
22
+ * a token-carrying URL — BrowserAuth.launchToken itself is TS-private) and
23
+ * inject it upstream, letting authorizeIndex pass. On 0.1.1-rc.2 (no
24
+ * BrowserAuth) getAuthenticatedUrl yields null and we fall through to a plain
25
+ * proxy — fully backward compatible.
26
+ */
27
+
28
+ import http from 'node:http'
29
+ import net from 'node:net'
30
+ import zlib from 'node:zlib'
31
+
32
+ /** RFC 7230 hop-by-hop headers, never forwarded. */
33
+ const HOP_BY_HOP = new Set([
34
+ 'connection',
35
+ 'keep-alive',
36
+ 'proxy-authenticate',
37
+ 'proxy-authorization',
38
+ 'proxy-connection',
39
+ 'te',
40
+ 'trailer',
41
+ 'transfer-encoding',
42
+ 'upgrade',
43
+ ])
44
+
45
+ /** Create a proxy target from an http upstream URL string. */
46
+ /**
47
+ * @param upstream - loopback upstream origin.
48
+ * @param getAuthenticatedUrl - BrowserAuth cookie minter (may yield null).
49
+ * @param hooks - optional enforcement seam:
50
+ * `rewriteResponse(req)` returns null when the response may stream through
51
+ * untouched, or `{ transform, maxBytes }` when it must be rewritten first
52
+ * (the caller — the gateway — owns that decision because only it knows the
53
+ * account's permissions; this module owns the HTTP mechanics).
54
+ */
55
+ function createProxy(upstream, getAuthenticatedUrl, hooks) {
56
+ const target = new URL(upstream)
57
+ if (target.protocol !== 'http:') {
58
+ throw new Error(`user-management: upstream must be plain http (loopback), got ${target.protocol}`)
59
+ }
60
+ const host = target.hostname
61
+ const port = Number(target.port || 80)
62
+ const agent = new http.Agent({ keepAlive: true, maxSockets: 64 })
63
+ /** Loopback authority we present upstream (rewrites the client's Host). */
64
+ const upstreamAuthority = port === 80 ? host : `${host}:${port}`
65
+ /** Origin header we present upstream, matching the rewritten Host. */
66
+ const upstreamOrigin = `http://${upstreamAuthority}`
67
+
68
+ // dsh-client-connection browser-session cookie for upstreamAuthority.
69
+ // Minted once via the host's authenticatedUrl() token exchange, reused
70
+ // across index requests, refreshed when the upstream answers 401 (cookie
71
+ // expired after its 30-day max age / dsh restarted with a new token).
72
+ let dshCookie = null
73
+
74
+ const forwardHeaders = (req) => {
75
+ const headers = {}
76
+ for (const [name, value] of Object.entries(req.headers || {})) {
77
+ if (value === undefined || HOP_BY_HOP.has(name.toLowerCase())) continue
78
+ headers[name] = value
79
+ }
80
+ // Loopback-masquerade mode: rewrite Host and Origin to the upstream
81
+ // loopback authority. The dsh trust fence accepts loopback hosts and
82
+ // requires Origin.host === Host.host, so both must move together.
83
+ headers.host = upstreamAuthority
84
+ if (headers.origin !== undefined) {
85
+ headers.origin = upstreamOrigin
86
+ }
87
+ const peer = (req.socket && req.socket.remoteAddress) || ''
88
+ const prior = req.headers['x-forwarded-for']
89
+ headers['x-forwarded-for'] = prior ? `${prior}, ${peer}` : peer
90
+ headers['x-forwarded-proto'] = 'https'
91
+ headers['x-forwarded-host'] = req.headers.host || ''
92
+ if (dshCookie) headers.cookie = dshCookie // patched: all paths carry BrowserAuth cookie
93
+ // patched: forward the caller's um_session as a header so same-origin
94
+ // plugins (e.g. dsh-git-server) can resolve the acting user — the cookie
95
+ // itself is replaced above, so this is the only identity channel through.
96
+ const umTok = /(?:^|;\s*)um_session=([^;]+)/.exec(String(req.headers.cookie || ''))
97
+ if (umTok) headers['x-um-session'] = decodeURIComponent(umTok[1])
98
+ return headers
99
+ }
100
+
101
+ const stripHop = (headers) => {
102
+ const out = {}
103
+ for (const [name, value] of Object.entries(headers || {})) {
104
+ if (value === undefined || HOP_BY_HOP.has(name.toLowerCase())) continue
105
+ out[name] = value
106
+ }
107
+ return out
108
+ }
109
+
110
+ /** Rewrite upstream absolute Location headers back to the public base. */
111
+ const rewriteLocation = (headers, clientHost) => {
112
+ const loc = headers.location
113
+ if (typeof loc === 'string' && loc.startsWith(`http://${upstreamAuthority}`)) {
114
+ headers.location = `https://${clientHost}${loc.slice(`http://${upstreamAuthority}`.length)}`
115
+ }
116
+ return headers
117
+ }
118
+
119
+ /**
120
+ * Mint a loopback browser cookie from the dsh launch token. The token URL
121
+ * comes from the host's PUBLIC `connection.authenticatedUrl(origin)` — we
122
+ * never touch BrowserAuth's private fields nor hardcode its query key.
123
+ */
124
+ const refreshDshCookie = () =>
125
+ new Promise((resolve) => {
126
+ let authUrl = null
127
+ try {
128
+ authUrl = typeof getAuthenticatedUrl === 'function' ? getAuthenticatedUrl(`http://${upstreamAuthority}`) : null
129
+ } catch {
130
+ authUrl = null
131
+ }
132
+ if (!authUrl) {
133
+ resolve(false)
134
+ return
135
+ }
136
+ let path = '/'
137
+ try {
138
+ const parsed = new URL(authUrl)
139
+ path = `${parsed.pathname}${parsed.search}` || '/'
140
+ } catch {
141
+ resolve(false)
142
+ return
143
+ }
144
+ const authReq = http.request(
145
+ {
146
+ host,
147
+ port,
148
+ method: 'GET',
149
+ path,
150
+ headers: { host: upstreamAuthority, origin: upstreamOrigin },
151
+ agent,
152
+ },
153
+ (authRes) => {
154
+ // authorizeIndex answers 303 + Set-Cookie on a valid token; either way
155
+ // we only care about the cookie header the upstream stamped in.
156
+ const setCookie = authRes.headers['set-cookie']
157
+ if (Array.isArray(setCookie) && setCookie.length) {
158
+ dshCookie = setCookie
159
+ .map((c) => c.split(';')[0].trim())
160
+ .filter(Boolean)
161
+ .join('; ')
162
+ }
163
+ authRes.resume()
164
+ resolve(!!dshCookie)
165
+ },
166
+ )
167
+ authReq.on('error', () => resolve(false))
168
+ authReq.end()
169
+ })
170
+
171
+ const ensureDshCookie = async () => {
172
+ if (dshCookie) return true
173
+ return refreshDshCookie()
174
+ }
175
+
176
+ const isIndexPath = (rawUrl) => {
177
+ const url = rawUrl || ''
178
+ const i = url.indexOf('?')
179
+ const path = i === -1 ? url : url.slice(0, i)
180
+ return path === '/' || path === '/index.html'
181
+ }
182
+
183
+ /**
184
+ * Serve an index (`/` or `/index.html`) request: inject the minted dsh
185
+ * browser cookie so authorizeIndex passes, then stream the upstream response
186
+ * back. Refreshes the cookie once on 401 and retries, then gives up (serves
187
+ * the 401 through) so a stuck token never loops.
188
+ */
189
+ async function handleIndexRequest(req, res, attempts) {
190
+ attempts = attempts || 0
191
+ await ensureDshCookie()
192
+ const headers = forwardHeaders(req)
193
+ if (dshCookie) headers.cookie = dshCookie
194
+ const upstreamReq = http.request(
195
+ {
196
+ host,
197
+ port,
198
+ method: req.method,
199
+ path: req.url,
200
+ headers,
201
+ agent,
202
+ },
203
+ (upstreamRes) => {
204
+ if (upstreamRes.statusCode === 401 && dshCookie && attempts < 1) {
205
+ upstreamRes.resume()
206
+ dshCookie = null
207
+ refreshDshCookie().then(() => handleIndexRequest(req, res, attempts + 1))
208
+ return
209
+ }
210
+ const outHeaders = stripHop(upstreamRes.headers)
211
+ // The cookie dsh stamps is authority-bound to the loopback upstream and
212
+ // must not leak to the public browser (wrong domain, never storable).
213
+ delete outHeaders['set-cookie']
214
+ if (outHeaders.location !== undefined) rewriteLocation(outHeaders, req.headers.host || '')
215
+ res.writeHead(upstreamRes.statusCode || 502, outHeaders)
216
+ if (req.method === 'HEAD' || upstreamRes.statusCode === 204 || upstreamRes.statusCode === 304) {
217
+ upstreamRes.resume()
218
+ res.end()
219
+ } else {
220
+ upstreamRes.pipe(res)
221
+ }
222
+ },
223
+ )
224
+ upstreamReq.on('error', (err) => {
225
+ if (res.headersSent) {
226
+ res.destroy()
227
+ return
228
+ }
229
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
230
+ res.end(`user-management: upstream unreachable (${err.code || err.message})`)
231
+ })
232
+ req.on('error', () => upstreamReq.destroy())
233
+ req.pipe(upstreamReq)
234
+ }
235
+
236
+ /** Proxy one plain HTTP request (non-index paths). */
237
+ function proxyPlain(req, res) {
238
+ const upstreamReq = http.request(
239
+ {
240
+ host,
241
+ port,
242
+ method: req.method,
243
+ path: req.url,
244
+ headers: forwardHeaders(req),
245
+ agent,
246
+ },
247
+ (upstreamRes) => {
248
+ const outHeaders = stripHop(upstreamRes.headers)
249
+ delete outHeaders['set-cookie']
250
+ if (outHeaders.location !== undefined) rewriteLocation(outHeaders, req.headers.host || '')
251
+ res.writeHead(upstreamRes.statusCode || 502, outHeaders)
252
+ if (req.method === 'HEAD' || upstreamRes.statusCode === 204 || upstreamRes.statusCode === 304) {
253
+ upstreamRes.resume()
254
+ res.end()
255
+ } else {
256
+ upstreamRes.pipe(res)
257
+ }
258
+ },
259
+ )
260
+ upstreamReq.on('error', (err) => {
261
+ if (res.headersSent) {
262
+ res.destroy()
263
+ return
264
+ }
265
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
266
+ res.end(`user-management: upstream unreachable (${err.code || err.message})`)
267
+ })
268
+ req.on('error', () => upstreamReq.destroy())
269
+ sendRequestBody(req, upstreamReq)
270
+ }
271
+
272
+ /**
273
+ * Buffered proxy for responses that must be REWRITTEN before they reach the
274
+ * client — today the workspace list, which has to be filtered per account.
275
+ *
276
+ * Streaming is impossible: the body has to be parsed before it can be judged.
277
+ * So this path buffers (with a hard cap) and **fails closed** — a body it
278
+ * cannot parse or rewrite becomes a 502, never an unfiltered pass-through,
279
+ * because passing the full list through would expose every other account's
280
+ * workspaces.
281
+ */
282
+ function proxyRewriteJson(req, res, transform, options) {
283
+ const maxBytes = Number.isFinite(options && options.maxBytes) ? options.maxBytes : 8 * 1024 * 1024
284
+ let settled = false
285
+ const failClosed = (reason) => {
286
+ if (settled) return
287
+ settled = true
288
+ if (res.headersSent) {
289
+ res.destroy()
290
+ return
291
+ }
292
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
293
+ res.end(`user-management: upstream response not usable for this account (${reason})`)
294
+ }
295
+ const upstreamReq = http.request(
296
+ {
297
+ host,
298
+ port,
299
+ method: req.method,
300
+ path: req.url,
301
+ headers: forwardHeaders(req),
302
+ agent,
303
+ },
304
+ (upstreamRes) => {
305
+ const chunks = []
306
+ let size = 0
307
+ upstreamRes.on('data', (chunk) => {
308
+ size += chunk.length
309
+ if (size > maxBytes) {
310
+ upstreamRes.destroy()
311
+ failClosed('too large')
312
+ return
313
+ }
314
+ chunks.push(chunk)
315
+ })
316
+ upstreamRes.on('error', () => failClosed('upstream stream error'))
317
+ upstreamRes.on('end', () => {
318
+ if (settled) return
319
+ try {
320
+ const encoding = String(upstreamRes.headers['content-encoding'] || '')
321
+ let body = Buffer.concat(chunks)
322
+ if (encoding.includes('gzip')) body = zlib.gunzipSync(body)
323
+ const parsed = JSON.parse(body.toString('utf8'))
324
+ const replacement = transform(parsed, { status: upstreamRes.statusCode, headers: upstreamRes.headers })
325
+ let out = Buffer.from(JSON.stringify(replacement === undefined ? parsed : replacement), 'utf8')
326
+ const outHeaders = stripHop(upstreamRes.headers)
327
+ delete outHeaders['set-cookie']
328
+ if (outHeaders.location !== undefined) rewriteLocation(outHeaders, req.headers.host || '')
329
+ const acceptsGzip = /\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))
330
+ if (encoding.includes('gzip') && acceptsGzip) {
331
+ out = zlib.gzipSync(out)
332
+ outHeaders['content-encoding'] = 'gzip'
333
+ } else {
334
+ delete outHeaders['content-encoding']
335
+ }
336
+ delete outHeaders['transfer-encoding']
337
+ outHeaders['content-length'] = String(out.length)
338
+ settled = true
339
+ if (!res.headersSent) res.writeHead(upstreamRes.statusCode || 200, outHeaders)
340
+ if (!res.writableEnded) res.end(out)
341
+ } catch {
342
+ failClosed('unprocessable')
343
+ }
344
+ })
345
+ },
346
+ )
347
+ upstreamReq.on('error', (err) => {
348
+ if (res.headersSent) {
349
+ res.destroy()
350
+ return
351
+ }
352
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
353
+ res.end(`user-management: upstream unreachable (${err.code || err.message})`)
354
+ })
355
+ req.on('error', () => upstreamReq.destroy())
356
+ sendRequestBody(req, upstreamReq)
357
+ }
358
+
359
+ /**
360
+ * Send the request body upstream.
361
+ *
362
+ * A body the gateway already consumed for a permission check is replayed from
363
+ * its buffer: by then the socket stream is spent, and piping it would send an
364
+ * empty body upstream (the request would look bodyless — a silently broken
365
+ * session.create rather than a denial).
366
+ */
367
+ function sendRequestBody(req, upstreamReq) {
368
+ if (Buffer.isBuffer(req.umReplayBody)) {
369
+ upstreamReq.end(req.umReplayBody)
370
+ return
371
+ }
372
+ req.pipe(upstreamReq)
373
+ }
374
+
375
+ /** Route index paths through the auth-injecting handler, everything else plain. */
376
+ async function handleRequest(req, res) {
377
+ await ensureDshCookie()
378
+ if (isIndexPath(req.url)) {
379
+ handleIndexRequest(req, res)
380
+ return
381
+ }
382
+ if (hooks && typeof hooks.rewriteResponse === 'function') {
383
+ const plan = hooks.rewriteResponse(req)
384
+ if (plan && typeof plan.transform === 'function') {
385
+ proxyRewriteJson(req, res, plan.transform, plan)
386
+ return
387
+ }
388
+ }
389
+ proxyPlain(req, res)
390
+ }
391
+
392
+ /** Build the raw HTTP/1.1 upgrade request line + headers for upstream. */
393
+ const buildUpgradeRequest = (req) => {
394
+ const headers = {}
395
+ for (const [name, value] of Object.entries(forwardHeaders(req))) {
396
+ headers[name] = value
397
+ }
398
+ headers.connection = req.headers.connection || 'Upgrade'
399
+ headers.upgrade = req.headers.upgrade || 'websocket'
400
+ let line = `${req.method} ${req.url} HTTP/1.1\r\n`
401
+ for (const [name, value] of Object.entries(headers)) {
402
+ line += `${name}: ${value}\r\n`
403
+ }
404
+ line += '\r\n'
405
+ return line
406
+ }
407
+
408
+ /**
409
+ * Proxy one WebSocket (or other protocol) upgrade. Ensures the dsh
410
+ * BrowserAuth cookie is minted before forwarding (the HTTP path does
411
+ * this in handleRequest; without it dsh 0.1.2-rc.1+ rejects the upgrade
412
+ * at the BrowserAuth fence — "Remote stream WebSocket closed"). If the
413
+ * upstream rejects the upgrade (non-101, e.g. cookie expired after a
414
+ * dsh restart), refreshes the cookie and retries once — mirrors the
415
+ * HTTP 401-retry in handleIndexRequest.
416
+ */
417
+ function handleUpgrade(req, socket, head, attempts) {
418
+ attempts = attempts || 0
419
+ ensureDshCookie().then(() => {
420
+ const upstreamSocket = net.connect(port, host, () => {
421
+ upstreamSocket.write(buildUpgradeRequest(req))
422
+ if (head && head.length > 0) upstreamSocket.write(head)
423
+ })
424
+ // Peek at the upstream's first response line: 101 = upgrade accepted,
425
+ // anything else (401/403/500) = rejected, likely an expired cookie.
426
+ // Refresh + retry once; on second failure destroy so the browser
427
+ // reconnects (HTTP traffic will have re-minted by then).
428
+ let upgraded = false
429
+ upstreamSocket.once('data', (chunk) => {
430
+ const statusLine = chunk.toString('utf8', 0, Math.min(chunk.length, 64)).split('\r\n')[0]
431
+ if (statusLine.includes(' 101 ')) {
432
+ upgraded = true
433
+ socket.write(chunk)
434
+ upstreamSocket.pipe(socket)
435
+ socket.pipe(upstreamSocket)
436
+ } else if (attempts < 1) {
437
+ upstreamSocket.destroy()
438
+ dshCookie = null
439
+ refreshDshCookie().then(() => handleUpgrade(req, socket, head, attempts + 1))
440
+ } else {
441
+ upstreamSocket.destroy()
442
+ if (!socket.destroyed) socket.destroy()
443
+ }
444
+ })
445
+ upstreamSocket.on('error', () => {
446
+ if (!upgraded && !socket.destroyed) {
447
+ socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
448
+ }
449
+ })
450
+ upstreamSocket.on('close', () => {
451
+ if (!upgraded && !socket.destroyed) socket.destroy()
452
+ })
453
+ socket.on('error', () => upstreamSocket.destroy())
454
+ socket.on('close', () => upstreamSocket.destroy())
455
+ }).catch(() => {
456
+ if (!socket.destroyed) socket.destroy()
457
+ })
458
+ }
459
+
460
+ return {
461
+ upstream,
462
+ handleRequest,
463
+ handleUpgrade,
464
+ /** Exposed so the gateway (and its tests) can drive the rewrite path. */
465
+ proxyRewriteJson,
466
+ /** @returns {string|null} the minted dsh browser cookie (for tests). */
467
+ getDshCookie: () => dshCookie,
468
+ close() {
469
+ agent.destroy()
470
+ },
471
+ }
472
+ }
473
+
474
+ export { createProxy }