@specific.dev/spectest 0.54.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,626 @@
1
+ // OAuth 2.1 for the MCP client (`mcp.ts`).
2
+ //
3
+ // An MCP server is an OAuth *resource server*. It rejects an unauthorized
4
+ // request with `401` and a `WWW-Authenticate` header, and that header is
5
+ // the entry point of this file. From there the flow is:
6
+ //
7
+ // 1. Read the protected-resource metadata (RFC 9728). It names the
8
+ // authorization server and the canonical `resource` identifier.
9
+ // 2. Read the authorization-server metadata (RFC 8414). Note the
10
+ // path-insertion rule: an issuer with a path is discovered at
11
+ // `https://host/.well-known/oauth-authorization-server/<path>`, not
12
+ // at `<issuer>/.well-known/...`.
13
+ // 3. Register the client dynamically (RFC 7591) when the server offers
14
+ // it. This is also what lets us declare a redirect URI on a port we
15
+ // only chose a moment ago.
16
+ // 4. Authorization code with PKCE (S256, mandatory) and the `resource`
17
+ // parameter (RFC 8707, required by MCP — it binds the token to this
18
+ // one server).
19
+ // 5. Exchange the code, then refresh the token when it expires.
20
+ //
21
+ // The SDK owns all of that. It does NOT own the browser. A test drives the
22
+ // login and the consent screen itself, because that screen belongs to the
23
+ // application under test and a framework that clicks it by guesswork turns
24
+ // a broken consent page into a passing test. See `mcp.ts` for the seam.
25
+ //
26
+ // WHY LOOPBACK. The default redirect URI is `http://127.0.0.1:<port>/callback`
27
+ // on an ephemeral port. That is what a desktop MCP client does — it is
28
+ // OAuth 2.0 for Native Apps (RFC 8252 §7.3), and §8.3 prefers the IP
29
+ // literal over the name `localhost`. It works here because Chromium is the
30
+ // guest's own browser, in the same VM and the same network namespace as
31
+ // this daemon, so the browser's loopback IS our loopback. The
32
+ // authorization server never touches the address: a redirect is a browser
33
+ // navigation, not a server-to-server call.
34
+ //
35
+ // LANDMINE — GoTrue (Supabase Auth) refuses a plain-http redirect URI
36
+ // unless it is localhost, and it recognises the NAME. So a Supabase-backed
37
+ // project must use `localhost`, not `127.0.0.1`. `loopbackHost()` below
38
+ // keeps the RFC's preference as the default and the name as the documented
39
+ // override; do not "correct" one into the other without testing both.
40
+
41
+ import { secureRandomBytes } from "./ids.js";
42
+ import { rawFetch } from "./harness/raw-fetch.js";
43
+ import { createHash } from "node:crypto";
44
+
45
+ /** The redirect host. RFC 8252 §8.3 prefers the IP literal. Some
46
+ * authorization servers only special-case the name — see the landmine at
47
+ * the top of this file. */
48
+ export type LoopbackHost = "127.0.0.1" | "localhost";
49
+
50
+ /** Protected-resource metadata (RFC 9728), the subset MCP uses. */
51
+ export interface ProtectedResourceMetadata {
52
+ resource?: string;
53
+ authorization_servers?: string[];
54
+ scopes_supported?: string[];
55
+ bearer_methods_supported?: string[];
56
+ }
57
+
58
+ /** Authorization-server metadata (RFC 8414), the subset we need. */
59
+ export interface AuthServerMetadata {
60
+ issuer: string;
61
+ authorization_endpoint: string;
62
+ token_endpoint: string;
63
+ registration_endpoint?: string;
64
+ scopes_supported?: string[];
65
+ code_challenge_methods_supported?: string[];
66
+ grant_types_supported?: string[];
67
+ }
68
+
69
+ /** What the client ended up with. Handed to the test, so it can assert on
70
+ * the grant and reuse the token elsewhere. */
71
+ export interface McpIdentity {
72
+ /** The bearer token. Redacted in every recorded event, never in this
73
+ * value — a test may need it to call the same API directly. */
74
+ accessToken: string;
75
+ refreshToken?: string;
76
+ tokenType: string;
77
+ /** What the server GRANTED, which is not always what was asked for. */
78
+ scopes: string[];
79
+ /** The RFC 8707 resource the token is bound to. */
80
+ resource?: string;
81
+ clientId: string;
82
+ issuer: string;
83
+ /** The redirect URI this grant was issued for. Kept so a test can prove
84
+ * which kind of client it just was — a loopback one, by default. */
85
+ redirectUri: string;
86
+ /** Where to spend the refresh token. Carried on the identity because a
87
+ * refresh usually happens in a forked test that never ran discovery,
88
+ * and an issuer's token endpoint is not derivable from its URL. */
89
+ tokenEndpoint: string;
90
+ /** Epoch milliseconds, when the server reported `expires_in`. */
91
+ expiresAt?: number;
92
+ }
93
+
94
+ /** The user (or the authorization server) refused the grant. */
95
+ export class McpAuthDeniedError extends Error {
96
+ readonly code: string;
97
+ readonly description?: string;
98
+
99
+ constructor(code: string, description?: string) {
100
+ super(`authorization denied: ${code}${description ? ` — ${description}` : ""}`);
101
+ this.name = "McpAuthDeniedError";
102
+ this.code = code;
103
+ this.description = description;
104
+ }
105
+ }
106
+
107
+ export interface AuthorizeOptions {
108
+ /** Skip dynamic registration and use a pre-registered client. */
109
+ clientId?: string;
110
+ /** For a confidential client. Sent with the token request. */
111
+ clientSecret?: string;
112
+ /**
113
+ * Use this redirect URI instead of a loopback listener. Nothing is
114
+ * served for it — the browser lands somewhere this daemon does not own,
115
+ * so the test must hand the landed URL back:
116
+ * `await auth.complete({ url: page.url() })`.
117
+ */
118
+ redirectUri?: string;
119
+ /**
120
+ * Escape hatch. Scopes normally come from the protected-resource
121
+ * metadata, because that is what a real MCP client does — it does not
122
+ * ask its user which scopes to request. Set this only for a server that
123
+ * advertises none and still demands one.
124
+ */
125
+ scopes?: string[];
126
+ /** Client name presented at dynamic registration and, usually, on the
127
+ * consent screen. */
128
+ clientName?: string;
129
+ /** Override the RFC 8707 resource. Defaults to the metadata's
130
+ * `resource`, else the MCP server URL. */
131
+ resource?: string;
132
+ /** Which loopback host to register. Defaults to `127.0.0.1`. */
133
+ loopbackHost?: LoopbackHost;
134
+ /** Budget for {@link Authorization.complete}. Default 120 s — a human
135
+ * flow driven by browser steps is slower than an HTTP call. */
136
+ timeoutMs?: number;
137
+ }
138
+
139
+ /** Everything discovery found, so a test can assert on it. */
140
+ export interface AuthorizationServerInfo {
141
+ issuer: string;
142
+ authorizationEndpoint: string;
143
+ tokenEndpoint: string;
144
+ registrationEndpoint?: string;
145
+ /** True when this client registered itself for this flow. */
146
+ dynamicallyRegistered: boolean;
147
+ }
148
+
149
+ const DEFAULT_COMPLETE_TIMEOUT_MS = 120_000;
150
+
151
+ /** A minimal loopback HTTP server. Declared structurally so this file does
152
+ * not need Bun's types at the call site. */
153
+ interface LoopbackServer {
154
+ port: number;
155
+ stop(closeActiveConnections?: boolean): void;
156
+ }
157
+
158
+ /**
159
+ * One authorization attempt, in progress.
160
+ *
161
+ * `authorize()` has already done discovery, registration and PKCE, and has
162
+ * bound the loopback listener. All that is left is the part with a human
163
+ * in it: the test navigates a browser to {@link url}, and then calls
164
+ * {@link complete}.
165
+ */
166
+ export class Authorization {
167
+ /** Send the browser here. */
168
+ readonly url: string;
169
+ readonly redirectUri: string;
170
+ readonly state: string;
171
+ readonly clientId: string;
172
+ readonly server: AuthorizationServerInfo;
173
+ /** Scopes requested, which may be empty — see {@link AuthorizeOptions.scopes}. */
174
+ readonly scopes: string[];
175
+ readonly resource?: string;
176
+
177
+ private readonly verifier: string;
178
+ private readonly clientSecret?: string;
179
+ private readonly timeoutMs: number;
180
+ private readonly listener?: LoopbackListener;
181
+ private settled = false;
182
+
183
+ constructor(init: {
184
+ url: string;
185
+ redirectUri: string;
186
+ state: string;
187
+ clientId: string;
188
+ clientSecret?: string;
189
+ server: AuthorizationServerInfo;
190
+ scopes: string[];
191
+ resource?: string;
192
+ verifier: string;
193
+ timeoutMs: number;
194
+ listener?: LoopbackListener;
195
+ }) {
196
+ this.url = init.url;
197
+ this.redirectUri = init.redirectUri;
198
+ this.state = init.state;
199
+ this.clientId = init.clientId;
200
+ this.clientSecret = init.clientSecret;
201
+ this.server = init.server;
202
+ this.scopes = init.scopes;
203
+ this.resource = init.resource;
204
+ this.verifier = init.verifier;
205
+ this.timeoutMs = init.timeoutMs;
206
+ this.listener = init.listener;
207
+ }
208
+
209
+ /**
210
+ * Wait for the redirect, then exchange the code for a token.
211
+ *
212
+ * With the default loopback redirect there is nothing to pass: the
213
+ * listener already holds the code, and this returns at once when the
214
+ * browser has landed. Pass `url` when the flow used a redirect URI this
215
+ * daemon does not serve — hand back where the browser ended up
216
+ * (`page.url()`).
217
+ */
218
+ async complete(opts?: { url?: string; timeoutMs?: number }): Promise<McpIdentity> {
219
+ try {
220
+ const params = opts?.url
221
+ ? new URL(opts.url).searchParams
222
+ : await this.waitForRedirect(opts?.timeoutMs ?? this.timeoutMs);
223
+
224
+ const error = params.get("error");
225
+ if (error) {
226
+ throw new McpAuthDeniedError(error, params.get("error_description") ?? undefined);
227
+ }
228
+ const returnedState = params.get("state");
229
+ if (returnedState !== this.state) {
230
+ throw new Error(
231
+ `authorization state mismatch: expected ${this.state}, got ${returnedState ?? "none"}`,
232
+ );
233
+ }
234
+ const code = params.get("code");
235
+ if (!code) throw new Error("the redirect carried no authorization code");
236
+
237
+ return await this.exchange(code);
238
+ } finally {
239
+ this.settled = true;
240
+ this.listener?.stop();
241
+ }
242
+ }
243
+
244
+ /** Release the loopback port without finishing the flow. */
245
+ cancel(): void {
246
+ if (this.settled) return;
247
+ this.settled = true;
248
+ this.listener?.stop();
249
+ }
250
+
251
+ private async waitForRedirect(timeoutMs: number): Promise<URLSearchParams> {
252
+ if (!this.listener) {
253
+ throw new Error(
254
+ "this authorization used a custom redirectUri, which spectest does not serve. " +
255
+ "Pass where the browser landed: await auth.complete({ url: page.url() })",
256
+ );
257
+ }
258
+ return this.listener.wait(timeoutMs);
259
+ }
260
+
261
+ private async exchange(code: string): Promise<McpIdentity> {
262
+ const body = new URLSearchParams({
263
+ grant_type: "authorization_code",
264
+ code,
265
+ redirect_uri: this.redirectUri,
266
+ client_id: this.clientId,
267
+ code_verifier: this.verifier,
268
+ });
269
+ // RFC 8707. MCP requires it on the token request too, not only on the
270
+ // authorize request — it is what binds the token to this one server.
271
+ if (this.resource) body.set("resource", this.resource);
272
+ if (this.clientSecret) body.set("client_secret", this.clientSecret);
273
+
274
+ const token = await postToken(this.server.tokenEndpoint, body);
275
+ return {
276
+ accessToken: token.access_token,
277
+ refreshToken: token.refresh_token,
278
+ tokenType: token.token_type ?? "Bearer",
279
+ scopes: splitScope(token.scope) ?? this.scopes,
280
+ resource: this.resource,
281
+ clientId: this.clientId,
282
+ issuer: this.server.issuer,
283
+ redirectUri: this.redirectUri,
284
+ tokenEndpoint: this.server.tokenEndpoint,
285
+ expiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
286
+ };
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Do everything up to the browser: discovery, registration, PKCE, and the
292
+ * loopback listener. Returns the URL to send the user to.
293
+ */
294
+ export async function authorize(
295
+ serverUrl: string,
296
+ resourceMetadataUrl: string | undefined,
297
+ opts: AuthorizeOptions = {},
298
+ ): Promise<Authorization> {
299
+ const prm = await fetchProtectedResourceMetadata(serverUrl, resourceMetadataUrl);
300
+ const issuer = prm?.authorization_servers?.[0] ?? new URL(serverUrl).origin;
301
+ const metadata = await fetchAuthServerMetadata(issuer);
302
+
303
+ const resource = opts.resource ?? prm?.resource ?? canonicalResource(serverUrl);
304
+ // A real MCP client does not ask its user for scopes: it uses what the
305
+ // resource advertises, and otherwise sends none and lets the server
306
+ // decide.
307
+ const scopes = opts.scopes ?? prm?.scopes_supported ?? [];
308
+
309
+ let listener: LoopbackListener | undefined;
310
+ let redirectUri = opts.redirectUri;
311
+ if (!redirectUri) {
312
+ listener = await startLoopbackListener(opts.loopbackHost ?? "127.0.0.1");
313
+ redirectUri = listener.redirectUri;
314
+ }
315
+
316
+ let clientId = opts.clientId;
317
+ let clientSecret = opts.clientSecret;
318
+ let dynamicallyRegistered = false;
319
+ if (!clientId) {
320
+ if (!metadata.registration_endpoint) {
321
+ listener?.stop();
322
+ throw new Error(
323
+ `${issuer} does not offer dynamic client registration. ` +
324
+ "Pass an existing client: mcp.authorize({ clientId, redirectUri }).",
325
+ );
326
+ }
327
+ const registered = await registerClient(metadata.registration_endpoint, {
328
+ redirectUri,
329
+ clientName: opts.clientName ?? "spectest",
330
+ scopes,
331
+ });
332
+ clientId = registered.client_id;
333
+ clientSecret = registered.client_secret;
334
+ dynamicallyRegistered = true;
335
+ }
336
+
337
+ const verifier = base64url(secureRandomBytes(32));
338
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
339
+ const state = base64url(secureRandomBytes(16));
340
+
341
+ const url = new URL(metadata.authorization_endpoint);
342
+ url.searchParams.set("response_type", "code");
343
+ url.searchParams.set("client_id", clientId);
344
+ url.searchParams.set("redirect_uri", redirectUri);
345
+ url.searchParams.set("state", state);
346
+ url.searchParams.set("code_challenge", challenge);
347
+ url.searchParams.set("code_challenge_method", "S256");
348
+ if (resource) url.searchParams.set("resource", resource);
349
+ if (scopes.length > 0) url.searchParams.set("scope", scopes.join(" "));
350
+
351
+ return new Authorization({
352
+ url: url.toString(),
353
+ redirectUri,
354
+ state,
355
+ clientId,
356
+ clientSecret,
357
+ server: {
358
+ issuer: metadata.issuer,
359
+ authorizationEndpoint: metadata.authorization_endpoint,
360
+ tokenEndpoint: metadata.token_endpoint,
361
+ registrationEndpoint: metadata.registration_endpoint,
362
+ dynamicallyRegistered,
363
+ },
364
+ scopes,
365
+ resource,
366
+ verifier,
367
+ timeoutMs: opts.timeoutMs ?? DEFAULT_COMPLETE_TIMEOUT_MS,
368
+ listener,
369
+ });
370
+ }
371
+
372
+ /** Exchange a refresh token for a new access token. Returns `undefined`
373
+ * when the identity has no refresh token to spend. */
374
+ export async function refreshIdentity(
375
+ identity: McpIdentity,
376
+ clientSecret?: string,
377
+ ): Promise<McpIdentity | undefined> {
378
+ if (!identity.refreshToken) return undefined;
379
+ const body = new URLSearchParams({
380
+ grant_type: "refresh_token",
381
+ refresh_token: identity.refreshToken,
382
+ client_id: identity.clientId,
383
+ });
384
+ if (identity.resource) body.set("resource", identity.resource);
385
+ if (clientSecret) body.set("client_secret", clientSecret);
386
+
387
+ const token = await postToken(identity.tokenEndpoint, body);
388
+ return {
389
+ ...identity,
390
+ accessToken: token.access_token,
391
+ // A server that rotates refresh tokens returns a new one; one that
392
+ // does not expects the old one to be reused.
393
+ refreshToken: token.refresh_token ?? identity.refreshToken,
394
+ scopes: splitScope(token.scope) ?? identity.scopes,
395
+ expiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
396
+ };
397
+ }
398
+
399
+ interface TokenResponse {
400
+ access_token: string;
401
+ token_type?: string;
402
+ expires_in?: number;
403
+ refresh_token?: string;
404
+ scope?: string;
405
+ }
406
+
407
+ async function postToken(endpoint: string, body: URLSearchParams): Promise<TokenResponse> {
408
+ const res = await rawFetch(endpoint, {
409
+ method: "POST",
410
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
411
+ body: body.toString(),
412
+ });
413
+ const text = await res.text();
414
+ if (!res.ok) {
415
+ // The error body is a JSON object with `error` / `error_description`
416
+ // (RFC 6749 §5.2). Surface both; the code is what a test asserts on.
417
+ let code = `HTTP ${res.status}`;
418
+ let description: string | undefined = text.slice(0, 300);
419
+ try {
420
+ const parsed = JSON.parse(text) as { error?: string; error_description?: string };
421
+ if (parsed.error) code = parsed.error;
422
+ description = parsed.error_description ?? description;
423
+ } catch {
424
+ // Not JSON. The raw body is the best description available.
425
+ }
426
+ throw new McpAuthDeniedError(code, description);
427
+ }
428
+ const token = JSON.parse(text) as TokenResponse;
429
+ if (!token.access_token) throw new Error(`token endpoint returned no access_token: ${text}`);
430
+ return token;
431
+ }
432
+
433
+ interface RegistrationResponse {
434
+ client_id: string;
435
+ client_secret?: string;
436
+ }
437
+
438
+ async function registerClient(
439
+ endpoint: string,
440
+ init: { redirectUri: string; clientName: string; scopes: string[] },
441
+ ): Promise<RegistrationResponse> {
442
+ const body: Record<string, unknown> = {
443
+ client_name: init.clientName,
444
+ redirect_uris: [init.redirectUri],
445
+ grant_types: ["authorization_code", "refresh_token"],
446
+ response_types: ["code"],
447
+ // A loopback client cannot keep a secret (RFC 8252 §8.4).
448
+ token_endpoint_auth_method: "none",
449
+ };
450
+ if (init.scopes.length > 0) body["scope"] = init.scopes.join(" ");
451
+
452
+ const res = await rawFetch(endpoint, {
453
+ method: "POST",
454
+ headers: { "content-type": "application/json", accept: "application/json" },
455
+ body: JSON.stringify(body),
456
+ });
457
+ const text = await res.text();
458
+ if (!res.ok) {
459
+ throw new Error(`dynamic client registration failed: HTTP ${res.status} — ${text.slice(0, 300)}`);
460
+ }
461
+ const parsed = JSON.parse(text) as RegistrationResponse;
462
+ if (!parsed.client_id) throw new Error(`registration returned no client_id: ${text}`);
463
+ return parsed;
464
+ }
465
+
466
+ async function fetchProtectedResourceMetadata(
467
+ serverUrl: string,
468
+ resourceMetadataUrl?: string,
469
+ ): Promise<ProtectedResourceMetadata | undefined> {
470
+ const candidates = resourceMetadataUrl
471
+ ? [resourceMetadataUrl]
472
+ : wellKnownUrls(serverUrl, "oauth-protected-resource");
473
+ for (const url of candidates) {
474
+ const found = await fetchJson<ProtectedResourceMetadata>(url);
475
+ if (found) return found;
476
+ }
477
+ // Legal: a server may protect itself without publishing metadata. The
478
+ // caller then falls back to the server's own origin as the issuer.
479
+ return undefined;
480
+ }
481
+
482
+ async function fetchAuthServerMetadata(issuer: string): Promise<AuthServerMetadata> {
483
+ for (const url of [
484
+ ...wellKnownUrls(issuer, "oauth-authorization-server"),
485
+ ...wellKnownUrls(issuer, "openid-configuration"),
486
+ ]) {
487
+ const found = await fetchJson<AuthServerMetadata>(url);
488
+ if (found?.authorization_endpoint && found?.token_endpoint) {
489
+ return { ...found, issuer: found.issuer ?? issuer };
490
+ }
491
+ }
492
+ // MCP's fallback for a server that publishes nothing.
493
+ const base = issuer.replace(/\/$/, "");
494
+ return {
495
+ issuer,
496
+ authorization_endpoint: `${base}/authorize`,
497
+ token_endpoint: `${base}/token`,
498
+ registration_endpoint: `${base}/register`,
499
+ };
500
+ }
501
+
502
+ /**
503
+ * Candidate well-known URLs for an issuer, in the order to try them.
504
+ *
505
+ * RFC 8414 inserts the well-known segment BEFORE the issuer's path:
506
+ * `https://host/tenant` is discovered at
507
+ * `https://host/.well-known/oauth-authorization-server/tenant`. OpenID
508
+ * Connect appends instead. A path-carrying issuer therefore has two valid
509
+ * spellings and servers differ on which they serve, so we try the RFC 8414
510
+ * order first and fall back.
511
+ */
512
+ export function wellKnownUrls(issuer: string, suffix: string): string[] {
513
+ const url = new URL(issuer);
514
+ const path = url.pathname.replace(/\/$/, "");
515
+ const root = `${url.origin}/.well-known/${suffix}`;
516
+ if (path === "" || path === "/") return [root];
517
+ return [`${url.origin}/.well-known/${suffix}${path}`, `${url.origin}${path}/.well-known/${suffix}`, root];
518
+ }
519
+
520
+ /** The canonical resource identifier (RFC 8707): the server URL with no
521
+ * fragment, and a lowercase host. */
522
+ export function canonicalResource(serverUrl: string): string {
523
+ const url = new URL(serverUrl);
524
+ url.hash = "";
525
+ url.host = url.host.toLowerCase();
526
+ return url.toString();
527
+ }
528
+
529
+ async function fetchJson<T>(url: string): Promise<T | undefined> {
530
+ try {
531
+ const res = await rawFetch(url, { headers: { accept: "application/json" } });
532
+ if (!res.ok) return undefined;
533
+ return (await res.json()) as T;
534
+ } catch {
535
+ // An unreachable or non-JSON endpoint is a miss, not a failure: the
536
+ // caller has other candidates and a documented fallback.
537
+ return undefined;
538
+ }
539
+ }
540
+
541
+ function splitScope(scope?: string): string[] | undefined {
542
+ if (!scope) return undefined;
543
+ const parts = scope.split(/\s+/).filter((s) => s !== "");
544
+ return parts.length > 0 ? parts : undefined;
545
+ }
546
+
547
+ export function base64url(bytes: Uint8Array | string): string {
548
+ const buf = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
549
+ let binary = "";
550
+ for (const b of buf) binary += String.fromCharCode(b);
551
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
552
+ }
553
+
554
+ /** The loopback callback server. One per flow, stopped when the flow
555
+ * settles. */
556
+ interface LoopbackListener {
557
+ redirectUri: string;
558
+ wait(timeoutMs: number): Promise<URLSearchParams>;
559
+ stop(): void;
560
+ }
561
+
562
+ const CALLBACK_PATH = "/callback";
563
+
564
+ /** Landing page. The browser is a real browser driven by a test, so this
565
+ * is what a screenshot of the last step will show. */
566
+ const CALLBACK_HTML = `<!doctype html>
567
+ <meta charset="utf-8">
568
+ <title>Authorized</title>
569
+ <body style="font: 16px system-ui; padding: 3rem; color: #222">
570
+ <h1 style="font-size: 1.25rem">Authorized</h1>
571
+ <p>You can close this window.</p>
572
+ `;
573
+
574
+ async function startLoopbackListener(host: LoopbackHost): Promise<LoopbackListener> {
575
+ let resolve: ((params: URLSearchParams) => void) | undefined;
576
+ const received = new Promise<URLSearchParams>((r) => {
577
+ resolve = r;
578
+ });
579
+
580
+ // Port 0 asks the kernel for a free port. It is registered with the
581
+ // authorization server a moment later, which is exactly why dynamic
582
+ // client registration exists (RFC 8252 §7.3 also forbids a server from
583
+ // pinning the port of a loopback redirect).
584
+ const bun = (globalThis as { Bun?: { serve(opts: unknown): LoopbackServer } }).Bun;
585
+ if (!bun) throw new Error("the OAuth loopback listener needs Bun's HTTP server");
586
+ const server = bun.serve({
587
+ hostname: host,
588
+ port: 0,
589
+ fetch(req: Request) {
590
+ const url = new URL(req.url);
591
+ if (url.pathname !== CALLBACK_PATH) return new Response("not found", { status: 404 });
592
+ resolve?.(url.searchParams);
593
+ return new Response(CALLBACK_HTML, {
594
+ status: 200,
595
+ headers: { "content-type": "text/html; charset=utf-8" },
596
+ });
597
+ },
598
+ });
599
+
600
+ return {
601
+ redirectUri: `http://${host}:${server.port}${CALLBACK_PATH}`,
602
+ async wait(timeoutMs: number): Promise<URLSearchParams> {
603
+ let timer: ReturnType<typeof setTimeout> | undefined;
604
+ const expired = new Promise<never>((_, reject) => {
605
+ timer = setTimeout(
606
+ () =>
607
+ reject(
608
+ new Error(
609
+ `no authorization redirect arrived within ${timeoutMs} ms. ` +
610
+ "Did the test navigate a browser to auth.url and complete the sign-in?",
611
+ ),
612
+ ),
613
+ timeoutMs,
614
+ );
615
+ });
616
+ try {
617
+ return await Promise.race([received, expired]);
618
+ } finally {
619
+ if (timer) clearTimeout(timer);
620
+ }
621
+ },
622
+ stop(): void {
623
+ server.stop(true);
624
+ },
625
+ };
626
+ }