@query-farm/vgi-rpc 0.6.4 → 0.7.1

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.
Files changed (177) hide show
  1. package/dist/access-log.d.ts +55 -0
  2. package/dist/access-log.d.ts.map +1 -0
  3. package/dist/arrow/impl-arrowjs/index.d.ts +96 -0
  4. package/dist/arrow/impl-arrowjs/index.d.ts.map +1 -0
  5. package/dist/arrow/impl-flechette/index.d.ts +102 -0
  6. package/dist/arrow/impl-flechette/index.d.ts.map +1 -0
  7. package/dist/arrow/impl-flechette/message-meta.d.ts +11 -0
  8. package/dist/arrow/impl-flechette/message-meta.d.ts.map +1 -0
  9. package/dist/arrow/index.d.ts +4 -0
  10. package/dist/arrow/index.d.ts.map +1 -0
  11. package/dist/arrow/predicates.d.ts +44 -0
  12. package/dist/arrow/predicates.d.ts.map +1 -0
  13. package/dist/arrow/types.d.ts +62 -0
  14. package/dist/arrow/types.d.ts.map +1 -0
  15. package/dist/auth.d.ts +5 -0
  16. package/dist/auth.d.ts.map +1 -1
  17. package/dist/client/capabilities.d.ts +25 -0
  18. package/dist/client/capabilities.d.ts.map +1 -0
  19. package/dist/client/connect.d.ts +10 -0
  20. package/dist/client/connect.d.ts.map +1 -1
  21. package/dist/client/introspect.d.ts +21 -0
  22. package/dist/client/introspect.d.ts.map +1 -1
  23. package/dist/client/ipc.d.ts +8 -2
  24. package/dist/client/ipc.d.ts.map +1 -1
  25. package/dist/client/oauth.d.ts +9 -0
  26. package/dist/client/oauth.d.ts.map +1 -1
  27. package/dist/client/pipe.d.ts +24 -0
  28. package/dist/client/pipe.d.ts.map +1 -1
  29. package/dist/client/stream.d.ts +19 -2
  30. package/dist/client/stream.d.ts.map +1 -1
  31. package/dist/client/types.d.ts +23 -0
  32. package/dist/client/types.d.ts.map +1 -1
  33. package/dist/client/uploadUrl.d.ts +25 -0
  34. package/dist/client/uploadUrl.d.ts.map +1 -0
  35. package/dist/constants.d.ts +30 -2
  36. package/dist/constants.d.ts.map +1 -1
  37. package/dist/crypto.d.ts +22 -0
  38. package/dist/crypto.d.ts.map +1 -0
  39. package/dist/dispatch/describe.d.ts +10 -6
  40. package/dist/dispatch/describe.d.ts.map +1 -1
  41. package/dist/dispatch/stream.d.ts +2 -2
  42. package/dist/dispatch/stream.d.ts.map +1 -1
  43. package/dist/dispatch/unary.d.ts +2 -2
  44. package/dist/dispatch/unary.d.ts.map +1 -1
  45. package/dist/errors.d.ts +64 -1
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/external.d.ts +27 -5
  48. package/dist/external.d.ts.map +1 -1
  49. package/dist/http/auth.d.ts +13 -0
  50. package/dist/http/auth.d.ts.map +1 -1
  51. package/dist/http/bearer.d.ts.map +1 -1
  52. package/dist/http/common.d.ts +43 -7
  53. package/dist/http/common.d.ts.map +1 -1
  54. package/dist/http/dispatch.d.ts +20 -2
  55. package/dist/http/dispatch.d.ts.map +1 -1
  56. package/dist/http/handler.d.ts.map +1 -1
  57. package/dist/http/index.d.ts +1 -0
  58. package/dist/http/index.d.ts.map +1 -1
  59. package/dist/http/jwt.d.ts +1 -0
  60. package/dist/http/jwt.d.ts.map +1 -1
  61. package/dist/http/mtls.d.ts +9 -1
  62. package/dist/http/mtls.d.ts.map +1 -1
  63. package/dist/http/oauth-pkce.d.ts +141 -0
  64. package/dist/http/oauth-pkce.d.ts.map +1 -0
  65. package/dist/http/pages.d.ts +3 -0
  66. package/dist/http/pages.d.ts.map +1 -1
  67. package/dist/http/sticky.d.ts +124 -0
  68. package/dist/http/sticky.d.ts.map +1 -0
  69. package/dist/http/token.d.ts +43 -12
  70. package/dist/http/token.d.ts.map +1 -1
  71. package/dist/http/types.d.ts +68 -5
  72. package/dist/http/types.d.ts.map +1 -1
  73. package/dist/index.d.ts +6 -4
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1275 -3511
  76. package/dist/index.js.map +20 -38
  77. package/dist/launcher/hash.d.ts +22 -0
  78. package/dist/launcher/hash.d.ts.map +1 -0
  79. package/dist/launcher/index.d.ts +23 -0
  80. package/dist/launcher/index.d.ts.map +1 -0
  81. package/dist/launcher/launch.d.ts +27 -0
  82. package/dist/launcher/launch.d.ts.map +1 -0
  83. package/dist/launcher/lock.d.ts +19 -0
  84. package/dist/launcher/lock.d.ts.map +1 -0
  85. package/dist/launcher/serve-unix.d.ts +55 -0
  86. package/dist/launcher/serve-unix.d.ts.map +1 -0
  87. package/dist/launcher/state.d.ts +71 -0
  88. package/dist/launcher/state.d.ts.map +1 -0
  89. package/dist/otel.d.ts.map +1 -1
  90. package/dist/protocol.d.ts +19 -2
  91. package/dist/protocol.d.ts.map +1 -1
  92. package/dist/schema.d.ts +45 -18
  93. package/dist/schema.d.ts.map +1 -1
  94. package/dist/server.d.ts +23 -2
  95. package/dist/server.d.ts.map +1 -1
  96. package/dist/types.d.ts +270 -12
  97. package/dist/types.d.ts.map +1 -1
  98. package/dist/util/gzip.d.ts +10 -0
  99. package/dist/util/gzip.d.ts.map +1 -0
  100. package/dist/util/schema.d.ts +3 -15
  101. package/dist/util/schema.d.ts.map +1 -1
  102. package/dist/util/web-crypto.d.ts +22 -0
  103. package/dist/util/web-crypto.d.ts.map +1 -0
  104. package/dist/util/zstd.d.ts +26 -3
  105. package/dist/util/zstd.d.ts.map +1 -1
  106. package/dist/wire/opaque.d.ts +11 -0
  107. package/dist/wire/opaque.d.ts.map +1 -0
  108. package/dist/wire/reader.d.ts +5 -5
  109. package/dist/wire/reader.d.ts.map +1 -1
  110. package/dist/wire/request.d.ts +11 -3
  111. package/dist/wire/request.d.ts.map +1 -1
  112. package/dist/wire/response.d.ts +6 -6
  113. package/dist/wire/response.d.ts.map +1 -1
  114. package/dist/wire/writer.d.ts +49 -39
  115. package/dist/wire/writer.d.ts.map +1 -1
  116. package/package.json +35 -21
  117. package/src/access-log.ts +200 -0
  118. package/src/arrow/impl-arrowjs/index.ts +433 -0
  119. package/src/arrow/impl-flechette/index.ts +414 -0
  120. package/src/arrow/impl-flechette/message-meta.ts +174 -0
  121. package/src/arrow/index.ts +89 -0
  122. package/src/arrow/predicates.ts +56 -0
  123. package/src/arrow/types.ts +73 -0
  124. package/src/auth.ts +5 -0
  125. package/src/client/capabilities.ts +84 -0
  126. package/src/client/connect.ts +113 -26
  127. package/src/client/introspect.ts +74 -38
  128. package/src/client/ipc.ts +37 -27
  129. package/src/client/oauth.ts +9 -0
  130. package/src/client/pipe.ts +36 -9
  131. package/src/client/stream.ts +43 -20
  132. package/src/client/types.ts +23 -0
  133. package/src/client/uploadUrl.ts +169 -0
  134. package/src/constants.ts +34 -2
  135. package/src/crypto.ts +95 -0
  136. package/src/dispatch/describe.ts +146 -107
  137. package/src/dispatch/stream.ts +53 -24
  138. package/src/dispatch/unary.ts +5 -4
  139. package/src/errors.ts +87 -0
  140. package/src/external.ts +49 -30
  141. package/src/http/auth.ts +13 -0
  142. package/src/http/bearer.ts +2 -5
  143. package/src/http/common.ts +91 -23
  144. package/src/http/dispatch.ts +373 -46
  145. package/src/http/handler.ts +790 -68
  146. package/src/http/index.ts +1 -0
  147. package/src/http/jwt.ts +1 -0
  148. package/src/http/mtls.ts +25 -3
  149. package/src/http/oauth-pkce.ts +1035 -0
  150. package/src/http/pages.ts +30 -15
  151. package/src/http/sticky.ts +429 -0
  152. package/src/http/token.ts +170 -75
  153. package/src/http/types.ts +69 -5
  154. package/src/index.ts +40 -1
  155. package/src/launcher/hash.ts +104 -0
  156. package/src/launcher/index.ts +35 -0
  157. package/src/launcher/launch.ts +284 -0
  158. package/src/launcher/lock.ts +171 -0
  159. package/src/launcher/serve-unix.ts +386 -0
  160. package/src/launcher/state.ts +257 -0
  161. package/src/otel.ts +39 -33
  162. package/src/protocol.ts +30 -3
  163. package/src/schema.ts +107 -56
  164. package/src/server.ts +196 -20
  165. package/src/types.ts +376 -18
  166. package/src/util/gzip.ts +63 -0
  167. package/src/util/schema.ts +4 -22
  168. package/src/util/web-crypto.ts +98 -0
  169. package/src/util/zstd.ts +133 -14
  170. package/src/wire/opaque.ts +37 -0
  171. package/src/wire/reader.ts +5 -4
  172. package/src/wire/request.ts +67 -8
  173. package/src/wire/response.ts +51 -85
  174. package/src/wire/writer.ts +165 -69
  175. package/dist/util/conform.d.ts +0 -18
  176. package/dist/util/conform.d.ts.map +0 -1
  177. package/src/util/conform.ts +0 -94
@@ -0,0 +1,1035 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Server-side OAuth PKCE authorization code flow for vgi-rpc HTTP browse pages.
6
+ *
7
+ * When both `authenticate` and `oauthResourceMetadata` (with a `clientId`)
8
+ * are configured, this module enables browser-based authentication:
9
+ *
10
+ * 1. A browser GET that would return 401 is instead redirected to the
11
+ * authorization server's login page (with PKCE code challenge).
12
+ * 2. After the user authenticates, the authorization server redirects back
13
+ * to `{prefix}/_oauth/callback` with an authorization code.
14
+ * 3. The callback exchanges the code for a token, stores it in a JS-readable
15
+ * cookie, and redirects back to the original page.
16
+ */
17
+
18
+ import type { AuthContext } from "../auth.js";
19
+ import type { AuthenticateFn } from "./auth.js";
20
+ import { ERROR_PAGE_STYLE, FONTS, LOGO_URL } from "./pages.js";
21
+
22
+ // Indirect-string require keeps node:crypto out of the static bundle for
23
+ // workerd. OAuth PKCE is opt-in (configureOAuthPkce); callers on workerd
24
+ // should not enable it.
25
+ const _NODE_CRYPTO_MOD = "node:crypto";
26
+ function _crypto(): {
27
+ createHash: any;
28
+ createHmac: any;
29
+ randomBytes: (n: number) => any;
30
+ timingSafeEqual: (a: any, b: any) => boolean;
31
+ } {
32
+ const req: any = (import.meta as any).require ?? (globalThis as any).require ?? null;
33
+ if (!req) {
34
+ throw new Error("OAuth PKCE requires Node.js or Bun (node:crypto).");
35
+ }
36
+ return req(_NODE_CRYPTO_MOD);
37
+ }
38
+ const createHash = (algo: string) => _crypto().createHash(algo);
39
+ const createHmac = (algo: string, key: any) => _crypto().createHmac(algo, key);
40
+ const randomBytes = (n: number) => _crypto().randomBytes(n);
41
+ const timingSafeEqual = (a: any, b: any) => _crypto().timingSafeEqual(a, b);
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Constants
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const SESSION_COOKIE_NAME = "_vgi_oauth_session";
48
+ const AUTH_COOKIE_NAME = "_vgi_auth";
49
+ const SESSION_COOKIE_VERSION = 4;
50
+ const SESSION_MAX_AGE = 600; // 10 minutes
51
+ const AUTH_COOKIE_DEFAULT_MAX_AGE = 3600; // 1 hour fallback
52
+ const MAX_ORIGINAL_URL_LEN = 2048;
53
+ const HMAC_LEN = 32;
54
+
55
+ /** Default origins allowed for _vgi_return_to redirects. */
56
+ const DEFAULT_ALLOWED_RETURN_ORIGINS: ReadonlySet<string> = new Set(["https://cupola.query-farm.services"]);
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // PKCE helpers (RFC 7636)
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** Generate a 43-character URL-safe random code verifier (RFC 7636 S4.1). */
63
+ export function generateCodeVerifier(): string {
64
+ // Match Python secrets.token_urlsafe(32) — 32 random bytes → base64url (no padding) = 43 chars
65
+ return randomBytes(32).toString("base64url");
66
+ }
67
+
68
+ /** Compute S256 code challenge from a code verifier (RFC 7636 S4.2). */
69
+ export function generateCodeChallenge(verifier: string): string {
70
+ const digest = createHash("sha256").update(verifier, "ascii").digest();
71
+ return digest.toString("base64url");
72
+ }
73
+
74
+ /** Generate a random state nonce for CSRF protection. */
75
+ export function generateStateNonce(): string {
76
+ return randomBytes(24).toString("base64url");
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Derived HMAC key
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /** Derive a separate HMAC key for OAuth session cookies. */
84
+ export function deriveSessionKey(signingKey: Uint8Array): Uint8Array {
85
+ return createHmac("sha256", signingKey).update("oauth-pkce-session").digest();
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Base64url encoding matching Python's base64.urlsafe_b64encode (WITH padding)
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /** Encode bytes as base64url WITH `=` padding (matches Python `base64.urlsafe_b64encode`). */
93
+ function b64urlEncode(buf: Buffer): string {
94
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
95
+ }
96
+
97
+ /** Decode base64url string (with or without padding) to Buffer. */
98
+ function b64urlDecode(s: string): Buffer {
99
+ // base64url → standard base64
100
+ const standard = s.replace(/-/g, "+").replace(/_/g, "/");
101
+ return Buffer.from(standard, "base64");
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Signed session cookie (stores code_verifier + state + original URL)
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Pack PKCE session data into a signed, base64-encoded cookie value.
110
+ *
111
+ * Wire format v4:
112
+ * [1B version=4] [8B created_at uint64 LE]
113
+ * [2B cv_len uint16 LE] [cv_len bytes code_verifier]
114
+ * [2B state_len uint16 LE] [state_len bytes state_nonce]
115
+ * [2B url_len uint16 LE] [url_len bytes original_url]
116
+ * [2B rt_len uint16 LE] [rt_len bytes return_to]
117
+ * [32B HMAC-SHA256(session_key, all above)]
118
+ */
119
+ export function packOAuthCookie(
120
+ codeVerifier: string,
121
+ stateNonce: string,
122
+ originalUrl: string,
123
+ sessionKey: Uint8Array,
124
+ createdAt?: number,
125
+ returnTo?: string,
126
+ ): string {
127
+ const now = createdAt ?? Math.floor(Date.now() / 1000);
128
+ const cvBytes = Buffer.from(codeVerifier, "utf-8");
129
+ const stateBytes = Buffer.from(stateNonce, "utf-8");
130
+ const urlBytes = Buffer.from(originalUrl, "utf-8");
131
+ const rtBytes = Buffer.from(returnTo ?? "", "utf-8");
132
+
133
+ const payloadLen = 1 + 8 + 2 + cvBytes.length + 2 + stateBytes.length + 2 + urlBytes.length + 2 + rtBytes.length;
134
+ const payload = Buffer.alloc(payloadLen);
135
+ let offset = 0;
136
+
137
+ payload.writeUInt8(SESSION_COOKIE_VERSION, offset);
138
+ offset += 1;
139
+
140
+ payload.writeBigUInt64LE(BigInt(now), offset);
141
+ offset += 8;
142
+
143
+ payload.writeUInt16LE(cvBytes.length, offset);
144
+ offset += 2;
145
+ cvBytes.copy(payload, offset);
146
+ offset += cvBytes.length;
147
+
148
+ payload.writeUInt16LE(stateBytes.length, offset);
149
+ offset += 2;
150
+ stateBytes.copy(payload, offset);
151
+ offset += stateBytes.length;
152
+
153
+ payload.writeUInt16LE(urlBytes.length, offset);
154
+ offset += 2;
155
+ urlBytes.copy(payload, offset);
156
+ offset += urlBytes.length;
157
+
158
+ payload.writeUInt16LE(rtBytes.length, offset);
159
+ offset += 2;
160
+ rtBytes.copy(payload, offset);
161
+
162
+ const mac = createHmac("sha256", sessionKey).update(payload).digest();
163
+ return b64urlEncode(Buffer.concat([payload, mac]));
164
+ }
165
+
166
+ export interface UnpackedOAuthCookie {
167
+ codeVerifier: string;
168
+ stateNonce: string;
169
+ originalUrl: string;
170
+ returnTo: string;
171
+ }
172
+
173
+ /**
174
+ * Unpack and verify a signed OAuth session cookie.
175
+ *
176
+ * @throws Error on tampered, expired, or malformed cookies.
177
+ */
178
+ export function unpackOAuthCookie(
179
+ cookieValue: string,
180
+ sessionKey: Uint8Array,
181
+ maxAge: number = SESSION_MAX_AGE,
182
+ ): UnpackedOAuthCookie {
183
+ let raw: Buffer;
184
+ try {
185
+ raw = b64urlDecode(cookieValue);
186
+ } catch {
187
+ throw new Error("Malformed session cookie");
188
+ }
189
+
190
+ // Minimum: version(1) + timestamp(8) + 4 x length(2) + HMAC(32) = 49
191
+ if (raw.length < 49) {
192
+ throw new Error("Session cookie too short");
193
+ }
194
+
195
+ // Verify HMAC before inspecting payload
196
+ const payload = raw.subarray(0, raw.length - HMAC_LEN);
197
+ const receivedMac = raw.subarray(raw.length - HMAC_LEN);
198
+ const expectedMac = createHmac("sha256", sessionKey).update(payload).digest();
199
+ if (!timingSafeEqual(receivedMac, expectedMac)) {
200
+ throw new Error("Session cookie signature mismatch");
201
+ }
202
+
203
+ // Parse payload
204
+ const version = payload.readUInt8(0);
205
+ if (version !== SESSION_COOKIE_VERSION) {
206
+ throw new Error(`Unexpected session cookie version: ${version}`);
207
+ }
208
+
209
+ const createdAt = Number(payload.readBigUInt64LE(1));
210
+ if (maxAge > 0) {
211
+ const age = Math.floor(Date.now() / 1000) - createdAt;
212
+ if (age < 0 || age > maxAge) {
213
+ throw new Error(`Session cookie expired (age=${age}s, max=${maxAge}s)`);
214
+ }
215
+ }
216
+
217
+ let pos = 9;
218
+ const cvLen = payload.readUInt16LE(pos);
219
+ pos += 2;
220
+ const codeVerifier = payload.subarray(pos, pos + cvLen).toString("utf-8");
221
+ pos += cvLen;
222
+
223
+ const stateLen = payload.readUInt16LE(pos);
224
+ pos += 2;
225
+ const stateNonce = payload.subarray(pos, pos + stateLen).toString("utf-8");
226
+ pos += stateLen;
227
+
228
+ const urlLen = payload.readUInt16LE(pos);
229
+ pos += 2;
230
+ const originalUrl = payload.subarray(pos, pos + urlLen).toString("utf-8");
231
+ pos += urlLen;
232
+
233
+ const rtLen = payload.readUInt16LE(pos);
234
+ pos += 2;
235
+ const returnTo = payload.subarray(pos, pos + rtLen).toString("utf-8");
236
+
237
+ return { codeVerifier, stateNonce, originalUrl, returnTo };
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // OIDC discovery cache
242
+ // ---------------------------------------------------------------------------
243
+
244
+ export interface OidcEndpoints {
245
+ authorizationEndpoint: string;
246
+ tokenEndpoint: string;
247
+ }
248
+
249
+ /**
250
+ * Create a lazy-cached OIDC discovery function.
251
+ *
252
+ * Caches the Promise; resets on rejection so a transient failure is retried.
253
+ */
254
+ export function createOidcDiscovery(issuer: string): () => Promise<OidcEndpoints | null> {
255
+ let cached: Promise<OidcEndpoints | null> | null = null;
256
+
257
+ return function discover(): Promise<OidcEndpoints | null> {
258
+ if (cached) return cached;
259
+ const url = `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
260
+ cached = fetch(url, { signal: AbortSignal.timeout(10000) })
261
+ .then(async (resp) => {
262
+ if (!resp.ok) throw new Error(`OIDC discovery HTTP ${resp.status}`);
263
+ const data = await resp.json();
264
+ return {
265
+ authorizationEndpoint: data.authorization_endpoint as string,
266
+ tokenEndpoint: data.token_endpoint as string,
267
+ };
268
+ })
269
+ .catch(() => {
270
+ cached = null; // reset on failure for retry
271
+ return null;
272
+ });
273
+ return cached;
274
+ };
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Token exchange
279
+ // ---------------------------------------------------------------------------
280
+
281
+ export interface TokenExchangeResult {
282
+ token: string;
283
+ maxAge: number;
284
+ refreshToken: string | null;
285
+ }
286
+
287
+ /** Exchange an authorization code for a token via the token endpoint. */
288
+ export async function exchangeCodeForToken(
289
+ tokenEndpoint: string,
290
+ code: string,
291
+ redirectUri: string,
292
+ codeVerifier: string,
293
+ clientId: string,
294
+ clientSecret?: string,
295
+ useIdToken?: boolean,
296
+ ): Promise<TokenExchangeResult> {
297
+ const params = new URLSearchParams({
298
+ grant_type: "authorization_code",
299
+ code,
300
+ redirect_uri: redirectUri,
301
+ code_verifier: codeVerifier,
302
+ client_id: clientId,
303
+ });
304
+ if (clientSecret) {
305
+ params.set("client_secret", clientSecret);
306
+ }
307
+
308
+ let body: any;
309
+ try {
310
+ const resp = await fetch(tokenEndpoint, {
311
+ method: "POST",
312
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
313
+ body: params.toString(),
314
+ signal: AbortSignal.timeout(15000),
315
+ });
316
+ if (!resp.ok) {
317
+ const text = await resp.text();
318
+ throw new Error(`HTTP ${resp.status}: ${text}`);
319
+ }
320
+ body = await resp.json();
321
+ } catch (err: any) {
322
+ throw new Error(`Token exchange failed: ${err.message ?? err}`);
323
+ }
324
+
325
+ const refreshToken: string | null = body.refresh_token ?? null;
326
+
327
+ if (useIdToken) {
328
+ const token = body.id_token;
329
+ if (!token) throw new Error("Token response missing id_token");
330
+ // Derive maxAge from the id_token's exp claim
331
+ try {
332
+ const parts = (token as string).split(".");
333
+ if (parts.length >= 2) {
334
+ const padding = 4 - (parts[1].length % 4);
335
+ const payloadJson = Buffer.from(parts[1] + "=".repeat(padding % 4), "base64").toString("utf-8");
336
+ const claims = JSON.parse(payloadJson);
337
+ if (claims.exp != null) {
338
+ const maxAge = Math.max(Number(claims.exp) - Math.floor(Date.now() / 1000), 60);
339
+ return { token, maxAge, refreshToken };
340
+ }
341
+ }
342
+ } catch {
343
+ // Fall through to default
344
+ }
345
+ return { token, maxAge: AUTH_COOKIE_DEFAULT_MAX_AGE, refreshToken };
346
+ }
347
+
348
+ const token = body.access_token;
349
+ if (!token) throw new Error("Token response missing access_token");
350
+ const expiresIn = body.expires_in ?? AUTH_COOKIE_DEFAULT_MAX_AGE;
351
+ return { token, maxAge: Number(expiresIn), refreshToken };
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Original URL validation
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /** Validate the original URL is relative and within the expected prefix. */
359
+ export function validateOriginalUrl(url: string, prefix: string): string {
360
+ let u = url;
361
+ if (u.length > MAX_ORIGINAL_URL_LEN) {
362
+ u = u.slice(0, MAX_ORIGINAL_URL_LEN);
363
+ }
364
+ try {
365
+ const parsed = new URL(u, "http://dummy");
366
+ // If the URL has a different origin than dummy, it's absolute
367
+ if (u.startsWith("http://") || u.startsWith("https://") || u.startsWith("//")) {
368
+ return prefix || "/";
369
+ }
370
+ // Check hostname is "dummy" (relative URL) — otherwise it's absolute
371
+ if (parsed.hostname !== "dummy") {
372
+ return prefix || "/";
373
+ }
374
+ } catch {
375
+ return prefix || "/";
376
+ }
377
+ if (prefix && !u.startsWith(prefix)) {
378
+ return prefix || "/";
379
+ }
380
+ return u;
381
+ }
382
+
383
+ function isLocalhost(hostname: string): boolean {
384
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
385
+ }
386
+
387
+ /** Validate an external return-to URL against an origin allowlist. */
388
+ export function validateReturnTo(url: string, allowedOrigins?: ReadonlySet<string>): string {
389
+ const origins = allowedOrigins ?? DEFAULT_ALLOWED_RETURN_ORIGINS;
390
+ if (!url || url.length > 2048) return "";
391
+ let parsed: URL;
392
+ try {
393
+ parsed = new URL(url);
394
+ } catch {
395
+ return "";
396
+ }
397
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
398
+ if (!parsed.hostname) return "";
399
+ // localhost with any port is always allowed
400
+ if (isLocalhost(parsed.hostname) && parsed.protocol === "http:") return url;
401
+ // Check against allowlist (scheme + host, ignoring path)
402
+ const origin = `${parsed.protocol}//${parsed.hostname}`;
403
+ if (origins.has(origin)) return url;
404
+ // Also try with explicit port
405
+ if (parsed.port) {
406
+ const originWithPort = `${parsed.protocol}//${parsed.hostname}:${parsed.port}`;
407
+ if (origins.has(originWithPort)) return url;
408
+ }
409
+ return "";
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Cookie helpers
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /** Parse the Cookie header from a Request into a Map. */
417
+ export function parseCookies(request: Request): Map<string, string> {
418
+ const header = request.headers.get("Cookie");
419
+ const map = new Map<string, string>();
420
+ if (!header) return map;
421
+ for (const pair of header.split(";")) {
422
+ const eq = pair.indexOf("=");
423
+ if (eq < 0) continue;
424
+ const name = pair.slice(0, eq).trim();
425
+ const value = pair.slice(eq + 1).trim();
426
+ map.set(name, value);
427
+ }
428
+ return map;
429
+ }
430
+
431
+ interface SetCookieOptions {
432
+ maxAge?: number;
433
+ path?: string;
434
+ secure?: boolean;
435
+ httpOnly?: boolean;
436
+ sameSite?: "Strict" | "Lax" | "None";
437
+ }
438
+
439
+ /** Build a Set-Cookie header string. */
440
+ export function buildSetCookieHeader(name: string, value: string, options: SetCookieOptions): string {
441
+ let cookie = `${name}=${value}`;
442
+ if (options.maxAge !== undefined) cookie += `; Max-Age=${options.maxAge}`;
443
+ if (options.path) cookie += `; Path=${options.path}`;
444
+ if (options.secure) cookie += "; Secure";
445
+ if (options.httpOnly) cookie += "; HttpOnly";
446
+ if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
447
+ return cookie;
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Error HTML page
452
+ // ---------------------------------------------------------------------------
453
+
454
+ function escapeHtml(s: string): string {
455
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
456
+ }
457
+
458
+ /** Render a user-friendly OAuth error page. */
459
+ export function buildOAuthErrorPage(message: string, detail: string | null, retryUrl: string): string {
460
+ const detailHtml = detail ? `<div class="detail">${escapeHtml(detail)}</div>` : "";
461
+ return `<!DOCTYPE html>
462
+ <html lang="en">
463
+ <head>
464
+ <meta charset="utf-8">
465
+ <meta name="viewport" content="width=device-width, initial-scale=1">
466
+ <title>Authentication Error \u2014 vgi-rpc</title>
467
+ ${FONTS}
468
+ ${ERROR_PAGE_STYLE}
469
+ </head>
470
+ <body>
471
+ <div class="logo">
472
+ <img src="${LOGO_URL}" alt="vgi-rpc logo">
473
+ </div>
474
+ <h1>Authentication Error</h1>
475
+ <p>${escapeHtml(message)}</p>
476
+ ${detailHtml}
477
+ <p><a href="${escapeHtml(retryUrl)}">Try again</a></p>
478
+ <footer>
479
+ Powered by <a href="https://vgi-rpc.query.farm"><code>vgi-rpc</code></a>
480
+ </footer>
481
+ </body>
482
+ </html>`;
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // User-info JS snippet for landing/describe pages
487
+ // ---------------------------------------------------------------------------
488
+
489
+ const USER_INFO_STYLE = `#vgi-user-info {
490
+ position: fixed; top: 12px; right: 16px; z-index: 1000;
491
+ font-family: 'Inter', system-ui, sans-serif; font-size: 0.85em;
492
+ display: flex; align-items: center; gap: 8px;
493
+ background: #fff; border: 1px solid #e0ddd0; border-radius: 20px;
494
+ padding: 4px 14px 4px 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.06);
495
+ }
496
+ #vgi-user-info img {
497
+ width: 26px; height: 26px; border-radius: 50%;
498
+ }
499
+ #vgi-user-info .email { color: #2c2c1e; font-weight: 500; }
500
+ #vgi-user-info a {
501
+ color: #6b6b5a; text-decoration: none; margin-left: 4px;
502
+ font-size: 0.9em;
503
+ }
504
+ #vgi-user-info a:hover { color: #8b0000; }`;
505
+
506
+ function buildUserInfoScript(cookieName: string, logoutUrl: string): string {
507
+ return `(function() {
508
+ var c = document.cookie.match('(^|;)\\\\s*${cookieName}=([^;]+)');
509
+ if (!c) return;
510
+ try {
511
+ var parts = c[2].split('.');
512
+ var payload = JSON.parse(atob(parts[1].replace(/-/g,'+').replace(/_/g,'/')));
513
+ var el = document.getElementById('vgi-user-info');
514
+ if (!el) return;
515
+ var html = '';
516
+ if (payload.picture) html += '<img src="' + payload.picture + '" alt="">';
517
+ html += '<span class="email">' + (payload.email || payload.sub || '') + '</span>';
518
+ html += '<a href="${logoutUrl}">Sign out</a>';
519
+ el.innerHTML = html;
520
+ } catch(e) {}
521
+ })();`;
522
+ }
523
+
524
+ /** Return HTML snippet (style + div + script) for user info display. */
525
+ export function buildUserInfoHtml(prefix: string): string {
526
+ const logoutUrl = `${prefix}/_oauth/logout`;
527
+ return (
528
+ `<style>${USER_INFO_STYLE}</style>\n` +
529
+ `<div id="vgi-user-info"></div>\n` +
530
+ `<script>${buildUserInfoScript(AUTH_COOKIE_NAME, logoutUrl)}</script>`
531
+ );
532
+ }
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // Cookie authenticate function
536
+ // ---------------------------------------------------------------------------
537
+
538
+ /**
539
+ * Create an authenticate callback that reads a bearer token from a cookie.
540
+ *
541
+ * Extracts the token from the named cookie and delegates validation to the
542
+ * `innerAuth` authenticator by creating a new Request with an Authorization header.
543
+ */
544
+ export function cookieAuthenticate(innerAuth: AuthenticateFn, cookieName: string = AUTH_COOKIE_NAME): AuthenticateFn {
545
+ return async function authenticate(request: Request): Promise<AuthContext> {
546
+ const cookies = parseCookies(request);
547
+ const token = cookies.get(cookieName);
548
+ if (!token) {
549
+ throw new Error("No auth cookie");
550
+ }
551
+ // Create a new Request with the cookie token as an Authorization header
552
+ const newHeaders = new Headers(request.headers);
553
+ newHeaders.set("Authorization", `Bearer ${token}`);
554
+ const newRequest = new Request(request.url, {
555
+ method: request.method,
556
+ headers: newHeaders,
557
+ });
558
+ return innerAuth(newRequest);
559
+ };
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // OAuth PKCE configuration
564
+ // ---------------------------------------------------------------------------
565
+
566
+ /** Configuration object produced by configureOAuthPkce. */
567
+ export interface OAuthPkceConfig {
568
+ sessionKey: Uint8Array;
569
+ oidcDiscovery: () => Promise<OidcEndpoints | null>;
570
+ clientId: string;
571
+ clientSecret: string | undefined;
572
+ useIdToken: boolean;
573
+ prefix: string;
574
+ secureCookie: boolean;
575
+ redirectUri: string;
576
+ scope: string;
577
+ allowedReturnOrigins: ReadonlySet<string>;
578
+ cookieAuthenticate: AuthenticateFn;
579
+ userInfoHtml: string;
580
+ }
581
+
582
+ /** Options for configureOAuthPkce. */
583
+ export interface OAuthPkceOptions {
584
+ signingKey: Uint8Array;
585
+ issuer: string;
586
+ clientId: string;
587
+ clientSecret?: string;
588
+ useIdToken?: boolean;
589
+ prefix: string;
590
+ secureCookie: boolean;
591
+ redirectUri: string;
592
+ scope?: string;
593
+ allowedReturnOrigins?: ReadonlySet<string>;
594
+ }
595
+
596
+ /**
597
+ * Resolve the OAuth PKCE `scope` string from available sources.
598
+ *
599
+ * Precedence:
600
+ * 1. `scopesSupported` from OAuth resource metadata (space-joined), when non-empty.
601
+ * 2. Explicit `optionsScope` override (e.g. `HttpHandlerOptions.oauthPkceScope`).
602
+ * 3. `undefined`, which lets `configureOAuthPkce` apply its built-in default of
603
+ * `"openid email"`.
604
+ *
605
+ * Mirrors the Python reference behavior introduced in vgi-rpc v0.6.12: authorization
606
+ * requests should use the scopes the server publishes in its protected resource
607
+ * metadata, so clients ask for exactly what the resource advertises.
608
+ */
609
+ export function resolvePkceScope(
610
+ scopesSupported: readonly string[] | undefined,
611
+ optionsScope: string | undefined,
612
+ ): string | undefined {
613
+ if (scopesSupported && scopesSupported.length > 0) {
614
+ return scopesSupported.join(" ");
615
+ }
616
+ return optionsScope;
617
+ }
618
+
619
+ /** Factory function wiring all PKCE components. */
620
+ export function configureOAuthPkce(opts: OAuthPkceOptions, innerAuth: AuthenticateFn): OAuthPkceConfig {
621
+ const sessionKey = deriveSessionKey(opts.signingKey);
622
+ const oidcDiscovery = createOidcDiscovery(opts.issuer);
623
+ return {
624
+ sessionKey,
625
+ oidcDiscovery,
626
+ clientId: opts.clientId,
627
+ clientSecret: opts.clientSecret,
628
+ useIdToken: opts.useIdToken ?? false,
629
+ prefix: opts.prefix,
630
+ secureCookie: opts.secureCookie,
631
+ redirectUri: opts.redirectUri,
632
+ scope: opts.scope ?? "openid email",
633
+ allowedReturnOrigins: opts.allowedReturnOrigins ?? DEFAULT_ALLOWED_RETURN_ORIGINS,
634
+ cookieAuthenticate: cookieAuthenticate(innerAuth),
635
+ userInfoHtml: buildUserInfoHtml(opts.prefix),
636
+ };
637
+ }
638
+
639
+ // ---------------------------------------------------------------------------
640
+ // OAuth token-exchange proxy
641
+ // ---------------------------------------------------------------------------
642
+
643
+ const ALLOWED_TOKEN_GRANT_TYPES: ReadonlySet<string> = new Set(["authorization_code", "refresh_token"]);
644
+
645
+ function isLocalhostHost(hostname: string): boolean {
646
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
647
+ }
648
+
649
+ /** Set Access-Control-Allow-Origin when the request's Origin is in the allowlist (or is localhost). */
650
+ function setProxyCors(headers: Headers, request: Request, config: OAuthPkceConfig): void {
651
+ headers.append("Vary", "Origin");
652
+ const origin = request.headers.get("Origin");
653
+ if (!origin) return;
654
+ let parsed: URL;
655
+ try {
656
+ parsed = new URL(origin);
657
+ } catch {
658
+ return;
659
+ }
660
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
661
+ if (!parsed.hostname) return;
662
+ if (isLocalhostHost(parsed.hostname) && parsed.protocol === "http:") {
663
+ headers.set("Access-Control-Allow-Origin", origin);
664
+ return;
665
+ }
666
+ if (config.allowedReturnOrigins.has(origin)) {
667
+ headers.set("Access-Control-Allow-Origin", origin);
668
+ }
669
+ }
670
+
671
+ function jsonErrorResponse(headers: Headers, status: number, error: string, description: string): Response {
672
+ headers.set("Content-Type", "application/json");
673
+ return new Response(JSON.stringify({ error, error_description: description }), { status, headers });
674
+ }
675
+
676
+ /**
677
+ * Handle POST/OPTIONS {prefix}/_oauth/token — the PKCE token-exchange proxy.
678
+ *
679
+ * SPA PKCE clients cannot safely hold a client_secret, but some IdPs
680
+ * (notably Google) reject token-endpoint requests from "Web application"
681
+ * clients without one. This handler accepts authorization_code/refresh_token
682
+ * exchanges from a browser, injects the configured server-side
683
+ * client_secret, and forwards the request to the IdP's real token_endpoint.
684
+ * The IdP response is returned verbatim (status code + body).
685
+ */
686
+ export async function handleOAuthTokenProxy(request: Request, config: OAuthPkceConfig): Promise<Response> {
687
+ const headers = new Headers();
688
+ setProxyCors(headers, request, config);
689
+
690
+ if (request.method === "OPTIONS") {
691
+ headers.set("Access-Control-Allow-Methods", "POST, OPTIONS");
692
+ headers.set("Access-Control-Allow-Headers", "Content-Type");
693
+ headers.set("Access-Control-Max-Age", "7200");
694
+ return new Response(null, { status: 204, headers });
695
+ }
696
+
697
+ if (request.method !== "POST") {
698
+ return new Response(null, { status: 405, headers });
699
+ }
700
+
701
+ const ctype = (request.headers.get("Content-Type") ?? "").split(";")[0].trim().toLowerCase();
702
+ if (ctype !== "application/x-www-form-urlencoded") {
703
+ return jsonErrorResponse(headers, 415, "invalid_request", "Content-Type must be application/x-www-form-urlencoded");
704
+ }
705
+
706
+ let raw: string;
707
+ try {
708
+ raw = await request.text();
709
+ } catch {
710
+ return jsonErrorResponse(headers, 400, "invalid_request", "Could not read request body");
711
+ }
712
+
713
+ let form: URLSearchParams;
714
+ try {
715
+ form = new URLSearchParams(raw);
716
+ } catch {
717
+ return jsonErrorResponse(headers, 400, "invalid_request", "Could not parse form body");
718
+ }
719
+
720
+ const grantType = form.get("grant_type") ?? "";
721
+ if (!ALLOWED_TOKEN_GRANT_TYPES.has(grantType)) {
722
+ return jsonErrorResponse(
723
+ headers,
724
+ 400,
725
+ "unsupported_grant_type",
726
+ "grant_type must be authorization_code or refresh_token",
727
+ );
728
+ }
729
+
730
+ const submittedClientId = form.get("client_id");
731
+ if (submittedClientId && submittedClientId !== config.clientId) {
732
+ return jsonErrorResponse(headers, 400, "invalid_client", "client_id does not match the configured client");
733
+ }
734
+
735
+ const endpoints = await config.oidcDiscovery();
736
+ if (!endpoints) {
737
+ return jsonErrorResponse(headers, 502, "server_error", "Authorization server discovery failed");
738
+ }
739
+
740
+ const upstream = new URLSearchParams();
741
+ upstream.set("grant_type", grantType);
742
+ upstream.set("client_id", config.clientId);
743
+ if (config.clientSecret) {
744
+ upstream.set("client_secret", config.clientSecret);
745
+ }
746
+ for (const key of ["code", "code_verifier", "redirect_uri", "refresh_token", "scope"]) {
747
+ const value = form.get(key);
748
+ if (value !== null && value !== "") {
749
+ upstream.set(key, value);
750
+ }
751
+ }
752
+
753
+ let upstreamResp: Response;
754
+ try {
755
+ upstreamResp = await fetch(endpoints.tokenEndpoint, {
756
+ method: "POST",
757
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
758
+ body: upstream.toString(),
759
+ signal: AbortSignal.timeout(15000),
760
+ });
761
+ } catch (err: any) {
762
+ return jsonErrorResponse(headers, 502, "server_error", `Upstream token endpoint failed: ${err?.message ?? err}`);
763
+ }
764
+
765
+ const body = new Uint8Array(await upstreamResp.arrayBuffer());
766
+ const ct = upstreamResp.headers.get("content-type") ?? "application/json";
767
+ headers.set("Content-Type", ct);
768
+ return new Response(body, { status: upstreamResp.status, headers });
769
+ }
770
+
771
+ // ---------------------------------------------------------------------------
772
+ // OAuth callback handler
773
+ // ---------------------------------------------------------------------------
774
+
775
+ /** Handle GET {prefix}/_oauth/callback — the redirect from the authorization server. */
776
+ export async function handleOAuthCallback(request: Request, config: OAuthPkceConfig): Promise<Response> {
777
+ const url = new URL(request.url);
778
+ const retryUrl = config.prefix || "/";
779
+
780
+ function errorResponse(status: number, message: string, detail: string | null): Response {
781
+ return new Response(buildOAuthErrorPage(message, detail, retryUrl), {
782
+ status,
783
+ headers: { "Content-Type": "text/html; charset=utf-8" },
784
+ });
785
+ }
786
+
787
+ // Check for authorization server error
788
+ const error = url.searchParams.get("error");
789
+ if (error) {
790
+ const errorDesc = url.searchParams.get("error_description") ?? error;
791
+ return errorResponse(400, "The authorization server returned an error.", errorDesc);
792
+ }
793
+
794
+ // Extract code and state from query
795
+ const code = url.searchParams.get("code");
796
+ const state = url.searchParams.get("state");
797
+ if (!code || !state) {
798
+ return errorResponse(400, "Missing authorization code or state parameter.", null);
799
+ }
800
+
801
+ // Read and validate session cookie
802
+ const cookies = parseCookies(request);
803
+ const sessionCookie = cookies.get(SESSION_COOKIE_NAME);
804
+ if (!sessionCookie) {
805
+ return errorResponse(400, "Session cookie missing or expired. Please try again.", null);
806
+ }
807
+
808
+ let unpacked: UnpackedOAuthCookie;
809
+ try {
810
+ unpacked = unpackOAuthCookie(sessionCookie, config.sessionKey);
811
+ } catch {
812
+ return errorResponse(400, "Session expired or invalid. Please try again.", null);
813
+ }
814
+
815
+ // CSRF: validate state matches (constant-time)
816
+ const stateA = Buffer.from(state, "utf-8");
817
+ const stateB = Buffer.from(unpacked.stateNonce, "utf-8");
818
+ if (stateA.length !== stateB.length || !timingSafeEqual(stateA, stateB)) {
819
+ return errorResponse(400, "State mismatch \u2014 possible CSRF. Please try again.", null);
820
+ }
821
+
822
+ // Discover token endpoint
823
+ const endpoints = await config.oidcDiscovery();
824
+ if (!endpoints) {
825
+ return errorResponse(502, "Could not reach the authorization server.", "OIDC discovery failed.");
826
+ }
827
+
828
+ // Exchange code for token
829
+ let result: TokenExchangeResult;
830
+ try {
831
+ result = await exchangeCodeForToken(
832
+ endpoints.tokenEndpoint,
833
+ code,
834
+ config.redirectUri,
835
+ unpacked.codeVerifier,
836
+ config.clientId,
837
+ config.clientSecret,
838
+ config.useIdToken,
839
+ );
840
+ } catch (err: any) {
841
+ return errorResponse(502, "Token exchange with the authorization server failed.", String(err.message ?? err));
842
+ }
843
+
844
+ const clearSessionCookie = buildSetCookieHeader(SESSION_COOKIE_NAME, "", {
845
+ maxAge: 0,
846
+ path: `${config.prefix}/_oauth/`,
847
+ secure: config.secureCookie,
848
+ httpOnly: true,
849
+ sameSite: "Lax",
850
+ });
851
+
852
+ // External frontend: redirect with token + OAuth metadata in URL fragment
853
+ if (unpacked.returnTo) {
854
+ const separator = unpacked.returnTo.includes("#") ? "&" : "#";
855
+ const fragmentParts = [`token=${result.token}`];
856
+ if (result.refreshToken) {
857
+ fragmentParts.push(`refresh_token=${encodeURIComponent(result.refreshToken)}`);
858
+ }
859
+ fragmentParts.push(`token_endpoint=${encodeURIComponent(endpoints.tokenEndpoint)}`);
860
+ fragmentParts.push(`client_id=${encodeURIComponent(config.clientId)}`);
861
+ if (config.clientSecret) {
862
+ fragmentParts.push(`client_secret=${encodeURIComponent(config.clientSecret)}`);
863
+ }
864
+ if (config.useIdToken) {
865
+ fragmentParts.push("use_id_token=true");
866
+ }
867
+ const redirectUrl = `${unpacked.returnTo}${separator}${fragmentParts.join("&")}`;
868
+
869
+ return new Response(null, {
870
+ status: 302,
871
+ headers: {
872
+ Location: redirectUrl,
873
+ "Cache-Control": "no-cache, no-store, must-revalidate",
874
+ "Content-Type": "text/html; charset=utf-8",
875
+ "Set-Cookie": clearSessionCookie,
876
+ },
877
+ });
878
+ }
879
+
880
+ // Same-origin: redirect to original page with cookies
881
+ const originalUrl = validateOriginalUrl(unpacked.originalUrl, config.prefix);
882
+ const cookiePath = config.prefix || "/";
883
+
884
+ const authCookie = buildSetCookieHeader(AUTH_COOKIE_NAME, result.token, {
885
+ maxAge: result.maxAge,
886
+ path: cookiePath,
887
+ secure: config.secureCookie,
888
+ httpOnly: false,
889
+ sameSite: "Lax",
890
+ });
891
+
892
+ // Response with two Set-Cookie headers
893
+ const headers = new Headers();
894
+ headers.set("Location", originalUrl);
895
+ headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
896
+ headers.set("Content-Type", "text/html; charset=utf-8");
897
+ headers.append("Set-Cookie", authCookie);
898
+ headers.append("Set-Cookie", clearSessionCookie);
899
+
900
+ return new Response(null, { status: 302, headers });
901
+ }
902
+
903
+ // ---------------------------------------------------------------------------
904
+ // OAuth logout handler
905
+ // ---------------------------------------------------------------------------
906
+
907
+ /** Handle GET {prefix}/_oauth/logout — clear auth cookie and redirect. */
908
+ export function handleOAuthLogout(_request: Request, config: OAuthPkceConfig): Response {
909
+ const cookiePath = config.prefix || "/";
910
+ const clearAuthCookie = buildSetCookieHeader(AUTH_COOKIE_NAME, "", {
911
+ maxAge: 0,
912
+ path: cookiePath,
913
+ secure: config.secureCookie,
914
+ httpOnly: false,
915
+ });
916
+ return new Response(null, {
917
+ status: 302,
918
+ headers: {
919
+ Location: config.prefix || "/",
920
+ "Set-Cookie": clearAuthCookie,
921
+ },
922
+ });
923
+ }
924
+
925
+ // ---------------------------------------------------------------------------
926
+ // Browser GET redirect (replaces 401 with OAuth redirect for browsers)
927
+ // ---------------------------------------------------------------------------
928
+
929
+ /** Redirect an unauthenticated browser GET to the OAuth authorization endpoint. Returns null if unable. */
930
+ export async function handleBrowserGetRedirect(request: Request, config: OAuthPkceConfig): Promise<Response | null> {
931
+ // Only redirect browsers (Accept: text/html)
932
+ const accept = request.headers.get("Accept") ?? "";
933
+ if (!accept.includes("text/html")) return null;
934
+
935
+ // Discover authorization endpoint
936
+ const endpoints = await config.oidcDiscovery();
937
+ if (!endpoints) return null;
938
+
939
+ const url = new URL(request.url);
940
+
941
+ // Generate PKCE parameters
942
+ const codeVerifier = generateCodeVerifier();
943
+ const codeChallenge = generateCodeChallenge(codeVerifier);
944
+ const stateNonce = generateStateNonce();
945
+
946
+ // Capture original URL
947
+ let originalUrl = url.pathname;
948
+ if (url.search) {
949
+ originalUrl = `${originalUrl}${url.search}`;
950
+ }
951
+ originalUrl = validateOriginalUrl(originalUrl, config.prefix);
952
+
953
+ // Check for external frontend return URL
954
+ const returnTo = validateReturnTo(url.searchParams.get("_vgi_return_to") ?? "", config.allowedReturnOrigins);
955
+
956
+ // Pack session cookie
957
+ const cookieValue = packOAuthCookie(codeVerifier, stateNonce, originalUrl, config.sessionKey, undefined, returnTo);
958
+
959
+ // Build authorization URL
960
+ const authParams = new URLSearchParams({
961
+ response_type: "code",
962
+ client_id: config.clientId,
963
+ redirect_uri: config.redirectUri,
964
+ code_challenge: codeChallenge,
965
+ code_challenge_method: "S256",
966
+ state: stateNonce,
967
+ scope: config.scope,
968
+ });
969
+ // When redirecting to an external frontend, request offline access
970
+ if (returnTo) {
971
+ authParams.set("access_type", "offline");
972
+ authParams.set("prompt", "consent");
973
+ }
974
+ const authUrl = `${endpoints.authorizationEndpoint}?${authParams.toString()}`;
975
+
976
+ const sessionCookie = buildSetCookieHeader(SESSION_COOKIE_NAME, cookieValue, {
977
+ maxAge: SESSION_MAX_AGE,
978
+ path: `${config.prefix}/_oauth/`,
979
+ secure: config.secureCookie,
980
+ httpOnly: true,
981
+ sameSite: "Lax",
982
+ });
983
+
984
+ return new Response(null, {
985
+ status: 302,
986
+ headers: {
987
+ Location: authUrl,
988
+ "Cache-Control": "no-cache, no-store, must-revalidate",
989
+ "Content-Type": "text/html; charset=utf-8",
990
+ "Set-Cookie": sessionCookie,
991
+ },
992
+ });
993
+ }
994
+
995
+ // ---------------------------------------------------------------------------
996
+ // Early return-to redirect (authenticated user with _vgi_return_to)
997
+ // ---------------------------------------------------------------------------
998
+
999
+ /** If user is already authenticated and has _vgi_return_to, redirect immediately. Returns null otherwise. */
1000
+ export function handleEarlyReturnTo(request: Request, config: OAuthPkceConfig): Response | null {
1001
+ const url = new URL(request.url);
1002
+ const returnTo = validateReturnTo(url.searchParams.get("_vgi_return_to") ?? "", config.allowedReturnOrigins);
1003
+ if (!returnTo) return null;
1004
+
1005
+ // Check for existing auth token in cookie
1006
+ const cookies = parseCookies(request);
1007
+ const token = cookies.get(AUTH_COOKIE_NAME);
1008
+ if (!token) return null;
1009
+
1010
+ // Don't redirect with an expired token — let the OAuth flow run again
1011
+ try {
1012
+ const parts = token.split(".");
1013
+ if (parts.length >= 2) {
1014
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
1015
+ if (typeof payload.exp === "number" && payload.exp <= Math.floor(Date.now() / 1000)) {
1016
+ return null;
1017
+ }
1018
+ }
1019
+ } catch {
1020
+ /* not a JWT or can't decode — proceed with redirect */
1021
+ }
1022
+
1023
+ // Already authenticated with a return_to — redirect back with the token
1024
+ const separator = returnTo.includes("#") ? "&" : "#";
1025
+ const fragmentParams = [`token=${token}`];
1026
+ const redirectUrl = `${returnTo}${separator}${fragmentParams.join("&")}`;
1027
+
1028
+ return new Response(null, {
1029
+ status: 302,
1030
+ headers: {
1031
+ Location: redirectUrl,
1032
+ "Cache-Control": "no-cache, no-store, must-revalidate",
1033
+ },
1034
+ });
1035
+ }