b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
125 lines
5.6 KiB
TypeScript
125 lines
5.6 KiB
TypeScript
/**
|
|
* Unit tests for OAuth discovery endpoint response shapes.
|
|
*
|
|
* These endpoints are public, unauthenticated, and return JSON metadata
|
|
* that MCP clients use to discover OAuth endpoints. The response shapes
|
|
* are contractual — changing them breaks MCP client compatibility.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
import { GET as getAuthorizationServer } from "../../../src/astro/routes/api/well-known/oauth-authorization-server.js";
|
|
// We import the GET handlers directly — they're plain functions that take
|
|
// an Astro-like context and return a Response.
|
|
import { GET as getProtectedResource } from "../../../src/astro/routes/api/well-known/oauth-protected-resource.js";
|
|
import { VALID_SCOPES } from "../../../src/auth/api-tokens.js";
|
|
|
|
/** Minimal mock of what the route handlers actually use from the Astro context. */
|
|
function mockContext(origin = "https://example.com") {
|
|
return {
|
|
url: new URL("/.well-known/test", origin),
|
|
locals: { emdash: undefined },
|
|
} as unknown as Parameters<typeof getProtectedResource>[0];
|
|
}
|
|
|
|
describe("Protected Resource Metadata (RFC 9728)", () => {
|
|
it("returns correct resource and authorization_servers", async () => {
|
|
const response = await getProtectedResource(mockContext());
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as Record<string, unknown>;
|
|
expect(body.resource).toBe("https://example.com/_emdash/api/mcp");
|
|
expect(body.authorization_servers).toEqual(["https://example.com/_emdash"]);
|
|
});
|
|
|
|
it("includes all valid scopes", async () => {
|
|
const response = await getProtectedResource(mockContext());
|
|
const body = (await response.json()) as { scopes_supported: string[] };
|
|
expect(body.scopes_supported).toEqual([...VALID_SCOPES]);
|
|
});
|
|
|
|
it("advertises header-based bearer method", async () => {
|
|
const response = await getProtectedResource(mockContext());
|
|
const body = (await response.json()) as { bearer_methods_supported: string[] };
|
|
expect(body.bearer_methods_supported).toEqual(["header"]);
|
|
});
|
|
|
|
it("sets CORS and cache headers", async () => {
|
|
const response = await getProtectedResource(mockContext());
|
|
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
|
expect(response.headers.get("Cache-Control")).toContain("public");
|
|
});
|
|
|
|
it("uses the request origin for URLs", async () => {
|
|
const response = await getProtectedResource(mockContext("https://cms.mysite.com"));
|
|
const body = (await response.json()) as Record<string, unknown>;
|
|
expect(body.resource).toBe("https://cms.mysite.com/_emdash/api/mcp");
|
|
expect(body.authorization_servers).toEqual(["https://cms.mysite.com/_emdash"]);
|
|
});
|
|
});
|
|
|
|
describe("Authorization Server Metadata (RFC 8414)", () => {
|
|
it("returns correct issuer and endpoints", async () => {
|
|
const response = await getAuthorizationServer(mockContext());
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as Record<string, unknown>;
|
|
expect(body.issuer).toBe("https://example.com/_emdash");
|
|
expect(body.authorization_endpoint).toBe("https://example.com/_emdash/oauth/authorize");
|
|
expect(body.token_endpoint).toBe("https://example.com/_emdash/api/oauth/token");
|
|
expect(body.device_authorization_endpoint).toBe(
|
|
"https://example.com/_emdash/api/oauth/device/code",
|
|
);
|
|
});
|
|
|
|
it("supports authorization_code, refresh_token, and device_code grants", async () => {
|
|
const response = await getAuthorizationServer(mockContext());
|
|
const body = (await response.json()) as { grant_types_supported: string[] };
|
|
expect(body.grant_types_supported).toContain("authorization_code");
|
|
expect(body.grant_types_supported).toContain("refresh_token");
|
|
expect(body.grant_types_supported).toContain("urn:ietf:params:oauth:grant-type:device_code");
|
|
});
|
|
|
|
it("requires S256 code challenge method only", async () => {
|
|
const response = await getAuthorizationServer(mockContext());
|
|
const body = (await response.json()) as { code_challenge_methods_supported: string[] };
|
|
expect(body.code_challenge_methods_supported).toEqual(["S256"]);
|
|
});
|
|
|
|
it("only supports code response type", async () => {
|
|
const response = await getAuthorizationServer(mockContext());
|
|
const body = (await response.json()) as { response_types_supported: string[] };
|
|
expect(body.response_types_supported).toEqual(["code"]);
|
|
});
|
|
|
|
it("supports public clients (no auth method)", async () => {
|
|
const response = await getAuthorizationServer(mockContext());
|
|
const body = (await response.json()) as { token_endpoint_auth_methods_supported: string[] };
|
|
expect(body.token_endpoint_auth_methods_supported).toEqual(["none"]);
|
|
});
|
|
|
|
it("advertises dynamic client registration", async () => {
|
|
const response = await getAuthorizationServer(mockContext());
|
|
const body = (await response.json()) as { registration_endpoint: string };
|
|
expect(body.registration_endpoint).toBe("https://example.com/_emdash/api/oauth/register");
|
|
});
|
|
|
|
it("includes all valid scopes", async () => {
|
|
const response = await getAuthorizationServer(mockContext());
|
|
const body = (await response.json()) as { scopes_supported: string[] };
|
|
expect(body.scopes_supported).toEqual([...VALID_SCOPES]);
|
|
});
|
|
|
|
it("sets CORS and cache headers", async () => {
|
|
const response = await getAuthorizationServer(mockContext());
|
|
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
|
expect(response.headers.get("Cache-Control")).toContain("public");
|
|
});
|
|
|
|
it("does not advertise unsupported client_id metadata documents", async () => {
|
|
const response = await getAuthorizationServer(mockContext());
|
|
const body = (await response.json()) as Record<string, unknown>;
|
|
expect(body).not.toHaveProperty("client_id_metadata_document_supported");
|
|
});
|
|
});
|