1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {
3 AuthAdministrativeScopes,
4 type AuthCreatePairingCredentialInput,
5 EnvironmentHttpApi,
6} from "@t3tools/contracts";
7import { expect, it } from "@effect/vitest";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Etag from "effect/http/Etag";
13import * as HttpPlatform from "effect/http/HttpPlatform";
14import * as HttpRouter from "effect/http/HttpRouter";
15import * as HttpServerRequest from "effect/http/HttpServerRequest";
16import * as HttpApi from "effect/http-api/HttpApi";
17import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
18import * as Schema from "effect/Schema";
19
20import * as ServerConfig from "../config.ts";
21import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
22import * as Sqlite from "../persistence/Sqlite.ts";
23import * as EnvironmentAuth from "./EnvironmentAuth.ts";
24import * as McpOAuth from "./McpOAuth.ts";
25import * as McpOAuthHttp from "./mcpOAuthHttp.ts";
26import * as ServerSecretStore from "./ServerSecretStore.ts";
27import * as AuthHttp from "./http.ts";
28
29class AuthTestApi extends HttpApi.make("environment")
30 .add(EnvironmentHttpApi.groups.auth)
31 .add(EnvironmentHttpApi.groups.mcpOAuth) {}
32
33const layerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-oauth-test-" });
34const layerEnvironmentAuth = EnvironmentAuth.layer.pipe(
35 Layer.provide(Sqlite.layerMemory),
36 Layer.provideMerge(ServerSecretStore.layer),
37 Layer.provideMerge(ServerEnvironment.layerIdentity),
38 Layer.provide(layerConfig),
39);
40// Each router gets its own database; the capture hands that router's EnvironmentAuth to its test.
41const makeLayerRoutes = (capture: (auth: EnvironmentAuth.EnvironmentAuth["Service"]) => void) =>
42 Layer.mergeAll(
43 Layer.effectDiscard(
44 EnvironmentAuth.EnvironmentAuth.pipe(Effect.tap((auth) => Effect.sync(() => capture(auth)))),
45 ),
46 HttpApiBuilder.layer(AuthTestApi).pipe(
47 Layer.provide(AuthHttp.layer),
48 Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))),
49 Layer.provide(AuthHttp.layerAuthenticatedAuth),
50 ),
51 ).pipe(
52 Layer.provideMerge(layerEnvironmentAuth),
53 Layer.provide(layerConfig),
54 Layer.provideMerge(
55 HttpPlatform.layer.pipe(
56 Layer.provideMerge(NodeServices.layer),
57 Layer.provideMerge(Etag.layerWeak),
58 ),
59 ),
60 Layer.provide(NodeServices.layer),
61 );
62
63const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
64const ORIGIN = "https://box.example.ts.net";
65const REDIRECT = "http://localhost/callback";
66const verifier = "a".repeat(43) + "-verifier-for-tests";
67/** base64url(SHA-256(verifier)), the S256 challenge for `verifier`. */
68const challenge = "DeB41nTVkPwpbbYecrnqtVq7VXLezustdHAK4SWt13c";
69
70/** Requests as they arrive behind an https proxy: plain http with the public Host. */
71const at = (path: string, init?: RequestInit) =>
72 new Request(`http://127.0.0.1${path}`, {
73 ...init,
74 headers: { host: "box.example.ts.net", "x-forwarded-proto": "https", ...init?.headers },
75 });
76const form = (body: Record<string, string>) => ({
77 method: "POST",
78 headers: { "content-type": "application/x-www-form-urlencoded" },
79 body: new URLSearchParams(body).toString(),
80});
81
82type Handler = (request: Request) => Effect.Effect<Response>;
83
84const withRoutes = <A, E>(
85 use: (handler: Handler, auth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect<A, E>,
86) =>
87 Effect.gen(function* () {
88 const crypto = yield* Crypto.Crypto;
89 const context = Context.make(Crypto.Crypto, crypto);
90 let routerAuth: EnvironmentAuth.EnvironmentAuth["Service"] | undefined;
91 const layerRoutes = makeLayerRoutes((auth) => {
92 routerAuth = auth;
93 });
94 return yield* Effect.acquireUseRelease(
95 Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })),
96 (web) =>
97 Effect.gen(function* () {
98 const handler: Handler = (request) => Effect.promise(() => web.handler(request, context));
99 // The router builds its layer on first request; a metadata read warms it.
100 yield* handler(at("/.well-known/oauth-authorization-server"));
101 return yield* use(handler, routerAuth!);
102 }),
103 (web) => Effect.promise(() => web.dispose()),
104 );
105 }).pipe(Effect.provide(NodeServices.layer));
106
107const json = <A>(response: Response) => Effect.promise(() => response.json() as Promise<A>);
108
109const postJson = (path: string, body: unknown, cookie?: string) =>
110 at(path, {
111 method: "POST",
112 headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}) },
113 body: encodeJson(body),
114 });
115
116/** What the approval page posts: the agent's request plus the user's choice. */
117const decide = (
118 handler: Handler,
119 authorization: Record<string, string>,
120 decision: Record<string, string>,
121 cookie?: string,
122) =>
123 handler(postJson("/oauth/mcp/decision", { authorization, decision }, cookie)).pipe(
124 Effect.flatMap((response) =>
125 json<{ redirectTo?: string; message?: string }>(response).pipe(
126 Effect.map((payload) => ({ status: response.status, ...payload })),
127 ),
128 ),
129 );
130
131const register = (handler: Handler, redirect = REDIRECT) =>
132 handler(
133 at("/oauth/mcp/register", {
134 method: "POST",
135 headers: { "content-type": "application/json" },
136 body: encodeJson({
137 client_name: "Claude Code",
138 redirect_uris: [redirect],
139 grant_types: ["authorization_code", "refresh_token"],
140 token_endpoint_auth_method: "none",
141 }),
142 }),
143 );
144
145const registeredClientId = (handler: Handler) =>
146 register(handler).pipe(
147 Effect.flatMap((response) => json<{ client_id: string }>(response)),
148 Effect.map((body) => body.client_id),
149 );
150
151const authorizeParams = (clientId: string, redirect = "http://localhost:51234/callback") => ({
152 response_type: "code",
153 client_id: clientId,
154 redirect_uri: redirect,
155 code_challenge: challenge,
156 code_challenge_method: "S256",
157 state: "state-1",
158 resource: `${ORIGIN}/mcp`,
159});
160
161it.live("derives discovery metadata from the origin the client reached", () =>
162 withRoutes((handler) =>
163 Effect.gen(function* () {
164 const resource = yield* handler(at("/.well-known/oauth-protected-resource/mcp"));
165 expect(yield* json<unknown>(resource)).toMatchObject({
166 resource: `${ORIGIN}/mcp`,
167 authorization_servers: [ORIGIN],
168 });
169 const server = yield* json<unknown>(
170 yield* handler(at("/.well-known/oauth-authorization-server")),
171 );
172 expect(server).toMatchObject({
173 issuer: ORIGIN,
174 authorization_endpoint: `${ORIGIN}/oauth/mcp/authorize`,
175 token_endpoint: `${ORIGIN}/oauth/mcp/token`,
176 registration_endpoint: `${ORIGIN}/oauth/mcp/register`,
177 code_challenge_methods_supported: ["S256"],
178 });
179 }),
180 ),
181);
182
183it.live("registers loopback and https clients and never redirects for an unverified client", () =>
184 withRoutes((handler) =>
185 Effect.gen(function* () {
186 for (const refused of [
187 "http://bot.example/callback",
188 "https://user:pass@bot.example/callback",
189 "https://bot.example/callback#",
190 "http://localhost/callback#",
191 "javascript:alert(1)",
192 ]) {
193 const response = yield* register(handler, refused);
194 expect(response.status).toBe(400);
195 expect(yield* json<unknown>(response)).toMatchObject({ error: "invalid_redirect_uri" });
196 }
197
198 const registered = yield* register(handler);
199 expect(registered.status).toBe(201);
200 const { client_id: clientId } = (yield* json<unknown>(registered)) as { client_id: string };
201
202 const forged = yield* handler(
203 at(
204 `/oauth/mcp/authorize?${new URLSearchParams(authorizeParams(`${clientId.split(".")[0]}.forged`))}`,
205 ),
206 );
207 expect(forged.status).toBe(400);
208 expect(forged.headers.get("location")).toBeNull();
209
210 const unregistered = yield* handler(
211 at(
212 `/oauth/mcp/authorize?${new URLSearchParams(
213 authorizeParams(clientId, "http://localhost:51234/elsewhere"),
214 )}`,
215 ),
216 );
217 expect(unregistered.status).toBe(400);
218 expect(unregistered.headers.get("location")).toBeNull();
219
220 // A valid request is handed to the web app's approval page, query intact.
221 const query = new URLSearchParams(authorizeParams(clientId)).toString();
222 const handoff = yield* handler(at(`/oauth/mcp/authorize?${query}`));
223 expect(handoff.status).toBe(302);
224 expect(handoff.headers.get("location")).toBe(`/connect-agent?${query}`);
225 const details = yield* handler(
226 postJson("/oauth/mcp/approval", authorizeParams(clientId)),
227 ).pipe(Effect.flatMap(json<Record<string, unknown>>));
228 expect(details).toEqual({
229 clientName: "Claude Code",
230 redirectHost: "localhost:51234",
231 environmentHost: "box.example.ts.net",
232 });
233
234 // The approval endpoints re-check the request: a forged client gets a message, not a URL.
235 const forgedDetails = yield* handler(
236 postJson("/oauth/mcp/approval", authorizeParams(`${clientId.split(".")[0]}.forged`)),
237 );
238 expect(forgedDetails.status).toBe(400);
239 expect(yield* json<Record<string, unknown>>(forgedDetails)).not.toHaveProperty("redirectTo");
240 }),
241 ),
242);
243
244it.live("signs in with a pairing code and issues a token only /mcp accepts", () =>
245 withRoutes((handler, auth) =>
246 Effect.gen(function* () {
247 const clientId = yield* registeredClientId(handler);
248 const pairing = yield* auth.issuePairingCredential();
249 const params = authorizeParams(clientId);
250
251 const wrongCode = yield* decide(handler, params, {
252 _tag: "pairing-code",
253 access: "auto",
254 code: "nope",
255 });
256 expect(wrongCode.status).toBe(400);
257 expect(wrongCode.message).toContain("unknown, expired, or already used");
258
259 const approved = yield* decide(handler, params, {
260 _tag: "pairing-code",
261 access: "auto",
262 code: pairing.credential,
263 });
264 expect(approved.status).toBe(200);
265 const callback = new URL(approved.redirectTo!);
266 expect(callback.origin).toBe("http://localhost:51234");
267 expect(callback.searchParams.get("state")).toBe("state-1");
268 expect(callback.searchParams.get("iss")).toBe(ORIGIN);
269 const code = callback.searchParams.get("code")!;
270
271 const exchange = (codeVerifier: string) =>
272 handler(
273 at(
274 "/oauth/mcp/token",
275 form({
276 grant_type: "authorization_code",
277 code,
278 redirect_uri: params.redirect_uri,
279 client_id: clientId,
280 code_verifier: codeVerifier,
281 resource: `${ORIGIN}/mcp`,
282 }),
283 ),
284 );
285 const tokenResponse = yield* exchange(verifier);
286 expect(tokenResponse.status).toBe(200);
287 const token = (yield* json<unknown>(tokenResponse)) as {
288 access_token: string;
289 scope: string;
290 };
291 expect(token.scope).toBe("orchestration:read orchestration:operate");
292
293 // Codes are single use.
294 expect((yield* exchange(verifier)).status).toBe(400);
295
296 const client = yield* auth.authenticateMcpClient(
297 HttpServerRequest.fromWeb(
298 at("/mcp", { headers: { authorization: `Bearer ${token.access_token}` } }),
299 ),
300 );
301 expect(client).toMatchObject({ label: "Claude Code", access: "auto" });
302
303 // The same token is refused by the rest of the environment.
304 const session = yield* handler(
305 at("/api/auth/session", { headers: { authorization: `Bearer ${token.access_token}` } }),
306 );
307 expect(yield* json<unknown>(session)).toMatchObject({ authenticated: false });
308 const ticket = yield* handler(
309 at("/api/auth/websocket-ticket", {
310 method: "POST",
311 headers: { authorization: `Bearer ${token.access_token}` },
312 }),
313 );
314 expect(ticket.status).toBe(401);
315 }),
316 ),
317);
318
319it.live("signs in a hosted agent with an https callback that asked for a client secret", () =>
320 withRoutes((handler, auth) =>
321 Effect.gen(function* () {
322 const callbackUrl = "https://bot.example/oauth/callback";
323 const registered = yield* handler(
324 at("/oauth/mcp/register", {
325 method: "POST",
326 headers: { "content-type": "application/json" },
327 body: encodeJson({
328 client_name: "Hosted bot",
329 redirect_uris: [callbackUrl],
330 token_endpoint_auth_method: "client_secret_post",
331 }),
332 }),
333 );
334 expect(registered.status).toBe(201);
335 const client = (yield* json<unknown>(registered)) as {
336 client_id: string;
337 token_endpoint_auth_method: string;
338 };
339 // Registered as a public client: PKCE, not a secret, proves it at the token endpoint.
340 expect(client.token_endpoint_auth_method).toBe("none");
341
342 const params = authorizeParams(client.client_id, callbackUrl);
343 const details = yield* handler(postJson("/oauth/mcp/approval", params)).pipe(
344 Effect.flatMap(json<Record<string, unknown>>),
345 );
346 expect(details).toMatchObject({ clientName: "Hosted bot", redirectHost: "bot.example" });
347
348 const pairing = yield* auth.issuePairingCredential();
349 const approved = yield* decide(handler, params, {
350 _tag: "pairing-code",
351 access: "approval-required",
352 code: pairing.credential,
353 });
354 const callback = new URL(approved.redirectTo!);
355 expect(`${callback.origin}${callback.pathname}`).toBe(callbackUrl);
356
357 const tokenResponse = yield* handler(
358 at(
359 "/oauth/mcp/token",
360 form({
361 grant_type: "authorization_code",
362 code: callback.searchParams.get("code")!,
363 redirect_uri: callbackUrl,
364 client_id: client.client_id,
365 // Sent by a client that asked for one; there is none to check.
366 client_secret: "unused",
367 code_verifier: verifier,
368 }),
369 ),
370 );
371 expect(tokenResponse.status).toBe(200);
372 const token = (yield* json<unknown>(tokenResponse)) as { access_token: string };
373 const signedIn = yield* auth.authenticateMcpClient(
374 HttpServerRequest.fromWeb(
375 at("/mcp", { headers: { authorization: `Bearer ${token.access_token}` } }),
376 ),
377 );
378 expect(signedIn).toMatchObject({ label: "Hosted bot", access: "approval-required" });
379 }),
380 ),
381);
382
383it.live("rejects a wrong PKCE verifier and spends the code", () =>
384 withRoutes((handler, auth) =>
385 Effect.gen(function* () {
386 const clientId = yield* registeredClientId(handler);
387 const pairing = yield* auth.issuePairingCredential();
388 const params = authorizeParams(clientId);
389 const approved = yield* decide(handler, params, {
390 _tag: "pairing-code",
391 access: "approval-required",
392 code: pairing.credential,
393 });
394 const code = new URL(approved.redirectTo!).searchParams.get("code")!;
395 const exchange = (codeVerifier: string) =>
396 handler(
397 at(
398 "/oauth/mcp/token",
399 form({
400 grant_type: "authorization_code",
401 code,
402 redirect_uri: params.redirect_uri,
403 client_id: clientId,
404 code_verifier: codeVerifier,
405 }),
406 ),
407 );
408 const wrong = yield* exchange("b".repeat(64));
409 expect(wrong.status).toBe(400);
410 expect(yield* json<unknown>(wrong)).toMatchObject({ error: "invalid_grant" });
411 expect((yield* exchange(verifier)).status).toBe(400);
412 }),
413 ),
414);
415
416it.live(
417 "denies and refuses codes bound to another client's key or without the scopes it grants",
418 () =>
419 withRoutes((handler, auth) =>
420 Effect.gen(function* () {
421 const clientId = yield* registeredClientId(handler);
422 const params = authorizeParams(clientId);
423
424 const denied = yield* decide(handler, params, { _tag: "deny" });
425 const deniedUrl = new URL(denied.redirectTo!);
426 expect(deniedUrl.searchParams.get("error")).toBe("access_denied");
427 expect(deniedUrl.searchParams.get("state")).toBe("state-1");
428
429 const approveWith = (code: string) =>
430 decide(handler, params, { _tag: "pairing-code", access: "auto", code });
431
432 // A T3 Connect code is bound to a device key: refused, and still usable by its device.
433 const bound = yield* auth.createPairingLink({
434 proofKeyThumbprint: "device-key-thumbprint",
435 });
436 expect((yield* approveWith(bound.credential)).status).toBe(400);
437 const stillValid = yield* auth
438 .exchangeBootstrapCredentialForAccessToken(
439 bound.credential,
440 undefined,
441 { deviceType: "mobile" },
442 { proofKeyThumbprint: "device-key-thumbprint" },
443 )
444 .pipe(
445 Effect.as(true),
446 Effect.orElseSucceed(() => false),
447 );
448 expect(stillValid).toBe(true);
449
450 const readOnly = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] });
451 const readOnlyResponse = yield* approveWith(readOnly.credential);
452 expect(readOnlyResponse.status).toBe(400);
453 expect(readOnlyResponse.message).toContain("cannot grant this access");
454 const operator = yield* auth.issuePairingCredential({
455 scopes: ["orchestration:read", "orchestration:operate"],
456 });
457 const operatorFull = yield* decide(handler, params, {
458 _tag: "pairing-code",
459 access: "full-access",
460 code: operator.credential,
461 });
462 expect(operatorFull.status).toBe(400);
463
464 // A read-only code can approve read-only access.
465 const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] });
466 const readOnlyApproval = yield* decide(handler, params, {
467 _tag: "pairing-code",
468 access: "read-only",
469 code: readOnlyCode.credential,
470 });
471 expect(readOnlyApproval.status).toBe(200);
472 const readOnlyToken = yield* handler(
473 at(
474 "/oauth/mcp/token",
475 form({
476 grant_type: "authorization_code",
477 code: new URL(readOnlyApproval.redirectTo!).searchParams.get("code")!,
478 redirect_uri: params.redirect_uri,
479 client_id: clientId,
480 code_verifier: verifier,
481 resource: `${ORIGIN}/mcp`,
482 }),
483 ),
484 ).pipe(Effect.flatMap(json<{ access_token: string; scope: string }>));
485 expect(readOnlyToken.scope).toBe("orchestration:read");
486 const readOnlyClient = yield* auth.authenticateMcpClient(
487 HttpServerRequest.fromWeb(
488 at("/mcp", { headers: { authorization: `Bearer ${readOnlyToken.access_token}` } }),
489 ),
490 );
491 expect(readOnlyClient.access).toBe("read-only");
492 }),
493 ),
494);
495
496it.live("one-click approves only access the browser session holds the scopes for", () =>
497 withRoutes((handler, auth) =>
498 Effect.gen(function* () {
499 const clientId = yield* registeredClientId(handler);
500 const params = authorizeParams(clientId);
501 // Signs a browser in through the real route and returns its session cookie.
502 const browserCookie = (scopes: NonNullable<AuthCreatePairingCredentialInput["scopes"]>) =>
503 Effect.gen(function* () {
504 const pairing = yield* auth.issuePairingCredential({ scopes });
505 const response = yield* handler(
506 at("/api/auth/browser-session", {
507 method: "POST",
508 headers: { "content-type": "application/json" },
509 body: encodeJson({ credential: pairing.credential }),
510 }),
511 );
512 return response.headers.getSetCookie()[0]!.split(";", 1)[0]!;
513 });
514 const details = (cookie: string) =>
515 handler(postJson("/oauth/mcp/approval", params, cookie)).pipe(
516 Effect.flatMap(json<{ csrfToken?: string; oneClickAccess?: ReadonlyArray<string> }>),
517 );
518
519 const admin = yield* browserCookie([...AuthAdministrativeScopes]);
520 const adminDetails = yield* details(admin);
521 expect(adminDetails.csrfToken).toEqual(expect.any(String));
522 expect(adminDetails.oneClickAccess).toEqual([
523 "read-only",
524 "approval-required",
525 "auto-accept-edits",
526 "auto",
527 "full-access",
528 ]);
529 const oneClick = yield* decide(
530 handler,
531 params,
532 { _tag: "browser-session", access: "auto", csrfToken: adminDetails.csrfToken! },
533 admin,
534 );
535 expect(new URL(oneClick.redirectTo!).searchParams.get("code")).toEqual(expect.any(String));
536
537 // access:write alone cannot hand an agent thread control it does not hold.
538 const accessOnly = yield* browserCookie(["access:read", "access:write"]);
539 expect((yield* details(accessOnly)).csrfToken).toBeUndefined();
540 const forged = yield* decide(
541 handler,
542 params,
543 { _tag: "browser-session", access: "auto", csrfToken: "forged" },
544 accessOnly,
545 );
546 expect(forged.status).toBe(400);
547 expect(forged.message).toContain("Enter a pairing code instead");
548
549 // A session that can read but not operate threads may approve read-only access only.
550 const reader = yield* browserCookie(["access:write", "orchestration:read"]);
551 const readerDetails = yield* details(reader);
552 expect(readerDetails.csrfToken).toEqual(expect.any(String));
553 // The page shows the pairing-code field for anything above read only.
554 expect(readerDetails.oneClickAccess).toEqual(["read-only"]);
555 const tooBroad = yield* decide(
556 handler,
557 params,
558 { _tag: "browser-session", access: "auto", csrfToken: readerDetails.csrfToken! },
559 reader,
560 );
561 expect(tooBroad.status).toBe(400);
562 const readOnly = yield* decide(
563 handler,
564 params,
565 { _tag: "browser-session", access: "read-only", csrfToken: readerDetails.csrfToken! },
566 reader,
567 );
568 expect(new URL(readOnly.redirectTo!).searchParams.get("code")).toEqual(expect.any(String));
569 }),
570 ),
571);
572
573it("matches loopback redirects on everything but the port, and https redirects exactly", () => {
574 const matches = McpOAuth.redirectMatches;
575 expect(matches("http://localhost/callback", "http://localhost:9/callback")).toBe(true);
576 expect(matches("http://127.0.0.1:1/callback", "http://127.0.0.1:2/callback")).toBe(true);
577 expect(matches("http://localhost/callback", "http://127.0.0.1/callback")).toBe(false);
578 expect(matches("http://localhost/callback", "https://localhost/callback")).toBe(false);
579 expect(matches("https://bot.example/cb", "https://bot.example/cb")).toBe(true);
580 expect(matches("https://bot.example/cb", "https://bot.example:8443/cb")).toBe(false);
581 expect(matches("https://bot.example/cb", "https://bot.example/cb?x=1")).toBe(false);
582 expect(matches("https://bot.example/cb", "https://evil.example/cb")).toBe(false);
583});