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:
Amruth Pillai
2026-09-05 09:33:15 -07:00
committed by GitHub
parent bf71253ca4
commit fe9b59e111
35 changed files with 7837 additions and 144 deletions
+14
View File
@@ -205,3 +205,17 @@ describe("createApp", () => {
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
});
});
it.each(["/auth/consent", "/auth/consent/", "/auth/login"])("prevents framing or caching %s", async (path) => {
const { createApp } = await import("./app");
mocks.serveWebDistStatic.mockImplementationOnce(async (_context: unknown, next: () => Promise<void>) => {
await next();
});
const response = await createApp().request(`http://localhost:3000${path}?sig=signed`);
expect(response.status).toBe(200);
expect(await response.text()).toBe("web");
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
expect(response.headers.get("cache-control")).toBe("no-store");
});
+8
View File
@@ -36,6 +36,14 @@ const getTrustedClient = (context: Context<ServerEnvironment>): string => {
export function createApp() {
const app = new Hono<ServerEnvironment>();
app.use("/auth/*", async (c, next) => {
await next();
c.header("Content-Security-Policy", "frame-ancestors 'none'");
c.header("X-Frame-Options", "DENY");
c.header("Referrer-Policy", "no-referrer");
c.header("Cache-Control", "no-store");
});
app.all("/api/rpc", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
+131 -2
View File
@@ -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();
},
);
});
+79 -86
View File
@@ -1,10 +1,6 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { APIError } from "better-auth/api";
import { auth } from "@reactive-resume/auth/config";
import { db } from "@reactive-resume/db/client";
import { oauthClient, verification } from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { generateId } from "@reactive-resume/utils/string";
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
const oauthAuthorizeSanitizedParams = [
@@ -19,8 +15,6 @@ const oauthAuthorizeSanitizedParams = [
"resource",
] as const;
const oauthCallbackPassthroughExcludedParams = new Set(["exp", "sig"]);
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
if (request.method !== "GET") return request;
@@ -33,9 +27,10 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request {
.replace(/\s+/g, " ")
.trim();
const sanitizeParam = (key: string) => {
const value = url.searchParams.get(key);
if (!value) return;
url.searchParams.set(key, sanitizeValue(value));
const values = url.searchParams.getAll(key);
if (!values.length) return;
url.searchParams.delete(key);
for (const value of values) url.searchParams.append(key, sanitizeValue(value));
};
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
@@ -56,6 +51,10 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request {
return new Request(url.toString(), request);
}
function isRegistrationPayload(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
if (request.method !== "POST") return request;
@@ -66,11 +65,23 @@ async function defaultPublicClientRegistration(request: Request): Promise<Reques
let body: Record<string, unknown>;
try {
body = await cloned.json();
const payload: unknown = await cloned.json();
if (!isRegistrationPayload(payload)) return request;
body = payload;
} catch {
return request;
}
// MCP native clients often omit OIDC application_type. Infer it only for
// exact HTTP loopback callbacks; the provider still validates every URI.
if (body.application_type === undefined && Array.isArray(body.redirect_uris) && body.redirect_uris.length > 0) {
const allLoopback = body.redirect_uris.every(
(uri: unknown) =>
typeof uri === "string" && /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?(?:[/?]|$)/i.test(uri),
);
if (allLoopback) body.application_type = "native";
}
if (!request.headers.get("authorization")) {
body.token_endpoint_auth_method = "none";
}
@@ -92,7 +103,11 @@ async function validateDynamicClientRegistrationRequest(request: Request): Promi
let body: Record<string, unknown>;
try {
body = await cloned.json();
const payload: unknown = await cloned.json();
if (!isRegistrationPayload(payload)) {
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
}
body = payload;
} catch {
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
}
@@ -125,90 +140,68 @@ export async function handleAuth(request: Request) {
return auth.handler(finalRequest);
}
function generateCode() {
return crypto.randomBytes(32).toString("base64url");
}
function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("base64url");
}
export async function handleOAuth(request: Request) {
try {
return await resumeOAuth(request);
} catch (error) {
// Before-hooks can throw even when the provider is called with asResponse.
if (error instanceof APIError) return Response.json(error.body, { status: error.statusCode });
throw error;
}
}
async function resumeOAuth(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
const url = new URL(request.url);
if (session?.user) {
const clientId = url.searchParams.get("client_id");
const redirectUri = url.searchParams.get("redirect_uri");
const state = url.searchParams.get("state");
const scope = url.searchParams.get("scope");
const codeChallenge = url.searchParams.get("code_challenge");
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
if (!clientId || !redirectUri) {
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
}
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
if (!client) {
return Response.json({ error: "invalid client" }, { status: 400 });
}
if (!client.redirectUris.includes(redirectUri)) {
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
}
const code = generateCode();
const hashedCode = hashCode(code);
const now = new Date();
const expiresAt = new Date(now.getTime() + 600_000);
await db.insert(verification).values({
id: generateId(),
identifier: hashedCode,
value: JSON.stringify({
type: "authorization_code",
query: {
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
},
userId: session.user.id,
sessionId: session.session.id,
authTime: new Date(session.session.createdAt).getTime(),
}),
expiresAt,
createdAt: now,
updatedAt: now,
});
const callbackUrl = new URL(redirectUri);
callbackUrl.searchParams.set("code", code);
if (state) callbackUrl.searchParams.set("state", state);
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
return new Response(null, {
status: 302,
headers: { Location: callbackUrl.toString() },
// Resume authorization without granting consent. The provider decides whether
// the user must sign in, explicitly approve a client, or reuse an existing grant.
// Its signed query must survive the login round trip byte-for-byte.
const response = await auth.api.oauth2Continue({
asResponse: true,
request,
headers: request.headers,
body: { postLogin: true, oauth_query: url.search.slice(1) },
});
if (!(response instanceof Response)) throw new Error("OAuth provider did not return a response");
if (!response.ok) return response;
const result: unknown = await response.json().catch(() => null);
if (
!result ||
typeof result !== "object" ||
!("url" in result) ||
typeof result.url !== "string" ||
!result.url ||
!(result.url.startsWith("/") || URL.canParse(result.url)) ||
!URL.canParse(result.url, env.APP_URL)
)
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
const headers = new Headers(response.headers);
headers.delete("content-type");
headers.delete("content-length");
const target = new URL(result.url, env.APP_URL);
if (["javascript:", "data:", "vbscript:", "file:", "blob:"].includes(target.protocol)) {
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
}
if (target.origin === new URL(env.APP_URL).origin && target.pathname === "/api/auth/oauth") {
return redirectToOAuthLogin(target, true, headers);
}
headers.set("Location", result.url);
return new Response(null, { status: 302, headers });
}
const loginUrl = new URL("/auth/login", env.APP_URL);
const oauthParams = new URLSearchParams();
for (const [key, value] of url.searchParams) {
if (!oauthCallbackPassthroughExcludedParams.has(key)) {
oauthParams.set(key, value);
}
}
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`);
return redirectToOAuthLogin(url);
}
function redirectToOAuthLogin(url: URL, reauthenticate = false, headers = new Headers()) {
const prompt = new Set(url.searchParams.get("prompt")?.split(" ") ?? []);
const loginUrl = new URL(prompt.has("create") ? "/auth/register" : "/auth/login", env.APP_URL);
if (reauthenticate) loginUrl.searchParams.set("reauthenticate", "true");
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth${url.search}`);
headers.set("Location", `${loginUrl.pathname}${loginUrl.search}`);
return new Response(null, {
status: 302,
headers: { Location: `${loginUrl.pathname}${loginUrl.search}` },
headers,
});
}
@@ -0,0 +1,254 @@
import { createHash, randomBytes } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
vi.mock("@reactive-resume/email/transport", () => ({ sendEmail: vi.fn() }));
// Run only against an explicitly supplied disposable database, after applying migrations.
const databaseURL = process.env.OAUTH_TEST_DATABASE_URL;
describe.skipIf(!databaseURL)("MCP OAuth flow with PostgreSQL", () => {
it("registers public clients, resumes login, and exchanges a resource-bound PKCE code", 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");
// Better Auth disables origin checks by default in test mode; exercise production behavior.
const { auth } = await import("@reactive-resume/auth/config");
(await auth.$context).skipOriginCheck = false;
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: "OAuth integration", redirect_uris: [redirectURI] }),
);
expect(registration.status, await registration.clone().text()).toBe(201);
const client = await registration.json();
expect(client.token_endpoint_auth_method).toBe("none");
const deniedRegistration = await handleAuth(
request("oauth2/register", {
client_name: "Denied resource",
redirect_uris: [redirectURI],
resources: ["https://untrusted.example/mcp"],
}),
);
expect(deniedRegistration.status).toBe(400);
await expect(deniedRegistration.json()).resolves.toMatchObject({ error: "invalid_target" });
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}`));
expect(authorize.status, await authorize.clone().text()).toBe(302);
const bridgeURL = authorize.headers.get("location");
expect(bridgeURL).toBeTruthy();
const login = await handleOAuth(new Request(new URL(bridgeURL ?? "", origin)));
const loginURL = new URL(login.headers.get("location") ?? "", origin);
const callbackURL = loginURL.searchParams.get("callbackURL");
expect(callbackURL).toContain("sig=");
expect(callbackURL).toContain("resource=");
const unique = randomBytes(6).toString("hex");
const signup = await handleAuth(
request("sign-up/email", {
name: "OAuth Test",
email: `oauth-${unique}@example.com`,
username: `oauth-${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 tamperedURL = new URL(`${origin}${callbackURL}`);
tamperedURL.searchParams.set("state", "tampered");
const tampered = await handleOAuth(new Request(tamperedURL, { headers: { cookie } }));
expect(tampered.status).toBe(400);
await expect(tampered.json()).resolves.toMatchObject({ error: "invalid_signature" });
const callback = await handleOAuth(new Request(`${origin}${callbackURL}`, { headers: { cookie } }));
expect(callback.status, await callback.clone().text()).toBe(302);
const consentURL = new URL(callback.headers.get("location") ?? "", origin);
expect(consentURL.pathname).toBe("/auth/consent");
expect(consentURL.searchParams.has("code")).toBe(false);
const oauth_query = consentURL.search.slice(1);
const consents = async () => {
const response = await handleAuth(new Request(`${origin}/api/auth/oauth2/get-consents`, { headers: { cookie } }));
expect(response.status).toBe(200);
return response.json();
};
expect(await consents()).toEqual([]);
const silent = await handleAuth(
new Request(`${origin}/api/auth/oauth2/authorize?${query}&prompt=none`, { headers: { cookie } }),
);
expect(new URL(silent.headers.get("location") ?? "").searchParams.get("error")).toBe("consent_required");
const tamperedConsentQuery = new URLSearchParams(oauth_query);
tamperedConsentQuery.set("scope", "openid profile email offline_access");
const tamperedConsent = await handleAuth(
request(
"oauth2/consent",
{
accept: true,
oauth_query: tamperedConsentQuery.toString(),
},
cookie,
),
);
expect(tamperedConsent.status).toBe(400);
expect(await consents()).toEqual([]);
const csrf = await handleAuth(
new Request(`${origin}/api/auth/oauth2/consent`, {
method: "POST",
headers: { cookie, origin: "https://untrusted.example", "content-type": "application/json" },
body: JSON.stringify({ accept: true, oauth_query }),
}),
);
expect(csrf.status).toBe(403);
const denied = await handleAuth(request("oauth2/consent", { accept: false, oauth_query }, cookie));
expect(denied.status, await denied.clone().text()).toBe(200);
const deniedURL = new URL((await denied.json()).url);
expect(deniedURL.searchParams.get("error")).toBe("access_denied");
expect(deniedURL.searchParams.get("state")).toBe("opaque-state");
expect(deniedURL.searchParams.has("code")).toBe(false);
expect(await consents()).toEqual([]);
const accepted = await handleAuth(request("oauth2/consent", { accept: true, oauth_query }, cookie));
expect(accepted.status, await accepted.clone().text()).toBe(200);
expect(await consents()).toHaveLength(1);
const codeURL = new URL((await accepted.json()).url);
expect(codeURL.origin).toBe(new URL(redirectURI).origin);
expect(codeURL.searchParams.get("state")).toBe("opaque-state");
const code = codeURL.searchParams.get("code");
expect(code).toBeTruthy();
const tokenRequest = () =>
new Request(`${origin}/api/auth/oauth2/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: client.client_id,
code: code ?? "",
redirect_uri: redirectURI,
code_verifier: verifier,
resource: `${origin}/mcp`,
}),
});
const tokenResponse = await handleAuth(tokenRequest());
expect(tokenResponse.status, await tokenResponse.clone().text()).toBe(200);
const token = await tokenResponse.json();
expect(token.access_token).toBeTruthy();
expect(token.refresh_token).toBeTruthy();
const claims = JSON.parse(Buffer.from(token.access_token.split(".")[1], "base64url").toString());
expect([claims.aud].flat()).toContain(`${origin}/mcp`);
expect((await handleAuth(tokenRequest())).status).toBe(400);
}, 30_000);
it.each(["login", "max-age", "create"])(
"requires fresh authentication for %s without looping",
async (mode) => {
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 cookieOf = (response: Response) =>
response.headers
.getSetCookie()
.map((value) => value.split(";", 1)[0])
.join("; ");
const post = (path: string, body: object, cookie = "") =>
handleAuth(
new Request(`${origin}/api/auth/${path}`, {
method: "POST",
headers: { "content-type": "application/json", origin, cookie },
body: JSON.stringify(body),
}),
);
const unique = randomBytes(6).toString("hex");
const credentials = {
name: "Reauth Test",
email: `reauth-${unique}@example.com`,
username: `reauth-${unique}`,
password: "password123",
};
const existingSignup = await post("sign-up/email", credentials);
expect(existingSignup.status).toBe(200);
const oldCookie = cookieOf(existingSignup);
const registration = await post("oauth2/register", {
client_name: "Reauth integration",
redirect_uris: ["http://127.0.0.1:33921/callback"],
});
expect(registration.status).toBe(201);
const client = await registration.json();
const query = new URLSearchParams({
client_id: client.client_id,
redirect_uri: "http://127.0.0.1:33921/callback",
response_type: "code",
scope: "openid profile",
resource: `${origin}/mcp`,
code_challenge: createHash("sha256").update(randomBytes(32)).digest("base64url"),
code_challenge_method: "S256",
...(mode === "max-age" ? { max_age: "0" } : { prompt: mode }),
});
const authorization = await handleAuth(
new Request(`${origin}/api/auth/oauth2/authorize?${query}`, { headers: { cookie: oldCookie } }),
);
expect(authorization.status).toBe(302);
const bridge = await handleOAuth(
new Request(new URL(authorization.headers.get("location") ?? "", origin), { headers: { cookie: oldCookie } }),
);
expect(bridge.status).toBe(302);
const loginURL = new URL(bridge.headers.get("location") ?? "", origin);
expect(loginURL.pathname).toBe(mode === "create" ? "/auth/register" : "/auth/login");
expect(loginURL.searchParams.get("reauthenticate")).toBe("true");
const callbackURL = new URL(loginURL.searchParams.get("callbackURL") ?? "", origin);
const oauth_query = callbackURL.search.slice(1);
const authenticated =
mode === "create"
? await post(
"sign-up/email",
{ ...credentials, email: `new-${unique}@example.com`, username: `new-${unique}` },
oldCookie,
)
: await post(
"sign-in/email",
{ email: credentials.email, password: credentials.password, oauth_query },
oldCookie,
);
expect(authenticated.status, await authenticated.clone().text()).toBe(200);
const newCookie = cookieOf(authenticated);
expect(newCookie).not.toBe(oldCookie);
const continuation =
mode === "create" ? await post("oauth2/continue", { created: true, oauth_query }, newCookie) : authenticated;
expect(continuation.status, await continuation.clone().text()).toBe(200);
const result = await continuation.json();
let target = new URL(result.url, origin);
if (target.pathname === "/api/auth/oauth") {
const response = await handleOAuth(new Request(target, { headers: { cookie: newCookie } }));
expect(response.status, await response.clone().text()).toBe(302);
target = new URL(response.headers.get("location") ?? "", origin);
}
expect(target.pathname).toBe("/auth/consent");
const accepted = await post("oauth2/consent", { accept: true, oauth_query: target.search.slice(1) }, newCookie);
expect(accepted.status, await accepted.clone().text()).toBe(200);
target = new URL((await accepted.json()).url, origin);
expect(target.origin).toBe("http://127.0.0.1:33921");
expect(target.searchParams.get("code")).toBeTruthy();
},
30_000,
);
});