diff --git a/apps/server/src/http/auth.test.ts b/apps/server/src/http/auth.test.ts index c33316b4c..a3bf05620 100644 --- a/apps/server/src/http/auth.test.ts +++ b/apps/server/src/http/auth.test.ts @@ -121,6 +121,25 @@ describe("handleAuth", () => { }, ); + it.each(["client_secret_basic", "client_secret_post"])( + "keeps an explicitly registered %s so the client receives a client secret", + async (method) => { + const { handleAuth } = await import("./auth"); + await handleAuth( + new Request("http://localhost:3000/api/auth/oauth2/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + redirect_uris: ["https://example.com/callback"], + token_endpoint_auth_method: method, + }), + }), + ); + const forwarded = mocks.handler.mock.calls[0]?.[0] as Request; + await expect(forwarded.json()).resolves.toMatchObject({ token_endpoint_auth_method: method }); + }, + ); + it.each([ { redirect_uris: ["https://example.com/callback"] }, { redirect_uris: ["http://localhost.evil.example/callback"] }, diff --git a/apps/server/src/http/auth.ts b/apps/server/src/http/auth.ts index 80b6ce27e..bb4c339ec 100644 --- a/apps/server/src/http/auth.ts +++ b/apps/server/src/http/auth.ts @@ -82,8 +82,12 @@ async function defaultPublicClientRegistration(request: Request): Promise { expect([claims.aud].flat()).toContain(`${origin}/mcp`); expect((await handleAuth(tokenRequest())).status).toBe(400); }, 30_000); + it("exchanges a code for a confidential client that registered client_secret_basic", async () => { + if (!databaseURL) return; + process.env.DATABASE_URL = databaseURL; + process.env.APP_URL = "http://localhost:33920"; + process.env.AUTH_SECRET = "oauth-integration-test-secret-only"; + const { handleAuth, handleOAuth } = await import("./auth"); + const origin = process.env.APP_URL; + const redirectURI = "http://127.0.0.1:33921/callback"; + const request = (path: string, body: object, cookie = "") => + new Request(`${origin}/api/auth/${path}`, { + method: "POST", + headers: { "content-type": "application/json", origin, cookie }, + body: JSON.stringify(body), + }); + + const registration = await handleAuth( + request("oauth2/register", { + client_name: "Confidential MCP client", + redirect_uris: [redirectURI], + token_endpoint_auth_method: "client_secret_basic", + }), + ); + expect(registration.status, await registration.clone().text()).toBe(201); + const client = await registration.json(); + // Downgrading this to a public client leaves the client without a secret, and its + // Basic-authenticated token exchange then fails with 401 invalid_client. + expect(client.token_endpoint_auth_method).toBe("client_secret_basic"); + expect(client.client_secret).toBeTruthy(); + + const verifier = randomBytes(32).toString("base64url"); + const query = new URLSearchParams({ + client_id: client.client_id, + redirect_uri: redirectURI, + response_type: "code", + scope: "openid profile offline_access", + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + resource: `${origin}/mcp`, + state: "opaque-state", + }); + const authorize = await handleAuth(new Request(`${origin}/api/auth/oauth2/authorize?${query}`)); + const login = await handleOAuth(new Request(new URL(authorize.headers.get("location") ?? "", origin))); + const callbackURL = new URL(login.headers.get("location") ?? "", origin).searchParams.get("callbackURL"); + + const unique = randomBytes(6).toString("hex"); + const signup = await handleAuth( + request("sign-up/email", { + name: "Confidential Test", + email: `confidential-${unique}@example.com`, + username: `confidential-${unique}`, + password: "password123", + }), + ); + expect(signup.status, await signup.clone().text()).toBe(200); + const cookie = signup.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + const callback = await handleOAuth(new Request(`${origin}${callbackURL}`, { headers: { cookie } })); + const oauth_query = new URL(callback.headers.get("location") ?? "", origin).search.slice(1); + const accepted = await handleAuth(request("oauth2/consent", { accept: true, oauth_query }, cookie)); + expect(accepted.status, await accepted.clone().text()).toBe(200); + const code = new URL((await accepted.json()).url).searchParams.get("code"); + + const tokenResponse = await handleAuth( + new Request(`${origin}/api/auth/oauth2/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString("base64")}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code ?? "", + redirect_uri: redirectURI, + code_verifier: verifier, + resource: `${origin}/mcp`, + }), + }), + ); + expect(tokenResponse.status, await tokenResponse.clone().text()).toBe(200); + await expect(tokenResponse.json()).resolves.toMatchObject({ token_type: "Bearer" }); + }, 30_000); it.each(["login", "max-age", "create"])( "requires fresh authentication for %s without looping", async (mode) => {