import { describe, expect, it } from "@effect/vitest"; import { Effect, Predicate } from "effect"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, ToolName, } from "./ids"; import { OAuthRegisterDynamicError } from "./oauth-client"; import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; // RFC 7591 Dynamic Client Registration, end to end: // probe → registerDynamicClient (no pasted client id/secret) → listClients // → start → complete mints a connection via a PUBLIC client (PKCE, no secret). // The test authorization server's /register endpoint mints a public client when // `token_endpoint_auth_method: "none"` is requested, and its /token endpoint // accepts that client WITHOUT a client_secret — proving the public-client path. const INTEG = IntegrationSlug.make("acme"); const TEMPLATE = AuthTemplateSlug.make("oauth"); const CLIENT = OAuthClientSlug.make("acme-dcr"); const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback"; const oauthPlugin = definePlugin(() => ({ id: "acme" as const, storage: () => ({}), resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }], }), invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: {}, }), }), }))(); const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; describe("oauth.registerDynamicClient", () => { it.effect("DCR mints + persists a public (no-secret) client that lists + connects", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(); // Probe surfaces the registration endpoint + advertised auth methods so // the caller knows a public client is allowed. const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); expect(probe.registrationEndpoint).toBe(server.registrationEndpoint); expect(probe.tokenEndpointAuthMethodsSupported).toContain("none"); expect(probe.resource).toBe(server.mcpResourceUrl); // Register dynamically — NO client id/secret pasted by the user. const slug = yield* executor.oauth.registerDynamicClient({ owner: "org", slug: CLIENT, registrationEndpoint: probe.registrationEndpoint!, authorizationUrl: probe.authorizationUrl, tokenUrl: probe.tokenUrl, resource: probe.resource, scopes: ["read"], tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, clientName: "Acme DCR", redirectUri: FLOW_REDIRECT_URI, originIntegration: INTEG, }); expect(String(slug)).toBe(String(CLIENT)); // The minted client appears in listClients with a server-issued // client_id and NO secret ever projected. const clients = yield* executor.oauth.listClients(); const minted = clients.find((c) => String(c.slug) === String(CLIENT)); expect(minted).toBeDefined(); expect(minted!.owner).toBe("org"); expect(minted!.grant).toBe("authorization_code"); expect(minted!.clientId.length).toBeGreaterThan(0); expect(minted!.clientId.startsWith("client_")).toBe(true); expect(minted!.origin).toEqual({ kind: "dynamic_client_registration", integration: INTEG, }); for (const client of clients) { expect(Object.keys(client)).not.toContain("clientSecret"); expect(JSON.stringify(client)).not.toContain("secret"); } // The DCR-minted public client drives the full authorization_code + // PKCE flow with NO client_secret on the token exchange. const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main"), integration: INTEG, template: TEMPLATE, redirectUri: FLOW_REDIRECT_URI, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; expect(new URL(started.authorizationUrl).searchParams.get("resource")).toBe( server.mcpResourceUrl, ); const callback = yield* server.completeAuthorizationCodeFlow({ authorizationUrl: started.authorizationUrl, }); expect(callback.state).toBe(String(started.state)); const connection = yield* executor.oauth.complete({ state: started.state, code: callback.code, }); expect(String(connection.name)).toBe("main"); expect(String(connection.address)).toBe("tools.acme.org.main"); // The /token request was made WITHOUT a client_secret (public client). const requests = yield* server.requests; const registerRequest = requests.find((r) => r.path === "/register" && r.method === "POST"); expect(registerRequest).toBeDefined(); expect(registerRequest!.body).toContain(FLOW_REDIRECT_URI); expect(registerRequest!.body).toContain("authorization_code"); expect(registerRequest!.body).toContain("refresh_token"); const authorizationRequest = requests.find( (r) => r.path === "/authorize" && r.method === "GET", ); expect(authorizationRequest).toBeDefined(); expect(authorizationRequest!.query.resource).toBe(server.mcpResourceUrl); const tokenRequest = requests.find( (r) => r.path === "/token" && r.method === "POST" && r.body.includes("grant_type"), ); expect(tokenRequest).toBeDefined(); expect(tokenRequest!.body).not.toContain("client_secret"); expect(new URLSearchParams(tokenRequest!.body).get("resource")).toBe(server.mcpResourceUrl); }), ), ); // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN // origin trips `invalid_redirect_uri`. The failure must explain the loopback // requirement and name the offending URI, not dump the raw RFC code. it.effect("DCR rejection of a non-loopback redirect URI yields an actionable loopback hint", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"], // Mirror Vercel: approve only loopback redirect URIs for anonymous DCR. approveRedirectUri: (uri) => uri.startsWith("http://localhost") || uri.startsWith("http://127."), }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); const nonLoopback = "https://app.example.com/api/oauth/callback"; const error = yield* Effect.flip( executor.oauth.registerDynamicClient({ owner: "org", slug: CLIENT, registrationEndpoint: probe.registrationEndpoint!, authorizationUrl: probe.authorizationUrl, tokenUrl: probe.tokenUrl, resource: probe.resource, scopes: ["read"], tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, clientName: "Acme DCR", redirectUri: nonLoopback, originIntegration: INTEG, }), ); // Predicate guard narrows the union so `.message` reads off a typed failure. expect(Predicate.isTagged("OAuthRegisterDynamicError")(error)).toBe(true); const registerError = error as OAuthRegisterDynamicError; const message = registerError.message; // Names the loopback-only requirement, the localhost fix, and the URI. expect(message).toContain("loopback"); expect(message).toContain("http://localhost"); expect(message).toContain(nonLoopback); }), ), ); // The loopback hint is gated on the redirect URI actually being non-loopback. // A server that rejects even a loopback URI keeps the generic message so we // never tell the user "use localhost" when they already are. it.effect( "DCR rejection of a loopback redirect URI keeps the generic message (no false hint)", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"], approveRedirectUri: () => false, // reject every redirect URI, even loopback }); const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(); const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); const error = yield* Effect.flip( executor.oauth.registerDynamicClient({ owner: "org", slug: CLIENT, registrationEndpoint: probe.registrationEndpoint!, authorizationUrl: probe.authorizationUrl, tokenUrl: probe.tokenUrl, resource: probe.resource, scopes: ["read"], tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, clientName: "Acme DCR", redirectUri: "http://127.0.0.1:5394/api/oauth/callback", originIntegration: INTEG, }), ); expect(Predicate.isTagged("OAuthRegisterDynamicError")(error)).toBe(true); const registerError = error as OAuthRegisterDynamicError; const message = registerError.message; expect(message).toContain("Dynamic Client Registration failed: invalid_redirect_uri"); expect(message).not.toContain("Automatic OAuth setup failed"); }), ), ); });