@riceawa/dsh-lan-gateway 0.3.0 → 0.5.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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Shared upstream session relay for session-capable dsh bases (>= 0.1.2).
3
+ *
4
+ * When dsh added browser-session authentication it stopped trusting a loopback
5
+ * Host header alone: every `/api` request (and the remote WebSocket mux) must
6
+ * now present a signed cookie bound to the authority it names
7
+ * (`dsh-auth-<sha256(authority)>`), minted at the index route by exchanging the
8
+ * process launch token. A reverse proxy that rewrites Host to loopback — which
9
+ * is what this gateway does — therefore gets a 401 no matter how the Host is
10
+ * forged. The gateway cannot mint that cookie itself (the signing secret lives
11
+ * in dsh's credential provider), so it does exactly what a browser does: on the
12
+ * loopback transport it visits the launch-token URL, keeps the Set-Cookie it
13
+ * earns, and replays that one shared session on every request it forwards.
14
+ *
15
+ * Semantics match the pre-existing "single password = single operator" model:
16
+ * whoever passes the gateway's own login rides this one upstream session. It is
17
+ * not multi-user authorization, and upstream (which holds the secret) remains
18
+ * the actual authority over what the session may do.
19
+ *
20
+ * The relay is a no-op on a base without browser sessions: acquisition fails
21
+ * and `cookie()` returns undefined, so the gateway simply forwards without a
22
+ * session cookie exactly as it did against an older dsh.
23
+ *
24
+ * @module @riceawa/dsh-lan-gateway/upstream-session
25
+ */
26
+
27
+ import http from 'node:http'
28
+
29
+ /** The session-cookie name prefix upstream signs (`dsh-auth-<b64url(sha256)>`). */
30
+ const UPSTREAM_COOKIE_PREFIX = 'dsh-auth-'
31
+
32
+ /** A held session: the raw `name=value` and the epoch-millis it lapses. */
33
+ interface HeldCookie {
34
+ header: string
35
+ expiresAt: number
36
+ }
37
+
38
+ /** The minimal shared-session contract the gateway consumes. */
39
+ export interface UpstreamSession {
40
+ /** The current `name=value` without triggering a re-acquisition. */
41
+ peek(): string | undefined
42
+ /** The current `name=value`, re-acquiring when missing or stale. Never throws. */
43
+ cookie(): Promise<string | undefined>
44
+ /** Forget a session upstream rejected, so the next request re-acquires. */
45
+ invalidate(): void
46
+ }
47
+
48
+ export interface UpstreamSessionRelayOptions {
49
+ /** The loopback dsh port both the exchange and every forward target. */
50
+ port: number
51
+ /** A host[:port] to send as Host on the exchange (default `127.0.0.1:<port>`). */
52
+ authority?: string
53
+ /**
54
+ * Returns the launch-token-carrying root URL for the upstream origin
55
+ * (`http://127.0.0.1:<port>/?token=…`), or undefined when the upstream does
56
+ * not expose one. Re-read on every acquisition so a fresh token after an
57
+ * upstream restart is picked up.
58
+ */
59
+ authenticatedUrl: () => string | undefined
60
+ }
61
+
62
+ /** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
63
+ function nameValueOnly(setCookie: string): string {
64
+ const semi = setCookie.indexOf(';')
65
+ return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim()
66
+ }
67
+
68
+ /** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
69
+ function maxAgeSeconds(setCookie: string): number | undefined {
70
+ const match = /\bMax-Age=(\d+)\b/i.exec(setCookie)
71
+ return match === null ? undefined : Number(match[1])
72
+ }
73
+
74
+ /** The result of one token exchange. */
75
+ interface ExchangeResult {
76
+ header: string
77
+ expiresAt: number
78
+ }
79
+
80
+ /**
81
+ * Perform the token exchange over loopback: GET the launch-token URL with the
82
+ * upstream authority as Host, read the Set-Cookie the index route mints, and
83
+ * return its `name=value` plus expiry (or undefined when the exchange failed
84
+ * or no session cookie came back — e.g. an older base without browser
85
+ * sessions).
86
+ */
87
+ function exchange(
88
+ url: string,
89
+ authority: string,
90
+ port: number,
91
+ ): Promise<ExchangeResult | undefined> {
92
+ return new Promise((resolve) => {
93
+ let target: URL
94
+ try {
95
+ target = new URL(url)
96
+ } catch {
97
+ resolve(undefined)
98
+ return
99
+ }
100
+ const request = http.request({
101
+ host: '127.0.0.1',
102
+ port,
103
+ method: 'GET',
104
+ path: `${target.pathname}${target.search}`,
105
+ headers: { host: authority, accept: 'text/html' },
106
+ }, (response) => {
107
+ const setCookies = response.headers['set-cookie']
108
+ response.resume() // drain so the socket can be reused
109
+ if (setCookies === undefined) {
110
+ resolve(undefined)
111
+ return
112
+ }
113
+ const raw = (Array.isArray(setCookies) ? setCookies : [setCookies])
114
+ .find((value) => value.startsWith(`${UPSTREAM_COOKIE_PREFIX}=`))
115
+ if (raw === undefined) {
116
+ resolve(undefined)
117
+ return
118
+ }
119
+ const header = nameValueOnly(raw)
120
+ const maxAge = maxAgeSeconds(raw)
121
+ resolve({ header, expiresAt: Date.now() + (maxAge ?? 0) * 1000 })
122
+ })
123
+ request.on('error', () => resolve(undefined))
124
+ request.setTimeout(5000, () => request.destroy(new Error('upstream-session exchange timeout')))
125
+ request.end()
126
+ })
127
+ }
128
+
129
+ /**
130
+ * A cached {@link UpstreamSession} acquired through the launch-token exchange.
131
+ * Acquisition runs at most once concurrently and the result is cached until it
132
+ * nears expiry or {@link invalidate} is called.
133
+ */
134
+ export class UpstreamSessionRelay implements UpstreamSession {
135
+ private readonly port: number
136
+ private readonly authority: string
137
+ private readonly authenticatedUrl: () => string | undefined
138
+ private held: HeldCookie | undefined
139
+ private inflight: Promise<string | undefined> | undefined
140
+
141
+ constructor(options: UpstreamSessionRelayOptions) {
142
+ this.port = options.port
143
+ this.authority = options.authority ?? `127.0.0.1:${options.port}`
144
+ this.authenticatedUrl = options.authenticatedUrl
145
+ }
146
+
147
+ /** Whether the held session is still comfortably inside its lifetime. */
148
+ private fresh(): boolean {
149
+ const held = this.held
150
+ if (held === undefined) return false
151
+ // Refresh up to a minute before the cookie actually lapses so a slow
152
+ // request is never rejected mid-flight by an expiring session.
153
+ return Date.now() < held.expiresAt - 60_000
154
+ }
155
+
156
+ peek(): string | undefined {
157
+ return this.held?.header
158
+ }
159
+
160
+ invalidate(): void {
161
+ this.held = undefined
162
+ }
163
+
164
+ async cookie(): Promise<string | undefined> {
165
+ if (this.fresh()) return this.held?.header
166
+ return this.acquire()
167
+ }
168
+
169
+ private acquire(): Promise<string | undefined> {
170
+ if (this.inflight !== undefined) return this.inflight
171
+ const pending = this.doExchange().finally(() => {
172
+ this.inflight = undefined
173
+ })
174
+ this.inflight = pending
175
+ return pending
176
+ }
177
+
178
+ private async doExchange(): Promise<string | undefined> {
179
+ const url = this.authenticatedUrl()
180
+ if (url === undefined) return undefined
181
+ const result = await exchange(url, this.authority, this.port)
182
+ if (result !== undefined) this.held = result
183
+ // On a transient failure keep whatever session is still held rather than
184
+ // dropping to anonymous.
185
+ return this.held?.header
186
+ }
187
+ }