diff --git a/CLAUDE.md b/CLAUDE.md index bb2510c..9fa05cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,7 @@ This is a pnpm monorepo with the following packages: - `packages/oauth` (`@keycardai/oauth`) - Pure OAuth 2.0 primitives (no MCP dependency) - `packages/mcp` (`@keycardai/mcp`) - MCP-specific OAuth integration +- `packages/cloudflare` (`@keycardai/cloudflare`) - Keycard auth for Cloudflare Workers (depends on oauth, no Express) - `packages/sdk` (`@keycardai/sdk`) - Aggregate package re-exporting from oauth + mcp ## Git Commits @@ -14,9 +15,9 @@ Follow conventional commits: `type(scope): description` Types: `docs`, `feat`, `fix`, `refactor`, `test`, `chore` -Scopes: `oauth`, `mcp`, `sdk`, `deps`, `docs` +Scopes: `oauth`, `mcp`, `cloudflare`, `sdk`, `deps`, `docs` ## Build Order -`@keycardai/oauth` must build before `@keycardai/mcp` (dependency). +`@keycardai/oauth` must build before `@keycardai/mcp` and `@keycardai/cloudflare` (dependency). Use `pnpm -r run build` to build in dependency order. diff --git a/examples/cloudflare-worker/README.md b/examples/cloudflare-worker/README.md new file mode 100644 index 0000000..c12ad6e --- /dev/null +++ b/examples/cloudflare-worker/README.md @@ -0,0 +1,129 @@ +# Cloudflare Worker MCP Server + +A Cloudflare Worker protected by Keycard OAuth authentication with delegated access to external APIs via token exchange. Demonstrates `@keycardai/cloudflare` — the Workers-native equivalent of `@keycardai/mcp`. + +## What This Example Shows + +- Using `createKeycardWorker()` for automatic OAuth metadata + bearer auth +- Registering MCP tools on a Worker +- Isolate-safe token exchange with `IsolateSafeTokenCache` +- Both credential modes: `ClientSecret` and `WebIdentity` (private key JWT) + +## Prerequisites + +- **Node.js 18+** and **wrangler CLI** (`npm install -g wrangler`) +- **Cloudflare account** — sign up at [dash.cloudflare.com](https://dash.cloudflare.com) +- **Keycard account** — sign up at [console.keycard.ai](https://console.keycard.ai) +- **Configured zone** with an identity provider (Okta, Auth0, Google, etc.) +- **Cursor IDE** or another MCP-compatible client for testing + +## Keycard Console Setup + +### 1. Register GitHub as a Provider + +1. Create a GitHub OAuth App at [github.com/settings/developers](https://github.com/settings/developers) +2. In Keycard Console, navigate to **Providers** → **Add Provider** +3. Configure: + - **Provider Name:** `GitHub OAuth` + - **Identifier:** `https://github.com` + - **Client ID / Secret:** from your GitHub OAuth App + +### 2. Create a GitHub API Resource + +1. Navigate to **Resources** → **Create Resource** +2. Configure: + - **Resource Name:** `GitHub API` + - **Resource Identifier:** `https://api.github.com` + - **Credential Provider:** Select `GitHub OAuth` + - **Scopes:** `user:email`, `read:user` + +### 3. Register This Worker as a Resource + +1. Navigate to **Resources** → **Create Resource** +2. Configure: + - **Resource Name:** `Cloudflare Worker MCP Server` + - **Resource Identifier:** `https://your-worker.your-subdomain.workers.dev` + - **Credential Provider:** `Keycard STS` + - **Scopes:** `mcp:tools` +3. Go to the resource details → **Dependencies** tab +4. Click **Connect Resource** and select `GitHub API` + +### 4. Create Application Credentials + +1. Navigate to **Applications** → **Create Application** +2. Note the **Client ID** and **Client Secret** + +## Install and Deploy + +```bash +cd examples/cloudflare-worker +npm install +``` + +Edit `wrangler.jsonc` and set `KEYCARD_ISSUER` to your zone URL, `KEYCARD_RESOURCE_URL` to `https://api.github.com`. + +### Option A: Client Credentials + +```bash +wrangler secret put KEYCARD_CLIENT_ID +wrangler secret put KEYCARD_CLIENT_SECRET +``` + +### Option B: Web Identity (no client secret) + +Generate a private key and store it as a Worker secret: + +```bash +openssl genrsa 2048 | wrangler secret put KEYCARD_PRIVATE_KEY +``` + +The Worker automatically serves its public key at `/.well-known/jwks.json`. Register this URL in Keycard Console as the application's JWKS endpoint. + +### Deploy + +```bash +wrangler deploy +``` + +## Test with Cursor IDE + +Add to your Cursor MCP settings (`~/.cursor/mcp_settings.json`): + +```json +{ + "mcpServers": { + "cloudflare-worker": { + "url": "https://your-worker.your-subdomain.workers.dev/mcp" + } + } +} +``` + +Restart Cursor, connect the MCP server, complete the OAuth flow, then try: +- "Run the whoami tool" +- "Get my GitHub user info" + +## Local Development + +```bash +wrangler dev +``` + +Then test against `http://localhost:8787`. + +## Environment Variables + +| Variable | Description | Set via | +|---|---|---| +| `KEYCARD_ISSUER` | Keycard zone URL | `wrangler.jsonc` vars | +| `KEYCARD_RESOURCE_URL` | Upstream API URL for token exchange | `wrangler.jsonc` vars | +| `KEYCARD_CLIENT_ID` | Application client ID (Option A) | `wrangler secret put` | +| `KEYCARD_CLIENT_SECRET` | Application client secret (Option A) | `wrangler secret put` | +| `KEYCARD_PRIVATE_KEY` | PEM-encoded RSA private key (Option B) | `wrangler secret put` | + +## Related + +- [Hello World example](../hello-world-server/) — Express-based equivalent +- [Delegated Access example](../delegated-access/) — Express-based token exchange +- [`@keycardai/cloudflare` docs](https://docs.keycard.ai/sdk/cloudflare/) — full API reference +- [`@keycardai/mcp` docs](https://docs.keycard.ai/sdk/mcp/) — Express-based auth (same primitives, different runtime) diff --git a/examples/cloudflare-worker/package.json b/examples/cloudflare-worker/package.json new file mode 100644 index 0000000..c7ea7ff --- /dev/null +++ b/examples/cloudflare-worker/package.json @@ -0,0 +1,22 @@ +{ + "name": "cloudflare-worker", + "version": "0.1.0", + "private": true, + "description": "Keycard-protected MCP server on Cloudflare Workers with bearer auth and token exchange", + "type": "module", + "scripts": { + "build": "tsc", + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@keycardai/cloudflare": "^0.1.0", + "@keycardai/oauth": "^0.2.0", + "@modelcontextprotocol/sdk": "^1.15.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250327.0", + "typescript": "^5.8.3", + "wrangler": "^4.14.0" + } +} diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts new file mode 100644 index 0000000..26ee2f6 --- /dev/null +++ b/examples/cloudflare-worker/src/index.ts @@ -0,0 +1,111 @@ +/** + * Cloudflare Worker MCP Server with Keycard Auth + * + * A minimal Worker protected by Keycard bearer auth with delegated + * access to an external API via token exchange. + * + * Prerequisites: + * 1. A Keycard zone with an identity provider configured + * 2. A resource registered in Keycard Console for this Worker + * 3. Application credentials (client ID + secret) or a private key + * + * See README.md for full setup instructions. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { + createKeycardWorker, + IsolateSafeTokenCache, + resolveCredential, +} from "@keycardai/cloudflare"; +import { TokenExchangeClient } from "@keycardai/oauth/tokenExchange"; + +interface Env { + KEYCARD_ISSUER: string; + KEYCARD_CLIENT_ID?: string; + KEYCARD_CLIENT_SECRET?: string; + KEYCARD_PRIVATE_KEY?: string; + KEYCARD_RESOURCE_URL: string; +} + +// Token cache is module-level but keyed by user identity — safe for isolate reuse +let tokenCache: IsolateSafeTokenCache | undefined; + +function getTokenCache(env: Env): IsolateSafeTokenCache { + if (!tokenCache) { + const credential = resolveCredential(env); + const client = new TokenExchangeClient(env.KEYCARD_ISSUER, credential.getAuth() ?? undefined); + tokenCache = new IsolateSafeTokenCache(client, { credential }); + } + return tokenCache; +} + +export default createKeycardWorker({ + resourceName: "Cloudflare Worker Example", + scopesSupported: ["mcp:tools"], + requiredScopes: ["mcp:tools"], + + async fetch(request, env, ctx, auth) { + const url = new URL(request.url); + + // Health check + if (url.pathname === "/health") { + return new Response(JSON.stringify({ status: "ok" }), { + headers: { "Content-Type": "application/json" }, + }); + } + + // MCP server on /mcp + if (url.pathname === "/mcp") { + const server = new McpServer({ name: "Cloudflare Worker Example", version: "0.1.0" }); + + // Simple tool: return authenticated user info + server.tool("whoami", "Returns information about the authenticated user", {}, async () => { + return { + content: [{ + type: "text", + text: JSON.stringify({ + subject: auth.subject, + clientId: auth.clientId, + scopes: auth.scopes, + }, null, 2), + }], + }; + }); + + // Delegated access tool: fetch from upstream API using token exchange + server.tool("github_user", "Fetches the authenticated user's GitHub profile", {}, async () => { + const cache = getTokenCache(env); + const token = await cache.getToken(auth.subject!, auth.token, env.KEYCARD_RESOURCE_URL); + + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${token.accessToken}`, + Accept: "application/vnd.github+json", + "User-Agent": "keycard-worker-example", + }, + }); + + if (!response.ok) { + const body = await response.text(); + return { content: [{ type: "text", text: `GitHub API error (${response.status}): ${body}` }] }; + } + + const user = await response.json() as Record; + return { + content: [{ + type: "text", + text: JSON.stringify({ login: user.login, name: user.name, email: user.email }, null, 2), + }], + }; + }); + + const transport = new StreamableHTTPServerTransport("/mcp"); + await server.connect(transport); + return transport.handleRequest(request); + } + + return new Response("Not found", { status: 404 }); + }, +}); diff --git a/examples/cloudflare-worker/tsconfig.json b/examples/cloudflare-worker/tsconfig.json new file mode 100644 index 0000000..ec70230 --- /dev/null +++ b/examples/cloudflare-worker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ES2022", + "moduleResolution": "bundler", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"] +} diff --git a/examples/cloudflare-worker/wrangler.jsonc b/examples/cloudflare-worker/wrangler.jsonc new file mode 100644 index 0000000..c2a2ccf --- /dev/null +++ b/examples/cloudflare-worker/wrangler.jsonc @@ -0,0 +1,21 @@ +{ + "name": "keycard-mcp-worker", + "main": "src/index.ts", + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_compat"], + + // Non-secret configuration — baked into the Worker + "vars": { + "KEYCARD_ISSUER": "https://your-zone-id.keycard.cloud", + "KEYCARD_RESOURCE_URL": "https://api.github.com" + } + + // Secrets — set via `wrangler secret put`: + // + // Option A: Client credentials + // wrangler secret put KEYCARD_CLIENT_ID + // wrangler secret put KEYCARD_CLIENT_SECRET + // + // Option B: Web identity (private key JWT — no client secret needed) + // wrangler secret put KEYCARD_PRIVATE_KEY +} diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md new file mode 100644 index 0000000..509c522 --- /dev/null +++ b/packages/cloudflare/README.md @@ -0,0 +1,172 @@ +# @keycardai/cloudflare + +Keycard auth for Cloudflare Workers — bearer token verification, OAuth metadata endpoints, token exchange, and per-user token caching. **No Express dependency.** + +This is the Workers equivalent of [`@keycardai/mcp`](../mcp/)'s server-side middleware. If you're building an Express server, use `@keycardai/mcp` instead. + +## Installation + +```bash +npm install @keycardai/cloudflare +``` + +`@keycardai/cloudflare` depends on `@keycardai/oauth` (included automatically). + +## Quick Start + +### One-Line Worker Setup + +The fastest way to add Keycard auth to a Worker: + +```typescript +import { createKeycardWorker } from "@keycardai/cloudflare"; + +export default createKeycardWorker({ + requiredScopes: ["read"], + scopesSupported: ["read", "write"], + resourceName: "My MCP Server", + + fetch(request, env, ctx, auth) { + // auth is guaranteed — token is verified, scopes checked + return new Response(JSON.stringify({ + message: `Hello ${auth.clientId}`, + scopes: auth.scopes, + })); + }, +}); +``` + +`createKeycardWorker()` handles the full lifecycle: CORS preflight, `/.well-known/*` metadata endpoints, bearer token verification, then delegates to your handler. + +### Environment Variables + +Configure in `wrangler.toml` or the Cloudflare dashboard: + +```toml +[vars] +KEYCARD_ISSUER = "https://your-zone.keycard.cloud" + +# Option A: Client credentials +KEYCARD_CLIENT_ID = "your-client-id" +KEYCARD_CLIENT_SECRET = "your-client-secret" + +# Option B: Web identity (private_key_jwt — no secret needed) +# KEYCARD_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n..." +``` + +### Manual Setup + +For more control, use the individual functions: + +```typescript +import { + verifyBearerToken, + isAuthError, + handleMetadataRequest, +} from "@keycardai/cloudflare"; + +export default { + async fetch(request: Request, env: Env): Promise { + // 1. Serve OAuth metadata + const metadata = await handleMetadataRequest(request, { + issuer: env.KEYCARD_ISSUER, + scopesSupported: ["read", "write"], + }); + if (metadata) return metadata; + + // 2. Verify bearer token + const auth = await verifyBearerToken(request, { + requiredScopes: ["read"], + }); + if (isAuthError(auth)) return auth; // 401/403 Response + + // 3. Use authenticated info + return new Response(`Hello ${auth.subject}`); + }, +}; +``` + +## Token Exchange with Caching + +Exchange user tokens for upstream API tokens with per-user caching designed for Workers' shared-isolate model: + +```typescript +import { + createKeycardWorker, + IsolateSafeTokenCache, + resolveCredential, +} from "@keycardai/cloudflare"; + +let tokenCache: IsolateSafeTokenCache; + +export default createKeycardWorker({ + requiredScopes: ["read"], + + async fetch(request, env, ctx, auth) { + // Lazy-init cache (module-level state is safe across requests in Workers) + if (!tokenCache) { + tokenCache = new IsolateSafeTokenCache({ + zoneUrl: env.KEYCARD_ISSUER, + credential: resolveCredential(env), + }); + } + + // Exchange for upstream token (cached per-user, auto-refreshes) + const upstream = await tokenCache.getToken(auth, env.KEYCARD_RESOURCE_URL!); + + const resp = await fetch("https://api.github.com/user", { + headers: { Authorization: `Bearer ${upstream}` }, + }); + + return new Response(resp.body, resp); + }, +}); +``` + +`IsolateSafeTokenCache` handles: +- Per-user keying (`sub::resource`) for shared isolates +- In-flight deduplication (concurrent requests share one exchange) +- Skew-aware TTL (refreshes before expiry) +- Bounded size with LRU-ish eviction + +## API + +### `createKeycardWorker(options)` + +Returns an `ExportedHandler` with Keycard auth built in. Auto-detects credential type from env. + +### `verifyBearerToken(request, options?)` + +Verifies a Bearer token. Returns `AuthInfo` on success or a `Response` (401/403) on failure. + +### `handleMetadataRequest(request, options)` + +Serves `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`. Returns `null` for non-metadata paths. + +### `IsolateSafeTokenCache` + +Per-user token exchange cache for Workers. See above for usage. + +### `resolveCredential(env)` + +Resolves `WorkersClientSecret` or `WorkersWebIdentity` from env bindings. + +### `AuthInfo` + +```typescript +interface AuthInfo { + token: string; // Raw bearer token + clientId: string; // client_id claim + scopes: string[]; // Granted scopes + expiresAt?: number; // Expiration (Unix seconds) + resource?: URL; // Audience/resource URL + subject?: string; // JWT sub claim +} +``` + +### Credential Types + +| Type | Env vars | Auth method | +|---|---|---| +| `WorkersClientSecret` | `KEYCARD_CLIENT_ID` + `KEYCARD_CLIENT_SECRET` | Basic auth | +| `WorkersWebIdentity` | `KEYCARD_PRIVATE_KEY` | Private key JWT (RFC 7523) | diff --git a/packages/cloudflare/jest.config.ts b/packages/cloudflare/jest.config.ts new file mode 100644 index 0000000..17ab1fb --- /dev/null +++ b/packages/cloudflare/jest.config.ts @@ -0,0 +1,12 @@ +import { createDefaultEsmPreset } from "ts-jest"; + +const defaultEsmPreset = createDefaultEsmPreset(); + +/** @type {import('ts-jest').JestConfigWithTsJest} **/ +export default { + ...defaultEsmPreset, + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + testPathIgnorePatterns: ["/node_modules/", "/dist/"], +}; diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json new file mode 100644 index 0000000..1063d11 --- /dev/null +++ b/packages/cloudflare/package.json @@ -0,0 +1,67 @@ +{ + "name": "@keycardai/cloudflare", + "version": "0.1.0", + "description": "Keycard auth integration for Cloudflare Workers — JWT verification, token exchange, and OAuth metadata", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/keycardai/typescript-sdk.git", + "directory": "packages/cloudflare" + }, + "type": "module", + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "types": "./dist/esm/index.d.ts" + }, + "./auth": { + "import": "./dist/esm/auth.js", + "require": "./dist/cjs/auth.js", + "types": "./dist/esm/auth.d.ts" + }, + "./metadata": { + "import": "./dist/esm/metadata.js", + "require": "./dist/cjs/metadata.js", + "types": "./dist/esm/metadata.d.ts" + }, + "./tokenCache": { + "import": "./dist/esm/tokenCache.js", + "require": "./dist/cjs/tokenCache.js", + "types": "./dist/esm/tokenCache.d.ts" + }, + "./credentials": { + "import": "./dist/esm/credentials.js", + "require": "./dist/cjs/credentials.js", + "types": "./dist/esm/credentials.d.ts" + } + }, + "files": [ + "dist" + ], + "typesVersions": { + "*": { + "*": [ + "./dist/esm/*" + ] + } + }, + "scripts": { + "build": "pnpm run build:esm && pnpm run build:cjs", + "build:esm": "tsc -p tsconfig.prod.json && echo '{\"type\": \"module\"}' > dist/esm/package.json", + "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", + "test": "NODE_OPTIONS='--experimental-vm-modules' jest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@keycardai/oauth": "workspace:*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250327.0", + "@jest/globals": "^30.0.4", + "jest": "^30.0.4", + "ts-jest": "^29.4.0", + "typescript": "^5.8.3" + } +} diff --git a/packages/cloudflare/src/__tests__/auth.test.ts b/packages/cloudflare/src/__tests__/auth.test.ts new file mode 100644 index 0000000..abd5593 --- /dev/null +++ b/packages/cloudflare/src/__tests__/auth.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; + +const mockVerify = jest.fn(); + +// Mock before any imports that trigger verifier creation +jest.unstable_mockModule("@keycardai/oauth/keyring", () => ({ + JWKSOAuthKeyring: jest.fn().mockImplementation(() => ({ + key: jest.fn(), + })), +})); + +jest.unstable_mockModule("@keycardai/oauth/jwt/verifier", () => ({ + JWTVerifier: jest.fn().mockImplementation(() => ({ + verify: mockVerify, + })), +})); + +const { verifyBearerToken, isAuthError, _resetVerifier } = await import("../auth.js"); + +function makeRequest(authHeader?: string): Request { + const headers = new Headers(); + if (authHeader) { + headers.set("Authorization", authHeader); + } + return new Request("https://example.com/mcp", { headers }); +} + +describe("verifyBearerToken", () => { + beforeEach(() => { + jest.clearAllMocks(); + _resetVerifier(); + }); + + it("returns 401 when no Authorization header is present", async () => { + const result = await verifyBearerToken(makeRequest()); + expect(isAuthError(result)).toBe(true); + expect((result as Response).status).toBe(401); + }); + + it("returns 400 for malformed credentials (scheme only)", async () => { + const result = await verifyBearerToken(makeRequest("Bearer")); + expect(isAuthError(result)).toBe(true); + expect((result as Response).status).toBe(400); + }); + + it("returns 401 for non-Bearer scheme", async () => { + const result = await verifyBearerToken(makeRequest("Basic abc123")); + expect(isAuthError(result)).toBe(true); + expect((result as Response).status).toBe(401); + }); + + it("returns AuthInfo on successful verification", async () => { + mockVerify.mockResolvedValue({ + sub: "user-123", + client_id: "app-456", + scope: "mcp:tools read", + exp: Math.floor(Date.now() / 1000) + 3600, + iss: "https://auth.keycard.ai", + }); + + const result = await verifyBearerToken(makeRequest("Bearer valid-token")); + + if (isAuthError(result)) { + throw new Error(`Expected AuthInfo, got Response with status ${result.status}`); + } + + expect(result.subject).toBe("user-123"); + expect(result.clientId).toBe("app-456"); + expect(result.scopes).toEqual(["mcp:tools", "read"]); + expect(result.token).toBe("valid-token"); + }); + + it("returns 403 when required scopes are missing", async () => { + mockVerify.mockResolvedValue({ + sub: "user-123", + client_id: "app-456", + scope: "read", + exp: Math.floor(Date.now() / 1000) + 3600, + iss: "https://auth.keycard.ai", + }); + + const result = await verifyBearerToken(makeRequest("Bearer valid-token"), { + requiredScopes: ["mcp:tools"], + }); + + expect(isAuthError(result)).toBe(true); + expect((result as Response).status).toBe(403); + }); + + it("returns 401 for expired tokens", async () => { + mockVerify.mockResolvedValue({ + sub: "user-123", + client_id: "app-456", + scope: "mcp:tools", + exp: Math.floor(Date.now() / 1000) - 100, + iss: "https://auth.keycard.ai", + }); + + const result = await verifyBearerToken(makeRequest("Bearer expired-token")); + expect(isAuthError(result)).toBe(true); + expect((result as Response).status).toBe(401); + }); + + it("includes WWW-Authenticate header with resource_metadata URL", async () => { + const result = (await verifyBearerToken(makeRequest())) as Response; + const wwwAuth = result.headers.get("WWW-Authenticate"); + expect(wwwAuth).toContain("resource_metadata="); + expect(wwwAuth).toContain("/.well-known/oauth-protected-resource"); + }); +}); diff --git a/packages/cloudflare/src/__tests__/metadata.test.ts b/packages/cloudflare/src/__tests__/metadata.test.ts new file mode 100644 index 0000000..c1c4e1a --- /dev/null +++ b/packages/cloudflare/src/__tests__/metadata.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { handleMetadataRequest } from "../metadata.js"; + +describe("handleMetadataRequest", () => { + const baseOptions = { + issuer: "https://z_abc123.keycard.cloud", + scopesSupported: ["mcp:tools"], + resourceName: "Test MCP Server", + }; + + it("returns null for non-metadata paths", async () => { + const request = new Request("https://example.com/mcp"); + const result = await handleMetadataRequest(request, baseOptions); + expect(result).toBeNull(); + }); + + it("returns protected resource metadata", async () => { + const request = new Request("https://example.com/.well-known/oauth-protected-resource"); + const result = await handleMetadataRequest(request, baseOptions); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(200); + + const json = await result!.json(); + expect(json.resource).toBe("https://example.com"); + expect(json.authorization_servers).toEqual(["https://z_abc123.keycard.cloud"]); + expect(json.scopes_supported).toEqual(["mcp:tools"]); + expect(json.resource_name).toBe("Test MCP Server"); + }); + + it("handles MCP protocol version 2025-03-26 backwards compat", async () => { + const request = new Request("https://example.com/.well-known/oauth-protected-resource", { + headers: { "mcp-protocol-version": "2025-03-26" }, + }); + const result = await handleMetadataRequest(request, baseOptions); + const json = await result!.json(); + + // Should rewrite authorization_servers to the base URL + expect(json.authorization_servers).toEqual(["https://example.com"]); + }); + + it("returns CORS headers", async () => { + const request = new Request("https://example.com/.well-known/oauth-protected-resource"); + const result = await handleMetadataRequest(request, baseOptions); + + expect(result!.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("handles CORS preflight for well-known paths", async () => { + const request = new Request("https://example.com/.well-known/oauth-protected-resource", { + method: "OPTIONS", + }); + const result = await handleMetadataRequest(request, baseOptions); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(204); + expect(result!.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("serves JWKS when publicJwks is configured", async () => { + const publicJwks = { keys: [{ kty: "RSA", n: "abc", e: "AQAB", kid: "test-key" }] }; + const request = new Request("https://example.com/.well-known/jwks.json"); + const result = await handleMetadataRequest(request, { ...baseOptions, publicJwks }); + + expect(result).not.toBeNull(); + const json = await result!.json(); + expect(json.keys).toHaveLength(1); + expect(json.keys[0].kid).toBe("test-key"); + }); + + it("returns null for JWKS when publicJwks is not configured", async () => { + const request = new Request("https://example.com/.well-known/jwks.json"); + const result = await handleMetadataRequest(request, baseOptions); + expect(result).toBeNull(); + }); + + it("proxies authorization server metadata", async () => { + const mockMetadata = { + issuer: "https://z_abc123.keycard.cloud", + authorization_endpoint: "https://z_abc123.keycard.cloud/oauth/authorize", + token_endpoint: "https://z_abc123.keycard.cloud/oauth/token", + }; + + const originalFetch = globalThis.fetch; + globalThis.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify(mockMetadata), { status: 200 }), + ); + + try { + const request = new Request("https://example.com/.well-known/oauth-authorization-server"); + const result = await handleMetadataRequest(request, baseOptions); + + expect(result).not.toBeNull(); + const json = await result!.json(); + + // Should rewrite authorization_endpoint to include ?resource= + expect(json.authorization_endpoint).toContain("resource=https%3A%2F%2Fexample.com"); + expect(json.token_endpoint).toBe("https://z_abc123.keycard.cloud/oauth/token"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/cloudflare/src/__tests__/tokenCache.test.ts b/packages/cloudflare/src/__tests__/tokenCache.test.ts new file mode 100644 index 0000000..3b5803c --- /dev/null +++ b/packages/cloudflare/src/__tests__/tokenCache.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import type { TokenResponse, TokenExchangeRequest } from "@keycardai/oauth/tokenExchange"; + +// Mock TokenExchangeClient +jest.unstable_mockModule("@keycardai/oauth/tokenExchange", () => ({ + TokenExchangeClient: jest.fn().mockImplementation(() => ({ + exchangeToken: jest.fn(), + })), +})); + +const { TokenExchangeClient } = await import("@keycardai/oauth/tokenExchange"); +const { IsolateSafeTokenCache } = await import("../tokenCache.js"); + +function mockTokenResponse(overrides?: Partial): TokenResponse { + return { + accessToken: "upstream-token-" + Math.random().toString(36).slice(2), + tokenType: "bearer", + expiresIn: 3600, + ...overrides, + }; +} + +describe("IsolateSafeTokenCache", () => { + let client: InstanceType; + let exchangeMock: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + client = new TokenExchangeClient("https://auth.keycard.ai"); + exchangeMock = client.exchangeToken as jest.Mock; + }); + + it("exchanges token on cache miss", async () => { + const response = mockTokenResponse(); + exchangeMock.mockResolvedValue(response); + + const cache = new IsolateSafeTokenCache(client); + const result = await cache.getToken("user-1", "jwt-token", "https://api.github.com"); + + expect(result).toBe(response); + expect(exchangeMock).toHaveBeenCalledTimes(1); + }); + + it("returns cached token on cache hit", async () => { + const response = mockTokenResponse(); + exchangeMock.mockResolvedValue(response); + + const cache = new IsolateSafeTokenCache(client); + await cache.getToken("user-1", "jwt-token", "https://api.github.com"); + const result = await cache.getToken("user-1", "jwt-token", "https://api.github.com"); + + expect(result).toBe(response); + expect(exchangeMock).toHaveBeenCalledTimes(1); // only one exchange + }); + + it("isolates cache entries by user (subject)", async () => { + const response1 = mockTokenResponse({ accessToken: "user-1-token" }); + const response2 = mockTokenResponse({ accessToken: "user-2-token" }); + exchangeMock.mockResolvedValueOnce(response1).mockResolvedValueOnce(response2); + + const cache = new IsolateSafeTokenCache(client); + + const result1 = await cache.getToken("user-1", "jwt-1", "https://api.github.com"); + const result2 = await cache.getToken("user-2", "jwt-2", "https://api.github.com"); + + expect(result1.accessToken).toBe("user-1-token"); + expect(result2.accessToken).toBe("user-2-token"); + expect(exchangeMock).toHaveBeenCalledTimes(2); + }); + + it("isolates cache entries by resource", async () => { + const response1 = mockTokenResponse({ accessToken: "github-token" }); + const response2 = mockTokenResponse({ accessToken: "gmail-token" }); + exchangeMock.mockResolvedValueOnce(response1).mockResolvedValueOnce(response2); + + const cache = new IsolateSafeTokenCache(client); + + const result1 = await cache.getToken("user-1", "jwt", "https://api.github.com"); + const result2 = await cache.getToken("user-1", "jwt", "https://gmail.googleapis.com"); + + expect(result1.accessToken).toBe("github-token"); + expect(result2.accessToken).toBe("gmail-token"); + }); + + it("deduplicates concurrent requests for the same user+resource", async () => { + const response = mockTokenResponse(); + exchangeMock.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(response), 50)), + ); + + const cache = new IsolateSafeTokenCache(client); + + const [r1, r2] = await Promise.all([ + cache.getToken("user-1", "jwt", "https://api.github.com"), + cache.getToken("user-1", "jwt", "https://api.github.com"), + ]); + + expect(r1).toBe(response); + expect(r2).toBe(response); + expect(exchangeMock).toHaveBeenCalledTimes(1); + }); + + it("evicts entries when maxEntries is exceeded", async () => { + exchangeMock.mockImplementation(() => Promise.resolve(mockTokenResponse())); + + const cache = new IsolateSafeTokenCache(client, { maxEntries: 3 }); + + // Fill cache beyond limit + await cache.getToken("user-1", "jwt", "https://api1.com"); + await cache.getToken("user-2", "jwt", "https://api1.com"); + await cache.getToken("user-3", "jwt", "https://api1.com"); + await cache.getToken("user-4", "jwt", "https://api1.com"); // triggers eviction + + expect(exchangeMock).toHaveBeenCalledTimes(4); + }); + + it("respects skew seconds for early cache expiry", async () => { + const response = mockTokenResponse({ expiresIn: 1 }); // 1 second TTL + exchangeMock.mockResolvedValue(response); + + // With 30s skew (default), a 1s token is immediately expired in cache + const cache = new IsolateSafeTokenCache(client); + + await cache.getToken("user-1", "jwt", "https://api.github.com"); + await cache.getToken("user-1", "jwt", "https://api.github.com"); + + // Should have exchanged twice because the token is too short-lived + expect(exchangeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cloudflare/src/__tests__/worker.test.ts b/packages/cloudflare/src/__tests__/worker.test.ts new file mode 100644 index 0000000..1106402 --- /dev/null +++ b/packages/cloudflare/src/__tests__/worker.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import type { AuthInfo, KeycardEnv } from "../types.js"; + +// Mock auth and metadata modules +jest.unstable_mockModule("../auth.js", () => ({ + verifyBearerToken: jest.fn(), + isAuthError: jest.fn((result: unknown) => result instanceof Response), +})); + +jest.unstable_mockModule("../metadata.js", () => ({ + handleMetadataRequest: jest.fn(), +})); + +const { verifyBearerToken } = await import("../auth.js"); +const { handleMetadataRequest } = await import("../metadata.js"); +const { createKeycardWorker, resolveCredential } = await import("../worker.js"); + +const mockEnv: KeycardEnv = { + KEYCARD_ISSUER: "https://z_abc123.keycard.cloud", + KEYCARD_CLIENT_ID: "app-123", + KEYCARD_CLIENT_SECRET: "secret-456", +}; + +const mockCtx: ExecutionContext = { + waitUntil: jest.fn() as unknown as ExecutionContext["waitUntil"], + passThroughOnException: jest.fn() as unknown as ExecutionContext["passThroughOnException"], +}; + +function makeAuthInfo(overrides?: Partial): AuthInfo { + return { + token: "test-token", + clientId: "app-123", + scopes: ["mcp:tools"], + subject: "user-1", + ...overrides, + }; +} + +describe("createKeycardWorker", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("handles CORS preflight", async () => { + const worker = createKeycardWorker({ + fetch: jest.fn<() => Promise>().mockResolvedValue(new Response("ok")), + }); + + const request = new Request("https://example.com/mcp", { method: "OPTIONS" }); + const response = await worker.fetch!(request, mockEnv, mockCtx); + + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("delegates to metadata handler for well-known paths", async () => { + const metadataResponse = new Response(JSON.stringify({ resource: "test" })); + (handleMetadataRequest as jest.Mock).mockResolvedValue(metadataResponse); + + const fetchHandler = jest.fn<() => Promise>(); + const worker = createKeycardWorker({ fetch: fetchHandler }); + + const request = new Request("https://example.com/.well-known/oauth-protected-resource"); + const response = await worker.fetch!(request, mockEnv, mockCtx); + + expect(response).toBe(metadataResponse); + expect(fetchHandler).not.toHaveBeenCalled(); + }); + + it("verifies bearer token and calls user handler on success", async () => { + (handleMetadataRequest as jest.Mock).mockResolvedValue(null); + const authInfo = makeAuthInfo(); + (verifyBearerToken as jest.Mock).mockResolvedValue(authInfo); + + const userResponse = new Response("success"); + const fetchHandler = jest.fn<() => Promise>().mockResolvedValue(userResponse); + + const worker = createKeycardWorker({ + requiredScopes: ["mcp:tools"], + fetch: fetchHandler, + }); + + const request = new Request("https://example.com/mcp", { + headers: { Authorization: "Bearer test-token" }, + }); + const response = await worker.fetch!(request, mockEnv, mockCtx); + + expect(response).toBe(userResponse); + expect(fetchHandler).toHaveBeenCalledWith(request, mockEnv, mockCtx, authInfo); + }); + + it("returns auth error response without calling user handler", async () => { + (handleMetadataRequest as jest.Mock).mockResolvedValue(null); + const errorResponse = new Response(null, { status: 401 }); + (verifyBearerToken as jest.Mock).mockResolvedValue(errorResponse); + + const fetchHandler = jest.fn<() => Promise>(); + const worker = createKeycardWorker({ fetch: fetchHandler }); + + const request = new Request("https://example.com/mcp"); + const response = await worker.fetch!(request, mockEnv, mockCtx); + + expect(response.status).toBe(401); + expect(fetchHandler).not.toHaveBeenCalled(); + }); +}); + +describe("resolveCredential", () => { + it("returns WorkersClientSecret when client_id and secret are set", () => { + const credential = resolveCredential({ + KEYCARD_ISSUER: "https://auth.keycard.ai", + KEYCARD_CLIENT_ID: "app-123", + KEYCARD_CLIENT_SECRET: "secret-456", + }); + + expect(credential.getAuth()).toEqual({ + clientId: "app-123", + clientSecret: "secret-456", + }); + }); + + it("returns WorkersWebIdentity when private key is set", () => { + const credential = resolveCredential({ + KEYCARD_ISSUER: "https://auth.keycard.ai", + KEYCARD_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + }); + + expect(credential.getAuth()).toBeNull(); // WebIdentity returns null + }); + + it("prefers WebIdentity over ClientSecret when both are set", () => { + const credential = resolveCredential({ + KEYCARD_ISSUER: "https://auth.keycard.ai", + KEYCARD_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + KEYCARD_CLIENT_ID: "app-123", + KEYCARD_CLIENT_SECRET: "secret-456", + }); + + // WebIdentity takes precedence + expect(credential.getAuth()).toBeNull(); + }); + + it("throws when no credentials are configured", () => { + expect(() => + resolveCredential({ KEYCARD_ISSUER: "https://auth.keycard.ai" }), + ).toThrow("Missing Keycard credentials"); + }); +}); diff --git a/packages/cloudflare/src/auth.ts b/packages/cloudflare/src/auth.ts new file mode 100644 index 0000000..8ac1782 --- /dev/null +++ b/packages/cloudflare/src/auth.ts @@ -0,0 +1,153 @@ +import { JWKSOAuthKeyring } from "@keycardai/oauth/keyring"; +import { JWTVerifier } from "@keycardai/oauth/jwt/verifier"; +import { + BadRequestError, + UnauthorizedError, + InvalidTokenError, + InsufficientScopeError, +} from "@keycardai/oauth/errors"; +import type { AuthInfo, BearerAuthOptions } from "./types.js"; + +// Module-level keyring is safe: it only caches public JWKS keys, not user tokens. +let sharedVerifier: JWTVerifier | undefined; + +function getVerifier(): JWTVerifier { + if (!sharedVerifier) { + const keyring = new JWKSOAuthKeyring(); + sharedVerifier = new JWTVerifier(keyring); + } + return sharedVerifier; +} + +/** @internal Reset shared verifier (for testing). */ +export function _resetVerifier(): void { + sharedVerifier = undefined; +} + +/** + * Constructs the OAuth Protected Resource Metadata URL for WWW-Authenticate headers. + */ +function getResourceMetadataUrl(requestUrl: URL): string { + return `${requestUrl.origin}/.well-known/oauth-protected-resource`; +} + +/** + * Verifies a Bearer token from a Workers request. + * + * Returns `AuthInfo` on success, or a `Response` (400/401/403) on failure. + * This is the Workers equivalent of `requireBearerAuth` Express middleware. + */ +export async function verifyBearerToken( + request: Request, + options: BearerAuthOptions = {}, +): Promise { + const url = new URL(request.url); + const resourceMetadataUrl = getResourceMetadataUrl(url); + + try { + const credentials = request.headers.get("Authorization"); + if (!credentials) { + throw new UnauthorizedError("No credentials"); + } + + const [scheme, token] = credentials.split(" "); + if (!token) { + throw new BadRequestError("Malformed credentials"); + } + if (scheme.toLowerCase() !== "bearer") { + throw new InvalidTokenError("Unsupported authentication scheme"); + } + + const verifier = getVerifier(); + const claims = await verifier.verify(token); + + const authInfo: AuthInfo = { + token, + clientId: typeof claims.client_id === "string" ? claims.client_id : "", + scopes: typeof claims.scope === "string" ? claims.scope.split(" ").filter(Boolean) : [], + subject: typeof claims.sub === "string" ? claims.sub : undefined, + }; + + if (typeof claims.aud === "string") { + try { + authInfo.resource = new URL(claims.aud); + } catch { + // aud is not a URL — skip + } + } + + if (typeof claims.exp === "number") { + authInfo.expiresAt = claims.exp; + } + + // Check resource audience — compare against origin only, since tokens + // are scoped to a resource server, not a specific path or query string. + if (authInfo.resource && authInfo.resource.origin !== url.origin) { + throw new InvalidTokenError("Token not intended for resource"); + } + + // Check required scopes + const { requiredScopes = [] } = options; + if (requiredScopes.length > 0) { + const hasAllScopes = requiredScopes.every((scope) => + authInfo.scopes.includes(scope), + ); + if (!hasAllScopes) { + throw new InsufficientScopeError("Insufficient scope"); + } + } + + // Check expiration + if (authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) { + throw new InvalidTokenError("Token has expired"); + } + + return authInfo; + } catch (error) { + if (error instanceof BadRequestError) { + return new Response(null, { status: 400 }); + } + + if (error instanceof UnauthorizedError) { + return new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`, + }, + }); + } + + if (error instanceof InsufficientScopeError) { + return new Response(null, { + status: 403, + headers: { + "WWW-Authenticate": `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`, + }, + }); + } + + if (error instanceof InvalidTokenError) { + return new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`, + }, + }); + } + + // Unexpected error — return 401 + return new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`, + }, + }); + } +} + +/** + * Type guard: returns true if the result is an error Response (not AuthInfo). + */ +export function isAuthError(result: AuthInfo | Response): result is Response { + return result instanceof Response; +} diff --git a/packages/cloudflare/src/credentials.ts b/packages/cloudflare/src/credentials.ts new file mode 100644 index 0000000..b08c389 --- /dev/null +++ b/packages/cloudflare/src/credentials.ts @@ -0,0 +1,159 @@ +import type { PrivateKeyring, IdentifiableKey } from "@keycardai/oauth/keyring"; +import { JWTSigner } from "@keycardai/oauth/jwt/signer"; +import type { TokenExchangeRequest } from "@keycardai/oauth/tokenExchange"; +import type { ApplicationCredential } from "@keycardai/oauth/credentials"; + +export type { ApplicationCredential } from "@keycardai/oauth/credentials"; + +// ============================================================================= +// WorkersClientSecret — Basic auth credential for Workers +// ============================================================================= + +export class WorkersClientSecret implements ApplicationCredential { + #clientId: string; + #clientSecret: string; + + constructor(clientId: string, clientSecret: string) { + this.#clientId = clientId; + this.#clientSecret = clientSecret; + } + + getAuth(): { clientId: string; clientSecret: string } { + return { clientId: this.#clientId, clientSecret: this.#clientSecret }; + } + + async prepareTokenExchangeRequest( + subjectToken: string, + resource: string, + ): Promise { + return { + subjectToken, + resource, + subjectTokenType: "urn:ietf:params:oauth:token-type:access_token", + }; + } +} + +// ============================================================================= +// WorkersWebIdentity — private_key_jwt credential for Workers (RFC 7523) +// ============================================================================= + +export class WorkersWebIdentity implements ApplicationCredential { + #privateKeyPem: string; + #keyId: string; + #cryptoKey?: CryptoKey; + #publicJwk?: Record; + #importPromise?: Promise; + + constructor(privateKeyPem: string, keyId?: string) { + this.#privateKeyPem = privateKeyPem; + this.#keyId = keyId ?? "worker-key"; + } + + getAuth(): null { + return null; + } + + async prepareTokenExchangeRequest( + subjectToken: string, + resource: string, + options?: { tokenEndpoint?: string; authInfo?: Record }, + ): Promise { + await this.#ensureImported(); + + const issuer = options?.authInfo?.resource_client_id ?? this.#keyId; + const audience = options?.tokenEndpoint ?? issuer; + + const keyring = new WorkersPrivateKeyring(this.#cryptoKey!, this.#keyId, issuer); + const signer = new JWTSigner(keyring); + + const now = Math.floor(Date.now() / 1000); + const clientAssertion = await signer.sign({ + iss: issuer, + sub: issuer, + aud: audience, + jti: crypto.randomUUID(), + iat: now, + exp: now + 300, + }); + + return { + subjectToken, + resource, + subjectTokenType: "urn:ietf:params:oauth:token-type:access_token", + clientAssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + clientAssertion, + }; + } + + /** + * Returns the public JWKS for serving at /.well-known/jwks.json. + * Must call after at least one prepareTokenExchangeRequest or importKey. + */ + async getPublicJwks(): Promise<{ keys: Record[] }> { + await this.#ensureImported(); + return { keys: [this.#publicJwk!] }; + } + + async #ensureImported(): Promise { + if (this.#cryptoKey) return; + if (!this.#importPromise) { + this.#importPromise = this.#importKey(); + } + return this.#importPromise; + } + + async #importKey(): Promise { + // Strip PEM headers and decode + const pemBody = this.#privateKeyPem + .replace(/-----BEGIN (?:RSA )?PRIVATE KEY-----/g, "") + .replace(/-----END (?:RSA )?PRIVATE KEY-----/g, "") + .replace(/\s/g, ""); + + const binaryDer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0)); + + // Import as private key for signing + this.#cryptoKey = await crypto.subtle.importKey( + "pkcs8", + binaryDer.buffer, + { name: "RSASSA-PKCS1-v1_5", hash: { name: "SHA-256" } }, + true, // extractable — needed to export public JWK + ["sign"], + ); + + // Export public key as JWK + const jwk = await crypto.subtle.exportKey("jwk", this.#cryptoKey); + this.#publicJwk = { + kty: jwk.kty, + n: jwk.n, + e: jwk.e, + kid: this.#keyId, + alg: "RS256", + use: "sig", + }; + } +} + +// ============================================================================= +// WorkersPrivateKeyring — adapts a CryptoKey for JWTSigner +// ============================================================================= + +class WorkersPrivateKeyring implements PrivateKeyring { + #key: CryptoKey; + #kid: string; + #issuer: string; + + constructor(key: CryptoKey, kid: string, issuer: string) { + this.#key = key; + this.#kid = kid; + this.#issuer = issuer; + } + + async key(_usage: string): Promise { + return { + key: this.#key, + kid: this.#kid, + issuer: this.#issuer, + }; + } +} diff --git a/packages/cloudflare/src/errors.ts b/packages/cloudflare/src/errors.ts new file mode 100644 index 0000000..cb49e1f --- /dev/null +++ b/packages/cloudflare/src/errors.ts @@ -0,0 +1,8 @@ +export { + HTTPError, + BadRequestError, + UnauthorizedError, + OAuthError, + InvalidTokenError, + InsufficientScopeError, +} from "@keycardai/oauth/errors"; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts new file mode 100644 index 0000000..2d16eb9 --- /dev/null +++ b/packages/cloudflare/src/index.ts @@ -0,0 +1,38 @@ +// Auth +export { verifyBearerToken, isAuthError } from "./auth.js"; + +// Metadata +export { handleMetadataRequest } from "./metadata.js"; + +// Credentials +export { + WorkersClientSecret, + WorkersWebIdentity, +} from "./credentials.js"; +export type { ApplicationCredential } from "./credentials.js"; + +// Token cache +export { IsolateSafeTokenCache } from "./tokenCache.js"; +export type { IsolateSafeTokenCacheOptions } from "./tokenCache.js"; + +// Worker +export { createKeycardWorker, resolveCredential } from "./worker.js"; + +// Types +export type { + KeycardEnv, + AuthInfo, + AuthenticatedFetchHandler, + KeycardWorkerOptions, + MetadataOptions, + BearerAuthOptions, +} from "./types.js"; + +// Errors (re-exported from @keycardai/oauth) +export { + BadRequestError, + UnauthorizedError, + InvalidTokenError, + InsufficientScopeError, + OAuthError, +} from "./errors.js"; diff --git a/packages/cloudflare/src/metadata.ts b/packages/cloudflare/src/metadata.ts new file mode 100644 index 0000000..553f432 --- /dev/null +++ b/packages/cloudflare/src/metadata.ts @@ -0,0 +1,114 @@ +import type { MetadataOptions } from "./types.js"; + +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, MCP-Protocol-Version", +}; + +/** + * Handles OAuth metadata and JWKS requests for Workers. + * + * Returns a `Response` for: + * - `/.well-known/oauth-protected-resource` + * - `/.well-known/oauth-authorization-server` + * - `/.well-known/jwks.json` (if `publicJwks` is provided) + * + * Returns `null` if the request path doesn't match any metadata endpoint. + */ +export async function handleMetadataRequest( + request: Request, + options: MetadataOptions, +): Promise { + const url = new URL(request.url); + + // CORS preflight for metadata endpoints + if (request.method === "OPTIONS" && url.pathname.startsWith("/.well-known/")) { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + if (url.pathname === "/.well-known/oauth-protected-resource") { + return handleProtectedResourceMetadata(request, url, options); + } + + if (url.pathname === "/.well-known/oauth-authorization-server") { + return handleAuthorizationServerMetadata(url, options); + } + + if (url.pathname === "/.well-known/jwks.json" && options.publicJwks) { + return jsonResponse(options.publicJwks); + } + + return null; +} + +function handleProtectedResourceMetadata( + request: Request, + url: URL, + options: MetadataOptions, +): Response { + const baseUrl = url.origin; + + const json: Record = { + resource: baseUrl, + authorization_servers: [options.issuer], + }; + + if (options.scopesSupported) { + json.scopes_supported = options.scopesSupported; + } + if (options.resourceName) { + json.resource_name = options.resourceName; + } + if (options.serviceDocumentationUrl) { + json.resource_documentation = options.serviceDocumentationUrl; + } + + // MCP protocol version 2025-03-26 backwards compat: + // rewrite authorization_servers to the base URL + const mcpVersion = request.headers.get("mcp-protocol-version"); + if (mcpVersion === "2025-03-26") { + json.authorization_servers = [baseUrl]; + } + + return jsonResponse(json); +} + +async function handleAuthorizationServerMetadata( + url: URL, + options: MetadataOptions, +): Promise { + const resp = await fetch( + options.issuer + "/.well-known/oauth-authorization-server", + ); + + if (!resp.ok) { + return new Response("Failed to fetch authorization server metadata", { + status: 502, + headers: CORS_HEADERS, + }); + } + + const json = (await resp.json()) as Record; + const baseUrl = url.origin; + + // Rewrite authorization_endpoint to include ?resource= so STS knows + // which resource is being requested + if (typeof json.authorization_endpoint === "string") { + const authorizationUrl = new URL(json.authorization_endpoint); + authorizationUrl.searchParams.set("resource", baseUrl); + json.authorization_endpoint = authorizationUrl.toString(); + } + + return jsonResponse(json); +} + +function jsonResponse(data: unknown): Response { + return new Response(JSON.stringify(data), { + status: 200, + headers: { + "Content-Type": "application/json", + ...CORS_HEADERS, + }, + }); +} diff --git a/packages/cloudflare/src/tokenCache.ts b/packages/cloudflare/src/tokenCache.ts new file mode 100644 index 0000000..876e207 --- /dev/null +++ b/packages/cloudflare/src/tokenCache.ts @@ -0,0 +1,122 @@ +import { TokenExchangeClient } from "@keycardai/oauth/tokenExchange"; +import type { TokenResponse } from "@keycardai/oauth/tokenExchange"; +import type { ApplicationCredential } from "./credentials.js"; + +export interface IsolateSafeTokenCacheOptions { + /** Seconds to subtract from token expiry for safety margin. Default: 30. */ + skewSeconds?: number; + /** Maximum cache entries to prevent unbounded memory in long-lived isolates. Default: 1000. */ + maxEntries?: number; +} + +interface CacheEntry { + response: TokenResponse; + expiresAt: number; +} + +/** + * Per-user token cache that is safe for Cloudflare Workers isolate reuse. + * + * Cache key: `${jwt_sub}::${resource}` — ensures user A's upstream token + * is never returned for user B, even when requests share the same isolate. + */ +export class IsolateSafeTokenCache { + #client: TokenExchangeClient; + #credential?: ApplicationCredential; + #cache = new Map(); + #inflight = new Map>(); + #skewSeconds: number; + #maxEntries: number; + + constructor( + client: TokenExchangeClient, + options?: IsolateSafeTokenCacheOptions & { credential?: ApplicationCredential }, + ) { + this.#client = client; + this.#credential = options?.credential; + this.#skewSeconds = options?.skewSeconds ?? 30; + this.#maxEntries = options?.maxEntries ?? 1000; + } + + /** + * Get an upstream access token, using the cache when possible. + * + * @param subject - JWT subject claim (user identity for cache keying) + * @param subjectToken - The user's bearer token for token exchange + * @param resource - The upstream resource URL to exchange for + */ + async getToken( + subject: string, + subjectToken: string, + resource: string, + ): Promise { + const cacheKey = `${subject}::${resource}`; + const now = Date.now(); + + // Check cache + const cached = this.#cache.get(cacheKey); + if (cached && cached.expiresAt > now) { + return cached.response; + } + + // Deduplicate concurrent requests for the same user+resource + const inflight = this.#inflight.get(cacheKey); + if (inflight) { + return inflight; + } + + const promise = this.#exchange(subjectToken, resource); + this.#inflight.set(cacheKey, promise); + + try { + const response = await promise; + + // Cache with TTL derived from expires_in + const expiresInMs = (response.expiresIn ?? 3600) * 1000; + const expiresAt = now + expiresInMs - this.#skewSeconds * 1000; + + this.#evictIfNeeded(); + this.#cache.set(cacheKey, { response, expiresAt }); + + return response; + } finally { + this.#inflight.delete(cacheKey); + } + } + + async #exchange(subjectToken: string, resource: string): Promise { + if (this.#credential) { + const request = await this.#credential.prepareTokenExchangeRequest(subjectToken, resource); + return this.#client.exchangeToken(request); + } + + return this.#client.exchangeToken({ + subjectToken, + resource, + subjectTokenType: "urn:ietf:params:oauth:token-type:access_token", + }); + } + + #evictIfNeeded(): void { + if (this.#cache.size < this.#maxEntries) return; + + // Evict expired entries first + const now = Date.now(); + for (const [key, entry] of this.#cache) { + if (entry.expiresAt <= now) { + this.#cache.delete(key); + } + } + + // If still over limit, evict oldest entries + if (this.#cache.size >= this.#maxEntries) { + const keysToDelete = Array.from(this.#cache.keys()).slice( + 0, + Math.ceil(this.#maxEntries / 4), + ); + for (const key of keysToDelete) { + this.#cache.delete(key); + } + } + } +} diff --git a/packages/cloudflare/src/types.ts b/packages/cloudflare/src/types.ts new file mode 100644 index 0000000..15103d6 --- /dev/null +++ b/packages/cloudflare/src/types.ts @@ -0,0 +1,87 @@ +// ============================================================================= +// Environment Bindings +// ============================================================================= + +export interface KeycardEnv { + /** Keycard Zone URL (issuer) for JWKS discovery and metadata. */ + KEYCARD_ISSUER: string; + + // Option A: Client credentials (client_id + client_secret) + KEYCARD_CLIENT_ID?: string; + KEYCARD_CLIENT_SECRET?: string; + + // Option B: Web identity (private_key_jwt — no client secret needed) + KEYCARD_PRIVATE_KEY?: string; + + /** Upstream resource URL for token exchange (e.g. "https://api.github.com"). */ + KEYCARD_RESOURCE_URL?: string; +} + +// ============================================================================= +// Auth Info (mirrors @modelcontextprotocol/sdk AuthInfo without the dependency) +// ============================================================================= + +export interface AuthInfo { + /** The raw bearer token from the request. */ + token: string; + /** The client_id claim from the JWT. */ + clientId: string; + /** Scopes granted to this token. */ + scopes: string[]; + /** Token expiration (Unix seconds). */ + expiresAt?: number; + /** The audience/resource URL the token is intended for. */ + resource?: URL; + /** JWT subject claim — critical for per-user cache keying in shared isolates. */ + subject?: string; +} + +// ============================================================================= +// Worker Options +// ============================================================================= + +export type AuthenticatedFetchHandler = ( + request: Request, + env: Env, + ctx: ExecutionContext, + auth: AuthInfo, +) => Response | Promise; + +export interface KeycardWorkerOptions { + /** Required scopes for bearer auth verification. */ + requiredScopes?: string[]; + /** Scopes advertised in the protected resource metadata. */ + scopesSupported?: string[]; + /** Human-readable resource name for metadata. */ + resourceName?: string; + /** Documentation URL for the resource. */ + serviceDocumentationUrl?: string; + /** The authenticated request handler. Only called after successful auth. */ + fetch: AuthenticatedFetchHandler; +} + +// ============================================================================= +// Metadata Options +// ============================================================================= + +export interface MetadataOptions { + /** Keycard Zone URL (issuer). */ + issuer: string; + /** Scopes supported by this resource. */ + scopesSupported?: string[]; + /** Human-readable resource name. */ + resourceName?: string; + /** Documentation URL. */ + serviceDocumentationUrl?: string; + /** Public JWKS to serve at /.well-known/jwks.json (for WebIdentity). */ + publicJwks?: { keys: Record[] }; +} + +// ============================================================================= +// Bearer Auth Options +// ============================================================================= + +export interface BearerAuthOptions { + /** Required scopes. Token must have all of these. */ + requiredScopes?: string[]; +} diff --git a/packages/cloudflare/src/worker.ts b/packages/cloudflare/src/worker.ts new file mode 100644 index 0000000..9a3064d --- /dev/null +++ b/packages/cloudflare/src/worker.ts @@ -0,0 +1,110 @@ +import { verifyBearerToken, isAuthError } from "./auth.js"; +import { handleMetadataRequest } from "./metadata.js"; +import { WorkersClientSecret, WorkersWebIdentity } from "./credentials.js"; +import type { KeycardEnv, KeycardWorkerOptions, MetadataOptions } from "./types.js"; + +/** + * Creates a Cloudflare Worker `ExportedHandler` with Keycard auth built in. + * + * Handles the full request lifecycle: + * 1. CORS preflight + * 2. OAuth metadata endpoints (/.well-known/*) + * 3. Bearer token verification + * 4. Delegates to your authenticated handler + * + * Automatically detects credential type from env: + * - `KEYCARD_PRIVATE_KEY` → WorkersWebIdentity (private_key_jwt) + * - `KEYCARD_CLIENT_ID` + `KEYCARD_CLIENT_SECRET` → WorkersClientSecret + */ +export function createKeycardWorker( + options: KeycardWorkerOptions, +): ExportedHandler { + // Cache the WebIdentity instance across requests (module-level is safe — + // it only holds the private key, which is the same for all requests) + let webIdentity: WorkersWebIdentity | undefined; + + return { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + // CORS preflight for non-metadata paths + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, MCP-Protocol-Version", + }, + }); + } + + // Resolve credential type and build metadata options + const metadataOptions = await buildMetadataOptions(env, options, () => { + if (!webIdentity && env.KEYCARD_PRIVATE_KEY) { + webIdentity = new WorkersWebIdentity(env.KEYCARD_PRIVATE_KEY); + } + return webIdentity; + }); + + // Handle metadata endpoints + const metadataResponse = await handleMetadataRequest(request, metadataOptions); + if (metadataResponse) { + return metadataResponse; + } + + // Verify bearer token + const authResult = await verifyBearerToken(request, { + requiredScopes: options.requiredScopes, + }); + + if (isAuthError(authResult)) { + return authResult; + } + + // Delegate to user handler + return options.fetch(request, env, ctx, authResult); + }, + }; +} + +async function buildMetadataOptions( + env: Env, + options: KeycardWorkerOptions, + getWebIdentity: () => WorkersWebIdentity | undefined, +): Promise { + const metadataOptions: MetadataOptions = { + issuer: env.KEYCARD_ISSUER, + scopesSupported: options.scopesSupported, + resourceName: options.resourceName, + serviceDocumentationUrl: options.serviceDocumentationUrl, + }; + + // If using WebIdentity, serve the public JWKS + const identity = getWebIdentity(); + if (identity) { + metadataOptions.publicJwks = await identity.getPublicJwks(); + } + + return metadataOptions; +} + +/** + * Resolves the appropriate ApplicationCredential from env bindings. + * + * Useful when building an IsolateSafeTokenCache outside of createKeycardWorker. + */ +export function resolveCredential( + env: Env, +): WorkersClientSecret | WorkersWebIdentity { + if (env.KEYCARD_PRIVATE_KEY) { + return new WorkersWebIdentity(env.KEYCARD_PRIVATE_KEY); + } + + if (env.KEYCARD_CLIENT_ID && env.KEYCARD_CLIENT_SECRET) { + return new WorkersClientSecret(env.KEYCARD_CLIENT_ID, env.KEYCARD_CLIENT_SECRET); + } + + throw new Error( + "Missing Keycard credentials in env. Set either KEYCARD_PRIVATE_KEY (WebIdentity) " + + "or KEYCARD_CLIENT_ID + KEYCARD_CLIENT_SECRET (ClientSecret).", + ); +} diff --git a/packages/cloudflare/tsconfig.cjs.json b/packages/cloudflare/tsconfig.cjs.json new file mode 100644 index 0000000..058a5d9 --- /dev/null +++ b/packages/cloudflare/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "outDir": "./dist/cjs" + }, + "exclude": ["**/*.test.ts"] +} diff --git a/packages/cloudflare/tsconfig.json b/packages/cloudflare/tsconfig.json new file mode 100644 index 0000000..54cb935 --- /dev/null +++ b/packages/cloudflare/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "baseUrl": ".", + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/cloudflare/tsconfig.prod.json b/packages/cloudflare/tsconfig.prod.json new file mode 100644 index 0000000..2c68666 --- /dev/null +++ b/packages/cloudflare/tsconfig.prod.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/esm" + }, + "exclude": ["**/*.test.ts"] +} diff --git a/packages/mcp/src/server/auth/credentials.ts b/packages/mcp/src/server/auth/credentials.ts index 72e9816..5164522 100644 --- a/packages/mcp/src/server/auth/credentials.ts +++ b/packages/mcp/src/server/auth/credentials.ts @@ -1,21 +1,11 @@ import * as fs from "node:fs"; import type { TokenExchangeRequest } from "@keycardai/oauth/tokenExchange"; +import type { ApplicationCredential } from "@keycardai/oauth/credentials"; import { PrivateKeyManager, FilePrivateKeyStorage } from "./privateKey.js"; import type { PrivateKeyStorage } from "./privateKey.js"; import { EKSWorkloadIdentityConfigurationError } from "./errors.js"; -// ============================================================================= -// ApplicationCredential interface -// ============================================================================= - -export interface ApplicationCredential { - getAuth(): { clientId: string; clientSecret: string } | null; - prepareTokenExchangeRequest( - subjectToken: string, - resource: string, - options?: { tokenEndpoint?: string; authInfo?: Record }, - ): Promise; -} +export type { ApplicationCredential } from "@keycardai/oauth/credentials"; // ============================================================================= // ClientSecret diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 203d455..171c6e5 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -49,6 +49,11 @@ "import": "./dist/esm/tokenExchange.js", "require": "./dist/cjs/tokenExchange.js", "types": "./dist/esm/tokenExchange.d.ts" + }, + "./credentials": { + "import": "./dist/esm/credentials.js", + "require": "./dist/cjs/credentials.js", + "types": "./dist/esm/credentials.d.ts" } }, "files": [ diff --git a/packages/oauth/src/credentials.ts b/packages/oauth/src/credentials.ts new file mode 100644 index 0000000..3259254 --- /dev/null +++ b/packages/oauth/src/credentials.ts @@ -0,0 +1,16 @@ +import type { TokenExchangeRequest } from "./tokenExchange.js"; + +/** + * Common interface for application-level credentials used in token exchange. + * + * Implementations live in downstream packages (@keycardai/mcp, @keycardai/cloudflare) + * because they depend on platform-specific APIs (Node.js fs, Cloudflare Workers, etc.). + */ +export interface ApplicationCredential { + getAuth(): { clientId: string; clientSecret: string } | null; + prepareTokenExchangeRequest( + subjectToken: string, + resource: string, + options?: { tokenEndpoint?: string; authInfo?: Record }, + ): Promise; +} diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 0b2ef6f..c3b2490 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -9,3 +9,4 @@ export type { JWTClaims } from "./jwt/signer.js"; export { JWTVerifier } from "./jwt/verifier.js"; export { TokenExchangeClient } from "./tokenExchange.js"; export type { TokenExchangeRequest, TokenResponse, TokenExchangeClientOptions } from "./tokenExchange.js"; +export type { ApplicationCredential } from "./credentials.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2d91a4..fc84c2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,28 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/cloudflare: + dependencies: + '@keycardai/oauth': + specifier: workspace:* + version: link:../oauth + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20250327.0 + version: 4.20260331.1 + '@jest/globals': + specifier: ^30.0.4 + version: 30.2.0 + jest: + specifier: ^30.0.4 + version: 30.2.0(@types/node@25.2.3) + ts-jest: + specifier: ^29.4.0 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(jest-util@30.2.0)(jest@30.2.0(@types/node@25.2.3))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/mcp: dependencies: '@keycardai/oauth': @@ -251,6 +273,9 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@cloudflare/workers-types@4.20260331.1': + resolution: {integrity: sha512-fsf+MWPQdQ8XPV0y3tQvqf035ETgEXfJgNTYqmiLcOHB32eH/5Kb75fyZIXS9nnBMq3x6c4+HJRTRxbovNLWiA==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -2014,6 +2039,8 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@cloudflare/workers-types@4.20260331.1': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0