diff --git a/README.md b/README.md index 7e4202c..49bcdce 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ Instructions checked against official documentation on **October 20, 2026**. App 1. Open **Customize > Connectors**, then **+ Add > Add custom connector**. 2. Enter **Are.na** and `https://mcp.are.na/mcp`, then continue. -3. Review the detected OAuth settings. Choose **Sign in now** and, if shown, **Register automatically** under **OAuth client**. -4. Finish adding the connector and sign in to Are.na. Select **Connect** if sign-in has not started. +3. Review the detected OAuth settings. Choose **Sign in now** and, if shown, **Use Claude's published identity** under **OAuth client**. **Register automatically** is also supported. +4. Finish adding the connector, review the app and permissions on the Are.na connection screen, then sign in to Are.na. Select **Connect** if sign-in has not started. 5. Enable Are.na in the conversation's connectors menu. -The server currently supports automatic registration (DCR), not **Use Claude's published identity**. In older dialogs with optional OAuth Client ID and Secret fields, leave both blank. +The server supports Claude's published identity (CIMD) and automatic registration (DCR). In older dialogs with optional OAuth Client ID and Secret fields, leave both blank. On Team or Enterprise, an authorized administrator must add the connector under **Organization settings > Connectors** first; each member then connects their own Are.na account. Claude currently allows one custom connector on Free accounts. @@ -41,7 +41,7 @@ On Team or Enterprise, an authorized administrator must add the connector under 1. Open [ChatGPT Plugins](https://chatgpt.com/plugins), select **+**, then **Add custom MCP server**. 2. Name it **Are.na**. Suggested description: “Search Are.na, explore channels, and save and organize blocks.” -3. Enter `https://mcp.are.na/mcp` as the public server URL and choose **OAuth**. Use dynamic client registration if a registration choice is shown; no client ID or secret needs to be supplied manually. +3. Enter `https://mcp.are.na/mcp` as the public server URL and choose **OAuth**. Published client identity (CIMD) and dynamic registration (DCR) are supported; no client ID or secret needs to be supplied manually. 4. Review the permissions and warning, then select **Create as a plugin**. Complete Are.na sign-in when prompted. 5. Review the discovered tools and install the resulting plugin. In a new chat, type **@** and select **Are.na**. @@ -122,7 +122,7 @@ Then try a channel you know: - Use the full `https://mcp.are.na/mcp` URL, including `/mcp`, and select Streamable HTTP if asked for a transport. - If tools are missing, check that Are.na is connected and enabled in the chat. In ChatGPT, refresh the custom connection's metadata and start a new chat after server updates. -- If sign-in fails with an unknown client, choose automatic registration in Claude and reconnect. The current server does not support published client identities (CIMD). +- If sign-in fails with an unknown or invalid client, remove and re-add the connection so the app registers again. Published client identities (CIMD) are supported for Claude and ChatGPT; other apps register automatically. - If a workspace hides custom-server setup, its administrator needs to enable or provision the connection. ## Advanced: local server and access tokens @@ -254,13 +254,24 @@ Generated output schemas preserve the OpenAPI success-response contracts and inc The server uses the MCP SDK's low-level tool handlers to publish JSON Schemas directly, with Zod for input parsing and a Workers-compatible JSON Schema validator for outputs. +## OAuth behavior + +The hosted server is an OAuth authorization server for MCP clients and delegates sign-in to Are.na. + +- **Scopes:** `read`, `write`, or both. Omitting `scope` requests `write`; unknown or empty scopes are rejected. The token response reports the scope Are.na granted, and the Are.na API enforces it. +- **Consent:** Before Are.na sign-in, a consent screen shows the app, the requested access, and where the user will return. Consent and the callback must happen in the same browser. Redirects back to the app include the `iss` parameter. +- **Dynamic client registration (DCR):** Clients register as public clients (`none`, with PKCE) or with `client_secret_post`, the default when no method is given. Redirect URIs must use HTTPS, loopback HTTP, or a private-use scheme such as `cursor://`. Token exchange verifies the client, its secret when one was issued, and PKCE. `redirect_uri` is optional at token exchange but must match exactly when supplied. +- **Client ID metadata documents (CIMD):** Only Claude (`https://claude.ai/oauth/mcp-oauth-client-metadata`) and ChatGPT (`https://chatgpt.com/oauth/client.json` and `https://chatgpt.com/oauth/{callback_id}/client.json`) are trusted; adding another requires a code change. A document must match its URL, list the callback, and support public-client authentication (`none`). Fetches time out after five seconds, are limited to 32 KiB, never follow redirects, and are cached only when the response allows, for at most five minutes. + +Known limitations: authorization state and codes are stored in Workers KV, which is eventually consistent and has no atomic consume, so single-use codes are not guaranteed under concurrent redemption. Access tokens are Are.na tokens passed through to the client rather than separate MCP-audience tokens. + ## Making installation easier (maintainers) -The recommended product flow is: choose an app, install Are.na, and sign in. The hosted endpoint already implements OAuth discovery, dynamic client registration, and PKCE. A local package or desktop extension is not needed for that flow. +The recommended product flow is: choose an app, install Are.na, and sign in. The hosted endpoint already implements OAuth discovery, client registration (DCR and CIMD), consent, and PKCE. A local package or desktop extension is not needed for that flow. Recommended next steps, in order: -1. **Verify complete installs and improve authentication compatibility.** Test a fresh account connection, a read, an explicitly approved write, and reconnect in each supported app. Add CIMD support so clients can use their published OAuth identities; until then, keep DCR as the documented choice. Review scope handling and client validation before expanding distribution. The current authorization route defaults upstream access to `write` when no scope is supplied, while the token response defaults its reported scope to `read`; those defaults need to agree. +1. **Verify complete installs.** Test a fresh account connection, consent, a read, an explicitly approved write, and reconnect in each supported app. See [OAuth behavior](#oauth-behavior). 2. **Add tool annotations before directory submission.** The tool registry currently publishes descriptions and input/output schemas, but no `readOnlyHint`, `destructiveHint`, or `openWorldHint`. Classify each tool by its actual behavior, including composite tools, and verify the advertised values. See [OpenAI's MCP review requirements](https://developers.openai.com/plugins/deploy/app-review). 3. **Submit to the app directories.** These listings would let users find Are.na by name and install it without pasting a server URL. Prepare branding, support and privacy URLs, a reviewer account, and representative prompts. Submission and publication are separate from updating these docs: 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..ae2767c --- /dev/null +++ b/src/__tests__/oauth.test.ts @@ -0,0 +1,755 @@ +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("