mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
fix: restore MCP OAuth registration and authorization (#3421)
* fix: align MCP OAuth provider schema and authorization flow * test: isolate OpenAPI generation from OAuth initialization * fix: accept auth routes without a callback query * fix: require explicit OAuth consent and preserve signed requests * test: verify OAuth audiences through real MCP initialization * test(e2e): isolate OAuth token audience validation
This commit is contained in:
@@ -2,6 +2,8 @@ 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,
|
||||
@@ -14,6 +16,8 @@ vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
getSession: mocks.getSession,
|
||||
oauth2Consent: mocks.consent,
|
||||
oauth2Continue: mocks.continueOAuth,
|
||||
},
|
||||
handler: mocks.handler,
|
||||
},
|
||||
@@ -32,6 +36,23 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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("rejects untrusted dynamic OAuth redirect URIs in safe mode", async () => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
|
||||
@@ -66,6 +87,57 @@ describe("handleAuth", () => {
|
||||
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([
|
||||
{ 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", () => {
|
||||
@@ -91,7 +163,64 @@ describe("handleOAuth", () => {
|
||||
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.has("exp")).toBe(false);
|
||||
expect(callbackUrl.searchParams.has("sig")).toBe(false);
|
||||
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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user