From cfb79a19078048d664e4a5d9089fda09e7524d2f Mon Sep 17 00:00:00 2001 From: Pedro Miranda Date: Tue, 3 Nov 2026 16:06:10 +0000 Subject: [PATCH 1/2] feat(oauth): add early support for oauth --- .../mcp-servers/crystallize/src/app.ts | 7 ++- .../mcp-servers/crystallize/src/core/oauth.ts | 54 +++++++++++++++++++ .../crystallize/src/middlewares/auth.ts | 27 ++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 use-crystallize/mcp-servers/crystallize/src/core/oauth.ts diff --git a/use-crystallize/mcp-servers/crystallize/src/app.ts b/use-crystallize/mcp-servers/crystallize/src/app.ts index b915c9f..504aa5a 100644 --- a/use-crystallize/mcp-servers/crystallize/src/app.ts +++ b/use-crystallize/mcp-servers/crystallize/src/app.ts @@ -8,10 +8,14 @@ import { buildSessionEvent } from "./core/analytics"; import { readExposeFlags } from "./core/expose-flags"; import { isSessionStartRequest } from "./core/mcp-request"; import { PLAUSIBLE_EVENTS_ENDPOINT } from "./core/services/plausible-analytics-tracker"; +import { protectedResourceMetadata, tenantFromMcpPath, type OAuthEnv } from "./core/oauth"; export const createApp = () => { const app = new Hono(); app.use(servicesProvider); + app.get("/.well-known/oauth-protected-resource/mcp/:tenant{@[^/]+}", (c) => + c.json(protectedResourceMetadata(c.req.url, c.req.param("tenant").slice(1), c.env as OAuthEnv)), + ); app.get("/", (c) => c.html( landingPage({ @@ -33,7 +37,8 @@ export const createApp = () => { // The server is already built per request by `servicesProvider`, so the factory just hands it over. const mcpServer = c.get("services").mcpServer; const handler = createMcpHandler(() => mcpServer, { - route: "/mcp", + // `/mcp`, or `/mcp/@` for OAuth clients (the handler matches its route exactly) + route: tenantFromMcpPath(new URL(c.req.url).pathname) ? new URL(c.req.url).pathname : "/mcp", authContext: { props: c.get("authContext"), }, diff --git a/use-crystallize/mcp-servers/crystallize/src/core/oauth.ts b/use-crystallize/mcp-servers/crystallize/src/core/oauth.ts new file mode 100644 index 0000000..b5a11b5 --- /dev/null +++ b/use-crystallize/mcp-servers/crystallize/src/core/oauth.ts @@ -0,0 +1,54 @@ +import { apiBaseUrl, PRODUCTION_API_ORIGIN } from "./api-origin"; + +export const OAUTH_ACCESS_TOKEN_PREFIX = "crz_oat_"; + +const TENANT_PATH = /^\/mcp\/@([^/?#@\s]+)$/; + +export const tenantFromMcpPath = (pathname: string): string | undefined => TENANT_PATH.exec(pathname)?.[1]; + +export type OAuthEnv = { + // local development only: where `https://api.crystallize.com` lives (e.g. http://localhost:3001). + CRYSTALLIZE_LOCAL_API_URL?: string; +}; + +export const authorizationServer = (env: OAuthEnv) => + `${env.CRYSTALLIZE_LOCAL_API_URL ?? apiBaseUrl("api", PRODUCTION_API_ORIGIN)}/oauth`; + +export const mcpResource = (requestUrl: string, tenantIdentifier: string) => + `${new URL(requestUrl).origin}/mcp/@${tenantIdentifier}`; + +export const protectedResourceMetadataUrl = (requestUrl: string, tenantIdentifier: string) => + `${new URL(requestUrl).origin}/.well-known/oauth-protected-resource/mcp/@${tenantIdentifier}`; + +/** RFC 9728: tells an MCP client which authorization server issues tokens for this tenant. */ +export const protectedResourceMetadata = (requestUrl: string, tenantIdentifier: string, env: OAuthEnv) => ({ + resource: mcpResource(requestUrl, tenantIdentifier), + authorization_servers: [authorizationServer(env)], + bearer_methods_supported: ["header"], +}); + +/** A 401 that starts the MCP OAuth flow (RFC 9728 §5.1). */ +export const oauthChallenge = (requestUrl: string, tenantIdentifier: string, error?: "invalid_token") => + new Response(JSON.stringify({ error: error ?? "unauthorized" }), { + status: 401, + headers: { + "Content-Type": "application/json", + "WWW-Authenticate": [ + `Bearer resource_metadata="${protectedResourceMetadataUrl(requestUrl, tenantIdentifier)}"`, + ...(error ? [`error="${error}"`] : []), + ].join(", "), + }, + }); + +/** + * Checks the token against the Core API for the tenant: Core validates it (audience, tenant, user) on every call, + * so a cheap query is the source of truth here too. + */ +export const isOAuthTokenValid = async (token: string, tenantIdentifier: string, origin: string) => { + const response = await fetch(`${apiBaseUrl("api", origin)}/@${tenantIdentifier}`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query: "{ __typename }" }), + }); + return response.ok; +}; diff --git a/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts b/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts index d19f7fe..cf9363e 100644 --- a/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts +++ b/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts @@ -4,6 +4,12 @@ import { createClient } from "@crystallize/js-api-client"; import { getCookie } from "hono/cookie"; import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; import { apiBaseUrl, ENVIRONMENT_HEADER, resolveApiOrigin } from "../core/api-origin"; +import { + isOAuthTokenValid, + OAUTH_ACCESS_TOKEN_PREFIX, + oauthChallenge, + tenantFromMcpPath, +} from "../core/oauth"; const meQuery = `query { me { tenants { tenant { id name identifier staticAuthToken } } } }`; @@ -53,6 +59,27 @@ export const authMiddleware = createMiddleware(async (c, next) => { const sessionId = getCookie(c, "connect.sid") ?? c.req.header("X-Crystallize-Session-Id"); const bearerToken = extractBearerToken(c.req.header("Authorization")); + // `/mcp/@`: the OAuth endpoint. Crystallize OAuth tokens only, and a 401 that starts the flow. + const oauthTenant = tenantFromMcpPath(new URL(c.req.url).pathname); + if (oauthTenant) { + if (!bearerToken?.startsWith(OAUTH_ACCESS_TOKEN_PREFIX)) { + return oauthChallenge(c.req.url, oauthTenant); + } + if (!(await isOAuthTokenValid(bearerToken, oauthTenant, origin))) { + return oauthChallenge(c.req.url, oauthTenant, "invalid_token"); + } + + c.set("authContext", { + type: "bearer", + bearerToken, + origin, + // the token is bound to this one tenant; Core enforces it on every call + tenants: [{ id: oauthTenant, identifier: oauthTenant, name: oauthTenant }], + }); + await next(); + return; + } + if (!sessionId && !bearerToken && (!accessTokenId || !accessTokenSecret)) { return c.json( { From ae4a9c8fd5739ba7e936df843b29acedc43eb4ed Mon Sep 17 00:00:00 2001 From: Pedro Miranda Date: Mon, 9 Nov 2026 17:16:56 +0000 Subject: [PATCH 2/2] feat(oauth): use /mcp --- .../mcp-servers/crystallize/AGENTS.md | 35 ++++- .../mcp-servers/crystallize/src/app.ts | 12 +- .../crystallize/src/contracts/app-context.ts | 4 +- .../mcp-servers/crystallize/src/core/oauth.ts | 96 +++++++++----- .../crystallize/src/middlewares/auth.ts | 32 +++-- .../tests/middlewares/auth.test.ts | 121 +++++++++++++++++- 6 files changed, 246 insertions(+), 54 deletions(-) diff --git a/use-crystallize/mcp-servers/crystallize/AGENTS.md b/use-crystallize/mcp-servers/crystallize/AGENTS.md index f3bff45..24f584f 100644 --- a/use-crystallize/mcp-servers/crystallize/AGENTS.md +++ b/use-crystallize/mcp-servers/crystallize/AGENTS.md @@ -503,6 +503,7 @@ Adding a tool means adding `origin: authContext.origin` to its `createClient` an builds. `grep -n 'crystallize\.com' src` should only ever hit `api-origin.ts`, the landing page and the UI panels' static CSP. +<<<<<<< HEAD #### Two workers: dev (top level) and production (`env.production`) `wrangler.jsonc` describes two Workers running the same code. The **top level is the dev worker**, @@ -552,6 +553,31 @@ One-time setup, outside the repo: so create the Worker in the dashboard first (any template; the first deploy replaces it). - **`CRYSTALLIZE_DEV_API_ORIGIN`** as a Secret on the dev Worker (dashboard or `wrangler secret put`). Secrets survive deploys; without it every header-less request is a 500. +======= +### OAuth (MCP authorization) + +MCP clients that support the MCP authorization spec (2025-06-18) can sign in with Crystallize instead of pasting +credentials. The Core API is the authorization server; this server is the protected resource. **Off** unless +`CRYSTALLIZE_OAUTH_API_URL` is set (see `src/core/oauth.ts`). + +- **The client acts as the user**, not a tenant: one token for `/mcp`, valid on every tenant the user can access. + Tool calls name their tenant; the Core API applies the user's role on that tenant on every call. Picking + tenants and per-tenant permissions at consent is planned, not built. +- **Discovery**: with OAuth on, a request with no credentials gets a **401** with + `WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp"` (instead of the 403). + That document (RFC 9728) names `/mcp` as the resource and `${CRYSTALLIZE_OAUTH_API_URL}/oauth` as the + authorization server; the client registers itself (DCR) and runs the PKCE code flow from there. +- **Tokens**: `crz_oat_…` bearer tokens go to the Core API's `GET /oauth-grant/tenants`, which validates them and + returns the user's tenants for `authContext.tenants` (`type: "bearer"`, forwarded as is). A refused token is a + 401 `error="invalid_token"`, so the client refreshes or signs in again. Other bearer tokens are plugin JWTs, as + before. +- **Environment**: the token only works where it was issued, so `authContext.origin` comes from + `CRYSTALLIZE_OAUTH_API_URL` (`https://api`), not `X-Crystallize-Environment`. A local + `http://localhost:…` Core API has no suffix: the flow and `tenant-overview` work locally, tools that call the + APIs through js-api-client do not. +- **Core API side**: its `OAUTH_MCP_RESOURCE` must be this server's exact `/mcp`, and its `oauth` flag on. +- Only the Core API accepts these tokens today. Tools for the Catalogue, Discovery and Shop APIs fail with them. +>>>>>>> 79bdd53 (feat(oauth): use /mcp) ### MCP protocol versions @@ -575,7 +601,8 @@ declares as its peer — bump them together with `agents`, never on their own. ` ```ts type AppContext = { // CRYSTALLIZE_DEV_API_ORIGIN is a secret, so `bun cf-typegen` only emits it when `.env` has it — declared by hand. - Bindings: CloudflareBindings & { CRYSTALLIZE_DEV_API_ORIGIN?: string }; + // So is the optional CRYSTALLIZE_OAUTH_API_URL (OAuthEnv). + Bindings: CloudflareBindings & { CRYSTALLIZE_DEV_API_ORIGIN?: string } & OAuthEnv; Variables: { authContext: AuthContext; // { type: token|session|bearer, credentials, origin, tenants } services: Services; // { mcpServer, tenantMatcher, analyticsTracker } @@ -623,6 +650,6 @@ Use `c.set()` / `c.get()` in middleware/handlers. Extend `Variables` when adding the top-level dev worker sets none of them. `CRYSTALLIZE_DEFAULT_ENVIRONMENT` is set on both (see "Two workers" above). - **Secrets** (`wrangler secret put`, or `.env` locally — never `vars`): `CRYSTALLIZE_DEV_API_ORIGIN`, the dev - origin suffix a request selects with `X-Crystallize-Environment: dev` or the dev worker's default (see - "Environments" above). Unset on a production-default server means production only; required on the dev worker - and locally. + origin suffix a request selects with `X-Crystallize-Environment: dev` (see "Environments" above). Unset means + production only. `CRYSTALLIZE_OAUTH_API_URL`, the Core API that issues OAuth tokens (see "OAuth" above); a secret + for the same reason when it names a dev host. Unset means OAuth is off. \ No newline at end of file diff --git a/use-crystallize/mcp-servers/crystallize/src/app.ts b/use-crystallize/mcp-servers/crystallize/src/app.ts index 504aa5a..ce4c23e 100644 --- a/use-crystallize/mcp-servers/crystallize/src/app.ts +++ b/use-crystallize/mcp-servers/crystallize/src/app.ts @@ -8,14 +8,15 @@ import { buildSessionEvent } from "./core/analytics"; import { readExposeFlags } from "./core/expose-flags"; import { isSessionStartRequest } from "./core/mcp-request"; import { PLAUSIBLE_EVENTS_ENDPOINT } from "./core/services/plausible-analytics-tracker"; -import { protectedResourceMetadata, tenantFromMcpPath, type OAuthEnv } from "./core/oauth"; +import { oauthApiUrl, PROTECTED_RESOURCE_METADATA_PATH, protectedResourceMetadata } from "./core/oauth"; export const createApp = () => { const app = new Hono(); app.use(servicesProvider); - app.get("/.well-known/oauth-protected-resource/mcp/:tenant{@[^/]+}", (c) => - c.json(protectedResourceMetadata(c.req.url, c.req.param("tenant").slice(1), c.env as OAuthEnv)), - ); + app.get(PROTECTED_RESOURCE_METADATA_PATH, (c) => { + const apiUrl = oauthApiUrl(c.env); + return apiUrl ? c.json(protectedResourceMetadata(c.req.url, apiUrl)) : c.notFound(); + }); app.get("/", (c) => c.html( landingPage({ @@ -37,8 +38,7 @@ export const createApp = () => { // The server is already built per request by `servicesProvider`, so the factory just hands it over. const mcpServer = c.get("services").mcpServer; const handler = createMcpHandler(() => mcpServer, { - // `/mcp`, or `/mcp/@` for OAuth clients (the handler matches its route exactly) - route: tenantFromMcpPath(new URL(c.req.url).pathname) ? new URL(c.req.url).pathname : "/mcp", + route: "/mcp", authContext: { props: c.get("authContext"), }, diff --git a/use-crystallize/mcp-servers/crystallize/src/contracts/app-context.ts b/use-crystallize/mcp-servers/crystallize/src/contracts/app-context.ts index a15fc33..05fb7a3 100644 --- a/use-crystallize/mcp-servers/crystallize/src/contracts/app-context.ts +++ b/use-crystallize/mcp-servers/crystallize/src/contracts/app-context.ts @@ -1,4 +1,5 @@ import { Services } from "../core/container"; +import type { OAuthEnv } from "../core/oauth"; type Tenant = { id: string; @@ -39,7 +40,8 @@ export type AppContext = { // CRYSTALLIZE_DEV_API_ORIGIN is a *secret* (`wrangler secret put` / `.env` locally), so `bun cf-typegen` only // emits it when the local `.env` has it. Declared here so the type never depends on that. // CRYSTALLIZE_DEFAULT_ENVIRONMENT is declared too, so the code never relies on it being set (unset = production). - Bindings: CloudflareBindings & { CRYSTALLIZE_DEV_API_ORIGIN?: string; CRYSTALLIZE_DEFAULT_ENVIRONMENT?: string }; + // CRYSTALLIZE_OAUTH_API_URL is optional (OAuth is off without it), so it is declared here too. See core/oauth.ts. + Bindings: CloudflareBindings & { CRYSTALLIZE_DEV_API_ORIGIN?: string; CRYSTALLIZE_DEFAULT_ENVIRONMENT?: string } & OAuthEnv; Variables: { authContext: AuthContext; services: Services; diff --git a/use-crystallize/mcp-servers/crystallize/src/core/oauth.ts b/use-crystallize/mcp-servers/crystallize/src/core/oauth.ts index b5a11b5..e5115c2 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/oauth.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/oauth.ts @@ -1,54 +1,90 @@ -import { apiBaseUrl, PRODUCTION_API_ORIGIN } from "./api-origin"; +import type { AuthContext } from "../contracts/app-context"; -export const OAUTH_ACCESS_TOKEN_PREFIX = "crz_oat_"; +/** + * The MCP client authorizes as the user, not a tenant: one token for `/mcp`, valid on every tenant the user can + * access. Each tool call names its tenant, and the Core API applies the user's role on that tenant. + */ -const TENANT_PATH = /^\/mcp\/@([^/?#@\s]+)$/; +export const OAUTH_ACCESS_TOKEN_PREFIX = "crz_oat_"; -export const tenantFromMcpPath = (pathname: string): string | undefined => TENANT_PATH.exec(pathname)?.[1]; +const MCP_PATH = "/mcp"; export type OAuthEnv = { - // local development only: where `https://api.crystallize.com` lives (e.g. http://localhost:3001). - CRYSTALLIZE_LOCAL_API_URL?: string; + CRYSTALLIZE_OAUTH_API_URL?: string; }; -export const authorizationServer = (env: OAuthEnv) => - `${env.CRYSTALLIZE_LOCAL_API_URL ?? apiBaseUrl("api", PRODUCTION_API_ORIGIN)}/oauth`; +export const oauthApiUrl = (env: OAuthEnv | undefined): URL | undefined => { + const value = env?.CRYSTALLIZE_OAUTH_API_URL?.trim(); + return value ? new URL(value) : undefined; +}; + +const apiEndpoint = (base: URL, path: string): string => { + const directory = new URL(base); + directory.pathname = `${directory.pathname.replace(/\/+$/, "")}/`; + directory.search = ""; + directory.hash = ""; + return new URL(path, directory).href; +}; + +export const oauthApiOrigin = (apiUrl: URL): string | undefined => { + if (apiUrl.protocol !== "https:" || apiUrl.port){ + return undefined; + } + return /^api([.-][a-z0-9.-]*[a-z0-9])$/.exec(apiUrl.hostname)?.[1]; +}; -export const mcpResource = (requestUrl: string, tenantIdentifier: string) => - `${new URL(requestUrl).origin}/mcp/@${tenantIdentifier}`; +export const mcpResource = (requestUrl: string) => new URL(MCP_PATH, requestUrl).href; -export const protectedResourceMetadataUrl = (requestUrl: string, tenantIdentifier: string) => - `${new URL(requestUrl).origin}/.well-known/oauth-protected-resource/mcp/@${tenantIdentifier}`; +export const PROTECTED_RESOURCE_METADATA_PATH = `/.well-known/oauth-protected-resource${MCP_PATH}`; -/** RFC 9728: tells an MCP client which authorization server issues tokens for this tenant. */ -export const protectedResourceMetadata = (requestUrl: string, tenantIdentifier: string, env: OAuthEnv) => ({ - resource: mcpResource(requestUrl, tenantIdentifier), - authorization_servers: [authorizationServer(env)], +export const protectedResourceMetadata = (requestUrl: string, apiUrl: URL) => ({ + resource: mcpResource(requestUrl), + authorization_servers: [apiEndpoint(apiUrl, "oauth")], bearer_methods_supported: ["header"], + resource_name: "Crystallize MCP", }); -/** A 401 that starts the MCP OAuth flow (RFC 9728 §5.1). */ -export const oauthChallenge = (requestUrl: string, tenantIdentifier: string, error?: "invalid_token") => +/** A 401 that starts the OAuth flow (RFC 9728 §5.1), or tells the client to get a new token. */ +export const oauthChallenge = (requestUrl: string, error?: "invalid_token") => new Response(JSON.stringify({ error: error ?? "unauthorized" }), { status: 401, headers: { "Content-Type": "application/json", "WWW-Authenticate": [ - `Bearer resource_metadata="${protectedResourceMetadataUrl(requestUrl, tenantIdentifier)}"`, + `Bearer resource_metadata="${new URL(PROTECTED_RESOURCE_METADATA_PATH, requestUrl).href}"`, ...(error ? [`error="${error}"`] : []), ].join(", "), }, }); -/** - * Checks the token against the Core API for the tenant: Core validates it (audience, tenant, user) on every call, - * so a cheap query is the source of truth here too. - */ -export const isOAuthTokenValid = async (token: string, tenantIdentifier: string, origin: string) => { - const response = await fetch(`${apiBaseUrl("api", origin)}/@${tenantIdentifier}`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, - body: JSON.stringify({ query: "{ __typename }" }), - }); - return response.ok; +export type OAuthTenantsResult = + | { ok: true; tenants: AuthContext["tenants"] } + | { ok: false; reason: "invalid_token" | "unavailable" }; + +type OAuthGrantTenantsResponse = { + tenants?: Array<{ id: string; identifier: string; name: string }>; +}; + +export const fetchOAuthTenants = async (token: string, apiUrl: URL): Promise => { + let response: Response; + try { + response = await fetch(apiEndpoint(apiUrl, "oauth-grant/tenants"), { + headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, + }); + } catch { + return { ok: false, reason: "unavailable" }; + } + + if (response.status === 401 || response.status === 403) { + return { ok: false, reason: "invalid_token" }; + } + if (!response.ok) { + return { ok: false, reason: "unavailable" }; + } + + const body = (await response.json()) as OAuthGrantTenantsResponse; + return { + ok: true, + tenants: (body.tenants ?? []).map(({ id, identifier, name }) => ({ id, identifier, name })), + }; }; diff --git a/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts b/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts index cf9363e..91fdfe9 100644 --- a/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts +++ b/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts @@ -5,10 +5,11 @@ import { getCookie } from "hono/cookie"; import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; import { apiBaseUrl, ENVIRONMENT_HEADER, resolveApiOrigin } from "../core/api-origin"; import { - isOAuthTokenValid, + fetchOAuthTenants, OAUTH_ACCESS_TOKEN_PREFIX, + oauthApiOrigin, + oauthApiUrl, oauthChallenge, - tenantFromMcpPath, } from "../core/oauth"; const meQuery = `query { me { tenants { tenant { id name identifier staticAuthToken } } } }`; @@ -59,28 +60,35 @@ export const authMiddleware = createMiddleware(async (c, next) => { const sessionId = getCookie(c, "connect.sid") ?? c.req.header("X-Crystallize-Session-Id"); const bearerToken = extractBearerToken(c.req.header("Authorization")); - // `/mcp/@`: the OAuth endpoint. Crystallize OAuth tokens only, and a 401 that starts the flow. - const oauthTenant = tenantFromMcpPath(new URL(c.req.url).pathname); - if (oauthTenant) { - if (!bearerToken?.startsWith(OAUTH_ACCESS_TOKEN_PREFIX)) { - return oauthChallenge(c.req.url, oauthTenant); + // OAuth (MCP authorization), when this deployment has an authorization server: a Crystallize OAuth token, + // acting as the user on every tenant they can access. + const oauthApi = oauthApiUrl(c.env); + if (oauthApi && bearerToken?.startsWith(OAUTH_ACCESS_TOKEN_PREFIX)) { + const result = await fetchOAuthTenants(bearerToken, oauthApi); + if (!result.ok) { + return result.reason === "invalid_token" + ? oauthChallenge(c.req.url, "invalid_token") + : c.json({ error: "Unauthorized: the authorization server is unavailable" }, 503); } - if (!(await isOAuthTokenValid(bearerToken, oauthTenant, origin))) { - return oauthChallenge(c.req.url, oauthTenant, "invalid_token"); + if (result.tenants.length === 0) { + return c.json({ error: "Unauthorized: the user has no tenants" }, 403); } c.set("authContext", { type: "bearer", bearerToken, - origin, - // the token is bound to this one tenant; Core enforces it on every call - tenants: [{ id: oauthTenant, identifier: oauthTenant, name: oauthTenant }], + // the token only works on the environment that issued it, whatever the request asked for + origin: oauthApiOrigin(oauthApi) ?? origin, + tenants: result.tenants, }); await next(); return; } if (!sessionId && !bearerToken && (!accessTokenId || !accessTokenSecret)) { + if (oauthApi) { + return oauthChallenge(c.req.url); + } return c.json( { error: "Unauthorized: provide X-Crystallize-Access-Token-Id/Secret headers, an Authorization: Bearer header, or a connect.sid cookie", diff --git a/use-crystallize/mcp-servers/crystallize/tests/middlewares/auth.test.ts b/use-crystallize/mcp-servers/crystallize/tests/middlewares/auth.test.ts index 2419991..9a738a4 100644 --- a/use-crystallize/mcp-servers/crystallize/tests/middlewares/auth.test.ts +++ b/use-crystallize/mcp-servers/crystallize/tests/middlewares/auth.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, mock, beforeEach } from "bun:test"; +import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"; import { Hono } from "hono"; const mockCreateClient = mock(); @@ -468,4 +468,123 @@ describe("authMiddleware", () => { expect(jwksHrefs().filter((href) => href === devHref)).toHaveLength(1); }); }); + + describe("OAuth (MCP authorization)", () => { + const env = { CRYSTALLIZE_OAUTH_API_URL: "https://api-dev.example.test/" }; + const oauthHeaders = { Authorization: "Bearer crz_oat_abc" }; + const tenants = [{ id: "t1", identifier: "shop", name: "Shop", scopes: ["orders:read"] }]; + const originalFetch = globalThis.fetch; + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = mock(); + globalThis.fetch = mockFetch as unknown as typeof fetch; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + const respond = (status: number, body: unknown = {}) => + mockFetch.mockResolvedValue(new Response(JSON.stringify(body), { status })); + + it("challenges a request without credentials with the resource metadata", async () => { + const res = await app.request("http://mcp.example.test/test", {}, env); + + expect(res.status).toBe(401); + expect(res.headers.get("WWW-Authenticate")).toBe( + 'Bearer resource_metadata="http://mcp.example.test/.well-known/oauth-protected-resource/mcp"', + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("keeps the 403 for missing credentials when OAuth is not configured", async () => { + const res = await app.request("/test"); + + expect(res.status).toBe(403); + expect(res.headers.get("WWW-Authenticate")).toBeNull(); + }); + + it("acts as the user on the tenants the Core API returns for the token", async () => { + respond(200, { tenants }); + const res = await app.request("/test", { headers: oauthHeaders }, env); + + expect(res.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith("https://api-dev.example.test/oauth-grant/tenants", { + headers: { Accept: "application/json", Authorization: "Bearer crz_oat_abc" }, + }); + const body = (await res.json()) as { authContext: Record }; + expect(body.authContext).toEqual({ + type: "bearer", + bearerToken: "crz_oat_abc", + origin: "-dev.example.test", + tenants: [{ id: "t1", identifier: "shop", name: "Shop" }], + }); + expect(mockJwtVerify).not.toHaveBeenCalled(); + expect(mockCreateClient).not.toHaveBeenCalled(); + }); + + it("uses the token's environment, not X-Crystallize-Environment", async () => { + respond(200, { tenants }); + const res = await app.request( + "/test", + { headers: { ...oauthHeaders, "X-Crystallize-Environment": "production" } }, + env, + ); + + const body = (await res.json()) as { authContext: { origin: string } }; + expect(body.authContext.origin).toBe("-dev.example.test"); + }); + + it("falls back to the request's environment for a local Core API", async () => { + respond(200, { tenants }); + const res = await app.request( + "/test", + { headers: oauthHeaders }, + { CRYSTALLIZE_OAUTH_API_URL: "http://localhost:3001" }, + ); + + expect(mockFetch).toHaveBeenCalledWith("http://localhost:3001/oauth-grant/tenants", expect.anything()); + const body = (await res.json()) as { authContext: { origin: string } }; + expect(body.authContext.origin).toBe(".crystallize.com"); + }); + + it.each([401, 403])("answers invalid_token when the Core API refuses the token (%i)", async (status) => { + respond(status); + const res = await app.request("http://mcp.example.test/test", { headers: oauthHeaders }, env); + + expect(res.status).toBe(401); + expect(res.headers.get("WWW-Authenticate")).toBe( + 'Bearer resource_metadata="http://mcp.example.test/.well-known/oauth-protected-resource/mcp", error="invalid_token"', + ); + }); + + it("answers 503 when the Core API cannot be reached", async () => { + mockFetch.mockRejectedValue(new TypeError("fetch failed")); + const unreachable = await app.request("/test", { headers: oauthHeaders }, env); + respond(500); + const failing = await app.request("/test", { headers: oauthHeaders }, env); + + expect(unreachable.status).toBe(503); + expect(failing.status).toBe(503); + }); + + it("refuses a user without tenants", async () => { + respond(200, { tenants: [] }); + const res = await app.request("/test", { headers: oauthHeaders }, env); + + expect(res.status).toBe(403); + }); + + it("leaves plugin JWT bearer tokens to the JWKS check", async () => { + mockJwtVerify.mockResolvedValue({ payload: { act: { tenantId: "t42" } } } as never); + const res = await app.request( + "/test", + { headers: { Authorization: "Bearer my-jwt-token", "X-Crystallize-Tenant-Identifier": "shop" } }, + env, + ); + + expect(res.status).toBe(200); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); });