From 634d1c059cbc556943d010d5d3020e2f70748169 Mon Sep 17 00:00:00 2001 From: dzucconi Date: Wed, 21 Oct 2026 14:45:09 -0400 Subject: [PATCH 1/5] Compare OAuth secrets in constant time Co-authored-by: Cursor --- src/auth/crypto.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/auth/crypto.ts b/src/auth/crypto.ts index de57894..e4d93a9 100644 --- a/src/auth/crypto.ts +++ b/src/auth/crypto.ts @@ -3,6 +3,15 @@ * available in Cloudflare Workers. */ +import { timingSafeEqual } from "node:crypto"; + +export function secretsEqual(left: string, right: string): boolean { + const encoder = new TextEncoder(); + const a = encoder.encode(left); + const b = encoder.encode(right); + return a.byteLength === b.byteLength && timingSafeEqual(a, b); +} + export function generateId(byteLength = 32): string { const bytes = new Uint8Array(byteLength); crypto.getRandomValues(bytes); @@ -24,7 +33,7 @@ export async function verifyPkce( const data = encoder.encode(codeVerifier); const digest = await crypto.subtle.digest("SHA-256", data); const computed = base64UrlEncode(new Uint8Array(digest)); - return computed === codeChallenge; + return secretsEqual(computed, codeChallenge); } function base64UrlEncode(bytes: Uint8Array): string { From 3460d81c7ab500c86136d879013affdb54011439 Mon Sep 17 00:00:00 2001 From: dzucconi Date: Wed, 21 Oct 2026 14:45:15 -0400 Subject: [PATCH 2/5] Add client metadata documents, consent, and stricter OAuth validation Support Claude and ChatGPT published client identities (CIMD) alongside dynamic registration, show a consent screen bound to the browser before Are.na sign-in, and include iss in redirects back to the client. Validate registration metadata and redirect URIs, honor public and client_secret_post authentication, bind codes to their client, and require well-formed PKCE verifiers and resource values. Scopes are validated, and the token response reports the scope Are.na granted. Co-authored-by: Cursor --- src/__tests__/http-transport.test.ts | 18 +- src/__tests__/oauth.test.ts | 607 +++++++++++++++++++++++++++ src/auth/clients.ts | 155 +++++++ src/auth/consent.ts | 55 +++ src/auth/routes.ts | 518 +++++++++++++---------- src/auth/scopes.ts | 11 + src/auth/store.ts | 42 +- 7 files changed, 1170 insertions(+), 236 deletions(-) create mode 100644 src/__tests__/oauth.test.ts create mode 100644 src/auth/clients.ts create mode 100644 src/auth/consent.ts create mode 100644 src/auth/scopes.ts diff --git a/src/__tests__/http-transport.test.ts b/src/__tests__/http-transport.test.ts index ae41c10..aa9e49f 100644 --- a/src/__tests__/http-transport.test.ts +++ b/src/__tests__/http-transport.test.ts @@ -188,7 +188,7 @@ describe("Authorization endpoint", () => { client_id: clientId, redirect_uri: "http://localhost:3000/callback", response_type: "token", - code_challenge: "abc", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256", }); const req = makeRequest("GET", `/authorize?${params}`); @@ -203,7 +203,7 @@ describe("Authorization endpoint", () => { client_id: "nonexistent", redirect_uri: "http://localhost:3000/callback", response_type: "code", - code_challenge: "abc", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256", }); const req = makeRequest("GET", `/authorize?${params}`); @@ -211,7 +211,7 @@ describe("Authorization endpoint", () => { expect(res.status).toBe(401); }); - it("redirects to Are.na OAuth on valid authorize request", async () => { + it("requests client consent on valid authorize request", async () => { const clientId = await getRegisteredClientId(); const params = new URLSearchParams({ client_id: clientId, @@ -223,12 +223,8 @@ describe("Authorization endpoint", () => { }); const req = makeRequest("GET", `/authorize?${params}`); const res = await app.fetch(req, env); - expect(res.status).toBe(302); - - const location = res.headers.get("Location")!; - expect(location).toContain("www.are.na/oauth/authorize"); - expect(location).toContain("client_id=test-arena-client-id"); - expect(location).toContain("response_type=code"); + expect(res.status).toBe(200); + expect(await res.text()).toContain("Continue to Are.na"); }); }); @@ -250,7 +246,7 @@ describe("Token endpoint", () => { expect(data.error).toBe("unsupported_grant_type"); }); - it("rejects invalid authorization code", async () => { + it("rejects incomplete token exchange parameters", async () => { const body = new URLSearchParams({ grant_type: "authorization_code", code: "invalid-code", @@ -264,6 +260,6 @@ describe("Token endpoint", () => { const res = await app.fetch(req, makeEnv()); expect(res.status).toBe(400); const data = (await res.json()) as { error: string }; - expect(data.error).toBe("invalid_grant"); + expect(data.error).toBe("invalid_request"); }); }); diff --git a/src/__tests__/oauth.test.ts b/src/__tests__/oauth.test.ts new file mode 100644 index 0000000..42f248a --- /dev/null +++ b/src/__tests__/oauth.test.ts @@ -0,0 +1,607 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import app from "../transports/http"; +import { OAuthStore } from "../auth/store"; + +const ORIGIN = "https://mcp.are.na"; +const REDIRECT = "http://127.0.0.1:3000/callback"; +const VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; +const CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; +const CLAUDE = "https://claude.ai/oauth/mcp-oauth-client-metadata"; +const CHATGPT = "https://chatgpt.com/oauth/client.json"; + +function environment() { + const data = new Map(); + const kv = { + get: vi + .fn() + .mockImplementation(async (key: string) => data.get(key) ?? null), + put: vi.fn(async (key: string, value: string) => { + data.set(key, value); + }), + delete: vi.fn(async (key: string) => { + data.delete(key); + }), + list: vi.fn(), + getWithMetadata: vi.fn(), + }; + return { + OAUTH_KV: kv, + ARENA_OAUTH_CLIENT_ID: "arena-app-id", + ARENA_OAUTH_CLIENT_SECRET: "arena-app-secret", + }; +} + +type Client = { client_id: string; client_secret?: string }; +let env: ReturnType; +const fetchMock = vi.fn(); + +beforeEach(() => { + env = environment(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +function request(path: string, init?: RequestInit) { + return app.fetch(new Request(ORIGIN + path, init), env); +} +function post( + path: string, + body: Record, + headers: Record = {}, +) { + return request(path, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...headers, + }, + body: new URLSearchParams(body), + }); +} +async function register(metadata: Record = {}) { + const response = await request("/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redirect_uris: [REDIRECT], + client_name: "Test app", + ...metadata, + }), + }); + expect(response.status).toBe(201); + return (await response.json()) as Client; +} +async function authorize( + client: Client, + overrides: Record = {}, +) { + return request( + `/authorize?${new URLSearchParams({ + client_id: client.client_id, + redirect_uri: REDIRECT, + response_type: "code", + code_challenge: CHALLENGE, + code_challenge_method: "S256", + state: "original-client-state", + ...overrides, + })}`, + ); +} +async function consent(client: Client, overrides: Record = {}) { + const response = await authorize(client, overrides); + expect(response.status).toBe(200); + const cookie = response.headers.get("Set-Cookie")!.split(";")[0]; + const page = await response.text(); + const state = /name="state" value="([a-f0-9]+)"/.exec(page)![1]; + const result = await post( + "/authorize/consent", + { state, decision: "allow" }, + { Cookie: cookie, Origin: ORIGIN }, + ); + expect(result.status).toBe(302); + return { state, cookie, upstream: new URL(result.headers.get("Location")!) }; +} +async function issueCode( + client: Client, + options: { scope?: string; tokenScope?: string; redirect?: string } = {}, +) { + const flow = await consent(client, { + ...(options.scope !== undefined ? { scope: options.scope } : {}), + ...(options.redirect ? { redirect_uri: options.redirect } : {}), + }); + fetchMock.mockResolvedValueOnce( + Response.json({ + access_token: "arena-user-token", + ...(options.tokenScope !== undefined + ? { scope: options.tokenScope } + : {}), + }), + ); + const response = await request( + `/callback?code=upstream-code&state=${flow.state}`, + { headers: { Cookie: flow.cookie } }, + ); + expect(response.status).toBe(302); + const location = new URL(response.headers.get("Location")!); + expect(location.searchParams.get("iss")).toBe(ORIGIN); + expect(location.searchParams.get("state")).toBe("original-client-state"); + const code = location.searchParams.get("code")!; + expect(code).toBeTruthy(); + return { code, flow }; +} +function exchange( + client: Client, + code: string, + overrides: Record = {}, +) { + return post("/token", { + grant_type: "authorization_code", + client_id: client.client_id, + code, + code_verifier: VERIFIER, + redirect_uri: REDIRECT, + ...(client.client_secret ? { client_secret: client.client_secret } : {}), + ...overrides, + }); +} +function metadata(clientId = CLAUDE, overrides: Record = {}) { + return { + client_id: clientId, + client_name: "Published app", + redirect_uris: [REDIRECT], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + ...overrides, + }; +} +function mockMetadata( + body = metadata(), + headers: Record = { "Cache-Control": "public, max-age=300" }, +) { + fetchMock.mockResolvedValueOnce(Response.json(body, { headers })); +} + +describe("OAuth scopes through authorization, callback, and token exchange", () => { + it.each([ + [undefined, "write"], + ["read", "read"], + ["write", "write"], + ["write read write", "read write"], + ])("keeps requested scope %s consistent", async (scope, expected) => { + const client = await register(); + const { code, flow } = await issueCode(client, { scope }); + expect(flow.upstream.searchParams.get("scope")).toBe(expected); + const response = await exchange(client, code); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + access_token: "arena-user-token", + token_type: "Bearer", + scope: expected, + }); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect((await exchange(client, code)).status).toBe(400); + }); + + it.each(["", "openid", "read admin", "READ", "read\twrite"])( + "rejects unsupported scope %j before saving a flow", + async (scope) => { + const client = await register(); + const response = await authorize(client, { scope }); + expect(await response.json()).toMatchObject({ error: "invalid_scope" }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("reports a narrower scope returned by Are.na", async () => { + const client = await register(); + const { code } = await issueCode(client, { + scope: "read write", + tokenScope: "read", + }); + expect(await (await exchange(client, code)).json()).toMatchObject({ + scope: "read", + }); + }); + + it("rejects unexpected scope escalation from upstream", async () => { + const client = await register(); + const flow = await consent(client, { scope: "read" }); + fetchMock.mockResolvedValueOnce( + Response.json({ access_token: "token", scope: "write" }), + ); + const response = await request( + `/callback?state=${flow.state}&code=upstream`, + { headers: { Cookie: flow.cookie } }, + ); + const url = new URL(response.headers.get("Location")!); + expect(url.searchParams.get("error")).toBe("server_error"); + expect(url.searchParams.has("code")).toBe(false); + }); + + it("reports the real write scope of legacy in-flight codes", async () => { + const client = await register(); + await new OAuthStore(env.OAUTH_KV).putAuthCode("legacy-code", { + arena_access_token: "legacy-token", + client_id: client.client_id, + redirect_uri: REDIRECT, + code_challenge: CHALLENGE, + code_challenge_method: "S256", + }); + expect(await (await exchange(client, "legacy-code")).json()).toMatchObject({ + scope: "write", + }); + }); +}); + +describe("client registration and token binding", () => { + it.each([ + { redirect_uris: [123] }, + { redirect_uris: [] }, + { redirect_uris: ["javascript:alert(1)"] }, + { redirect_uris: ["JavaScript://app.example/%0Aalert(1)"] }, + { redirect_uris: ["data:text/html,callback"] }, + { redirect_uris: ["https://app.example/callback#fragment"] }, + { redirect_uris: ["https://user:password@app.example/callback"] }, + { redirect_uris: ["https://*.example/callback"] }, + { redirect_uris: ["http://public.example/callback"] }, + { client_name: {} }, + { token_endpoint_auth_method: "client_secret_basic" }, + { grant_types: ["client_credentials"] }, + { response_types: ["token"] }, + ])("rejects malformed or unsupported metadata: %j", async (metadata) => { + const response = await request("/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ redirect_uris: [REDIRECT], ...metadata }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: "invalid_client_metadata", + }); + }); + + it("returns a registration error for malformed JSON", async () => { + const response = await request("/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }); + expect(response.status).toBe(400); + }); + + it.each([ + "https://app.example/callback", + "http://localhost:4321/callback", + "http://[::1]:4321/callback", + "cursor://anysphere.cursor-mcp/oauth/callback", + ])("supports callback %s", async (redirect) => { + await register({ redirect_uris: [redirect] }); + }); + + it("honors public DCR clients without generating a secret", async () => { + const client = await register({ token_endpoint_auth_method: "none" }); + expect(client.client_secret).toBeUndefined(); + const { code } = await issueCode(client); + expect( + (await exchange(client, code, { client_secret: "unexpected" })).status, + ).toBe(401); + expect((await exchange(client, code)).status).toBe(200); + }); + + it("requires confidential client credentials, including legacy registrations", async () => { + const client = await register(); + const store = new OAuthStore(env.OAUTH_KV); + const saved = (await store.getClient(client.client_id))!; + delete saved.token_endpoint_auth_method; + await store.putClient(saved); + const { code } = await issueCode(client); + expect((await exchange({ client_id: client.client_id }, code)).status).toBe( + 401, + ); + expect( + (await exchange(client, code, { client_secret: "wrong" })).status, + ).toBe(401); + expect((await exchange(client, code)).status).toBe(200); + }); + + it("accepts OAuth 2.1 token exchange without a redundant redirect_uri", async () => { + const client = await register({ token_endpoint_auth_method: "none" }); + const { code } = await issueCode(client); + const response = await post("/token", { + grant_type: "authorization_code", + client_id: client.client_id, + code, + code_verifier: VERIFIER, + }); + expect(response.status).toBe(200); + }); + + it("binds authorization codes to the client even when PKCE and redirect match", async () => { + const client = await register(); + const other = await register(); + const { code } = await issueCode(client); + expect(await (await exchange(other, code)).json()).toMatchObject({ + error: "invalid_grant", + }); + expect((await exchange(client, code)).status).toBe(200); + }); + + it.each>([ + { client_id: "" }, + { redirect_uri: "" }, + { redirect_uri: "https://attacker.example/callback" }, + { code_verifier: "short" }, + { code_verifier: "x".repeat(43) }, + { resource: "https://another-resource.example/mcp" }, + { client_assertion: "unverified-jwt" }, + ])( + "rejects invalid token requests without consuming the code: %j", + async (overrides) => { + const client = await register(); + const { code } = await issueCode(client); + const response = await exchange(client, code, overrides); + expect(response.status).toBeGreaterThanOrEqual(400); + expect((await exchange(client, code)).status).toBe(200); + }, + ); + + it.each>([ + { code_challenge_method: "" }, + { code_challenge_method: "plain" }, + { code_challenge: "short" }, + { redirect_uri: "https://attacker.example" }, + { resource: "https://wrong.example" }, + ])("rejects invalid authorization parameters: %j", async (overrides) => { + expect((await authorize(await register(), overrides)).status).toBe(400); + }); +}); + +describe("browser consent and callbacks", () => { + it("shows the client and destination with escaped untrusted text", async () => { + const client = await register({ + client_name: '', + }); + const response = await authorize(client); + const page = await response.text(); + expect(page).not.toContain("