| 1 | /** |
| 2 | * OAuth for MCP clients (auth/oauth.ts): which redirect URIs an app may register and use, the access |
| 3 | * preselected on the consent page, resource indicators, metadata document client IDs, and the |
| 4 | * discovery documents. The whole flow, in a browser, is test/e2e/oauth.test.mjs. |
| 5 | */ |
| 6 | import { describe, expect, it } from "vitest"; |
| 7 | import { adminAllowed, authorizationServerMetadata, checkResource, formActionSource, isMetadataClientId, mcpChallenge, protectedResourceMetadata, redirectLabel, redirectMatches, requestedScope, validRedirectUri } from "../../src/auth/oauth.js"; |
| 8 | import { bucketsFor } from "../../src/lib/rate-limit.js"; |
| 9 | |
| 10 | const env = { PUBLIC_URL: "https://diffinery.com" }; |
| 11 | |
| 12 | describe("redirect URIs", () => { |
| 13 | it("allows https anywhere, http only on this computer, and apps' own schemes", () => { |
| 14 | for (const ok of ["https://claude.ai/api/mcp/auth_callback", "http://localhost/callback", "http://127.0.0.1:33418/cb", "http://[::1]:9/x", "cursor://anysphere.cursor-retrieval/oauth/callback", "vscode://vscode.github-authentication/did-authenticate"]) expect(validRedirectUri(ok), ok).toBe(true); |
| 15 | for (const bad of ["http://evil.example/cb", "http://localhost.evil.example/cb", "javascript:alert(1)", "data:text/html,x", "file:///etc/passwd", "https://a.example/cb#x", "https://user:pw@a.example/cb", "chrome-extension://abc/cb", "not a url", "", 42]) expect(validRedirectUri(bad), String(bad)).toBe(false); |
| 16 | }); |
| 17 | |
| 18 | it("matches exactly, except a loopback port (RFC 8252)", () => { |
| 19 | const reg = ["http://127.0.0.1/callback", "https://app.example/cb"]; |
| 20 | expect(redirectMatches(reg, "https://app.example/cb")).toBe(true); |
| 21 | expect(redirectMatches(reg, "http://127.0.0.1:51234/callback")).toBe(true); |
| 22 | expect(redirectMatches(reg, "http://127.0.0.1:51234/other")).toBe(false); |
| 23 | expect(redirectMatches(reg, "http://localhost:51234/callback")).toBe(false); |
| 24 | expect(redirectMatches(reg, "https://app.example:8443/cb")).toBe(false); |
| 25 | expect(redirectMatches(reg, "https://app.example/cb?x=1")).toBe(false); |
| 26 | }); |
| 27 | |
| 28 | it("names where the person goes back to, and lets the form lead there", () => { |
| 29 | expect(redirectLabel("http://127.0.0.1:5/callback")).toBe("an app on this computer"); |
| 30 | expect(redirectLabel("https://claude.ai/api/mcp/auth_callback")).toBe("claude.ai"); |
| 31 | expect(redirectLabel("cursor://anysphere.cursor-retrieval/oauth")).toBe("cursor://anysphere.cursor-retrieval"); |
| 32 | expect(formActionSource("https://claude.ai/api/mcp/auth_callback")).toBe("https://claude.ai"); |
| 33 | expect(formActionSource("cursor://anysphere.cursor-retrieval/oauth")).toBe("cursor:"); |
| 34 | }); |
| 35 | }); |
| 36 | |
| 37 | describe("scopes and resources", () => { |
| 38 | it("preselects write for apps that ask for everything, never admin", () => { |
| 39 | expect(requestedScope("read write admin")).toBe("write"); |
| 40 | expect(requestedScope("admin")).toBe("write"); |
| 41 | expect(requestedScope("read")).toBe("read"); |
| 42 | expect(requestedScope("")).toBe("write"); |
| 43 | expect(requestedScope("openid profile")).toBe("write"); |
| 44 | }); |
| 45 | |
| 46 | it("never lets an app act as a site administrator with admin access", () => { |
| 47 | expect(adminAllowed({ site_admin: 1 } as never)).toBe(false); |
| 48 | expect(adminAllowed({ site_admin: 0 } as never)).toBe(true); |
| 49 | }); |
| 50 | |
| 51 | it("accepts only this server or its /mcp as the resource", () => { |
| 52 | expect(() => checkResource(env, "https://diffinery.com/mcp")).not.toThrow(); |
| 53 | expect(() => checkResource(env, "https://diffinery.com/")).not.toThrow(); |
| 54 | expect(() => checkResource(env, undefined)).not.toThrow(); |
| 55 | expect(() => checkResource(env, "https://evil.example/mcp")).toThrow(/resource must be/); |
| 56 | }); |
| 57 | |
| 58 | it("takes https URLs with a path as metadata document client IDs (plain http only locally)", () => { |
| 59 | expect(isMetadataClientId("https://claude.ai/oauth/claude-code-client-metadata")).toBe(true); |
| 60 | expect(isMetadataClientId("https://claude.ai/")).toBe(false); |
| 61 | expect(isMetadataClientId("http://127.0.0.1:9/client.json")).toBe(false); |
| 62 | expect(isMetadataClientId("http://127.0.0.1:9/client.json", true)).toBe(true); |
| 63 | expect(isMetadataClientId("dfc_abc")).toBe(false); |
| 64 | }); |
| 65 | }); |
| 66 | |
| 67 | describe("discovery", () => { |
| 68 | it("points /mcp's 401 at the resource metadata, and that at this server", () => { |
| 69 | expect(mcpChallenge(env)).toBe('Bearer realm="Diffinery", resource_metadata="https://diffinery.com/.well-known/oauth-protected-resource/mcp"'); |
| 70 | expect(mcpChallenge(env, "invalid_token")).toMatch(/error="invalid_token"$/); |
| 71 | expect(protectedResourceMetadata(env)).toMatchObject({ resource: "https://diffinery.com/mcp", authorization_servers: ["https://diffinery.com"] }); |
| 72 | expect(authorizationServerMetadata(env)).toMatchObject({ issuer: "https://diffinery.com", token_endpoint: "https://diffinery.com/oauth/token", code_challenge_methods_supported: ["S256"], client_id_metadata_document_supported: true }); |
| 73 | }); |
| 74 | |
| 75 | it("counts registrations against the sign-in rate limit", () => { |
| 76 | expect(bucketsFor("POST", "/oauth/register", { type: "anonymous" }, "1.2.3.4")).toEqual([["RL_AUTH", "ip:1.2.3.4"]]); |
| 77 | expect(bucketsFor("POST", "/oauth/token", { type: "anonymous" }, "1.2.3.4")).toEqual([["RL_ANON", "ip:1.2.3.4"]]); |
| 78 | }); |
| 79 | }); |