mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 02:04:31 +10:00
Dynamic client registration unconditionally rewrote token_endpoint_auth_method to "none" for every unauthenticated request, downgrading clients that asked for client_secret_basic or client_secret_post to public clients. Those clients were issued no client_secret but still authenticated at the token endpoint with the method they registered, so the exchange failed with 401 invalid_client. Connecting Composio to the MCP server hit this on every attempt. Default to "none" only when the client omits the field, which keeps PKCE-only MCP clients working while confidential clients receive a usable secret.
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
getSession: vi.fn(),
|
|
consent: vi.fn(),
|
|
continueOAuth: vi.fn(),
|
|
handler: vi.fn(),
|
|
env: {
|
|
SERVER_PORT: 3001,
|
|
APP_URL: "http://localhost:3000",
|
|
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: false,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@reactive-resume/auth/config", () => ({
|
|
auth: {
|
|
api: {
|
|
getSession: mocks.getSession,
|
|
oauth2Consent: mocks.consent,
|
|
oauth2Continue: mocks.continueOAuth,
|
|
},
|
|
handler: mocks.handler,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@reactive-resume/db/client", () => ({ db: {} }));
|
|
vi.mock("@reactive-resume/db/schema", () => ({ oauthClient: {}, verification: {} }));
|
|
vi.mock("@reactive-resume/env/server", () => ({
|
|
env: mocks.env,
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = false;
|
|
mocks.handler.mockResolvedValue(new Response("ok"));
|
|
});
|
|
|
|
describe("handleAuth", () => {
|
|
it.for([null, false, 42, "client", [], [{ redirect_uris: [] }]])(
|
|
"rejects non-object registration payload %j",
|
|
async (body) => {
|
|
const { handleAuth } = await import("./auth");
|
|
const response = await handleAuth(
|
|
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
);
|
|
expect(response.status).toBe(400);
|
|
await expect(response.json()).resolves.toEqual({ message: "Invalid registration payload" });
|
|
expect(mocks.handler).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("registers third-party https callbacks so remote MCP clients can complete DCR", async () => {
|
|
const { handleAuth } = await import("./auth");
|
|
|
|
const response = await handleAuth(
|
|
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
|
method: "POST",
|
|
body: JSON.stringify({ redirect_uris: ["https://claude.ai/api/mcp/auth_callback"] }),
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mocks.handler).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("rejects unsafe dynamic OAuth redirect URIs in safe mode", async () => {
|
|
const { handleAuth } = await import("./auth");
|
|
|
|
const response = await handleAuth(
|
|
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
|
method: "POST",
|
|
body: JSON.stringify({ redirect_uris: ["https://192.168.1.10/callback"] }),
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
await expect(response.json()).resolves.toEqual({
|
|
error: "invalid_redirect_uri",
|
|
error_description: "redirect_uri is not allowed",
|
|
});
|
|
expect(mocks.handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("forwards custom-scheme dynamic OAuth redirect URIs when unsafe mode is enabled", async () => {
|
|
const { handleAuth } = await import("./auth");
|
|
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
|
|
|
|
const response = await handleAuth(
|
|
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
|
method: "POST",
|
|
body: JSON.stringify({ redirect_uris: ["myapp://callback"] }),
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mocks.handler).toHaveBeenCalledOnce();
|
|
});
|
|
it.each(["localhost", "127.0.0.1", "[::1]"])(
|
|
"infers native application type for exact %s loopback callbacks",
|
|
async (host) => {
|
|
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: [`http://${host}:3210/callback`] }),
|
|
}),
|
|
);
|
|
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
|
await expect(forwarded.json()).resolves.toMatchObject({
|
|
application_type: "native",
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
},
|
|
);
|
|
|
|
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"] },
|
|
{ redirect_uris: ["http://localhost:3210/callback"], application_type: "web" },
|
|
{ redirect_uris: ["http://localhost:3210/callback", "https://example.com/callback"] },
|
|
])("does not infer native for explicit web or non-loopback clients: %j", async (body) => {
|
|
const { handleAuth } = await import("./auth");
|
|
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
|
|
await handleAuth(
|
|
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
);
|
|
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
|
expect((await forwarded.json()).application_type).not.toBe("native");
|
|
});
|
|
|
|
it("preserves repeated resource indicators during authorization sanitization", async () => {
|
|
const { handleAuth } = await import("./auth");
|
|
await handleAuth(
|
|
new Request(
|
|
"http://localhost:3000/api/auth/oauth2/authorize?resource=http%3A%2F%2Flocalhost%3A3000&resource=http%3A%2F%2Flocalhost%3A3000%2Fmcp",
|
|
),
|
|
);
|
|
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
|
expect(new URL(forwarded.url).searchParams.getAll("resource")).toEqual([
|
|
"http://localhost:3000",
|
|
"http://localhost:3000/mcp",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("handleOAuth", () => {
|
|
it("redirects unauthenticated users to the same-origin login route", async () => {
|
|
const { handleOAuth } = await import("./auth");
|
|
mocks.getSession.mockResolvedValueOnce(null);
|
|
|
|
const response = await handleOAuth(
|
|
new Request(
|
|
"http://localhost:3001/api/auth/oauth?client_id=test-client&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=abc&exp=123&sig=456",
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(302);
|
|
const location = response.headers.get("Location");
|
|
expect(location).toMatch(/^\/auth\/login\?/);
|
|
|
|
const loginUrl = new URL(location ?? "", "http://localhost:3000");
|
|
const callbackUrl = new URL(loginUrl.searchParams.get("callbackURL") ?? "", "http://localhost:3000");
|
|
|
|
expect(loginUrl.origin).toBe("http://localhost:3000");
|
|
expect(callbackUrl.pathname).toBe("/api/auth/oauth");
|
|
expect(callbackUrl.searchParams.get("client_id")).toBe("test-client");
|
|
expect(callbackUrl.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
|
|
expect(callbackUrl.searchParams.get("state")).toBe("abc");
|
|
expect(callbackUrl.searchParams.get("exp")).toBe("123");
|
|
expect(callbackUrl.searchParams.get("sig")).toBe("456");
|
|
});
|
|
it("continues signed authorization without approving consent on GET", async () => {
|
|
const { handleOAuth } = await import("./auth");
|
|
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
|
mocks.continueOAuth.mockResolvedValueOnce(
|
|
Response.json({ redirect: true, url: "/auth/consent?client_id=client&sig=signed" }),
|
|
);
|
|
const query = "client_id=client&resource=one&resource=two&exp=123&sig=456";
|
|
const response = await handleOAuth(new Request(`http://localhost:3000/api/auth/oauth?${query}`));
|
|
expect(mocks.continueOAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ body: { postLogin: true, oauth_query: query } }),
|
|
);
|
|
expect(mocks.consent).not.toHaveBeenCalled();
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.get("location")).toBe("/auth/consent?client_id=client&sig=signed");
|
|
});
|
|
|
|
it("preserves provider failures instead of issuing an authorization code", async () => {
|
|
const { handleOAuth } = await import("./auth");
|
|
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
|
mocks.continueOAuth.mockResolvedValueOnce(Response.json({ error: "invalid_signature" }, { status: 400 }));
|
|
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=invalid"));
|
|
expect(response.status).toBe(400);
|
|
expect(response.headers.get("location")).toBeNull();
|
|
await expect(response.json()).resolves.toEqual({ error: "invalid_signature" });
|
|
});
|
|
it("preserves provider cookies and cache headers on forced reauthentication", async () => {
|
|
const { handleOAuth } = await import("./auth");
|
|
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
|
const headers = new Headers({ "cache-control": "no-store", "content-length": "123" });
|
|
headers.append("set-cookie", "oauth_state=state; Path=/; HttpOnly");
|
|
headers.append("set-cookie", "session=refreshed; Path=/; HttpOnly");
|
|
mocks.continueOAuth.mockResolvedValueOnce(
|
|
Response.json({ redirect: true, url: "/api/auth/oauth?prompt=login&sig=signed" }, { headers }),
|
|
);
|
|
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=original"));
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.get("location")).toMatch(/^\/auth\/login\?reauthenticate=true&/);
|
|
expect(response.headers.getSetCookie()).toEqual(headers.getSetCookie());
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(response.headers.get("content-type")).toBeNull();
|
|
expect(response.headers.get("content-length")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("OAuth provider response validation", () => {
|
|
it.for([{}, { url: null }, { url: 7 }, { url: "" }, { url: "undefined" }, { url: "javascript:alert(1)" }])(
|
|
"fails closed for malformed provider response %j",
|
|
async (body) => {
|
|
const { handleOAuth } = await import("./auth");
|
|
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
|
mocks.continueOAuth.mockResolvedValueOnce(Response.json(body));
|
|
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=signed"));
|
|
expect(response.status).toBe(502);
|
|
expect(response.headers.get("location")).toBeNull();
|
|
expect(mocks.consent).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
});
|