From d7a5909b7a78ea011364edd107b6970082fe8152 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Fri, 9 Oct 2026 14:58:43 +0800 Subject: [PATCH] Accept OAuth and API-key callers on one HTTP endpoint Add FMSG_MCP_AUTH_MODE=oauth+api-key for HTTP. It requires the full OAuth configuration and routes each request by its Authorization header: `Bearer fmsgk_...` uses the existing API-key path unchanged, and every other request (including unauthenticated ones) uses the OAuth path with its resource_metadata challenge and scope checks. The two mechanisms keep separate providers, caches and MCP handlers, so a caller is only resolved by the provider that authenticated it. Protected-resource metadata is served and attachment downloads accept either credential. A rejected API key is also challenged with the resource_metadata URL (without the internal API-key scope) so hosts can discover sign-in. api-key and oauth modes behave as before. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 + CHANGELOG.md | 5 ++ README.md | 7 ++- SECURITY.md | 2 +- docs/http-deployment.md | 8 +-- docs/oauth.md | 27 ++++++++- src/http.ts | 71 +++++++++++++++-------- src/index.ts | 3 +- src/oauth/config.ts | 9 ++- test/oauth.test.ts | 122 +++++++++++++++++++++++++++++++++++++++- 10 files changed, 216 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab200f4..8f55e61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,8 @@ test/fmsg-docker.e2e.test.ts real two-host run, gated by FMSG_E2E=1 - stdout is the stdio protocol channel: log with `console.error` only. - Public OSS repo: never name a specific identity provider; use `example.com` in examples. +Combined `oauth+api-key` mode routes `Bearer fmsgk_…` to the API-key path and everything else to +OAuth, with separate providers, caches and MCP handlers; never let one resolve the other's callers. OAuth mode validates incoming tokens for the exact MCP audience and exchanges them for a separate Web API token. Never forward the incoming JWT or send `X-FMSG-Act-As`. Keep scope classification in `src/oauth/scopes.ts` synchronized with tools; reply needs read and write. diff --git a/CHANGELOG.md b/CHANGELOG.md index b83eddf..accf438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Add HTTP `FMSG_MCP_AUTH_MODE=oauth+api-key`, serving OAuth and API-key callers on one endpoint. + `Bearer fmsgk_...` requests use API-key authentication; all others, including unauthenticated + requests, use OAuth with its discovery challenge and scope checks. Providers and caches stay + separate. Rejected API keys are also challenged with the protected-resource metadata URL. + `api-key` and `oauth` modes are unchanged. `HttpServerHandle.providers` exposes both providers. - Add optional `FMSG_API_PUBLIC_URL`: the Web API URL `whoami` reports to users, while requests keep using `FMSG_API_URL`. It defaults to `FMSG_API_URL`, except that HTTP mode no longer reports a cleartext (internal or loopback) upstream URL. `whoami`'s `api_url` is therefore diff --git a/README.md b/README.md index 5a3fd75..428b108 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ configured `Authorization: Bearer fmsgk_...` header. Each caller supplies its ow header means a shared fmsg identity. For browser sign-in without user API keys, configure [HTTP OAuth](docs/oauth.md): the operator supplies an issuer, resource URI and exchange client. Users add the public MCP URL in a host supporting that issuer's client registration method. +`FMSG_MCP_AUTH_MODE=oauth+api-key` accepts both OAuth and API-key callers on the same URL. For [Claude Code over HTTP](https://code.claude.com/docs/en/mcp): @@ -155,8 +156,8 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and | `FMSG_API_URL` | — | Base URL of the fmsg Web API (required) | | `FMSG_API_KEY` | — | `fmsgk_…` key; stdio mode only | | `FMSG_API_PUBLIC_URL` | `FMSG_API_URL` (HTTP mode: only if HTTPS) | Web API URL reported to users by `whoami`; never used for requests. Set it when the server reaches the Web API at an internal address. Without it, HTTP mode reports no URL for a cleartext `FMSG_API_URL`. Validated like `FMSG_API_URL` | -| `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key` or `oauth`; see [OAuth settings](docs/oauth.md#operator-configuration) | -| `FMSG_MCP_PUBLIC_URL` | OAuth resource URL, otherwise unset | Public MCP endpoint, including `/mcp`; enables the HTTP download-link tool. HTTPS required except loopback; in OAuth mode must equal `FMSG_MCP_OAUTH_RESOURCE_URL` | +| `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key`, `oauth`, or `oauth+api-key` to accept both on one endpoint; see [OAuth settings](docs/oauth.md#operator-configuration) and [combined mode](docs/oauth.md#combined-oauth-and-api-key-mode) | +| `FMSG_MCP_PUBLIC_URL` | OAuth resource URL, otherwise unset | Public MCP endpoint, including `/mcp`; enables the HTTP download-link tool. HTTPS required except loopback; when OAuth is enabled must equal `FMSG_MCP_OAUTH_RESOURCE_URL` | | `FMSG_ALLOW_INSECURE_HTTP` | disabled | Set to `1` only to permit cleartext API access on a trusted development/private network; loopback HTTP is allowed by default | | `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@` | | `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses | @@ -165,7 +166,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and | `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) | | `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds | | `FMSG_MCP_ALLOWED_ORIGINS` | same origin; loopback origins on loopback binds | Comma-separated browser origins including scheme and port; an explicit list replaces the loopback default; hostname-only values are rejected | -| `FMSG_MCP_KEY_CACHE_MAX` / `FMSG_MCP_KEY_CACHE_TTL_SECONDS` | `500` / `1800` | HTTP client cache bound; TTL applies only to API-key mode | +| `FMSG_MCP_KEY_CACHE_MAX` / `FMSG_MCP_KEY_CACHE_TTL_SECONDS` | `500` / `1800` | HTTP client cache bound; TTL applies only to API-key callers | The API key is exchanged for a short-lived access token that the server renews automatically. API URLs must not contain credentials, query strings or fragments. Authenticated requests do not diff --git a/SECURITY.md b/SECURITY.md index 73513bb..e7825cd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ rather than a public issue. - Messaging authorization, grants, address status, quotas and acceptance remain the responsibility of fmsg-webapi and the host services. Each MCP request uses its caller's upstream identity. -- Over stdio the API key comes from the environment. HTTP callers supply their own bearer keys or OAuth access tokens, according to the configured mode. +- Over stdio the API key comes from the environment. HTTP callers supply their own bearer keys or OAuth access tokens, according to the configured mode. Combined `oauth+api-key` mode routes `fmsgk_` keys to API-key authentication and all other credentials to OAuth, with separate caches. Keys and JWTs are retained in process memory for token renewal; hashes index the HTTP client cache. This server does not intentionally persist them. Idle entries expire on access and periodic sweeps (at most 30 seconds apart). Evicted or invalidated clients close immediately if idle; active requests diff --git a/docs/http-deployment.md b/docs/http-deployment.md index ed20ca1..d98962c 100644 --- a/docs/http-deployment.md +++ b/docs/http-deployment.md @@ -2,8 +2,8 @@ This example runs Caddy and fmsg-mcp on the same machine. Replace `mcp.example.com` with your DNS name pointing to that machine; Caddy needs access to ports 80/443 for automatic public TLS. -Keep port 8765 bound to loopback. The command below uses API keys; for OAuth add the -[OAuth configuration](oauth.md#operator-configuration). +Keep port 8765 bound to loopback. The command below uses API keys; for OAuth, or OAuth and API +keys on the same endpoint, add the [OAuth configuration](oauth.md#operator-configuration). ```sh FMSG_API_URL=https://api.example.com \ @@ -54,8 +54,8 @@ allow response idle time beyond `FMSG_MCP_WAIT_MAX_SECONDS` with assembly headro allowed and denied Host/Origin requests, unauthenticated 401 responses, CORS preflight, a read, and cancellation through the actual deployed proxy before advertising that deployment. -The metadata routes are public in OAuth mode and must reach the server for MCP authorization -discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies +The metadata routes are public in `oauth` and `oauth+api-key` modes and must reach the server for +MCP authorization discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies the public same-origin value, so explicitly listing that origin is optional. ## Binary attachment downloads diff --git a/docs/oauth.md b/docs/oauth.md index 7398ece..9c86d83 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -1,8 +1,9 @@ # HTTP OAuth OAuth is an optional HTTP authentication mode. API-key HTTP and stdio remain available with -`FMSG_MCP_AUTH_MODE=api-key` (the default). Each endpoint uses one mode; run separate instances -if you need both. There is no API-key fallback in OAuth mode. +`FMSG_MCP_AUTH_MODE=api-key` (the default). `FMSG_MCP_AUTH_MODE=oauth` accepts only OAuth access +tokens; there is no API-key fallback. `FMSG_MCP_AUTH_MODE=oauth+api-key` serves both kinds of +caller on one endpoint; see [combined mode](#combined-oauth-and-api-key-mode). A user adds the public MCP URL to a compatible host, signs in with the configured authorization server, and consents to messaging scopes. The host handles authorization-code/PKCE and refresh; @@ -57,6 +58,26 @@ Use the [TLS deployment recipe](http-deployment.md), including its metadata rout on other origins need `FMSG_MCP_ALLOWED_ORIGINS`. The configured resource origin is accepted as same-origin behind a TLS proxy; forwarded headers never select an issuer, resource or token endpoint. +## Combined OAuth and API-key mode + +`FMSG_MCP_AUTH_MODE=oauth+api-key` (HTTP only) accepts OAuth and API-key callers at the same +public MCP URL. It requires the complete OAuth configuration above. Each request is routed by its +`Authorization` header: + +- `Bearer fmsgk_...` is handled exactly as in API-key mode. The key is exchanged at the Web API + and is never sent to the authorization server. OAuth scopes do not apply; the Web API decides + access as for any API-key caller. +- Any other bearer token, or a missing or malformed header, is handled exactly as in OAuth mode, + including scope checks. Unauthenticated requests receive the OAuth `401` challenge with + `resource_metadata`, so hosts can discover sign-in. + +The two kinds of caller use separate providers and caches: a request is only ever served by the +mechanism that authenticated it, and a JWT is never treated as an API key. Protected-resource +metadata is served as in OAuth mode, and attachment downloads accept either credential. A rejected +API key returns `401` with `error="invalid_token"` and the same `resource_metadata`, without a +`scope` parameter, so a host can offer sign-in instead. API-key callers need a host that sends a +configured `Authorization` header; hosts that only support MCP authorization sign in with OAuth. + ## Discovery and client onboarding Public [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728.html) metadata is served at @@ -126,7 +147,7 @@ Each incoming token has its own client and cache, even when two tokens name the The cache lifetime is the earliest of `expires_in`, exchanged JWT expiry, incoming JWT expiry and five minutes from the exchange request. Renewal normally starts halfway through that lifetime. `FMSG_MCP_KEY_CACHE_MAX` bounds retained client entries; idle OAuth entries are evicted after five -minutes. `FMSG_MCP_KEY_CACHE_TTL_SECONDS` applies only to API-key mode and cannot extend OAuth +minutes. `FMSG_MCP_KEY_CACHE_TTL_SECONDS` applies only to API-key callers and cannot extend OAuth credentials. Active requests retain their own leases through cache eviction. No token is persisted. Web API `401` retries once after re-exchange; `403` never retries. An expiry timer closes an OAuth diff --git a/src/http.ts b/src/http.ts index d6ee20e..746bbc3 100644 --- a/src/http.ts +++ b/src/http.ts @@ -16,7 +16,7 @@ import { MESSAGING_SCOPES, READ_SCOPE, requestScopes } from "./oauth/scopes.js"; import { DOWNLOAD_PATH, downloadBaseUrl, downloadHeaders, parseDownloadPath } from "./download.js"; import { FmsgHttpError } from "./client/errors.js"; import { describeError } from "./errors.js"; -import type { Caller } from "./context.js"; +import type { Caller, CallerProvider } from "./context.js"; import { VERSION } from "./version.js"; import { safeErrorMessage } from "./client/redact.js"; import { isLoopbackHost, normalizeOrigin } from "./client/url.js"; @@ -25,6 +25,8 @@ export const MCP_PATH = "/mcp"; const CORS_METHODS = ["POST", "GET", "DELETE"]; const CORS_HEADERS = ["authorization", "content-type", "accept", "mcp-protocol-version", "mcp-method", "mcp-name", "mcp-session-id", "last-event-id"]; +// Combined mode routes only fmsg API keys to API-key authentication; everything else is OAuth. +const API_KEY_BEARER = /^Bearer fmsgk_/iu; /** Convert a Node request into a web-standard Request for the MCP handler. */ export function toWebRequest(req: IncomingMessage, signal?: AbortSignal): Request { @@ -92,7 +94,14 @@ export async function sendWebResponse(res: ServerResponse, response: Response, f } } -export type HttpServerHandle = { server: Server; close: () => Promise; provider: ApiKeyCallerProvider | OAuthCallerProvider }; +export type HttpServerHandle = { + server: Server; + close: () => Promise; + /** The OAuth provider when OAuth is configured, otherwise the API-key provider. */ + provider: ApiKeyCallerProvider | OAuthCallerProvider; + /** Each configured provider; both are set in combined `oauth+api-key` mode. */ + providers: { apiKey?: ApiKeyCallerProvider; oauth?: OAuthCallerProvider }; +}; export function createHttpServer(config: Config, log: (line: string) => void = (l) => console.error(l)): HttpServerHandle { const allowedHosts = config.http.allowedHosts.length @@ -104,21 +113,24 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( if (allowedHosts.some((host) => /[*\s/@?#]/u.test(host))) throw new Error("FMSG_MCP_ALLOWED_HOSTS must contain explicit hostnames without wildcards, schemes or paths"); const allowedOrigins = config.http.allowedOrigins.map(normalizeOrigin); const safeLog = (line: string) => log(safeErrorMessage(line)); - const provider = config.oauth ? new OAuthCallerProvider(config, safeLog) : new ApiKeyCallerProvider(config, safeLog); + // Combined mode keeps separate providers and MCP handlers, so a caller is only + // ever resolved by the provider that authenticated it. + const oauthProvider = config.oauth ? new OAuthCallerProvider(config, safeLog) : undefined; + const apiKeyProvider = !config.oauth || config.oauth.acceptApiKeys ? new ApiKeyCallerProvider(config, safeLog) : undefined; const resource = config.oauth ? new URL(config.oauth.resourceUrl) : undefined; const publicOrigin = downloadBaseUrl(config) ? new URL(downloadBaseUrl(config)!).origin : undefined; const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined; const metadataUrl = resource ? `${resource.origin}${metadataPath}` : ""; - const handler = createMcpHandler(({ authInfo }) => - createFmsgMcpServer(provider, config, authInfo ? { - address: config.oauth ? String(authInfo.extra?.address ?? "") : authInfo.clientId, - } : {}), + const mcpHandler = (provider: CallerProvider, address: (authInfo: AuthInfo) => string) => createMcpHandler(({ authInfo }) => + createFmsgMcpServer(provider, config, authInfo ? { address: address(authInfo) } : {}), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) }, ); + const oauthHandler = oauthProvider && mcpHandler(oauthProvider, (authInfo) => String(authInfo.extra?.address ?? "")); + const apiKeyHandler = apiKeyProvider && mcpHandler(apiKeyProvider, (authInfo) => authInfo.clientId); const active = new Set(); const server = createServer((req, res) => { - let authenticated: AuthInfo | undefined; + let release: (() => void) | undefined; const controller = new AbortController(); active.add(controller); const abort = () => controller.abort(); @@ -188,7 +200,7 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( try { download = parseDownloadPath(url.pathname); } catch { return sendWebResponse(res, new Response("invalid attachment download path", { status: 400 })); } } - const serveDownload = async (auth: AuthInfo) => { + const serveDownload = async (provider: ApiKeyCallerProvider | OAuthCallerProvider, auth: AuthInfo) => { let caller: Caller | undefined; try { caller = await provider.forRequest(auth); @@ -211,16 +223,19 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( } } const status = rejectedAuth ? 401 : error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 ? error.status : 502; + const challenge = `Bearer error="invalid_token"${resource ? `, resource_metadata=${JSON.stringify(metadataUrl)}` : ""}`; return sendWebResponse(res, Response.json({ error: describeError(error) }, { - status, headers: status === 401 ? { "www-authenticate": 'Bearer error="invalid_token"' } : {}, + status, headers: status === 401 ? { "www-authenticate": challenge } : {}, })); } }; - if (provider instanceof OAuthCallerProvider) { + const authorization = request.headers.get("authorization") ?? ""; + if (oauthProvider && oauthHandler && !(apiKeyProvider && API_KEY_BEARER.test(authorization))) { try { - const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(request.headers.get("authorization") ?? ""); + const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(authorization); if (!bearer) throw invalidToken(); - authenticated = await provider.verifyAccessToken(bearer[1]!); + const authenticated = await oauthProvider.verifyAccessToken(bearer[1]!); + release = () => oauthProvider.release(authenticated); let parsedBody: unknown; if (request.method === "POST") { try { parsedBody = await request.json(); } @@ -228,17 +243,17 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( if (Array.isArray(parsedBody)) return sendWebResponse(res, new Response("MCP batch requests are not supported", { status: 400 })); } const scopes = download ? [READ_SCOPE] : requestScopes(parsedBody); - if (scopes.some(scope => !authenticated!.scopes.includes(scope))) throw insufficientScope(scopes); - if (download) return await serveDownload(authenticated); + if (scopes.some(scope => !authenticated.scopes.includes(scope))) throw insufficientScope(scopes); + if (download) return await serveDownload(oauthProvider, authenticated); // Discover/list operations only need the incoming JWT. Resolve an // exchanged credential before starting any protected tool response. if (scopes.length) { - const caller = await provider.forRequest(authenticated); + const caller = await oauthProvider.forRequest(authenticated); await caller.client.getToken(false, controller.signal); } const state: OAuthRequestState = { scopes }; return await oauthRequestState.run(state, async () => { - const response = await handler.fetch(request, { authInfo: authenticated, parsedBody }); + const response = await oauthHandler.fetch(request, { authInfo: authenticated, parsedBody }); await sendWebResponse(res, response, () => state.error ? oauthErrorResponse(state.error, metadataUrl) : undefined); }); } catch (error) { @@ -246,19 +261,24 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( throw error; } } + if (!apiKeyProvider || !apiKeyHandler) throw new Error("no authentication provider configured"); // Capture the lease before middleware's expiry/scope checks, so finally also // releases requests rejected after the upstream identity was verified. const gate = requireBearerAuth({ verifier: { verifyAccessToken: async (token) => { - authenticated = await provider.verifyAccessToken(token); + const authenticated = await apiKeyProvider.verifyAccessToken(token); + release = () => apiKeyProvider.release(authenticated); return authenticated; } }, - requiredScopes: [FMSG_SCOPE], + // In combined mode, challenge with the OAuth metadata so hosts can discover + // sign-in. The internal API-key scope is omitted there: it is always granted + // and is not an authorization-server scope a host could request. + ...(resource ? { resourceMetadataUrl: metadataUrl } : { requiredScopes: [FMSG_SCOPE] }), }); const auth = await gate(request); if (auth instanceof Response) return sendWebResponse(res, auth); - if (download) return serveDownload(auth); - return sendWebResponse(res, await handler.fetch(request, { authInfo: auth })); + if (download) return serveDownload(apiKeyProvider, auth); + return sendWebResponse(res, await apiKeyHandler.fetch(request, { authInfo: auth })); })().catch((error) => { safeLog(`request failed: ${error instanceof Error ? error.message : String(error)}`); if (res.destroyed) return; @@ -266,7 +286,7 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( res.writeHead(500, { "content-type": "text/plain" }); res.end("internal error"); }).finally(() => { - if (authenticated) provider.release(authenticated); + release?.(); active.delete(controller); req.off("aborted", abort); res.off("close", abort); @@ -279,8 +299,9 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( const close = async () => { for (const controller of active) controller.abort(); - provider.close(); - await handler.close(); + oauthProvider?.close(); + apiKeyProvider?.close(); + await Promise.all([oauthHandler?.close(), apiKeyHandler?.close()]); await new Promise((resolve) => { server.close(() => resolve()); // Aborted fetches may open replacement sockets without a request, so @@ -288,5 +309,5 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( server.closeAllConnections(); }); }; - return { server, close, provider }; + return { server, close, provider: (oauthProvider ?? apiKeyProvider)!, providers: { apiKey: apiKeyProvider, oauth: oauthProvider } }; } diff --git a/src/index.ts b/src/index.ts index 413071a..23fc950 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,8 @@ Options (HTTP mode): Environment: FMSG_API_URL base URL of the fmsg Web API (required) FMSG_API_KEY fmsgk_... key (stdio mode only) - FMSG_MCP_AUTH_MODE api-key (default) or oauth (HTTP only) + FMSG_MCP_AUTH_MODE api-key (default), oauth, or oauth+api-key to accept + both on one endpoint (HTTP only) FMSG_MCP_OAUTH_* OAuth resource/issuer URLs, client ID/secret and exchange audience; see docs/oauth.md for required settings FMSG_ALLOW_INSECURE_HTTP 1 to allow a trusted private HTTP API outside loopback diff --git a/src/oauth/config.ts b/src/oauth/config.ts index 5144c51..82ccd58 100644 --- a/src/oauth/config.ts +++ b/src/oauth/config.ts @@ -7,6 +7,8 @@ export type OAuthConfig = { clientSecret: string; exchangeAudience: string; addressClaim: string; + /** Combined mode: `fmsgk_` bearer keys use API-key authentication on the same endpoint. */ + acceptApiKeys?: boolean; }; /** Preserve exact issuer/resource identifiers while checking their transport and shape. */ @@ -22,10 +24,12 @@ export function oauthUrl(value: string, label: string, allowQuery = false): stri export function loadOAuthConfig(env: NodeJS.ProcessEnv, transport: string): OAuthConfig | undefined { const mode = env.FMSG_MCP_AUTH_MODE ?? "api-key"; - if (mode !== "api-key" && mode !== "oauth") throw new Error("FMSG_MCP_AUTH_MODE must be api-key or oauth"); + if (mode !== "api-key" && mode !== "oauth" && mode !== "oauth+api-key") { + throw new Error("FMSG_MCP_AUTH_MODE must be api-key, oauth or oauth+api-key"); + } const configured = Object.keys(env).some(key => key.startsWith("FMSG_MCP_OAUTH_") && env[key]); if (mode === "api-key") { - if (configured) throw new Error("Set FMSG_MCP_AUTH_MODE=oauth to use FMSG_MCP_OAUTH_* settings"); + if (configured) throw new Error("Set FMSG_MCP_AUTH_MODE=oauth or oauth+api-key to use FMSG_MCP_OAUTH_* settings"); return undefined; } if (transport !== "http") throw new Error("OAuth mode requires HTTP; use API-key mode for stdio"); @@ -43,5 +47,6 @@ export function loadOAuthConfig(env: NodeJS.ProcessEnv, transport: string): OAut resourceUrl, issuerUrl, exchangeAudience, clientId: required("CLIENT_ID"), clientSecret: required("CLIENT_SECRET"), addressClaim: env.FMSG_MCP_OAUTH_ADDRESS_CLAIM?.trim() || "sub", + ...(mode === "oauth+api-key" ? { acceptApiKeys: true } : {}), }; } diff --git a/test/oauth.test.ts b/test/oauth.test.ts index 942e41a..45493d9 100644 --- a/test/oauth.test.ts +++ b/test/oauth.test.ts @@ -5,12 +5,13 @@ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/cli import { createHttpServer, type HttpServerHandle } from "../src/http.js"; import { OAuthIssuer } from "../src/oauth/issuer.js"; import { OAuthCallerProvider } from "../src/oauth/provider.js"; +import { ApiKeyCallerProvider } from "../src/auth.js"; import { loadOAuthConfig } from "../src/oauth/config.js"; import { TOOL_SCOPES } from "../src/oauth/scopes.js"; import { waitForMessage } from "../src/wait.js"; import { FakeFmsgServer } from "./fake-fmsg-server.js"; import { FakeOAuthServer } from "./fake-oauth-server.js"; -import { ALICE, BOB, call, configFor, structured } from "./helpers.js"; +import { ALICE, BOB, CAROL, call, configFor, structured } from "./helpers.js"; describe("HTTP OAuth", () => { let api: FakeFmsgServer; @@ -442,6 +443,120 @@ describe("HTTP OAuth", () => { }); }); +describe("HTTP OAuth combined with API keys", () => { + let api: FakeFmsgServer; + let idp: FakeOAuthServer; + let http: HttpServerHandle; + let base: string; + let oauth: OAuthCallerProvider; + let apiKeys: ApiKeyCallerProvider; + const clients: Client[] = []; + const metadata = 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"'; + beforeEach(async () => { + api = new FakeFmsgServer(); await api.start(); + idp = new FakeOAuthServer(api); await idp.start(); + http = createHttpServer({ ...configFor(api, "http"), oauth: { ...idp.config, acceptApiKeys: true } }, () => undefined); + oauth = http.providers.oauth!; + apiKeys = http.providers.apiKey!; + await new Promise(resolve => http.server.listen(0, "127.0.0.1", resolve)); + base = `http://127.0.0.1:${(http.server.address() as AddressInfo).port}`; + }); + afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(clients.splice(0).map(client => client.close())); + await http.close(); await idp.stop(); await api.stop(); + }); + async function post(token?: string, method = "tools/list", params: unknown = {}) { + return fetch(`${base}/mcp`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", + ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) }); + } + async function connect(token: string): Promise { + const client = new Client({ name: "combined-test", version: "0.0.0" }, { versionNegotiation: { mode: "auto" } }); + clients.push(client); + await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { authorization: `Bearer ${token}` } } })); + return client; + } + + it("serves protected-resource metadata and OAuth challenges for missing or non-key tokens", async () => { + expect(http.provider).toBe(oauth); + for (const path of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) { + const response = await fetch(`${base}${path}`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ resource: idp.config.resourceUrl, authorization_servers: [idp.config.issuerUrl] }); + } + const apiKeyVerify = vi.spyOn(apiKeys, "verifyAccessToken"); + for (const token of [undefined, "invalid", await idp.token({ aud: idp.config.exchangeAudience })]) { + const response = await post(token); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain(metadata); + expect(response.headers.get("www-authenticate")).toContain('error="invalid_token"'); + await response.body?.cancel(); + } + expect(apiKeyVerify).not.toHaveBeenCalled(); + expect(api.requests).toHaveLength(0); + }); + + it("serves API-key callers through the API-key provider without contacting the authorization server", async () => { + const oauthVerify = vi.spyOn(oauth, "verifyAccessToken"); + const issuerVerify = vi.spyOn(oauth.issuer, "verify"); + const client = await connect("fmsgk_alice_secret"); + expect(structured(await call(client, "whoami"))).toMatchObject({ address: ALICE, transport: "http" }); + expect((await call(client, "send_message", { to: [BOB], topic: "Hello", body: "hello" })).isError).toBeFalsy(); + expect(oauthVerify).not.toHaveBeenCalled(); + expect(issuerVerify).not.toHaveBeenCalled(); + expect(idp.requests).toHaveLength(0); + expect(api.requests.some(req => req.path === "/fmsg/token")).toBe(true); + expect(apiKeys.size).toBe(1); + expect(oauth.size).toBe(0); + }); + + it("serves OAuth callers through token exchange, never as API keys", async () => { + const apiKeyVerify = vi.spyOn(apiKeys, "verifyAccessToken"); + const client = await connect(await idp.token()); + expect(structured(await call(client, "whoami"))).toMatchObject({ address: ALICE, transport: "http" }); + expect(apiKeyVerify).not.toHaveBeenCalled(); + expect(idp.exchanges).toHaveLength(1); + expect(api.requests.every(req => req.path !== "/fmsg/token" && req.authorization === `Bearer ${idp.exchanges[0]!.token}`)).toBe(true); + expect(oauth.size).toBe(1); + expect(apiKeys.size).toBe(0); + }); + + it("challenges rejected API keys with OAuth discovery and still enforces OAuth scopes", async () => { + const issuerVerify = vi.spyOn(oauth.issuer, "verify"); + const rejected = await post("fmsgk_unknown_secret"); + expect(rejected.status).toBe(401); + const challenge = rejected.headers.get("www-authenticate")!; + expect(challenge).toContain('error="invalid_token"'); + expect(challenge).toContain(metadata); + expect(challenge).not.toContain("scope="); + expect(await rejected.text()).not.toContain("fmsgk_unknown_secret"); + expect(issuerVerify).not.toHaveBeenCalled(); + const readOnly = await post(await idp.token({ scope: "fmsg:read" }), "tools/call", { name: "send_message", arguments: { to: [BOB], topic: "x", body: "x" } }); + expect(readOnly.status).toBe(403); + expect(readOnly.headers.get("www-authenticate")).toContain('scope="fmsg:write"'); + expect(idp.exchanges).toHaveLength(0); + }); + + it("downloads attachments for both API-key and OAuth callers", async () => { + const message = api.seed({ from: CAROL, to: [ALICE], attachments: [{ filename: "data.bin", data: Buffer.from([1, 2, 3]), type: "application/octet-stream" }] }); + const path = `${base}/mcp/attachments/${message.id}/data.bin`; + for (const token of ["fmsgk_alice_secret", await idp.token()]) { + const response = await fetch(path, { headers: { authorization: `Bearer ${token}` } }); + expect(response.status).toBe(200); + expect(Buffer.from(await response.arrayBuffer())).toEqual(Buffer.from([1, 2, 3])); + } + for (const token of [undefined, "fmsgk_unknown_secret"]) { + const response = await fetch(path, { headers: token ? { authorization: `Bearer ${token}` } : {} }); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain(metadata); + await response.body?.cancel(); + } + const writeOnly = await fetch(path, { headers: { authorization: `Bearer ${await idp.token({ scope: "fmsg:write" })}` } }); + expect(writeOnly.status).toBe(403); + expect(idp.exchanges).toHaveLength(1); + }); +}); + describe("OAuth configuration", () => { const env = { FMSG_MCP_AUTH_MODE: "oauth", FMSG_MCP_OAUTH_RESOURCE_URL: "https://mcp.example.com/mcp", FMSG_MCP_OAUTH_ISSUER_URL: "https://idp.example.com/oauth", FMSG_MCP_OAUTH_CLIENT_ID: "mcp", @@ -450,6 +565,11 @@ describe("OAuth configuration", () => { expect(loadOAuthConfig({}, "http")).toBeUndefined(); expect(loadOAuthConfig(env, "http")).toMatchObject({ addressClaim: "sub", exchangeAudience: "fmsg-webapi" }); expect(() => loadOAuthConfig(env, "stdio")).toThrow("requires HTTP"); + expect(loadOAuthConfig(env, "http")?.acceptApiKeys).toBeUndefined(); + expect(loadOAuthConfig({ ...env, FMSG_MCP_AUTH_MODE: "oauth+api-key" }, "http")).toMatchObject({ acceptApiKeys: true, addressClaim: "sub" }); + expect(() => loadOAuthConfig({ ...env, FMSG_MCP_AUTH_MODE: "oauth+api-key" }, "stdio")).toThrow("requires HTTP"); + expect(() => loadOAuthConfig({ ...env, FMSG_MCP_AUTH_MODE: "oauth+api-key", FMSG_MCP_OAUTH_CLIENT_ID: "" }, "http")).toThrow("required"); + expect(() => loadOAuthConfig({ ...env, FMSG_MCP_AUTH_MODE: "api-key+oauth" }, "http")).toThrow("must be api-key, oauth or oauth+api-key"); expect(() => loadOAuthConfig({ ...env, FMSG_MCP_AUTH_MODE: "api-key" }, "http")).toThrow("Set FMSG_MCP_AUTH_MODE"); for (const key of Object.keys(env).filter(key => key !== "FMSG_MCP_AUTH_MODE")) { expect(() => loadOAuthConfig({ ...env, [key]: "" }, "http")).toThrow("required");