1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 AuthMcpClientAccess,
5 AuthStandardClientScopes,
6 type AuthCreatePairingCredentialInput,
7 type EnvironmentCloudLinkStateResult,
8 EnvironmentHttpApi,
9 MCP_PEER_LINK_SOFTWARE_ID,
10 mcpClientScopes,
11} from "@t3tools/contracts";
12import { expect, it } from "@effect/vitest";
13import * as Clock from "effect/Clock";
14import * as Context from "effect/Context";
15import * as SecureRandom from "effect/crypto/SecureRandom";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Etag from "effect/http/Etag";
19import * as HttpPlatform from "effect/http/HttpPlatform";
20import * as HttpRouter from "effect/http/HttpRouter";
21import * as HttpServerRequest from "effect/http/HttpServerRequest";
22import * as HttpApi from "effect/http-api/HttpApi";
23import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
24import * as Schema from "effect/Schema";
25
26import * as CloudLink from "../cloud/CloudLink.ts";
27import * as ServerConfig from "../config.ts";
28import { makeDpopKey } from "./dpop.testkit.ts";
29import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
30import * as ServerSettings from "../serverSettings.ts";
31import * as Sqlite from "../persistence/Sqlite.ts";
32import * as EnvironmentAuth from "./EnvironmentAuth.ts";
33import * as McpOAuth from "./McpOAuth.ts";
34import * as McpOAuthHttp from "./mcpOAuthHttp.ts";
35import * as ServerSecretStore from "./ServerSecretStore.ts";
36import * as AuthHttp from "./http.ts";
37import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
38
39class AuthTestApi extends HttpApi.make("environment")
40 .add(EnvironmentHttpApi.groups.auth)
41 .add(EnvironmentHttpApi.groups.mcpOAuth) {}
42
43const layerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-oauth-test-" });
44const layerEnvironmentAuth = EnvironmentAuth.layer.pipe(
45 Layer.provide(ManagedPolicy.layerTest()),
46 Layer.provide(Sqlite.layerMemory),
47 Layer.provideMerge(ServerSecretStore.layer),
48 Layer.provideMerge(ServerEnvironment.layerIdentity),
49 Layer.provide(layerConfig),
50);
51const notLinked: EnvironmentCloudLinkStateResult = {
52 linked: false,
53 cloudUserId: null,
54 relayUrl: null,
55 relayIssuer: null,
56 publishAgentActivity: false,
57};
58const makeLayerRoutes = (
59 capture: (auth: EnvironmentAuth.EnvironmentAuth["Service"]) => void,
60 linkState: EnvironmentCloudLinkStateResult,
61) =>
62 Layer.mergeAll(
63 Layer.effectDiscard(
64 EnvironmentAuth.EnvironmentAuth.pipe(Effect.tap((auth) => Effect.sync(() => capture(auth)))),
65 ),
66 HttpApiBuilder.layer(AuthTestApi).pipe(
67 Layer.provide(AuthHttp.layer),
68 Layer.provide(
69 McpOAuthHttp.layer.pipe(
70 Layer.provide(McpOAuth.layer),
71 Layer.provide(ServerSettings.layerTest({ linkedEnvironmentsEnabled: true })),
72 Layer.provide(
73 Layer.mock(CloudLink.CloudLink)({ linkState: () => Effect.succeed(linkState) }),
74 ),
75 ),
76 ),
77 Layer.provide(AuthHttp.layerAuthenticatedAuth),
78 ),
79 ).pipe(
80 Layer.provideMerge(layerEnvironmentAuth),
81 Layer.provide(layerConfig),
82 Layer.provideMerge(
83 HttpPlatform.layer.pipe(
84 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
85 Layer.provideMerge(Etag.layerWeak),
86 ),
87 ),
88 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
89 );
90
91const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
92const ORIGIN = "https://box.example.ts.net";
93const REDIRECT = "http://localhost/callback";
94const verifier = "a".repeat(43) + "-verifier-for-tests";
95const challenge = "DeB41nTVkPwpbbYecrnqtVq7VXLezustdHAK4SWt13c";
96
97const at = (path: string, init?: RequestInit) =>
98 new Request(`http://127.0.0.1${path}`, {
99 ...init,
100 headers: { host: "box.example.ts.net", "x-forwarded-proto": "https", ...init?.headers },
101 });
102const form = (body: Record<string, string>) => ({
103 method: "POST",
104 headers: { "content-type": "application/x-www-form-urlencoded" },
105 body: new URLSearchParams(body).toString(),
106});
107
108type Handler = (request: Request) => Effect.Effect<Response>;
109
110const withRoutes = <A, E>(
111 use: (handler: Handler, auth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect<A, E>,
112 linkState = notLinked,
113) =>
114 Effect.gen(function* () {
115 const random = yield* SecureRandom.SecureRandom;
116 const context = Context.make(SecureRandom.SecureRandom, random);
117 let routerAuth: EnvironmentAuth.EnvironmentAuth["Service"] | undefined;
118 const layerRoutes = makeLayerRoutes((auth) => {
119 routerAuth = auth;
120 }, linkState);
121 return yield* Effect.acquireUseRelease(
122 Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })),
123 (web) =>
124 Effect.gen(function* () {
125 const handler: Handler = (request) => Effect.promise(() => web.handler(request, context));
126 yield* handler(at("/.well-known/oauth-authorization-server"));
127 return yield* use(handler, routerAuth!);
128 }),
129 (web) => Effect.promise(() => web.dispose()),
130 );
131 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));
132
133const json = <A>(response: Response) => Effect.promise(() => response.json() as Promise<A>);
134
135/** `credential` is a browser cookie, `Bearer <token>`, or the headers of a DPoP request. */
136const postJson = (path: string, body: unknown, credential?: string | Record<string, string>) =>
137 at(path, {
138 method: "POST",
139 headers: {
140 "content-type": "application/json",
141 ...(credential === undefined
142 ? {}
143 : typeof credential !== "string"
144 ? credential
145 : credential.startsWith("Bearer ")
146 ? { authorization: credential }
147 : { cookie: credential }),
148 },
149 body: encodeJson(body),
150 });
151
152const decide = (
153 handler: Handler,
154 authorization: Record<string, string>,
155 decision: Record<string, string>,
156 cookie?: string | Record<string, string>,
157) =>
158 handler(postJson("/oauth/mcp/decision", { authorization, decision }, cookie)).pipe(
159 Effect.flatMap((response) =>
160 json<{ redirectTo?: string; message?: string }>(response).pipe(
161 Effect.map((payload) => ({ status: response.status, ...payload })),
162 ),
163 ),
164 );
165
166const register = (handler: Handler, redirect = REDIRECT) =>
167 handler(
168 at("/oauth/mcp/register", {
169 method: "POST",
170 headers: { "content-type": "application/json" },
171 body: encodeJson({
172 client_name: "Claude Code",
173 redirect_uris: [redirect],
174 grant_types: ["authorization_code", "refresh_token"],
175 token_endpoint_auth_method: "none",
176 }),
177 }),
178 );
179
180const registeredClientId = (handler: Handler) =>
181 register(handler).pipe(
182 Effect.flatMap((response) => json<{ client_id: string }>(response)),
183 Effect.map((body) => body.client_id),
184 );
185
186const authorizeParams = (clientId: string, redirect = "http://localhost:51234/callback") => ({
187 response_type: "code",
188 client_id: clientId,
189 redirect_uri: redirect,
190 code_challenge: challenge,
191 code_challenge_method: "S256",
192 state: "state-1",
193 resource: `${ORIGIN}/mcp`,
194});
195
196it.effect("derives discovery metadata from the origin the client reached", () =>
197 withRoutes((handler) =>
198 Effect.gen(function* () {
199 const resource = yield* handler(at("/.well-known/oauth-protected-resource/mcp"));
200 expect(yield* json<unknown>(resource)).toMatchObject({
201 resource: `${ORIGIN}/mcp`,
202 authorization_servers: [ORIGIN],
203 });
204 const server = yield* json<unknown>(
205 yield* handler(at("/.well-known/oauth-authorization-server")),
206 );
207 expect(server).toMatchObject({
208 issuer: ORIGIN,
209 authorization_endpoint: `${ORIGIN}/oauth/mcp/authorize`,
210 token_endpoint: `${ORIGIN}/oauth/mcp/token`,
211 registration_endpoint: `${ORIGIN}/oauth/mcp/register`,
212 code_challenge_methods_supported: ["S256"],
213 });
214 }),
215 ),
216);
217
218it.effect("registers loopback and https clients and never redirects for an unverified client", () =>
219 withRoutes((handler) =>
220 Effect.gen(function* () {
221 for (const refused of [
222 "http://bot.example/callback",
223 "https://user:pass@bot.example/callback",
224 "https://bot.example/callback#",
225 "http://localhost/callback#",
226 "javascript:alert(1)",
227 ]) {
228 const response = yield* register(handler, refused);
229 expect(response.status).toBe(400);
230 expect(yield* json<unknown>(response)).toMatchObject({ error: "invalid_redirect_uri" });
231 }
232
233 const registered = yield* register(handler);
234 expect(registered.status).toBe(201);
235 const { client_id: clientId } = (yield* json<unknown>(registered)) as { client_id: string };
236
237 const forged = yield* handler(
238 at(
239 `/oauth/mcp/authorize?${new URLSearchParams(authorizeParams(`${clientId.split(".")[0]}.forged`))}`,
240 ),
241 );
242 expect(forged.status).toBe(400);
243 expect(forged.headers.get("location")).toBeNull();
244
245 const unregistered = yield* handler(
246 at(
247 `/oauth/mcp/authorize?${new URLSearchParams(
248 authorizeParams(clientId, "http://localhost:51234/elsewhere"),
249 )}`,
250 ),
251 );
252 expect(unregistered.status).toBe(400);
253 expect(unregistered.headers.get("location")).toBeNull();
254
255 const query = new URLSearchParams(authorizeParams(clientId)).toString();
256 const handoff = yield* handler(at(`/oauth/mcp/authorize?${query}`));
257 expect(handoff.status).toBe(302);
258 expect(handoff.headers.get("location")).toBe(`/connect-agent?${query}`);
259 const details = yield* handler(
260 postJson("/oauth/mcp/approval", authorizeParams(clientId)),
261 ).pipe(Effect.flatMap(json<Record<string, unknown>>));
262 expect(details).toEqual({
263 clientName: "Claude Code",
264 redirectHost: "localhost:51234",
265 environmentHost: "box.example.ts.net",
266 });
267
268 const forgedDetails = yield* handler(
269 postJson("/oauth/mcp/approval", authorizeParams(`${clientId.split(".")[0]}.forged`)),
270 );
271 expect(forgedDetails.status).toBe(400);
272 expect(yield* json<Record<string, unknown>>(forgedDetails)).not.toHaveProperty("redirectTo");
273 }),
274 ),
275);
276
277it.live("signs in with a pairing code and issues a token only /mcp accepts", () =>
278 withRoutes((handler, auth) =>
279 Effect.gen(function* () {
280 const clientId = yield* registeredClientId(handler);
281 const pairing = yield* auth.issuePairingCredential();
282 const params = authorizeParams(clientId);
283
284 const wrongCode = yield* decide(handler, params, {
285 _tag: "pairing-code",
286 access: "auto",
287 code: "nope",
288 });
289 expect(wrongCode.status).toBe(400);
290 expect(wrongCode.message).toContain("unknown, expired, or already used");
291
292 const approved = yield* decide(handler, params, {
293 _tag: "pairing-code",
294 access: "auto",
295 code: pairing.credential,
296 });
297 expect(approved.status).toBe(200);
298 const callback = new URL(approved.redirectTo!);
299 expect(callback.origin).toBe("http://localhost:51234");
300 expect(callback.searchParams.get("state")).toBe("state-1");
301 expect(callback.searchParams.get("iss")).toBe(ORIGIN);
302 const code = callback.searchParams.get("code")!;
303
304 const exchange = (codeVerifier: string) =>
305 handler(
306 at(
307 "/oauth/mcp/token",
308 form({
309 grant_type: "authorization_code",
310 code,
311 redirect_uri: params.redirect_uri,
312 client_id: clientId,
313 code_verifier: codeVerifier,
314 resource: `${ORIGIN}/mcp`,
315 }),
316 ),
317 );
318 const tokenResponse = yield* exchange(verifier);
319 expect(tokenResponse.status).toBe(200);
320 const token = (yield* json<unknown>(tokenResponse)) as {
321 access_token: string;
322 scope: string;
323 };
324 expect(token.scope).toBe("orchestration:read orchestration:operate");
325
326 expect((yield* exchange(verifier)).status).toBe(400);
327
328 for (const scheme of ["Bearer", "bearer", "bEaReR"]) {
329 const client = yield* auth.authenticateMcpClient(
330 HttpServerRequest.fromWeb(
331 at("/mcp", { headers: { authorization: `${scheme} ${token.access_token}` } }),
332 ),
333 );
334 expect(client).toMatchObject({ label: "Claude Code", access: "auto" });
335 }
336
337 const session = yield* handler(
338 at("/api/auth/session", { headers: { authorization: `Bearer ${token.access_token}` } }),
339 );
340 expect(yield* json<unknown>(session)).toMatchObject({ authenticated: false });
341 const ticket = yield* handler(
342 at("/api/auth/websocket-ticket", {
343 method: "POST",
344 headers: { authorization: `Bearer ${token.access_token}` },
345 }),
346 );
347 expect(ticket.status).toBe(401);
348 }),
349 ),
350);
351
352it.live("signs in a hosted agent with an https callback that asked for a client secret", () =>
353 withRoutes((handler, auth) =>
354 Effect.gen(function* () {
355 const callbackUrl = "https://bot.example/oauth/callback";
356 const registered = yield* handler(
357 at("/oauth/mcp/register", {
358 method: "POST",
359 headers: { "content-type": "application/json" },
360 body: encodeJson({
361 client_name: "Hosted bot",
362 redirect_uris: [callbackUrl],
363 token_endpoint_auth_method: "client_secret_post",
364 }),
365 }),
366 );
367 expect(registered.status).toBe(201);
368 const client = (yield* json<unknown>(registered)) as {
369 client_id: string;
370 token_endpoint_auth_method: string;
371 };
372 expect(client.token_endpoint_auth_method).toBe("none");
373
374 const params = authorizeParams(client.client_id, callbackUrl);
375 const details = yield* handler(postJson("/oauth/mcp/approval", params)).pipe(
376 Effect.flatMap(json<Record<string, unknown>>),
377 );
378 expect(details).toMatchObject({ clientName: "Hosted bot", redirectHost: "bot.example" });
379
380 const pairing = yield* auth.issuePairingCredential();
381 const approved = yield* decide(handler, params, {
382 _tag: "pairing-code",
383 access: "approval-required",
384 code: pairing.credential,
385 });
386 const callback = new URL(approved.redirectTo!);
387 expect(`${callback.origin}${callback.pathname}`).toBe(callbackUrl);
388
389 const tokenResponse = yield* handler(
390 at(
391 "/oauth/mcp/token",
392 form({
393 grant_type: "authorization_code",
394 code: callback.searchParams.get("code")!,
395 redirect_uri: callbackUrl,
396 client_id: client.client_id,
397 client_secret: "unused",
398 code_verifier: verifier,
399 }),
400 ),
401 );
402 expect(tokenResponse.status).toBe(200);
403 const token = (yield* json<unknown>(tokenResponse)) as { access_token: string };
404 const signedIn = yield* auth.authenticateMcpClient(
405 HttpServerRequest.fromWeb(
406 at("/mcp", { headers: { authorization: `Bearer ${token.access_token}` } }),
407 ),
408 );
409 expect(signedIn).toMatchObject({ label: "Hosted bot", access: "approval-required" });
410 }),
411 ),
412);
413
414it.live("rejects a wrong PKCE verifier and spends the code", () =>
415 withRoutes((handler, auth) =>
416 Effect.gen(function* () {
417 const clientId = yield* registeredClientId(handler);
418 const pairing = yield* auth.issuePairingCredential();
419 const params = authorizeParams(clientId);
420 const approved = yield* decide(handler, params, {
421 _tag: "pairing-code",
422 access: "approval-required",
423 code: pairing.credential,
424 });
425 const code = new URL(approved.redirectTo!).searchParams.get("code")!;
426 const exchange = (codeVerifier: string) =>
427 handler(
428 at(
429 "/oauth/mcp/token",
430 form({
431 grant_type: "authorization_code",
432 code,
433 redirect_uri: params.redirect_uri,
434 client_id: clientId,
435 code_verifier: codeVerifier,
436 }),
437 ),
438 );
439 const wrong = yield* exchange("b".repeat(64));
440 expect(wrong.status).toBe(400);
441 expect(yield* json<unknown>(wrong)).toMatchObject({ error: "invalid_grant" });
442 expect((yield* exchange(verifier)).status).toBe(400);
443 }),
444 ),
445);
446
447it.live(
448 "denies and refuses codes bound to another client's key or without the scopes it grants",
449 () =>
450 withRoutes((handler, auth) =>
451 Effect.gen(function* () {
452 const clientId = yield* registeredClientId(handler);
453 const params = authorizeParams(clientId);
454
455 const denied = yield* decide(handler, params, { _tag: "deny" });
456 const deniedUrl = new URL(denied.redirectTo!);
457 expect(deniedUrl.searchParams.get("error")).toBe("access_denied");
458 expect(deniedUrl.searchParams.get("state")).toBe("state-1");
459
460 const approveWith = (code: string) =>
461 decide(handler, params, { _tag: "pairing-code", access: "auto", code });
462
463 const bound = yield* auth.createPairingLink({
464 proofKeyThumbprint: "device-key-thumbprint",
465 });
466 expect((yield* approveWith(bound.credential)).status).toBe(400);
467 const stillValid = yield* auth
468 .exchangeBootstrapCredentialForAccessToken(
469 bound.credential,
470 undefined,
471 { deviceType: "mobile" },
472 { proofKeyThumbprint: "device-key-thumbprint" },
473 )
474 .pipe(
475 Effect.as(true),
476 Effect.orElseSucceed(() => false),
477 );
478 expect(stillValid).toBe(true);
479
480 const readOnly = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] });
481 const readOnlyResponse = yield* approveWith(readOnly.credential);
482 expect(readOnlyResponse.status).toBe(400);
483 expect(readOnlyResponse.message).toContain("cannot grant this access");
484 const operator = yield* auth.issuePairingCredential({
485 scopes: ["orchestration:read", "orchestration:operate"],
486 });
487 const operatorFull = yield* decide(handler, params, {
488 _tag: "pairing-code",
489 access: "full-access",
490 code: operator.credential,
491 });
492 expect(operatorFull.status).toBe(400);
493
494 const readOnlyApproval = yield* decide(handler, params, {
495 _tag: "pairing-code",
496 access: "read-only",
497 code: readOnly.credential,
498 });
499 expect(readOnlyApproval.status).toBe(200);
500 const readOnlyToken = yield* handler(
501 at(
502 "/oauth/mcp/token",
503 form({
504 grant_type: "authorization_code",
505 code: new URL(readOnlyApproval.redirectTo!).searchParams.get("code")!,
506 redirect_uri: params.redirect_uri,
507 client_id: clientId,
508 code_verifier: verifier,
509 resource: `${ORIGIN}/mcp`,
510 }),
511 ),
512 ).pipe(Effect.flatMap(json<{ access_token: string; scope: string }>));
513 expect(readOnlyToken.scope).toBe("orchestration:read");
514 const readOnlyClient = yield* auth.authenticateMcpClient(
515 HttpServerRequest.fromWeb(
516 at("/mcp", { headers: { authorization: `Bearer ${readOnlyToken.access_token}` } }),
517 ),
518 );
519 expect(readOnlyClient.access).toBe("read-only");
520 }),
521 ),
522);
523
524it.live("a code minted with an access level's scopes approves that access", () =>
525 withRoutes((handler, auth) =>
526 Effect.gen(function* () {
527 const params = authorizeParams(yield* registeredClientId(handler));
528 for (const access of AuthMcpClientAccess.literals) {
529 const code = yield* auth.issuePairingCredential({ scopes: [...mcpClientScopes(access)] });
530 const approval = yield* decide(handler, params, {
531 _tag: "pairing-code",
532 access,
533 code: code.credential,
534 });
535 expect(approval.status, access).toBe(200);
536 }
537 }),
538 ),
539);
540
541it.live("an app approval code approves only an app sign-in, at its own level", () =>
542 withRoutes((handler, auth) =>
543 Effect.gen(function* () {
544 const clientId = yield* registeredClientId(handler);
545 const params = authorizeParams(clientId);
546 const issued = yield* auth.issuePairingCredential({ access: "approval-required" });
547 expect(issued.access).toBe("approval-required");
548 const code = issued.credential;
549 expect(code).toMatch(/^SUPERVISED-[2-9A-HJ-NP-Z]{12}$/);
550
551 // Signing in a browser, a token exchange and linking all refuse it and leave it unspent.
552 const browser = yield* handler(
553 at("/api/auth/browser-session", {
554 method: "POST",
555 headers: { "content-type": "application/json" },
556 body: encodeJson({ credential: code }),
557 }),
558 );
559 expect(browser.status).toBe(401);
560 const exchanged = yield* auth
561 .exchangeBootstrapCredentialForAccessToken(code, undefined, { deviceType: "bot" })
562 .pipe(Effect.flip);
563 expect(exchanged._tag).toBe("ServerAuthInvalidCredentialError");
564 const peer = yield* handler(
565 at("/oauth/mcp/register", {
566 method: "POST",
567 headers: { "content-type": "application/json" },
568 body: encodeJson({
569 client_name: "Studio",
570 software_id: MCP_PEER_LINK_SOFTWARE_ID,
571 redirect_uris: [REDIRECT],
572 }),
573 }),
574 ).pipe(Effect.flatMap(json<{ client_id: string }>));
575 const linked = yield* decide(handler, authorizeParams(peer.client_id), {
576 _tag: "pairing-code",
577 access: "approval-required",
578 code,
579 });
580 expect(linked.status).toBe(400);
581
582 // Supervised and Auto share scopes, so only the code's own level may spend it.
583 const otherLevel = yield* decide(handler, params, {
584 _tag: "pairing-code",
585 access: "auto",
586 code,
587 });
588 expect(otherLevel.status).toBe(400);
589
590 const approved = yield* decide(handler, params, {
591 _tag: "pairing-code",
592 access: "approval-required",
593 code: code.toLowerCase(),
594 });
595 expect(approved.status).toBe(200);
596 const token = yield* handler(
597 at(
598 "/oauth/mcp/token",
599 form({
600 grant_type: "authorization_code",
601 code: new URL(approved.redirectTo!).searchParams.get("code")!,
602 redirect_uri: params.redirect_uri,
603 client_id: clientId,
604 code_verifier: verifier,
605 }),
606 ),
607 ).pipe(Effect.flatMap(json<{ access_token: string }>));
608 const signedIn = yield* auth.authenticateMcpClient(
609 HttpServerRequest.fromWeb(
610 at("/mcp", { headers: { authorization: `Bearer ${token.access_token}` } }),
611 ),
612 );
613 expect(signedIn.access).toBe("approval-required");
614 expect(
615 (yield* decide(handler, params, {
616 _tag: "pairing-code",
617 access: "approval-required",
618 code,
619 })).status,
620 ).toBe(400);
621 }),
622 ),
623);
624
625it.effect(
626 "sends an app sign-in on the linked tunnel to the hosted app, and a link to its own page",
627 () =>
628 Effect.gen(function* () {
629 const handoff = (tunnelHostname: string, peerLink = false) =>
630 withRoutes(
631 (handler) =>
632 Effect.gen(function* () {
633 const registered = yield* handler(
634 at("/oauth/mcp/register", {
635 method: "POST",
636 headers: { "content-type": "application/json" },
637 body: encodeJson({
638 client_name: "Agent",
639 redirect_uris: [REDIRECT],
640 token_endpoint_auth_method: "none",
641 ...(peerLink ? { software_id: MCP_PEER_LINK_SOFTWARE_ID } : {}),
642 }),
643 }),
644 ).pipe(Effect.flatMap(json<{ client_id: string }>));
645 const query = new URLSearchParams(
646 authorizeParams(registered.client_id, "http://localhost:51234/callback"),
647 );
648 const response = yield* handler(at(`/oauth/mcp/authorize?${query}`));
649 return { location: response.headers.get("location")!, query: query.toString() };
650 }),
651 { ...notLinked, linked: true, cloudUserId: "user_1", tunnelHostname },
652 );
653
654 const onTunnel = yield* handoff("box.example.ts.net");
655 const hosted = new URL(onTunnel.location);
656 expect(`${hosted.origin}${hosted.pathname}`).toBe("https://app.t3.codes/approve-agent");
657 expect(hosted.searchParams.get("environment")).toEqual(expect.any(String));
658 hosted.searchParams.delete("environment");
659 expect(hosted.searchParams.toString()).toBe(onTunnel.query);
660
661 const elsewhere = yield* handoff("other.t3coderelay.com");
662 expect(elsewhere.location).toBe(`/connect-agent?${elsewhere.query}`);
663 const peerLink = yield* handoff("box.example.ts.net", true);
664 expect(peerLink.location).toBe(`/connect-agent?${peerLink.query}`);
665 }),
666);
667
668it.live("one-click approves only access the browser session holds the scopes for", () =>
669 withRoutes((handler, auth) =>
670 Effect.gen(function* () {
671 const clientId = yield* registeredClientId(handler);
672 const params = authorizeParams(clientId);
673 const browserCookie = (scopes: NonNullable<AuthCreatePairingCredentialInput["scopes"]>) =>
674 Effect.gen(function* () {
675 const pairing = yield* auth.issuePairingCredential({ scopes });
676 const response = yield* handler(
677 at("/api/auth/browser-session", {
678 method: "POST",
679 headers: { "content-type": "application/json" },
680 body: encodeJson({ credential: pairing.credential }),
681 }),
682 );
683 return response.headers.getSetCookie()[0]!.split(";", 1)[0]!;
684 });
685 const details = (cookie: string) =>
686 handler(postJson("/oauth/mcp/approval", params, cookie)).pipe(
687 Effect.flatMap(json<{ csrfToken?: string; oneClickAccess?: ReadonlyArray<string> }>),
688 );
689
690 const admin = yield* browserCookie([...AuthStandardClientScopes]);
691 const adminDetails = yield* details(admin);
692 expect(adminDetails.csrfToken).toEqual(expect.any(String));
693 expect(adminDetails.oneClickAccess).toEqual([
694 "read-only",
695 "approval-required",
696 "auto-accept-edits",
697 "auto",
698 "full-access",
699 ]);
700 const oneClick = yield* decide(
701 handler,
702 params,
703 { _tag: "browser-session", access: "auto", csrfToken: adminDetails.csrfToken! },
704 admin,
705 );
706 expect(new URL(oneClick.redirectTo!).searchParams.get("code")).toEqual(expect.any(String));
707
708 const accessOnly = yield* browserCookie(["access:read", "access:write"]);
709 expect((yield* details(accessOnly)).csrfToken).toBeUndefined();
710 const forged = yield* decide(
711 handler,
712 params,
713 { _tag: "browser-session", access: "auto", csrfToken: "forged" },
714 accessOnly,
715 );
716 expect(forged.status).toBe(400);
717 expect(forged.message).toContain("Enter a pairing code instead");
718
719 const reader = yield* browserCookie(["access:write", "orchestration:read"]);
720 const readerDetails = yield* details(reader);
721 expect(readerDetails.csrfToken).toEqual(expect.any(String));
722 expect(readerDetails.oneClickAccess).toEqual(["read-only"]);
723 const tooBroad = yield* decide(
724 handler,
725 params,
726 { _tag: "browser-session", access: "auto", csrfToken: readerDetails.csrfToken! },
727 reader,
728 );
729 expect(tooBroad.status).toBe(400);
730 const readOnly = yield* decide(
731 handler,
732 params,
733 { _tag: "browser-session", access: "read-only", csrfToken: readerDetails.csrfToken! },
734 reader,
735 );
736 expect(new URL(readOnly.redirectTo!).searchParams.get("code")).toEqual(expect.any(String));
737
738 // The hosted app approves with the owner's T3 Connect session, which sends no cookie.
739 const connect = `Bearer ${(yield* auth.issueSession({ scopes: [...AuthStandardClientScopes] })).token}`;
740 const connectDetails = yield* details(connect);
741 expect(connectDetails.oneClickAccess).toContain("full-access");
742 const viaConnect = yield* decide(
743 handler,
744 params,
745 { _tag: "browser-session", access: "auto", csrfToken: connectDetails.csrfToken! },
746 connect,
747 );
748 expect(new URL(viaConnect.redirectTo!).searchParams.get("code")).toEqual(expect.any(String));
749
750 // The hosted app may load and decide with two credentials minted for the same browser key.
751 const key = makeDpopKey();
752 const dpopSession = (thumbprint: string) =>
753 auth.issuePairingCredential({ scopes: [...AuthStandardClientScopes] }).pipe(
754 Effect.flatMap((pairing) =>
755 auth.exchangeBootstrapCredentialForAccessToken(
756 pairing.credential,
757 undefined,
758 { deviceType: "desktop" },
759 { proofKeyThumbprint: thumbprint },
760 ),
761 ),
762 Effect.map((exchanged) => exchanged.access_token),
763 );
764 const iat = Math.floor((yield* Clock.currentTimeMillis) / 1_000);
765 const dpopHeaders = (signer: typeof key, token: string, path: string) => ({
766 authorization: `DPoP ${token}`,
767 dpop: signer.sign({ method: "POST", url: `${ORIGIN}${path}`, token, iat }),
768 });
769 const loadedWith = yield* dpopSession(key.thumbprint);
770 const loaded = yield* handler(
771 postJson(
772 "/oauth/mcp/approval",
773 params,
774 dpopHeaders(key, loadedWith, "/oauth/mcp/approval"),
775 ),
776 ).pipe(Effect.flatMap(json<{ csrfToken?: string }>));
777 const renewed = yield* dpopSession(key.thumbprint);
778 const sameKey = yield* decide(
779 handler,
780 params,
781 { _tag: "browser-session", access: "auto", csrfToken: loaded.csrfToken! },
782 dpopHeaders(key, renewed, "/oauth/mcp/decision"),
783 );
784 expect(new URL(sameKey.redirectTo!).searchParams.get("code")).toEqual(expect.any(String));
785 const otherKey = makeDpopKey();
786 const elsewhere = yield* decide(
787 handler,
788 params,
789 { _tag: "browser-session", access: "auto", csrfToken: loaded.csrfToken! },
790 dpopHeaders(otherKey, yield* dpopSession(otherKey.thumbprint), "/oauth/mcp/decision"),
791 );
792 expect(elsewhere.status).toBe(400);
793 }),
794 ),
795);
796
797it("matches loopback redirects on everything but the port, and https redirects exactly", () => {
798 const matches = McpOAuth.redirectMatches;
799 expect(matches("http://localhost/callback", "http://localhost:9/callback")).toBe(true);
800 expect(matches("http://127.0.0.1:1/callback", "http://127.0.0.1:2/callback")).toBe(true);
801 expect(matches("http://localhost/callback", "http://127.0.0.1/callback")).toBe(false);
802 expect(matches("http://localhost/callback", "https://localhost/callback")).toBe(false);
803 expect(matches("https://bot.example/cb", "https://bot.example/cb")).toBe(true);
804 expect(matches("https://bot.example/cb", "https://bot.example:8443/cb")).toBe(false);
805 expect(matches("https://bot.example/cb", "https://bot.example/cb?x=1")).toBe(false);
806 expect(matches("https://bot.example/cb", "https://evil.example/cb")).toBe(false);
807});