@remnic/server 9.54.5 → 9.54.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/oauth.ts ADDED
@@ -0,0 +1,1078 @@
1
+ /**
2
+ * OAuth 2.1 authorization-server facade for the standalone Remnic server.
3
+ *
4
+ * Why this exists: ChatGPT developer-mode apps (chatgpt.com) can only talk
5
+ * to remote MCP servers with OAuth, "No Authentication", or Mixed auth —
6
+ * there is no static API-key/bearer option in the ChatGPT UI. Remnic's MCP
7
+ * endpoint (`POST /mcp`) is bearer-token protected, so this module lets
8
+ * Remnic act as its OWN authorization server: ChatGPT runs a standard
9
+ * authorization-code + PKCE flow against these endpoints and receives a
10
+ * regular Remnic connector token (connector id `chatgpt`) as the OAuth
11
+ * access token. The existing bearer validation on `/mcp` then works
12
+ * unchanged.
13
+ *
14
+ * Protocol layer: the security-critical endpoints are NOT hand-rolled.
15
+ * `authorizationHandler`, `tokenHandler` (PKCE S256 verification, client
16
+ * authentication, param validation, rate limiting) and the RFC 8414 /
17
+ * RFC 9728 metadata router all come from `@modelcontextprotocol/sdk`.
18
+ * This module contributes only the Remnic-specific pieces:
19
+ *
20
+ * - config parsing (`server.oauth` block + `REMNIC_OAUTH_*` env overrides),
21
+ * - the static single-client store (Remnic pre-registers ChatGPT; the
22
+ * operator pastes the same client id/secret into ChatGPT's app UI),
23
+ * - the pending-approval interaction: `/authorize` renders a page that
24
+ * instructs the operator to run `remnic oauth approve <ref>` locally;
25
+ * the page polls with a per-transaction secret and redirects to the
26
+ * ChatGPT callback once the operator approves,
27
+ * - operator-only pending/approve/deny endpoints (gated by the core
28
+ * bearer authorization passed in from the access server), and
29
+ * - access-token minting into the Remnic token store.
30
+ *
31
+ * Security model (do not weaken):
32
+ * - The approval page NEVER asks for credentials. Approval happens
33
+ * out-of-band via the CLI, which authenticates with the operator
34
+ * bearer token against the local daemon.
35
+ * - `txn` id and `pollSecret` are independent 128-bit random values;
36
+ * the human-readable `ref` shown on the page cannot authorize
37
+ * anything by itself.
38
+ * - Authorization codes are 128-bit, single-use, TTL-capped at 120 s,
39
+ * and bound to client_id + redirect_uri + PKCE challenge + resource.
40
+ * - Redirect URIs are validated by the SDK against the exact
41
+ * pre-registered allowlist (`server.oauth.redirectUris`); an empty
42
+ * allowlist refuses every authorization (setup mode: discovery works,
43
+ * authorization does not).
44
+ * - Secret comparisons go through SHA-256 digests + `timingSafeEqual`,
45
+ * which is constant-time and never throws on length mismatch.
46
+ */
47
+
48
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
49
+ import type { IncomingMessage, ServerResponse } from "node:http";
50
+ import express, { type Express, type Request, type Response } from "express";
51
+ import { rateLimit } from "express-rate-limit";
52
+ import { authorizationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/authorize.js";
53
+ import { tokenHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/token.js";
54
+ import { createOAuthMetadata, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
55
+ import {
56
+ InvalidGrantError,
57
+ InvalidTokenError,
58
+ ServerError,
59
+ UnsupportedGrantTypeError,
60
+ } from "@modelcontextprotocol/sdk/server/auth/errors.js";
61
+ import type { OAuthServerProvider, AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js";
62
+ import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
63
+ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
64
+ import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
65
+ import { buildTokenEntry, commitTokenEntry, getAllValidTokensCached, log } from "@remnic/core";
66
+
67
+ // ── Constants ────────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Token-endpoint client auth methods this facade supports. The SDK's
71
+ * client-auth middleware reads credentials from the request body only,
72
+ * so `client_secret_basic` is intentionally NOT offered: advertising a
73
+ * method the token endpoint would reject is worse than not offering it.
74
+ * ChatGPT supports `client_secret_post` and `none` for predefined
75
+ * clients.
76
+ */
77
+ const ALLOWED_TOKEN_AUTH_METHODS = ["client_secret_post", "none"] as const;
78
+ export type OAuthTokenEndpointAuthMethod = (typeof ALLOWED_TOKEN_AUTH_METHODS)[number];
79
+
80
+ /** Default TTL for pending authorizations (10 minutes). */
81
+ const DEFAULT_APPROVAL_TTL_SECONDS = 600;
82
+
83
+ /** Authorization-code TTL (OAuth 2.1 recommends short single-use codes). */
84
+ const AUTHORIZATION_CODE_TTL_MS = 120_000;
85
+
86
+ /** 128 bits of entropy in hex = 32 chars. */
87
+ const HIGH_ENTROPY_HEX_BYTES = 16;
88
+
89
+ /** Connector id under which OAuth access tokens are minted. */
90
+ const CHATGPT_CONNECTOR_ID = "chatgpt";
91
+
92
+ // ── Config schema + parser ──────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Raw config block as it appears in `server.oauth`. All fields optional at
96
+ * the type level; the parser enforces requirements when `enabled: true`.
97
+ */
98
+ export interface OAuthConfigInput {
99
+ enabled?: unknown;
100
+ issuerUrl?: unknown;
101
+ clientId?: unknown;
102
+ clientSecret?: unknown;
103
+ tokenEndpointAuthMethod?: unknown;
104
+ redirectUris?: unknown;
105
+ approvalTtlSeconds?: unknown;
106
+ }
107
+
108
+ export interface ParsedOAuthConfig {
109
+ enabled: boolean;
110
+ issuerUrl: string;
111
+ clientId: string;
112
+ clientSecret: string;
113
+ tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
114
+ redirectUris: string[];
115
+ approvalTtlSeconds: number;
116
+ }
117
+
118
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
119
+ return !!value && typeof value === "object" && !Array.isArray(value);
120
+ }
121
+
122
+ /**
123
+ * Coerce a boolean coming from JSON (any) or env (string). Mirrors the
124
+ * repo's canonical boolean coercion (rules 17, 24): "true"/"1"/"yes"/"on"
125
+ * are truthy, "false"/"0"/"no"/"off" are falsy. Anything else throws.
126
+ */
127
+ function coerceBoolean(value: unknown, source: string): boolean {
128
+ if (typeof value === "boolean") return value;
129
+ if (typeof value === "string") {
130
+ const normalized = value.trim().toLowerCase();
131
+ if (["true", "1", "yes", "on"].includes(normalized)) return true;
132
+ if (["false", "0", "no", "off"].includes(normalized)) return false;
133
+ }
134
+ throw new Error(`Invalid ${source}: expected a boolean (got: ${JSON.stringify(value)})`);
135
+ }
136
+
137
+ function coerceNonEmptyString(value: unknown, source: string): string {
138
+ if (typeof value !== "string" || value.trim().length === 0) {
139
+ throw new Error(`Invalid ${source}: expected a non-empty string`);
140
+ }
141
+ return value.trim();
142
+ }
143
+
144
+ function coercePositiveInteger(value: unknown, source: string): number {
145
+ const num = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
146
+ if (typeof num !== "number" || !Number.isInteger(num) || num <= 0) {
147
+ throw new Error(`Invalid ${source}: expected a positive integer (got: ${JSON.stringify(value)})`);
148
+ }
149
+ return num;
150
+ }
151
+
152
+ function coerceAbsoluteHttpsUrl(
153
+ value: unknown,
154
+ source: string,
155
+ opts: { allowHttpForLocalhost: boolean },
156
+ ): string {
157
+ if (typeof value !== "string" || value.trim().length === 0) {
158
+ throw new Error(`Invalid ${source}: expected a non-empty URL string`);
159
+ }
160
+ const trimmed = value.trim();
161
+ let parsed: URL;
162
+ try {
163
+ parsed = new URL(trimmed);
164
+ } catch {
165
+ throw new Error(`Invalid ${source}: "${trimmed}" is not a valid URL`);
166
+ }
167
+ if (parsed.hash || parsed.search) {
168
+ throw new Error(`Invalid ${source}: URL must not include a query string or fragment`);
169
+ }
170
+ const isHttps = parsed.protocol === "https:";
171
+ const isLocalhostHttp =
172
+ opts.allowHttpForLocalhost &&
173
+ parsed.protocol === "http:" &&
174
+ (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost");
175
+ if (!isHttps && !isLocalhostHttp) {
176
+ throw new Error(
177
+ `Invalid ${source}: must be an absolute https:// URL (http:// allowed only for 127.0.0.1/localhost)`,
178
+ );
179
+ }
180
+ return trimmed;
181
+ }
182
+
183
+ function coerceTokenAuthMethod(value: unknown, source: string): OAuthTokenEndpointAuthMethod {
184
+ if (typeof value !== "string") {
185
+ throw new Error(`Invalid ${source}: expected one of ${ALLOWED_TOKEN_AUTH_METHODS.join(", ")}`);
186
+ }
187
+ const trimmed = value.trim();
188
+ const match = ALLOWED_TOKEN_AUTH_METHODS.find((method) => method === trimmed);
189
+ if (!match) {
190
+ throw new Error(
191
+ `Invalid ${source}: "${trimmed}" is not allowed. Use one of ${ALLOWED_TOKEN_AUTH_METHODS.join(", ")}.`,
192
+ );
193
+ }
194
+ return match;
195
+ }
196
+
197
+ function coerceRedirectUris(value: unknown, source: string): string[] {
198
+ if (!Array.isArray(value)) {
199
+ throw new Error(`Invalid ${source}: expected an array of exact redirect URI strings`);
200
+ }
201
+ return value.map((entry, index) =>
202
+ coerceAbsoluteHttpsUrl(entry, `${source}[${index}]`, { allowHttpForLocalhost: true }),
203
+ );
204
+ }
205
+
206
+ const DISABLED_OAUTH_CONFIG: ParsedOAuthConfig = {
207
+ enabled: false,
208
+ issuerUrl: "",
209
+ clientId: "",
210
+ clientSecret: "",
211
+ tokenEndpointAuthMethod: "client_secret_post",
212
+ redirectUris: [],
213
+ approvalTtlSeconds: DEFAULT_APPROVAL_TTL_SECONDS,
214
+ };
215
+
216
+ /**
217
+ * Parse the `server.oauth` config block. Throws on invalid input.
218
+ *
219
+ * When disabled (or absent), only `enabled` is consulted — a
220
+ * partially-filled disabled block is legal. When enabled, `issuerUrl`,
221
+ * `clientId`, and (unless auth method is `none`) `clientSecret` are
222
+ * required. `redirectUris` MAY be empty: that is "setup mode" — the
223
+ * discovery documents are served so ChatGPT can create the app, but every
224
+ * authorization attempt is refused until the exact per-app callback URL
225
+ * (shown in ChatGPT's app management page) is added to the allowlist.
226
+ */
227
+ export function parseOAuthConfig(raw: unknown): ParsedOAuthConfig {
228
+ if (raw === undefined) return { ...DISABLED_OAUTH_CONFIG };
229
+ if (!isPlainObject(raw)) {
230
+ throw new Error("Invalid server.oauth: expected a JSON object");
231
+ }
232
+ const input = raw as OAuthConfigInput;
233
+ const enabled = input.enabled === undefined ? false : coerceBoolean(input.enabled, "server.oauth.enabled");
234
+ if (!enabled) return { ...DISABLED_OAUTH_CONFIG };
235
+
236
+ const issuerUrl =
237
+ input.issuerUrl === undefined
238
+ ? ""
239
+ : coerceAbsoluteHttpsUrl(input.issuerUrl, "server.oauth.issuerUrl", { allowHttpForLocalhost: true });
240
+ const clientId =
241
+ input.clientId === undefined ? "" : coerceNonEmptyString(input.clientId, "server.oauth.clientId");
242
+ const clientSecret =
243
+ input.clientSecret === undefined ? "" : coerceNonEmptyString(input.clientSecret, "server.oauth.clientSecret");
244
+ const tokenEndpointAuthMethod =
245
+ input.tokenEndpointAuthMethod === undefined
246
+ ? "client_secret_post"
247
+ : coerceTokenAuthMethod(input.tokenEndpointAuthMethod, "server.oauth.tokenEndpointAuthMethod");
248
+ const redirectUris =
249
+ input.redirectUris === undefined ? [] : coerceRedirectUris(input.redirectUris, "server.oauth.redirectUris");
250
+ const approvalTtlSeconds =
251
+ input.approvalTtlSeconds === undefined
252
+ ? DEFAULT_APPROVAL_TTL_SECONDS
253
+ : coercePositiveInteger(input.approvalTtlSeconds, "server.oauth.approvalTtlSeconds");
254
+
255
+ if (issuerUrl === "") {
256
+ throw new Error("Invalid server.oauth.issuerUrl: required when enabled (absolute https URL)");
257
+ }
258
+ if (clientId === "") {
259
+ throw new Error("Invalid server.oauth.clientId: required when enabled (non-empty string)");
260
+ }
261
+ if (tokenEndpointAuthMethod !== "none" && clientSecret === "") {
262
+ throw new Error(
263
+ `Invalid server.oauth.clientSecret: required when enabled and tokenEndpointAuthMethod is "${tokenEndpointAuthMethod}"`,
264
+ );
265
+ }
266
+
267
+ return {
268
+ enabled: true,
269
+ issuerUrl,
270
+ clientId,
271
+ clientSecret,
272
+ tokenEndpointAuthMethod,
273
+ redirectUris,
274
+ approvalTtlSeconds,
275
+ };
276
+ }
277
+
278
+ // ── Env overrides (REMNIC_OAUTH_*) ───────────────────────────────────────────
279
+
280
+ /**
281
+ * Build the OAuth config overrides from env vars (REMNIC_OAUTH_*). The
282
+ * merged result goes back through `parseOAuthConfig`, so invalid values
283
+ * throw the same precise messages.
284
+ */
285
+ export function readOAuthEnvOverrides(): Record<string, unknown> {
286
+ const overrides: Record<string, unknown> = {};
287
+ if (process.env.REMNIC_OAUTH_ENABLED !== undefined) overrides.enabled = process.env.REMNIC_OAUTH_ENABLED;
288
+ if (process.env.REMNIC_OAUTH_ISSUER_URL !== undefined) overrides.issuerUrl = process.env.REMNIC_OAUTH_ISSUER_URL;
289
+ if (process.env.REMNIC_OAUTH_CLIENT_ID !== undefined) overrides.clientId = process.env.REMNIC_OAUTH_CLIENT_ID;
290
+ if (process.env.REMNIC_OAUTH_CLIENT_SECRET !== undefined) {
291
+ overrides.clientSecret = process.env.REMNIC_OAUTH_CLIENT_SECRET;
292
+ }
293
+ if (process.env.REMNIC_OAUTH_TOKEN_AUTH_METHOD !== undefined) {
294
+ overrides.tokenEndpointAuthMethod = process.env.REMNIC_OAUTH_TOKEN_AUTH_METHOD;
295
+ }
296
+ if (process.env.REMNIC_OAUTH_REDIRECT_URIS !== undefined) {
297
+ overrides.redirectUris = process.env.REMNIC_OAUTH_REDIRECT_URIS.split(",")
298
+ .map((entry) => entry.trim())
299
+ .filter((entry) => entry.length > 0);
300
+ }
301
+ if (process.env.REMNIC_OAUTH_APPROVAL_TTL_SECONDS !== undefined) {
302
+ overrides.approvalTtlSeconds = process.env.REMNIC_OAUTH_APPROVAL_TTL_SECONDS;
303
+ }
304
+ return overrides;
305
+ }
306
+
307
+ /** Merge env overrides over the file block and parse the result. */
308
+ export function applyOAuthEnvOverrides(fileBlock: unknown): ParsedOAuthConfig {
309
+ // A present-but-non-object `server.oauth` (e.g. `true` or a string) is
310
+ // invalid config, not "disabled". Hand it straight to parseOAuthConfig,
311
+ // which throws a precise error, instead of silently coercing it to `{}`
312
+ // and treating OAuth as off (repo rule: reject invalid input).
313
+ if (fileBlock !== undefined && !isPlainObject(fileBlock)) {
314
+ return parseOAuthConfig(fileBlock);
315
+ }
316
+ const overrides = readOAuthEnvOverrides();
317
+ const merged: Record<string, unknown> = isPlainObject(fileBlock) ? { ...fileBlock } : {};
318
+ for (const [key, value] of Object.entries(overrides)) {
319
+ merged[key] = value;
320
+ }
321
+ return parseOAuthConfig(merged);
322
+ }
323
+
324
+ // ── Small crypto helpers ─────────────────────────────────────────────────────
325
+
326
+ /**
327
+ * Constant-time string equality via fixed-size SHA-256 digests. Digest
328
+ * comparison never throws on length mismatch and does not leak length
329
+ * or content timing. Hash equality implies content equality.
330
+ */
331
+ function timingSafeStringEqual(a: string, b: string): boolean {
332
+ if (typeof a !== "string" || typeof b !== "string") return false;
333
+ const digestA = createHash("sha256").update(a, "utf8").digest();
334
+ const digestB = createHash("sha256").update(b, "utf8").digest();
335
+ return timingSafeEqual(digestA, digestB);
336
+ }
337
+
338
+ function randomHex(bytes: number): string {
339
+ return randomBytes(bytes).toString("hex");
340
+ }
341
+
342
+ /**
343
+ * Human-readable approval ref: 8 random bytes rendered in base36
344
+ * (~62 bits, 13 chars max). Unguessable in practice, but knowing the ref
345
+ * alone never authorizes anything — approval additionally requires the
346
+ * operator bearer token.
347
+ */
348
+ function newApprovalRef(): string {
349
+ return BigInt(`0x${randomHex(8)}`).toString(36);
350
+ }
351
+
352
+ // ── Pending-transaction store ────────────────────────────────────────────────
353
+
354
+ export interface PendingTransaction {
355
+ /** High-entropy public id; key for the poll endpoint. */
356
+ txn: string;
357
+ /** Separate high-entropy secret required alongside `txn` to poll. */
358
+ pollSecret: string;
359
+ /** Human-visible approval ref for the CLI. */
360
+ ref: string;
361
+ clientId: string;
362
+ redirectUri: string;
363
+ scopes: string[];
364
+ resource: string | undefined;
365
+ state: string | undefined;
366
+ codeChallenge: string;
367
+ createdAt: number;
368
+ expiresAt: number;
369
+ outcome?: { kind: "approved"; code: string } | { kind: "denied" };
370
+ }
371
+
372
+ interface AuthorizationCodeEntry {
373
+ code: string;
374
+ clientId: string;
375
+ redirectUri: string;
376
+ codeChallenge: string;
377
+ resource: string | undefined;
378
+ expiresAt: number;
379
+ consumed: boolean;
380
+ }
381
+
382
+ export class OAuthState {
383
+ private readonly pending = new Map<string, PendingTransaction>();
384
+ private readonly codes = new Map<string, AuthorizationCodeEntry>();
385
+ private readonly config: ParsedOAuthConfig;
386
+
387
+ constructor(config: ParsedOAuthConfig) {
388
+ this.config = config;
389
+ }
390
+
391
+ get approvalTtlMs(): number {
392
+ return this.config.approvalTtlSeconds * 1000;
393
+ }
394
+
395
+ /** Delete expired entries. Run before each lookup. */
396
+ private gc(now: number): void {
397
+ // Expire codes first so the pending check below sees the post-GC code
398
+ // set.
399
+ for (const [key, code] of this.codes) {
400
+ if (code.expiresAt < now) this.codes.delete(key);
401
+ }
402
+ for (const [key, txn] of this.pending) {
403
+ if (txn.expiresAt >= now) continue;
404
+ // Keep an APPROVED transaction alive while its authorization code is
405
+ // still live, even past the pending TTL: an approval made just
406
+ // before the deadline (or under a short approvalTtlSeconds) must
407
+ // still be pollable+redirectable until the code itself expires.
408
+ if (txn.outcome?.kind === "approved" && this.codes.has(txn.outcome.code)) continue;
409
+ this.pending.delete(key);
410
+ }
411
+ }
412
+
413
+ createPending(args: {
414
+ clientId: string;
415
+ redirectUri: string;
416
+ scopes: string[];
417
+ resource: string | undefined;
418
+ state: string | undefined;
419
+ codeChallenge: string;
420
+ }): PendingTransaction {
421
+ const now = Date.now();
422
+ this.gc(now);
423
+ const txn: PendingTransaction = {
424
+ txn: randomHex(HIGH_ENTROPY_HEX_BYTES),
425
+ pollSecret: randomHex(HIGH_ENTROPY_HEX_BYTES),
426
+ ref: newApprovalRef(),
427
+ clientId: args.clientId,
428
+ redirectUri: args.redirectUri,
429
+ scopes: args.scopes,
430
+ resource: args.resource,
431
+ state: args.state,
432
+ codeChallenge: args.codeChallenge,
433
+ createdAt: now,
434
+ expiresAt: now + this.approvalTtlMs,
435
+ };
436
+ this.pending.set(txn.txn, txn);
437
+ return txn;
438
+ }
439
+
440
+ listPending(): PendingTransaction[] {
441
+ const now = Date.now();
442
+ this.gc(now);
443
+ return Array.from(this.pending.values())
444
+ .filter((txn) => !txn.outcome)
445
+ .sort((a, b) => (a.createdAt === b.createdAt ? a.txn.localeCompare(b.txn) : a.createdAt - b.createdAt));
446
+ }
447
+
448
+ findByRef(ref: string): PendingTransaction | undefined {
449
+ const now = Date.now();
450
+ this.gc(now);
451
+ for (const txn of this.pending.values()) {
452
+ if (txn.ref === ref) return txn;
453
+ }
454
+ return undefined;
455
+ }
456
+
457
+ /**
458
+ * Approve a pending transaction by ref. Returns the authorization code.
459
+ * Re-approval of an already-approved txn returns the same code so the
460
+ * operator can recover from a client-side miss. Throws on
461
+ * missing/expired/denied.
462
+ */
463
+ approveByRef(ref: string): { code: string; txn: PendingTransaction } {
464
+ const now = Date.now();
465
+ this.gc(now);
466
+ const txn = this.findByRef(ref);
467
+ if (!txn) throw new Error("unknown or expired ref");
468
+ if (txn.outcome) {
469
+ if (txn.outcome.kind === "approved") return { code: txn.outcome.code, txn };
470
+ throw new Error("denied");
471
+ }
472
+ const code = randomHex(HIGH_ENTROPY_HEX_BYTES);
473
+ this.codes.set(code, {
474
+ code,
475
+ clientId: txn.clientId,
476
+ redirectUri: txn.redirectUri,
477
+ codeChallenge: txn.codeChallenge,
478
+ resource: txn.resource,
479
+ expiresAt: now + AUTHORIZATION_CODE_TTL_MS,
480
+ consumed: false,
481
+ });
482
+ txn.outcome = { kind: "approved", code };
483
+ return { code, txn };
484
+ }
485
+
486
+ denyByRef(ref: string): void {
487
+ const now = Date.now();
488
+ this.gc(now);
489
+ const txn = this.findByRef(ref);
490
+ if (!txn) throw new Error("unknown or expired ref");
491
+ if (txn.outcome) {
492
+ if (txn.outcome.kind === "denied") return; // idempotent
493
+ throw new Error("already approved");
494
+ }
495
+ txn.outcome = { kind: "denied" };
496
+ }
497
+
498
+ /** Redirect target carrying the code (+ state) back to the client. */
499
+ buildRedirect(txn: PendingTransaction, code: string): string {
500
+ const url = new URL(txn.redirectUri);
501
+ url.searchParams.set("code", code);
502
+ if (txn.state) url.searchParams.set("state", txn.state);
503
+ return url.toString();
504
+ }
505
+
506
+ /** Poll status; `undefined` on wrong pollSecret. */
507
+ poll(
508
+ txnId: string,
509
+ pollSecret: string,
510
+ ):
511
+ | { status: "pending" }
512
+ | { status: "approved"; redirect: string }
513
+ | { status: "denied" }
514
+ | { status: "expired" }
515
+ | undefined {
516
+ const now = Date.now();
517
+ this.gc(now);
518
+ const txn = this.pending.get(txnId);
519
+ if (!txn) return { status: "expired" };
520
+ if (!timingSafeStringEqual(txn.pollSecret, pollSecret)) return undefined;
521
+ if (!txn.outcome) {
522
+ if (txn.expiresAt < now) return { status: "expired" };
523
+ return { status: "pending" };
524
+ }
525
+ if (txn.outcome.kind === "denied") return { status: "denied" };
526
+ // Approved, but only hand back the redirect while the authorization
527
+ // code is still live. The code TTL (120 s) is shorter than the txn TTL
528
+ // (approvalTtlSeconds, default 600 s), so a slow browser poll could
529
+ // otherwise be redirected with a code that has already expired,
530
+ // failing the token exchange. Once the code is gone (expired or
531
+ // already exchanged), report expired so the client restarts cleanly.
532
+ if (!this.peekCode(txn.outcome.code)) return { status: "expired" };
533
+ return { status: "approved", redirect: this.buildRedirect(txn, txn.outcome.code) };
534
+ }
535
+
536
+ /**
537
+ * Read a code entry without consuming it (SDK PKCE verification path).
538
+ * Returns undefined on unknown/expired/consumed.
539
+ */
540
+ peekCode(code: string): AuthorizationCodeEntry | undefined {
541
+ const now = Date.now();
542
+ this.gc(now);
543
+ const entry = this.codes.get(code);
544
+ if (!entry || entry.consumed || entry.expiresAt < now) return undefined;
545
+ return entry;
546
+ }
547
+
548
+ /**
549
+ * Consume a code after the SDK verified PKCE. Enforces single use and
550
+ * the client/redirect/resource bindings. Returns the entry on success,
551
+ * undefined on any mismatch.
552
+ */
553
+ takeCode(args: {
554
+ code: string;
555
+ clientId: string;
556
+ redirectUri: string | undefined;
557
+ resource: string | undefined;
558
+ }): AuthorizationCodeEntry | undefined {
559
+ const entry = this.peekCode(args.code);
560
+ if (!entry) return undefined;
561
+ // Burn the code before the binding checks: a code that reaches the
562
+ // exchange step is spent regardless of outcome (OAuth 2.1 single use).
563
+ entry.consumed = true;
564
+ if (!timingSafeStringEqual(entry.clientId, args.clientId)) return undefined;
565
+ // The token request MAY omit redirect_uri only when the authorization
566
+ // request used the client's single registered URI; when provided it
567
+ // must match the bound value exactly.
568
+ if (args.redirectUri !== undefined && entry.redirectUri !== args.redirectUri) return undefined;
569
+ if (entry.resource !== undefined) {
570
+ if (args.resource === undefined) return undefined;
571
+ if (entry.resource !== args.resource) return undefined;
572
+ }
573
+ return entry;
574
+ }
575
+
576
+ /**
577
+ * Un-burn a code consumed by takeCode when the downstream token persist
578
+ * fails (disk full, permissions). Lets the client retry the exchange
579
+ * instead of being forced through a fresh authorize+approve. Only
580
+ * revives an entry still present and within TTL; a binding/PKCE
581
+ * mismatch is NOT revived (that is a genuine client error, and OAuth
582
+ * 2.1 revokes a code presented incorrectly).
583
+ */
584
+ reviveCode(code: string): void {
585
+ const entry = this.codes.get(code);
586
+ if (entry && entry.expiresAt >= Date.now()) {
587
+ entry.consumed = false;
588
+ }
589
+ }
590
+ }
591
+
592
+ // ── HTML approval page (auto-polling) ────────────────────────────────────────
593
+
594
+ function escapeHtml(value: string): string {
595
+ return value
596
+ .replaceAll("&", "&amp;")
597
+ .replaceAll("<", "&lt;")
598
+ .replaceAll(">", "&gt;")
599
+ .replaceAll('"', "&quot;")
600
+ .replaceAll("'", "&#39;");
601
+ }
602
+
603
+ function renderApprovalPage(args: { txn: PendingTransaction; issuerUrl: string; command: string }): string {
604
+ const { txn, issuerUrl, command } = args;
605
+ // The page NEVER asks for credentials. It displays the approval ref and
606
+ // CLI instructions, then polls /oauth/authorize/poll with the
607
+ // per-transaction pollSecret. On approval the browser follows the
608
+ // redirect to the client's callback.
609
+ const safeRef = escapeHtml(txn.ref);
610
+ const safeClientId = escapeHtml(txn.clientId);
611
+ const safeRedirect = escapeHtml(txn.redirectUri);
612
+ const safeResource = txn.resource ? escapeHtml(txn.resource) : "";
613
+ const safeCommand = escapeHtml(command);
614
+ return `<!DOCTYPE html>
615
+ <html lang="en">
616
+ <head>
617
+ <meta charset="utf-8">
618
+ <meta name="viewport" content="width=device-width, initial-scale=1">
619
+ <title>Remnic MCP authorization</title>
620
+ <style>
621
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; color: #1a1a1a; }
622
+ h1 { font-size: 1.4rem; }
623
+ .ref { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.1rem; background: #f4f4f5; padding: 0.25rem 0.5rem; border-radius: 4px; }
624
+ details { margin: 1rem 0; padding: 0.75rem 1rem; background: #fafafa; border: 1px solid #e4e4e7; border-radius: 6px; }
625
+ summary { cursor: pointer; font-weight: 600; }
626
+ code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; }
627
+ pre { background: #f4f4f5; padding: 0.75rem; border-radius: 4px; overflow-x: auto; }
628
+ #status { margin-top: 1.5rem; padding: 0.75rem 1rem; border-radius: 6px; font-weight: 600; }
629
+ .pending { background: #fef9c3; color: #713f12; }
630
+ .approved { background: #dcfce7; color: #14532d; }
631
+ .denied { background: #fee2e2; color: #7f1d1d; }
632
+ .expired { background: #f3f4f6; color: #374151; }
633
+ </style>
634
+ </head>
635
+ <body>
636
+ <h1>Remnic MCP authorization pending</h1>
637
+ <p>An external application has requested access to your Remnic MCP server.
638
+ Open a terminal on the Remnic host and run:</p>
639
+ <pre>${safeCommand}</pre>
640
+ <p>Approval ref: <span class="ref">${safeRef}</span></p>
641
+ <details>
642
+ <summary>Request details</summary>
643
+ <ul>
644
+ <li>Client ID: <code>${safeClientId}</code></li>
645
+ <li>Redirect URI: <code>${safeRedirect}</code></li>
646
+ ${safeResource ? `<li>Resource: <code>${safeResource}</code></li>` : ""}
647
+ <li>Scopes: <code>${escapeHtml(txn.scopes.join(" ")) || "(none requested)"}</code></li>
648
+ </ul>
649
+ </details>
650
+ <div id="status" class="pending">Waiting for operator approval&hellip;</div>
651
+ <script>
652
+ (function () {
653
+ var statusEl = document.getElementById("status");
654
+ var txn = ${JSON.stringify(txn.txn)};
655
+ var secret = ${JSON.stringify(txn.pollSecret)};
656
+ var issuer = ${JSON.stringify(issuerUrl)};
657
+ var done = false;
658
+ function render(result) {
659
+ if (result.status === "pending") return;
660
+ done = true;
661
+ if (result.status === "approved") {
662
+ statusEl.className = "approved";
663
+ statusEl.textContent = "Approved — redirecting\\u2026";
664
+ window.location.replace(result.redirect);
665
+ } else if (result.status === "denied") {
666
+ statusEl.className = "denied";
667
+ statusEl.textContent = "Denied by operator. Return to the calling app to retry or cancel.";
668
+ } else {
669
+ statusEl.className = "expired";
670
+ statusEl.textContent = "This request expired. Return to the calling app to retry.";
671
+ }
672
+ }
673
+ function poll() {
674
+ if (done) return;
675
+ fetch(issuer + "/oauth/authorize/poll", {
676
+ method: "POST",
677
+ headers: { "content-type": "application/json" },
678
+ body: JSON.stringify({ txn: txn, pollSecret: secret }),
679
+ })
680
+ .then(function (res) {
681
+ if (!res.ok) throw new Error("poll failed");
682
+ return res.json();
683
+ })
684
+ .then(render)
685
+ .catch(function () { /* transient; next interval retries */ });
686
+ }
687
+ poll();
688
+ setInterval(poll, 3000);
689
+ })();
690
+ </script>
691
+ </body>
692
+ </html>`;
693
+ }
694
+
695
+ // ── Provider (SDK OAuthServerProvider implementation) ────────────────────────
696
+
697
+ /**
698
+ * Static single-client provider backed by the pending-approval store and
699
+ * the Remnic token store. All protocol validation (PKCE, client auth,
700
+ * redirect membership, param shapes) happens in the SDK handlers; this
701
+ * provider only implements the Remnic-specific decisions.
702
+ */
703
+ class RemnicOAuthProvider implements OAuthServerProvider {
704
+ private readonly config: ParsedOAuthConfig;
705
+ private readonly state: OAuthState;
706
+ private readonly staticClient: OAuthClientInformationFull;
707
+ /** Token-store override for tests; production uses the default path. */
708
+ private readonly tokensPath?: string;
709
+
710
+ constructor(config: ParsedOAuthConfig, state: OAuthState, tokensPath?: string) {
711
+ this.config = config;
712
+ this.state = state;
713
+ this.tokensPath = tokensPath;
714
+ this.staticClient = {
715
+ client_id: config.clientId,
716
+ // With `none`, the client is public: no secret is stored, so the
717
+ // SDK's client-auth middleware will not demand one. Otherwise the
718
+ // secret is REQUIRED on every token request.
719
+ ...(config.tokenEndpointAuthMethod === "none" ? {} : { client_secret: config.clientSecret }),
720
+ redirect_uris: config.redirectUris,
721
+ token_endpoint_auth_method: config.tokenEndpointAuthMethod,
722
+ grant_types: ["authorization_code"],
723
+ response_types: ["code"],
724
+ };
725
+ }
726
+
727
+ get clientsStore(): OAuthRegisteredClientsStore {
728
+ const client = this.staticClient;
729
+ return {
730
+ getClient: (clientId: string) => (clientId === client.client_id ? client : undefined),
731
+ // No registerClient: dynamic client registration is deliberately
732
+ // NOT supported — the single client is pre-registered via config.
733
+ };
734
+ }
735
+
736
+ async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
737
+ const txn = this.state.createPending({
738
+ clientId: client.client_id,
739
+ redirectUri: params.redirectUri,
740
+ scopes: params.scopes ?? [],
741
+ resource: params.resource?.href,
742
+ state: params.state,
743
+ codeChallenge: params.codeChallenge,
744
+ });
745
+ log.info(
746
+ `OAuth authorization pending: ref=${txn.ref} client=${txn.clientId} redirect=${txn.redirectUri} — ` +
747
+ `run \`remnic oauth approve ${txn.ref}\` to approve`,
748
+ );
749
+ res
750
+ .status(200)
751
+ .type("html")
752
+ .send(
753
+ renderApprovalPage({
754
+ txn,
755
+ issuerUrl: this.config.issuerUrl.replace(/\/+$/, ""),
756
+ command: `remnic oauth approve ${txn.ref}`,
757
+ }),
758
+ );
759
+ }
760
+
761
+ async challengeForAuthorizationCode(
762
+ client: OAuthClientInformationFull,
763
+ authorizationCode: string,
764
+ ): Promise<string> {
765
+ const entry = this.state.peekCode(authorizationCode);
766
+ if (!entry || !timingSafeStringEqual(entry.clientId, client.client_id)) {
767
+ throw new InvalidGrantError("authorization code is invalid, expired, or already used");
768
+ }
769
+ return entry.codeChallenge;
770
+ }
771
+
772
+ async exchangeAuthorizationCode(
773
+ client: OAuthClientInformationFull,
774
+ authorizationCode: string,
775
+ _codeVerifier?: string,
776
+ redirectUri?: string,
777
+ resource?: URL,
778
+ ): Promise<OAuthTokens> {
779
+ const entry = this.state.takeCode({
780
+ code: authorizationCode,
781
+ clientId: client.client_id,
782
+ redirectUri,
783
+ resource: resource?.href,
784
+ });
785
+ if (!entry) {
786
+ throw new InvalidGrantError("authorization code is invalid, expired, or already used");
787
+ }
788
+ // Mint a fresh Remnic connector token as the OAuth access token.
789
+ // commitTokenEntry replaces the previous `chatgpt` entry, so
790
+ // re-linking rotates the token — documented behavior. If the
791
+ // token-store write fails (disk full, permissions), revive the code
792
+ // so the client can retry the exchange instead of being forced
793
+ // through a fresh authorize+approve (AGENTS.md #14 — don't burn old
794
+ // state before the replacement is confirmed).
795
+ const tokenEntry = buildTokenEntry(CHATGPT_CONNECTOR_ID);
796
+ try {
797
+ commitTokenEntry(tokenEntry, this.tokensPath);
798
+ } catch (err) {
799
+ this.state.reviveCode(authorizationCode);
800
+ log.warn(
801
+ `OAuth token persist failed; authorization code revived for retry: ${
802
+ err instanceof Error ? err.message : String(err)
803
+ }`,
804
+ );
805
+ throw new ServerError("failed to persist the issued token; retry the exchange");
806
+ }
807
+ log.info(`OAuth token issued for connector "${CHATGPT_CONNECTOR_ID}" (client=${client.client_id})`);
808
+ return {
809
+ access_token: tokenEntry.token,
810
+ token_type: "Bearer",
811
+ };
812
+ }
813
+
814
+ async exchangeRefreshToken(): Promise<OAuthTokens> {
815
+ throw new UnsupportedGrantTypeError(
816
+ "refresh_token grant is not supported; re-link the app to rotate the token",
817
+ );
818
+ }
819
+
820
+ async verifyAccessToken(token: string): Promise<AuthInfo> {
821
+ const valid = getAllValidTokensCached(this.tokensPath).some((candidate) =>
822
+ timingSafeStringEqual(candidate, token),
823
+ );
824
+ if (!valid) {
825
+ throw new InvalidTokenError("unknown or revoked token");
826
+ }
827
+ return {
828
+ token,
829
+ clientId: this.config.clientId,
830
+ scopes: [],
831
+ };
832
+ }
833
+ }
834
+
835
+ // ── Express app builder ─────────────────────────────────────────────────────
836
+
837
+ export interface OAuthAppBundle {
838
+ app: Express;
839
+ state: OAuthState;
840
+ /** Paths owned by the OAuth facade (exact matches). */
841
+ ownedExactPaths: string[];
842
+ /** Path prefixes owned by the OAuth facade. */
843
+ ownedPathPrefixes: string[];
844
+ }
845
+
846
+ /**
847
+ * Build the express app hosting all OAuth endpoints.
848
+ *
849
+ * SDK-owned: `/authorize`, `/token`,
850
+ * `/.well-known/oauth-authorization-server`,
851
+ * `/.well-known/oauth-protected-resource/mcp`.
852
+ * Remnic-owned: `/oauth/authorize/poll` (public, secret-gated),
853
+ * `/oauth/pending[...]` (operator-only), plus a bare
854
+ * `/.well-known/oauth-protected-resource` alias for clients that do not
855
+ * implement RFC 9728 path insertion.
856
+ *
857
+ * @param authCtxLookup returns the operator-authorization context the core
858
+ * access server computed for this request; the facade never validates
859
+ * bearer tokens itself.
860
+ */
861
+ export function buildOAuthApp(
862
+ config: ParsedOAuthConfig,
863
+ authCtxLookup: (req: IncomingMessage) => { authorized: boolean },
864
+ deps?: { tokensPath?: string },
865
+ ): OAuthAppBundle {
866
+ const state = new OAuthState(config);
867
+ const provider = new RemnicOAuthProvider(config, state, deps?.tokensPath);
868
+ const issuer = new URL(config.issuerUrl);
869
+ const resourceServerUrl = new URL("/mcp", issuer);
870
+
871
+ if (config.redirectUris.length === 0) {
872
+ log.warn(
873
+ "server.oauth.redirectUris is empty — OAuth discovery is live but every authorization will be refused " +
874
+ "until the exact callback URL from ChatGPT's app management page is added (setup mode).",
875
+ );
876
+ }
877
+
878
+ const app = express();
879
+ app.disable("x-powered-by");
880
+
881
+ // SDK metadata, truthfully narrowed to what this server actually
882
+ // accepts: a single client-auth method and the authorization_code grant.
883
+ const oauthMetadata = createOAuthMetadata({ provider, issuerUrl: issuer });
884
+ oauthMetadata.token_endpoint_auth_methods_supported = [config.tokenEndpointAuthMethod];
885
+ oauthMetadata.grant_types_supported = ["authorization_code"];
886
+
887
+ // SDK handlers own the protocol-critical endpoints.
888
+ app.use("/authorize", authorizationHandler({ provider }));
889
+ app.use("/token", tokenHandler({ provider }));
890
+ app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl, resourceName: "Remnic" }));
891
+
892
+ // Alias: serve the protected-resource document at the bare well-known
893
+ // path too (the SDK mounts only the RFC 9728 path-inserted variant).
894
+ app.get("/.well-known/oauth-protected-resource", (_req: Request, res: Response) => {
895
+ res.status(200).json({
896
+ resource: resourceServerUrl.href,
897
+ authorization_servers: [oauthMetadata.issuer],
898
+ resource_name: "Remnic",
899
+ });
900
+ });
901
+
902
+ // Remnic-specific endpoints below parse JSON bodies.
903
+ app.use(express.json({ limit: "32kb" }));
904
+ // Rate limiters for the Remnic-owned endpoints (the SDK already
905
+ // rate-limits /authorize and /token). Two policies:
906
+ // - pollLimiter: generous. The approval page polls every ~3 s for up
907
+ // to approvalTtlSeconds, so a full flow is ~200 polls; 120/min per IP
908
+ // leaves headroom for several concurrent flows without locking the
909
+ // browser out of its own approval.
910
+ // - operatorReadLimiter / operatorDecisionLimiter: operator list vs
911
+ // approve/deny. Both are low-frequency, so tight bounds blunt
912
+ // brute-forcing an approval ref while never impeding a human
913
+ // operator; separate buckets keep listing from starving decisions.
914
+ // Shared limiter shape: deterministic JSON 429 (stable `error` code) so
915
+ // ChatGPT and the CLI get a machine-parseable body, never default HTML.
916
+ const makeLimiter = (max: number) =>
917
+ rateLimit({
918
+ windowMs: 60_000,
919
+ max,
920
+ standardHeaders: true,
921
+ legacyHeaders: false,
922
+ message: { error: "rate_limited", error_description: "too many requests; retry later" },
923
+ });
924
+ const pollLimiter = makeLimiter(120);
925
+ // Read listing gets its own bucket so polling `pending` can never
926
+ // exhaust the decision (approve/deny) capacity below.
927
+ const operatorReadLimiter = makeLimiter(60);
928
+ const operatorDecisionLimiter = makeLimiter(30);
929
+
930
+ // ── Poll endpoint (public; gated by txn id + pollSecret) ──────────────
931
+ app.post("/oauth/authorize/poll", pollLimiter, (req: Request, res: Response) => {
932
+ const body: unknown = req.body;
933
+ if (
934
+ !isPlainObject(body) ||
935
+ typeof body.txn !== "string" ||
936
+ typeof body.pollSecret !== "string" ||
937
+ body.txn.length === 0 ||
938
+ body.pollSecret.length === 0
939
+ ) {
940
+ res.status(400).json({ error: "invalid_request", error_description: "txn and pollSecret are required" });
941
+ return;
942
+ }
943
+ const result = state.poll(body.txn, body.pollSecret);
944
+ if (result === undefined) {
945
+ res.status(401).json({ error: "invalid_request", error_description: "unknown txn or wrong pollSecret" });
946
+ return;
947
+ }
948
+ res.status(200).json(result);
949
+ });
950
+
951
+ // ── Operator-only endpoints ────────────────────────────────────────────
952
+ function operatorGuard(req: Request, res: Response): boolean {
953
+ if (authCtxLookup(req).authorized === true) return true;
954
+ res.status(401).json({ error: "unauthorized", error_description: "operator authentication required" });
955
+ return false;
956
+ }
957
+
958
+ app.get("/oauth/pending", operatorReadLimiter, (req: Request, res: Response) => {
959
+ if (!operatorGuard(req, res)) return;
960
+ const pending = state.listPending().map((txn) => ({
961
+ ref: txn.ref,
962
+ clientId: txn.clientId,
963
+ redirectUri: txn.redirectUri,
964
+ scopes: txn.scopes,
965
+ resource: txn.resource ?? null,
966
+ createdAt: new Date(txn.createdAt).toISOString(),
967
+ expiresAt: new Date(txn.expiresAt).toISOString(),
968
+ }));
969
+ res.status(200).json({ pending });
970
+ });
971
+
972
+ function decisionHandler(action: "approve" | "deny") {
973
+ return (req: Request, res: Response) => {
974
+ if (!operatorGuard(req, res)) return;
975
+ const ref = req.params.ref;
976
+ if (!ref) {
977
+ res.status(400).json({ error: "invalid_request", error_description: "ref is required" });
978
+ return;
979
+ }
980
+ try {
981
+ if (action === "approve") {
982
+ const { txn, code } = state.approveByRef(ref);
983
+ res.status(200).json({ ref, status: "approved", redirect: state.buildRedirect(txn, code) });
984
+ } else {
985
+ state.denyByRef(ref);
986
+ res.status(200).json({ ref, status: "denied" });
987
+ }
988
+ } catch (err) {
989
+ const message = err instanceof Error ? err.message : String(err);
990
+ if (message === "unknown or expired ref") {
991
+ res.status(404).json({ error: "invalid_request", error_description: message });
992
+ return;
993
+ }
994
+ if (message === "denied" || message === "already approved") {
995
+ res.status(409).json({ error: "invalid_request", error_description: message });
996
+ return;
997
+ }
998
+ log.warn(`OAuth ${action} endpoint unexpected error: ${message}`);
999
+ res.status(500).json({ error: "server_error", error_description: "internal error" });
1000
+ }
1001
+ };
1002
+ }
1003
+
1004
+ app.post("/oauth/pending/:ref/approve", operatorDecisionLimiter, decisionHandler("approve"));
1005
+ app.post("/oauth/pending/:ref/deny", operatorDecisionLimiter, decisionHandler("deny"));
1006
+
1007
+ return {
1008
+ app,
1009
+ state,
1010
+ ownedExactPaths: [
1011
+ "/authorize",
1012
+ "/token",
1013
+ "/.well-known/oauth-authorization-server",
1014
+ "/.well-known/oauth-protected-resource",
1015
+ "/.well-known/oauth-protected-resource/mcp",
1016
+ ],
1017
+ ownedPathPrefixes: ["/oauth/"],
1018
+ };
1019
+ }
1020
+
1021
+ // ── External request handler (mount point) ──────────────────────────────────
1022
+
1023
+ /**
1024
+ * Adapt the OAuth express app to the core access server's
1025
+ * `externalRequestHandler` hook. Ownership is decided by pathname BEFORE
1026
+ * delegating to express (deterministic — never inferred from express
1027
+ * fallthrough), so non-OAuth requests always continue down the normal
1028
+ * core pipeline.
1029
+ */
1030
+ export function buildOAuthRequestHandler(
1031
+ config: ParsedOAuthConfig,
1032
+ deps?: { tokensPath?: string },
1033
+ ): (req: IncomingMessage, res: ServerResponse, ctx: { authorized: boolean }) => Promise<boolean> {
1034
+ if (!config.enabled) {
1035
+ return async () => false;
1036
+ }
1037
+ // Core computes the operator-authorization ctx per request and hands it
1038
+ // to the hook; stash it per-request so express handlers can read it
1039
+ // without ever re-validating headers themselves.
1040
+ const ctxByRequest = new WeakMap<IncomingMessage, { authorized: boolean }>();
1041
+ const bundle = buildOAuthApp(config, (req) => ctxByRequest.get(req) ?? { authorized: false }, deps);
1042
+ const exactPaths = new Set(bundle.ownedExactPaths);
1043
+
1044
+ return (req, res, ctx) => {
1045
+ let pathname: string;
1046
+ try {
1047
+ pathname = new URL(req.url ?? "/", "http://placeholder").pathname;
1048
+ } catch {
1049
+ return Promise.resolve(false);
1050
+ }
1051
+ const owned = exactPaths.has(pathname) || bundle.ownedPathPrefixes.some((prefix) => pathname.startsWith(prefix));
1052
+ if (!owned) return Promise.resolve(false);
1053
+
1054
+ ctxByRequest.set(req, ctx);
1055
+ const { promise, resolve } = Promise.withResolvers<boolean>();
1056
+ const finish = () => resolve(true);
1057
+ res.on("finish", finish);
1058
+ res.on("close", finish);
1059
+ // An express app instance is a Node request listener; the third
1060
+ // argument runs when no route matched or a handler errored.
1061
+ bundle.app(req as Request, res as Response, (err: unknown) => {
1062
+ if (!res.headersSent) {
1063
+ if (err) {
1064
+ log.warn(`OAuth facade error: ${err instanceof Error ? err.message : String(err)}`);
1065
+ res.statusCode = 500;
1066
+ res.setHeader("content-type", "application/json; charset=utf-8");
1067
+ res.end(JSON.stringify({ error: "server_error", error_description: "internal error" }));
1068
+ } else {
1069
+ res.statusCode = 404;
1070
+ res.setHeader("content-type", "application/json; charset=utf-8");
1071
+ res.end(JSON.stringify({ error: "not_found", error_description: "unknown OAuth endpoint" }));
1072
+ }
1073
+ }
1074
+ resolve(true);
1075
+ });
1076
+ return promise;
1077
+ };
1078
+ }