Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions use-crystallize/mcp-servers/crystallize/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**,
Expand Down Expand Up @@ -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 `<origin>/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<suffix>`), 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 `<origin>/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

Expand All @@ -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 }
Expand Down Expand Up @@ -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.
5 changes: 5 additions & 0 deletions use-crystallize/mcp-servers/crystallize/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +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 { oauthApiUrl, PROTECTED_RESOURCE_METADATA_PATH, protectedResourceMetadata } from "./core/oauth";

export const createApp = () => {
const app = new Hono<AppContext>();
app.use(servicesProvider);
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({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Services } from "../core/container";
import type { OAuthEnv } from "../core/oauth";

type Tenant = {
id: string;
Expand Down Expand Up @@ -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;
Expand Down
90 changes: 90 additions & 0 deletions use-crystallize/mcp-servers/crystallize/src/core/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { AuthContext } from "../contracts/app-context";

/**
* 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.
*/

export const OAUTH_ACCESS_TOKEN_PREFIX = "crz_oat_";

const MCP_PATH = "/mcp";

export type OAuthEnv = {
CRYSTALLIZE_OAUTH_API_URL?: string;
};

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) => new URL(MCP_PATH, requestUrl).href;

export const PROTECTED_RESOURCE_METADATA_PATH = `/.well-known/oauth-protected-resource${MCP_PATH}`;

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 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="${new URL(PROTECTED_RESOURCE_METADATA_PATH, requestUrl).href}"`,
...(error ? [`error="${error}"`] : []),
].join(", "),
},
});

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<OAuthTenantsResult> => {
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 })),
};
};
35 changes: 35 additions & 0 deletions use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ 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 {
fetchOAuthTenants,
OAUTH_ACCESS_TOKEN_PREFIX,
oauthApiOrigin,
oauthApiUrl,
oauthChallenge,
} from "../core/oauth";

const meQuery = `query { me { tenants { tenant { id name identifier staticAuthToken } } } }`;

Expand Down Expand Up @@ -53,7 +60,35 @@ export const authMiddleware = createMiddleware<AppContext>(async (c, next) => {
const sessionId = getCookie(c, "connect.sid") ?? c.req.header("X-Crystallize-Session-Id");
const bearerToken = extractBearerToken(c.req.header("Authorization"));

// 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 (result.tenants.length === 0) {
return c.json({ error: "Unauthorized: the user has no tenants" }, 403);
}

c.set("authContext", {
type: "bearer",
bearerToken,
// 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 <jwt> header, or a connect.sid cookie",
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -468,4 +468,123 @@
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<typeof mock>;

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);

Check failure on line 503 in use-crystallize/mcp-servers/crystallize/tests/middlewares/auth.test.ts

View workflow job for this annotation

GitHub Actions / 🧪 Tests

error: expect(received).toBe(expected)

Expected: 403 Received: 500 at <anonymous> (/home/runner/work/ai/ai/use-crystallize/mcp-servers/crystallize/tests/middlewares/auth.test.ts:503:32)
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<string, unknown> };
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();
});
});
});
Loading