@query-farm/vgi-rpc 0.6.4 → 0.7.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/dist/access-log.d.ts +50 -0
- package/dist/access-log.d.ts.map +1 -0
- package/dist/arrow/impl-arrowjs/index.d.ts +96 -0
- package/dist/arrow/impl-arrowjs/index.d.ts.map +1 -0
- package/dist/arrow/impl-flechette/index.d.ts +102 -0
- package/dist/arrow/impl-flechette/index.d.ts.map +1 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts +11 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts.map +1 -0
- package/dist/arrow/index.d.ts +4 -0
- package/dist/arrow/index.d.ts.map +1 -0
- package/dist/arrow/predicates.d.ts +44 -0
- package/dist/arrow/predicates.d.ts.map +1 -0
- package/dist/arrow/types.d.ts +62 -0
- package/dist/arrow/types.d.ts.map +1 -0
- package/dist/client/capabilities.d.ts +25 -0
- package/dist/client/capabilities.d.ts.map +1 -0
- package/dist/client/connect.d.ts.map +1 -1
- package/dist/client/introspect.d.ts +7 -0
- package/dist/client/introspect.d.ts.map +1 -1
- package/dist/client/ipc.d.ts +8 -2
- package/dist/client/ipc.d.ts.map +1 -1
- package/dist/client/pipe.d.ts.map +1 -1
- package/dist/client/stream.d.ts +11 -2
- package/dist/client/stream.d.ts.map +1 -1
- package/dist/client/uploadUrl.d.ts +25 -0
- package/dist/client/uploadUrl.d.ts.map +1 -0
- package/dist/constants.d.ts +15 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/crypto.d.ts +22 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/dispatch/describe.d.ts +10 -6
- package/dist/dispatch/describe.d.ts.map +1 -1
- package/dist/dispatch/stream.d.ts +2 -2
- package/dist/dispatch/stream.d.ts.map +1 -1
- package/dist/dispatch/unary.d.ts +2 -2
- package/dist/dispatch/unary.d.ts.map +1 -1
- package/dist/errors.d.ts +46 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/external.d.ts +25 -5
- package/dist/external.d.ts.map +1 -1
- package/dist/http/bearer.d.ts.map +1 -1
- package/dist/http/common.d.ts +42 -7
- package/dist/http/common.d.ts.map +1 -1
- package/dist/http/dispatch.d.ts +20 -2
- package/dist/http/dispatch.d.ts.map +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/mtls.d.ts +2 -1
- package/dist/http/mtls.d.ts.map +1 -1
- package/dist/http/oauth-pkce.d.ts +141 -0
- package/dist/http/oauth-pkce.d.ts.map +1 -0
- package/dist/http/pages.d.ts +3 -0
- package/dist/http/pages.d.ts.map +1 -1
- package/dist/http/sticky.d.ts +124 -0
- package/dist/http/sticky.d.ts.map +1 -0
- package/dist/http/token.d.ts +38 -12
- package/dist/http/token.d.ts.map +1 -1
- package/dist/http/types.d.ts +66 -5
- package/dist/http/types.d.ts.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1275 -3511
- package/dist/index.js.map +19 -37
- package/dist/launcher/hash.d.ts +22 -0
- package/dist/launcher/hash.d.ts.map +1 -0
- package/dist/launcher/index.d.ts +23 -0
- package/dist/launcher/index.d.ts.map +1 -0
- package/dist/launcher/launch.d.ts +27 -0
- package/dist/launcher/launch.d.ts.map +1 -0
- package/dist/launcher/lock.d.ts +19 -0
- package/dist/launcher/lock.d.ts.map +1 -0
- package/dist/launcher/serve-unix.d.ts +54 -0
- package/dist/launcher/serve-unix.d.ts.map +1 -0
- package/dist/launcher/state.d.ts +59 -0
- package/dist/launcher/state.d.ts.map +1 -0
- package/dist/otel.d.ts.map +1 -1
- package/dist/protocol.d.ts +16 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/schema.d.ts +45 -18
- package/dist/schema.d.ts.map +1 -1
- package/dist/server.d.ts +23 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +216 -12
- package/dist/types.d.ts.map +1 -1
- package/dist/util/gzip.d.ts +10 -0
- package/dist/util/gzip.d.ts.map +1 -0
- package/dist/util/schema.d.ts +3 -15
- package/dist/util/schema.d.ts.map +1 -1
- package/dist/util/web-crypto.d.ts +22 -0
- package/dist/util/web-crypto.d.ts.map +1 -0
- package/dist/util/zstd.d.ts +26 -3
- package/dist/util/zstd.d.ts.map +1 -1
- package/dist/wire/opaque.d.ts +11 -0
- package/dist/wire/opaque.d.ts.map +1 -0
- package/dist/wire/reader.d.ts +5 -5
- package/dist/wire/reader.d.ts.map +1 -1
- package/dist/wire/request.d.ts +11 -3
- package/dist/wire/request.d.ts.map +1 -1
- package/dist/wire/response.d.ts +6 -6
- package/dist/wire/response.d.ts.map +1 -1
- package/dist/wire/writer.d.ts +49 -39
- package/dist/wire/writer.d.ts.map +1 -1
- package/package.json +24 -10
- package/src/access-log.ts +195 -0
- package/src/arrow/impl-arrowjs/index.ts +433 -0
- package/src/arrow/impl-flechette/index.ts +414 -0
- package/src/arrow/impl-flechette/message-meta.ts +174 -0
- package/src/arrow/index.ts +89 -0
- package/src/arrow/predicates.ts +56 -0
- package/src/arrow/types.ts +73 -0
- package/src/client/capabilities.ts +84 -0
- package/src/client/connect.ts +103 -26
- package/src/client/introspect.ts +60 -38
- package/src/client/ipc.ts +37 -27
- package/src/client/pipe.ts +12 -9
- package/src/client/stream.ts +34 -19
- package/src/client/uploadUrl.ts +169 -0
- package/src/constants.ts +18 -1
- package/src/crypto.ts +95 -0
- package/src/dispatch/describe.ts +146 -107
- package/src/dispatch/stream.ts +53 -24
- package/src/dispatch/unary.ts +5 -4
- package/src/errors.ts +76 -0
- package/src/external.ts +43 -29
- package/src/http/bearer.ts +2 -5
- package/src/http/common.ts +90 -23
- package/src/http/dispatch.ts +373 -46
- package/src/http/handler.ts +790 -68
- package/src/http/index.ts +1 -0
- package/src/http/mtls.ts +18 -3
- package/src/http/oauth-pkce.ts +1035 -0
- package/src/http/pages.ts +30 -15
- package/src/http/sticky.ts +429 -0
- package/src/http/token.ts +165 -75
- package/src/http/types.ts +67 -5
- package/src/index.ts +40 -1
- package/src/launcher/hash.ts +104 -0
- package/src/launcher/index.ts +35 -0
- package/src/launcher/launch.ts +284 -0
- package/src/launcher/lock.ts +171 -0
- package/src/launcher/serve-unix.ts +385 -0
- package/src/launcher/state.ts +245 -0
- package/src/otel.ts +39 -33
- package/src/protocol.ts +27 -3
- package/src/schema.ts +107 -56
- package/src/server.ts +196 -20
- package/src/types.ts +322 -18
- package/src/util/gzip.ts +63 -0
- package/src/util/schema.ts +4 -22
- package/src/util/web-crypto.ts +98 -0
- package/src/util/zstd.ts +133 -14
- package/src/wire/opaque.ts +37 -0
- package/src/wire/reader.ts +5 -4
- package/src/wire/request.ts +67 -8
- package/src/wire/response.ts +51 -85
- package/src/wire/writer.ts +165 -69
- package/dist/util/conform.d.ts +0 -18
- package/dist/util/conform.d.ts.map +0 -1
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|