From efa36827bf87813737f4e2c42b5b5f870990170c Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 17 Sep 2026 18:42:05 +0800 Subject: [PATCH 1/4] Add vendor-neutral HTTP OAuth and delegated token exchange --- .env.example | 11 +- AGENTS.md | 8 +- CHANGELOG.md | 7 +- README.md | 16 +- ROADMAP.md | 9 +- SECURITY.md | 21 ++- docs/http-deployment.md | 10 +- docs/oauth.md | 163 +++++++++++++++++ docs/token-providers.md | 13 +- package-lock.json | 8 +- package.json | 2 + src/client/client.ts | 7 +- src/client/errors.ts | 2 + src/client/ws.ts | 7 +- src/config.ts | 6 +- src/errors.ts | 7 +- src/http.ts | 65 ++++++- src/index.ts | 5 +- src/oauth/config.ts | 47 +++++ src/oauth/errors.ts | 36 ++++ src/oauth/issuer.ts | 147 +++++++++++++++ src/oauth/provider.ts | 96 ++++++++++ src/oauth/scopes.ts | 31 ++++ src/thread.ts | 2 +- src/tools/common.ts | 3 +- src/tools/identity.ts | 2 +- src/wait.ts | 113 +++++++----- test/fake-fmsg-server.ts | 8 +- test/fake-oauth-server.ts | 87 +++++++++ test/oauth.test.ts | 375 ++++++++++++++++++++++++++++++++++++++ 30 files changed, 1228 insertions(+), 86 deletions(-) create mode 100644 docs/oauth.md create mode 100644 src/oauth/config.ts create mode 100644 src/oauth/errors.ts create mode 100644 src/oauth/issuer.ts create mode 100644 src/oauth/provider.ts create mode 100644 src/oauth/scopes.ts create mode 100644 test/fake-oauth-server.ts create mode 100644 test/oauth.test.ts diff --git a/.env.example b/.env.example index dc490fd..4a91e33 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ FMSG_API_URL=https://api.example.com # stdio mode only: the fmsgk_... API key for the address this server sends as. -# Leave unset in HTTP mode, where each client sends its own key as a bearer token. +# Leave unset in HTTP mode, where each client sends its own bearer credential. FMSG_API_KEY=fmsgk_xxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Optional: lets short names resolve, e.g. "bob" -> @bob@example.com @@ -19,3 +19,12 @@ FMSG_API_KEY=fmsgk_xxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx #FMSG_MCP_ALLOWED_ORIGINS=https://app.example.com # Explicit opt-in for a trusted development/private HTTP API outside loopback: #FMSG_ALLOW_INSECURE_HTTP=1 + +# Optional HTTP OAuth; see docs/oauth.md. Unset FMSG_API_KEY when enabling HTTP. +#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=fmsg-mcp +# Supply FMSG_MCP_OAUTH_CLIENT_SECRET through your service's secret manager. +#FMSG_MCP_OAUTH_EXCHANGE_AUDIENCE=fmsg-webapi +#FMSG_MCP_OAUTH_ADDRESS_CLAIM=sub diff --git a/AGENTS.md b/AGENTS.md index 662f903..b0f0bce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,9 @@ The canonical contract is the fmsg-webapi README and src/index.ts bin entry: stdio by default, --http [host:port], --version, --help src/config.ts env → Config; FMSG_API_KEY required for stdio and refused for HTTP src/server.ts createFmsgMcpServer(provider, config): registration only, no I/O -src/context.ts CallerProvider: fixed caller over stdio, per-bearer-key over HTTP +src/context.ts CallerProvider: fixed caller over stdio, per-bearer-credential over HTTP src/auth.ts HTTP bearer verifier: key hash → cached FmsgClient + address +src/oauth/ HTTP OAuth discovery, validation, scopes and token exchange src/http.ts node:http server, /mcp + /healthz, Host/Origin allowlist, bearer gate src/tools/*.ts one file per tool group; src/tools/common.ts has shared schemas/helpers src/wait.ts wait_for_message engine (WebSocket first, inbox catch-up, settle batching) @@ -54,6 +55,11 @@ 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. +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. +See `docs/oauth.md` for the vendor-neutral claims contract and deployment requirements. + ## Adding a tool 1. Register it in the matching `src/tools/*.ts` (or a new file wired in `src/server.ts`) with diff --git a/CHANGELOG.md b/CHANGELOG.md index 8196a5d..ce430ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,10 +19,15 @@ This is the next planned release; publication still happens through a `v0.2.0` G ### Fixes and improvements +- Add opt-in, vendor-neutral HTTP OAuth with protected-resource discovery, signed JWT validation, + per-tool scopes and authenticated RFC 8693 exchange. Isolate caches per incoming token, cap + upstream credentials at five minutes and reconnect waits on expiry. Preserve API-key mode. + Deployed IdP and actual hosted-client acceptance remain rollout checks. + - Accept caller-bound `TokenProvider` implementations in the client library alongside API keys. Share renewal across concurrent requests, pin the address, bound acquisition time, and propagate cancellation. Cap early renewal for short-lived tokens and reuse renewal after late 401 responses. - This is the OAuth foundation; hosted OAuth remains separate integration work. + This provides the credential lifecycle used by API keys and HTTP OAuth. - Retry protected reads when a WebSocket announces a message before it is readable. If retries run out, schedule a delayed inbox catch-up without requiring another push. Fix pre-cancelled waits and preserve request deadlines. diff --git a/README.md b/README.md index 85d8357..8bd68fe 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,18 @@ An [MCP](https://modelcontextprotocol.io) server that gives any AI agent its own [fmsg](https://github.com/markmnl/fmsg) address: send messages, follow threads, react, exchange attachments and wait for replies, through a deployed [fmsg Web API](https://github.com/markmnl/fmsg-webapi). Connect through stdio in hosts such as -Claude Code, Claude Desktop, Cursor and VS Code, or through HTTP in clients that support bearer headers. +Claude Code, Claude Desktop, Cursor and VS Code, or through HTTP using API-key headers or configured OAuth. - **stdio** for local hosts: one address per server process, configured by two environment variables. - **Streamable HTTP** for shared or remote deployments: one endpoint serving many users, each - authenticated by their own fmsg API key. + authenticated by their own fmsg API key or OAuth connection. - The fmsg Web API client is exported for reuse: `import { FmsgClient } from "@markmnl/fmsg-mcp/client"`. ## 1. Get an fmsg address and API key +Connecting to an existing OAuth-enabled endpoint? Add its MCP URL to your host and sign in; +you can skip the API-key setup below. Operators can enable this with [HTTP OAuth](docs/oauth.md). + You send as an fmsg address, authenticated by an API key (`fmsgk_…`) issued by your fmsg host: - **No host yet?** Create an account at a public fmsg host such as [fmsg.io](https://fmsg.io) and @@ -75,7 +78,9 @@ docker run -e FMSG_API_URL=https://api.example.com \ The MCP endpoint is `/mcp`; `/healthz` reports liveness. Use a client that supports an explicitly configured `Authorization: Bearer fmsgk_...` header. Each caller supplies its own key; a shared -header means a shared fmsg identity. Hosted connectors that require OAuth are not supported yet. +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. For [Claude Code over HTTP](https://code.claude.com/docs/en/mcp): @@ -126,6 +131,7 @@ 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_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key` or `oauth`; see [OAuth settings](docs/oauth.md#operator-configuration) | | `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 | @@ -134,7 +140,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 mode per-key client cache | +| `FMSG_MCP_KEY_CACHE_MAX` / `FMSG_MCP_KEY_CACHE_TTL_SECONDS` | `500` / `1800` | HTTP client cache bound; TTL applies only to API-key mode | 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 @@ -193,7 +199,7 @@ unchanged. Use `streamAttachment()` to consume large files incrementally; consum Applications with their own authorization integration can pass a `TokenProvider` instead of an API-key string. The client shares renewal across concurrent requests and keeps the authenticated address fixed. See the [token-provider contract](./docs/token-providers.md). This library interface -does not enable hosted OAuth in the MCP executable yet. +is also used by the executable's optional [OAuth mode](./docs/oauth.md). ## Development diff --git a/ROADMAP.md b/ROADMAP.md index 2822498..242ccef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,10 +29,11 @@ Support claims must name tested clients and versions; agents without MCP need an Ship A first, then B–D. Plan E with the host/account-system maintainer. Release work in F can proceed earlier; hosted-service promises depend on verified OAuth and operational behavior. -E's [client token-provider foundation](docs/token-providers.md) supports renewable upstream credentials -and local isolation tests. Incoming OAuth validation, discovery, token exchange and actual hosted-client -acceptance remain open. Coordinate exchanged-token scope enforcement with the Web API before enabling -OAuth: messaging credentials must not inherit owner credential-management privileges. +E's [HTTP OAuth implementation](docs/oauth.md) includes discovery, incoming JWT validation, tool +scopes, token exchange, isolated renewal and expiring-socket reconnects. Local signed-token tests +cover these boundaries. Deployed IdP/Web API and hosted-client acceptance remain open. The Web API +must enforce delegated scopes and closed owner routes before OAuth is enabled; messaging +credentials must not inherit owner credential-management privileges. Release-triggered npm publication, OIDC trusted publishing, provenance generation and version synchronization already exist in [publish.yml](.github/workflows/publish.yml). Preserve them. diff --git a/SECURITY.md b/SECURITY.md index 10a065f..7d61cf0 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. +- 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. 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 @@ -49,9 +49,24 @@ rather than a public issue. network (`FMSG_ALLOW_INSECURE_HTTP=1`). Authenticated HTTP redirects are refused. - Tool annotations and message-data labels guide the AI host; they do not prove user approval or prevent prompt injection. The AI host owns tool-use permissions and authorization of automation. - fmsg-mcp adds no separate login, messaging permissions, approval gate or per-message confirmation - requirement. Normal stdio setup needs only an HTTPS API URL and API key; token renewal and cache + OAuth mode adds resource-token validation and messaging scope checks, with sign-in/consent at + the configured authorization server. fmsg-mcp adds no recipient ACL, approval gate or per-message + confirmation requirement. Normal stdio setup needs only an HTTPS API URL and API key; token renewal and cache management run automatically. Guidance permits ongoing work within the user's authorized task or automation, subject to the AI host's own approval settings. +## OAuth boundary + +OAuth mode validates the configured issuer, exact MCP audience, EdDSA signature, key ID, +`at+jwt` type, lifetime and address. Only the configured issuer's discovered JWKS is trusted. +Incoming tokens are exchanged with confidential-client authentication and are never forwarded +to the Web API. No `X-FMSG-Act-As` header is sent. The Web API must enforce delegated scopes +and refuse owner-only routes independently of this server. + +Exchanged tokens are cached per incoming token for at most five minutes and never past either +token's expiry. Existing sockets close and renew within that deadline. Incoming tokens validate +offline until expiry; immediate revocation is checked at exchange. No local cache makes +revocation immediate or shortens the lifetime of a copied token at another service. See the +[OAuth contract and revocation limits](docs/oauth.md#exchange-renewal-and-revocation). + When reporting, please remove API keys, tokens, addresses and message bodies from logs. diff --git a/docs/http-deployment.md b/docs/http-deployment.md index ced1291..abbe77f 100644 --- a/docs/http-deployment.md +++ b/docs/http-deployment.md @@ -2,7 +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. Each MCP client supplies its own fmsg API key. +Keep port 8765 bound to loopback. The command below uses API keys; for OAuth add the +[OAuth configuration](oauth.md#operator-configuration). ```sh FMSG_API_URL=https://api.example.com \ @@ -19,7 +20,8 @@ Save this as `Caddyfile`: ```caddyfile mcp.example.com { - handle /mcp { + @fmsg path /mcp /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/* + handle @fmsg { reverse_proxy 127.0.0.1:8765 { transport http { response_header_timeout 240s @@ -46,4 +48,6 @@ 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. -This is an API-key deployment recipe. Per-user hosted OAuth onboarding is a separate workstream. +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 public same-origin value, so explicitly listing that origin is optional. diff --git a/docs/oauth.md b/docs/oauth.md new file mode 100644 index 0000000..c92fde6 --- /dev/null +++ b/docs/oauth.md @@ -0,0 +1,163 @@ +# 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. + +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; +fmsg-mcp validates its access token and exchanges it for a separate Web API token. Users do not +need to create an fmsg API key for this connection. The MCP server's exchange secret belongs to +the operator and is never given to users or AI hosts. + +## Operator configuration + +Provision an OAuth resource and a confidential exchange client at your authorization server. +Use the exact same resource URI in its resource registration, MCP configuration and host setup. +The Web API must support [delegated OAuth claims](https://github.com/markmnl/fmsg-webapi/blob/main/docs/oauth-claims.md), +with distinct OAuth and owner audiences. Configure the IdP's exchange target to issue that OAuth +audience and the consented address, with `fmsg:read` and/or `fmsg:write`. + +```sh +export FMSG_API_URL=https://api.example.com +export FMSG_MCP_AUTH_MODE=oauth +export FMSG_MCP_OAUTH_RESOURCE_URL=https://mcp.example.com/mcp +export FMSG_MCP_OAUTH_ISSUER_URL=https://idp.example.com/oauth +export FMSG_MCP_OAUTH_CLIENT_ID=fmsg-mcp +export FMSG_MCP_OAUTH_EXCHANGE_AUDIENCE=fmsg-webapi +export FMSG_MCP_ALLOWED_HOSTS=mcp.example.com +# Supply FMSG_MCP_OAUTH_CLIENT_SECRET through your service's secret manager. +# Leave FMSG_API_KEY unset. +npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765 +``` + +| Setting | Required in OAuth mode | Meaning | +|---|---|---| +| `FMSG_MCP_OAUTH_RESOURCE_URL` | Yes | Exact public MCP URL, including `/mcp`; also the incoming JWT audience | +| `FMSG_MCP_OAUTH_ISSUER_URL` | Yes | Exact authorization-server issuer, including any path | +| `FMSG_MCP_OAUTH_CLIENT_ID` | Yes | This MCP server's confidential token-exchange client ID | +| `FMSG_MCP_OAUTH_CLIENT_SECRET` | Yes | Its exchange secret; sent only in HTTP Basic authentication | +| `FMSG_MCP_OAUTH_EXCHANGE_AUDIENCE` | Yes | Actual Web API OAuth audience; must differ from the MCP resource URI | +| `FMSG_MCP_OAUTH_ADDRESS_CLAIM` | No; `sub` | Incoming JWT claim containing the consented full `@user@domain` address | +| `FMSG_API_URL` | Yes | Fixed deployed Web API base URL | + +Use the actual audience value for `EXCHANGE_AUDIENCE`, even if the IdP also supports target +aliases: fmsg-mcp checks the returned JWT audience against this value. In the example the Web API +and IdP target must both use `fmsg-webapi` as their OAuth audience. The outgoing token's `sub` +must equal the incoming consented address; its issuer/signature are validated by the Web API. +If your IdP stores a hash of the exchange secret, provision its SHA-256 there and give the original +secret to this server. Never put the secret in source control or client configuration. + +OAuth URLs require HTTPS, with HTTP permitted only on loopback for local testing. Discovery, +JWKS and exchange requests refuse redirects and have a five-second/64-KiB response budget. +Configuration is vendor neutral, but authorization servers must implement the token profile below; +an arbitrary OAuth provider without token exchange is not sufficient. + +Use the [TLS deployment recipe](http-deployment.md), including its metadata routes. Browser hosts +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. + +## Discovery and client onboarding + +Public [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728.html) metadata is served at +`/.well-known/oauth-protected-resource` and the resource-specific path +`/.well-known/oauth-protected-resource/mcp`. Both advertise the exact configured resource, +authorization server, supported scopes and header bearer authentication. A resource served under +another public path requires the corresponding metadata path to reach this process too. +Unauthenticated or invalid-token requests receive `401` with a `WWW-Authenticate: Bearer` +challenge containing `resource_metadata`. + +Authorization-server metadata uses [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414.html): +issuer `https://idp.example.com/oauth` is discovered at +`https://idp.example.com/.well-known/oauth-authorization-server/oauth`. A 404 permits the +OpenID-style discovery fallback below the issuer. The discovered issuer must match exactly. +Only its `jwks_uri` is used; the server does not guess `/.well-known/jwks.json` or trust JWT +`jku`/`x5u` headers. Discovery and keys cache for five minutes. An unknown signing key can trigger +a refresh after a five-second fetch cooldown; publish new keys before using them. + +Client registration belongs to the authorization server. If it offers no dynamic registration, +use pre-registered clients or Client ID Metadata Documents where supported by both server and +host. Check the intended host's registration support before advertising compatibility. This +implementation does not add a registration proxy, login UI or consent UI. + +## Token validation and scopes + +Incoming access tokens must be signed EdDSA JWTs with `typ: at+jwt`, a nonempty `kid` in the +discovered key set, exact `iss`, and exactly one audience equal to the resource URI. Signature, +`exp` and any `nbf` are checked on every request. `sub` is required, the configured address claim +must contain a full fmsg address, and `scope` is a space-delimited string. Unrecognized scopes +grant no messaging capability. Tokens for another audience, including Web API/owner tokens, +are refused. + +| Operations | Required scope | +|---|---| +| Identity/address lookup, inbox/sent, message/thread, delivery status, attachment download, wait, resource reads | `fmsg:read` | +| Send a new message, add recipients, react, mark read | `fmsg:write` | +| Reply (reads the parent before sending) | `fmsg:read fmsg:write` | +| Protocol initialization, tool/resource/prompt discovery and prompt templates | Valid incoming token; no messaging scope needed | + +Missing scope returns `403` with `error="insufficient_scope"` and the required `scope` in the +bearer challenge. The SDK then validates tool arguments and dispatches the request. Scopes are +checked against the actual request body, including resource reads; JSON batches are refused. +The Web API still decides message visibility, recipients, account grants, quotas and acceptance. +No duplicate messaging ACLs or per-message approvals are introduced. + +## Exchange, renewal and revocation + +For protected operations, fmsg-mcp calls the discovered token endpoint with an +[RFC 8693](https://www.rfc-editor.org/rfc/rfc8693.html) form: token-exchange `grant_type`, incoming +`subject_token`, access-token `subject_token_type`, configured `audience`, and the granted +messaging `scope`. HTTP Basic carries the server client ID and secret. The incoming token is +never sent to the Web API, and `X-FMSG-Act-As` is never set. + +The response must contain a separate JWT `access_token`, Bearer `token_type`, access-token +`issued_token_type`, positive `expires_in`, matching `scope`, and no refresh token. Its audience, +address, expiry and scopes are checked before use; the trusted token endpoint issues it, and the +Web API verifies its signature and issuer. Delegated tokens must not reach owner-only routes +such as sub-account, API-key or push administration, even outside the MCP tool surface. + +Each incoming token has its own client and cache, even when two tokens name the same user. +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 +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 +WebSocket by its cached credential deadline, even if the Web API leaves it open. Waits reconnect, +catch up, and retain their original cursor, accumulated batch and timeout. Network failures use +bounded reconnect backoff with polling; cancellation closes sockets and stops exchange work. +This does not resolve the separate large-backlog/pending-thread cursor work in [ROADMAP.md](../ROADMAP.md). + +- `invalid_grant` from exchange means `401`: refresh or reconnect the client connection. +- `invalid_client` / `invalid_target` means `500` plus a sanitized operator-action log. Monitor this + log as an alert; correct the exchange configuration rather than asking users to sign in again. +- Unavailable discovery/JWKS/exchange means `503`; there is no fallback credential. +- Web API `403` with `insufficient_scope` is returned as a scope challenge. It may indicate either + missing scope or a route permanently closed to delegated tokens; additional consent cannot open + owner-only routes. + +Authentication is checked before protected responses start. A later authentication failure also +becomes an HTTP challenge if headers have not been sent. Once a long wait has streamed progress, +HTTP cannot change its status: it finishes with an MCP authentication error and the next request +receives the appropriate challenge. Hosts should reconnect/refresh after that error, not repeat sends. + +Offline validation cannot instantly detect revocation. With ten-minute incoming and five-minute +exchanged tokens, discovery operations may accept a revoked incoming token for up to ten minutes; +new Web API requests stop within five minutes once exchange starts refusing the grant. Longer +IdP lifetimes extend offline acceptance at the corresponding service. The MCP cache/socket cap +stays five minutes, but a copied upstream token remains usable directly until its actual expiry. +Configure upstream lifetimes to meet your intended revocation guarantee. + +## Validation and rollout + +`test/oauth.test.ts` uses a local authorization server with real Ed25519 signatures and separate +OAuth/Web API keys. It exercises discovery, invalid claims, scopes, exchange, caller isolation, +revocation, token expiry and waiting across reconnects. The fake Web API accepts registered token +fixtures; it is not a substitute for the Web API's JWT authorization tests. + +Before enabling a hosted deployment, verify the deployed IdP and Web API audience configuration, +then exercise sign-in/consent, `whoami`, send/read/reply, refresh, revoked grants, scope denial and +wait cancellation through the real TLS proxy in each advertised MCP host. No live hosted-client +or deployed IdP compatibility is claimed by the local tests. API-key integration can keep running +while these services are prepared. diff --git a/docs/token-providers.md b/docs/token-providers.md index 508c01f..adddf8f 100644 --- a/docs/token-providers.md +++ b/docs/token-providers.md @@ -1,8 +1,8 @@ # Web API token providers `FmsgClient` accepts either the existing `fmsgk_…` API-key string or a `TokenProvider`. -This is a client-library extension point. The MCP executable still uses API keys; -HTTP OAuth discovery, incoming-token validation and RFC 8693 exchange are separate work. +This is a client-library extension point. The MCP executable uses it for API keys and +optional [HTTP OAuth](oauth.md), including discovery, JWT validation and RFC 8693 exchange. Import `TokenProvider`, `TokenProviderRequest` and `AccessToken` from `@markmnl/fmsg-mcp/client` (also exported from the package root): @@ -70,9 +70,11 @@ replace a later token. `close()` aborts outstanding token acquisition and HTTP w The optional signal cancels token acquisition before opening the socket. The caller owns the returned WebSocket and must handle its events and close it; long-lived connections do not gain automatic token renewal or revocation handling from this helper. -`wait_for_message` already owns its socket and passes cancellation into acquisition. +`wait_for_message` owns its socket and passes cancellation into acquisition. With +`reconnectOnTokenExpiry: true`, it closes and reopens sockets by the token metadata expiry, +retaining the original wait deadline and cursor; OAuth mode enables this option. -## Contract for a future OAuth adapter +## OAuth adapter contract Validate the incoming MCP access token for the configured issuer and MCP audience, then use authenticated [RFC 8693 token exchange](https://www.rfc-editor.org/rfc/rfc8693.html) @@ -84,8 +86,7 @@ configurable; no particular identity provider is required by this interface. The IdP and Web API must agree on scopes and consented-identity binding before hosted OAuth is enabled. Exchanged messaging tokens must not acquire owner key-management rights or broaden identity via `X-FMSG-Act-As`. The Web API enforces those restrictions; -the tool list is not an authorization boundary. OAuth refresh/revocation and browser -consent are adapter/authorization-service work. Offline JWT validation alone does not +the tool list is not an authorization boundary. Browser consent and refresh/revocation endpoints belong to the authorization service and host. Offline JWT validation alone does not provide immediate revocation of already issued upstream tokens. Tests in `test/token-provider.test.ts` use registered opaque token fixtures to exercise diff --git a/package-lock.json b/package-lock.json index b610b7c..b0ff5c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/server": "^2.0.0", + "jose": "^6.2.12", "ws": "^8.21.3", "zod": "^4.5.4" }, @@ -1312,10 +1313,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.2.10", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", - "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", - "dev": true, + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/package.json b/package.json index 5577850..f2e24ef 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "ROADMAP.md", "docs/http-deployment.md", "docs/token-providers.md", + "docs/oauth.md", "LICENSE", "server.json" ], @@ -66,6 +67,7 @@ }, "dependencies": { "@modelcontextprotocol/server": "^2.0.0", + "jose": "^6.2.12", "ws": "^8.21.3", "zod": "^4.5.4" }, diff --git a/src/client/client.ts b/src/client/client.ts index 9c44394..529e8b4 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -27,6 +27,8 @@ export type FmsgClientOptions = { timeoutMs?: number; /** Allow HTTP outside loopback only on an explicitly trusted network. */ allowInsecureHttp?: boolean; + /** Renew event sockets when their bearer expires (delegated OAuth clients). */ + reconnectOnTokenExpiry?: boolean; }; function withId(message: FmsgMessage, id: string): FmsgMessage { @@ -77,6 +79,8 @@ export class FmsgClient { return this.options.fetch ?? fetch; } + get reconnectOnTokenExpiry(): boolean { return this.options.reconnectOnTokenExpiry === true; } + /** The provider's authenticated address, pinned for this client's lifetime. */ async address(signal?: AbortSignal): Promise { return (await this.getToken(false, signal)).address; @@ -165,7 +169,8 @@ export class FmsgClient { if (!response.ok) { const { message, code } = await readError(response); const method = init.method ?? "GET"; - throw new FmsgHttpError(message, response.status, method, path, code); + throw new FmsgHttpError(message, response.status, method, path, code, + response.status === 403 && /\bBearer\b.*\berror="insufficient_scope"/iu.test(response.headers.get("www-authenticate") ?? "")); } return response; } finally { clearTimeout(headerTimer); } diff --git a/src/client/errors.ts b/src/client/errors.ts index bfeafde..9d3c1d6 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -10,6 +10,8 @@ export class FmsgHttpError extends Error { readonly path: string, /** Machine-readable `code` from the body, when the host sends one (thread routes). */ readonly code?: string, + /** The Web API rejected a delegated scope or closed route. */ + readonly insufficientScope = false, ) { super(redactSecrets(message).text); if (this.code) this.code = redactSecrets(this.code).text; diff --git a/src/client/ws.ts b/src/client/ws.ts index e5c2aa3..44850f9 100644 --- a/src/client/ws.ts +++ b/src/client/ws.ts @@ -3,6 +3,9 @@ import type { FmsgClient } from "./client.js"; import { normalizeMessageId, parseFmsgJson } from "./message-id.js"; import type { FmsgMessage, WsEvent } from "./types.js"; +const expiries = new WeakMap(); +export function webSocketTokenExpiresAt(socket: WebSocket): number | undefined { return expiries.get(socket); } + /** Open the event WebSocket, authenticating with the bearer JWT in the header. */ export async function openFmsgWebSocket(client: FmsgClient, signal?: AbortSignal): Promise { const token = await client.getToken(false, signal); @@ -11,7 +14,9 @@ export async function openFmsgWebSocket(client: FmsgClient, signal?: AbortSignal url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; url.pathname = `${url.pathname.replace(/\/+$/u, "")}/fmsg/ws`; url.search = ""; - return new WebSocket(url, { headers: { authorization: `Bearer ${token.accessToken}` } }); + const socket = new WebSocket(url, { headers: { authorization: `Bearer ${token.accessToken}` } }); + if (client.reconnectOnTokenExpiry) expiries.set(socket, token.expiresAtMs); + return socket; } export function parseWsEvent(raw: WebSocket.RawData): WsEvent | undefined { diff --git a/src/config.ts b/src/config.ts index eb52aae..ed0e4c9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { normalizeFmsgAddress } from "./address.js"; import { normalizeApiUrl, normalizeOrigin } from "./client/url.js"; +import { loadOAuthConfig, type OAuthConfig } from "./oauth/config.js"; export type Transport = "stdio" | "http"; @@ -29,6 +30,7 @@ export type Config = { /** Hard cap on a single wait_for_message call. */ waitMaxSeconds: number; http: HttpConfig; + oauth?: OAuthConfig; }; export const DEFAULT_HTTP_PORT = 8765; @@ -87,6 +89,7 @@ export function loadConfig( options: LoadConfigOptions = {}, ): Config { const requireCredentials = options.requireCredentials ?? true; + const oauth = loadOAuthConfig(env, transport); const apiUrl = env.FMSG_API_URL?.trim() ?? ""; if (!apiUrl && (transport === "http" || requireCredentials)) { throw new Error("FMSG_API_URL is required (base URL of the fmsg Web API, e.g. https://api.example.com)"); @@ -101,7 +104,7 @@ export function loadConfig( } if (transport === "http" && apiKey) { throw new Error( - "FMSG_API_KEY must not be set in HTTP mode: each client supplies its own key as `Authorization: Bearer fmsgk_...`", + "FMSG_API_KEY must not be set in HTTP mode: each client supplies its own bearer credential", ); } @@ -115,6 +118,7 @@ export function loadConfig( return { transport, apiUrl: normalizedApiUrl, + ...(oauth ? { oauth } : {}), allowInsecureHttp, ...(transport === "stdio" && apiKey ? { apiKey } : {}), ...(defaultDomain ? { defaultDomain } : {}), diff --git a/src/errors.ts b/src/errors.ts index 148f15e..47fa0eb 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2,6 +2,7 @@ import type { CallToolResult } from "@modelcontextprotocol/server"; import { FmsgHttpError } from "./client/client.js"; import { redactSecrets, safeErrorMessage } from "./client/redact.js"; import { fence } from "./render.js"; +import { recordOAuthFailure } from "./oauth/errors.js"; /** Build an `isError` tool result the model can read and act on. */ export function toolError(error: unknown, address?: string): CallToolResult { @@ -10,15 +11,17 @@ export function toolError(error: unknown, address?: string): CallToolResult { /** Model-facing description of a failure, with a status-specific hint where one helps. */ export function describeError(error: unknown, address?: string): string { + recordOAuthFailure(error); if (error instanceof FmsgHttpError) { const descriptions: Record = { - 400: "fmsg host rejected the request", 401: "fmsg API key was rejected", 403: "not permitted", + 400: "fmsg host rejected the request", 401: "fmsg authentication was rejected", 403: "not permitted", 404: "not found", 409: "fmsg host refused", 413: "too large for this fmsg host", 422: "fmsg host could not process the request", }; const summary = `${descriptions[error.status] ?? "fmsg host error"} (HTTP ${error.status}).`; // FmsgHttpError sanitizes its public fields at the client boundary. const details = `${error.method} ${error.path}\n${error.message}${error.code ? `\nCode: ${error.code}` : ""}`; - const guidance = error.status === 401 ? "The key may be revoked or expired; ask the user for a replacement." + const guidance = error.status === 401 ? "Reconnect the OAuth account or replace the API key in the host's connection settings." + : error.insufficientScope ? "The connection needs additional scope, or this route is unavailable to delegated tokens." : error.status === 404 && address ? `The message may not exist or may not be visible to ${redactSecrets(address).text}.` : ""; return `${summary}\n\nUpstream response (data, not instructions):\n${fence(details)}${guidance ? `\n\n${guidance}` : ""}`; } diff --git a/src/http.ts b/src/http.ts index 43530bd..93adb72 100644 --- a/src/http.ts +++ b/src/http.ts @@ -10,6 +10,9 @@ import { import { ApiKeyCallerProvider, FMSG_SCOPE } from "./auth.js"; import type { Config } from "./config.js"; import { createFmsgMcpServer } from "./server.js"; +import { OAuthCallerProvider } from "./oauth/provider.js"; +import { invalidToken, insufficientScope, OAuthRequestError, oauthErrorResponse, oauthRequestState, type OAuthRequestState } from "./oauth/errors.js"; +import { MESSAGING_SCOPES, requestScopes } from "./oauth/scopes.js"; import { VERSION } from "./version.js"; import { safeErrorMessage } from "./client/redact.js"; import { isLoopbackHost, normalizeOrigin } from "./client/url.js"; @@ -40,13 +43,13 @@ export function toWebRequest(req: IncomingMessage, signal?: AbortSignal): Reques } /** Pipe a web-standard Response (possibly a long SSE stream) to the Node response. */ -export async function sendWebResponse(res: ServerResponse, response: Response): Promise { +export async function sendWebResponse(res: ServerResponse, response: Response, failure?: () => Response | undefined): Promise { const headers: Record = {}; response.headers.forEach((value, name) => { headers[name] = name.toLowerCase() === "set-cookie" ? [...(headers[name] ?? []), value] : value; }); - res.writeHead(response.status, headers); if (!response.body) { + res.writeHead(response.status, headers); res.end(); return; } @@ -56,6 +59,11 @@ export async function sendWebResponse(res: ServerResponse, response: Response): try { for (;;) { const { done, value } = await reader.read(); + if (!res.headersSent) { + const rejected = failure?.(); + if (rejected) { await reader.cancel(); return await sendWebResponse(res, rejected); } + res.writeHead(response.status, headers); + } if (done || res.destroyed) break; if (!res.write(value)) await new Promise((resolve) => { const finish = () => { @@ -75,7 +83,7 @@ export async function sendWebResponse(res: ServerResponse, response: Response): } } -export type HttpServerHandle = { server: Server; close: () => Promise; provider: ApiKeyCallerProvider }; +export type HttpServerHandle = { server: Server; close: () => Promise; provider: ApiKeyCallerProvider | OAuthCallerProvider }; export function createHttpServer(config: Config, log: (line: string) => void = (l) => console.error(l)): HttpServerHandle { const allowedHosts = config.http.allowedHosts.length @@ -87,9 +95,14 @@ 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 = new ApiKeyCallerProvider(config, safeLog); + const provider = config.oauth ? new OAuthCallerProvider(config, safeLog) : new ApiKeyCallerProvider(config, safeLog); + const resource = config.oauth ? new URL(config.oauth.resourceUrl) : 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?.clientId ? { address: authInfo.clientId } : {}), + createFmsgMcpServer(provider, config, authInfo ? { + address: config.oauth ? String(authInfo.extra?.address ?? "") : authInfo.clientId, + } : {}), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) }, ); const active = new Set(); @@ -108,6 +121,17 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION })); return; } + if (resource && (url.pathname === metadataPath || url.pathname === "/.well-known/oauth-protected-resource")) { + const request = toWebRequest(req, controller.signal); + if (hostHeaderValidationResponse(request, allowedHosts)) return sendWebResponse(res, new Response("host not allowed", { status: 403 })); + const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, OPTIONS", "cache-control": "public, max-age=300" }; + if (req.method === "OPTIONS") return sendWebResponse(res, new Response(null, { status: 204, headers })); + if (req.method !== "GET") return sendWebResponse(res, new Response(null, { status: 405, headers: { ...headers, allow: "GET, OPTIONS" } })); + return sendWebResponse(res, Response.json({ resource: config.oauth!.resourceUrl, + authorization_servers: [config.oauth!.issuerUrl], scopes_supported: MESSAGING_SCOPES, + bearer_methods_supported: ["header"], resource_name: "fmsg messaging", + }, { headers })); + } if (url.pathname !== MCP_PATH) { res.writeHead(404, { "content-type": "text/plain" }); res.end("not found; the MCP endpoint is /mcp"); @@ -126,7 +150,7 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( const localDevelopment = isLoopbackHost(config.http.host) && !allowedOrigins.length && /^https?:$/u.test(parsed.protocol) && isLoopbackHost(parsed.hostname); valid = parsed.origin === origin && - (localDevelopment || allowedOrigins.includes(origin) || origin === new URL(request.url).origin); + (localDevelopment || allowedOrigins.includes(origin) || origin === (resource?.origin ?? new URL(request.url).origin)); } catch { /* malformed origins are rejected */ } if (!valid) return sendWebResponse(res, new Response("origin not allowed", { status: 403 })); res.setHeader("access-control-allow-origin", origin); @@ -144,6 +168,35 @@ export function createHttpServer(config: Config, log: (line: string) => void = ( res.setHeader("access-control-allow-headers", CORS_HEADERS.join(", ")); return sendWebResponse(res, new Response(null, { status: 204 })); } + if (provider instanceof OAuthCallerProvider) { + try { + const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(request.headers.get("authorization") ?? ""); + if (!bearer) throw invalidToken(); + authenticated = await provider.verifyAccessToken(bearer[1]!); + let parsedBody: unknown; + if (request.method === "POST") { + try { parsedBody = await request.json(); } + catch { return sendWebResponse(res, new Response("invalid JSON request", { status: 400 })); } + if (Array.isArray(parsedBody)) return sendWebResponse(res, new Response("MCP batch requests are not supported", { status: 400 })); + } + const scopes = requestScopes(parsedBody); + if (scopes.some(scope => !authenticated!.scopes.includes(scope))) throw insufficientScope(scopes); + // 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); + 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 }); + await sendWebResponse(res, response, () => state.error ? oauthErrorResponse(state.error, metadataUrl) : undefined); + }); + } catch (error) { + if (error instanceof OAuthRequestError) return sendWebResponse(res, oauthErrorResponse(error, metadataUrl)); + throw error; + } + } // Capture the lease before middleware's expiry/scope checks, so finally also // releases requests rejected after the upstream identity was verified. const gate = requireBearerAuth({ diff --git a/src/index.ts b/src/index.ts index ff12e54..413071a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ const USAGE = `${PACKAGE_NAME} ${VERSION} — MCP server for fmsg Usage: fmsg-mcp serve MCP over stdio (FMSG_API_URL + FMSG_API_KEY) fmsg-mcp --http [host:port] serve Streamable HTTP at /mcp; clients send their own - fmsg API key as "Authorization: Bearer fmsgk_..." + bearer credential (API key by default, or configured OAuth) fmsg-mcp --version | --help Options (HTTP mode): @@ -25,6 +25,9 @@ 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_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 FMSG_DEFAULT_DOMAIN lets short names resolve: bob -> @bob@ FMSG_DIRECTORY JSON file mapping short names to @user@domain diff --git a/src/oauth/config.ts b/src/oauth/config.ts new file mode 100644 index 0000000..5144c51 --- /dev/null +++ b/src/oauth/config.ts @@ -0,0 +1,47 @@ +import { isLoopbackHost } from "../client/url.js"; + +export type OAuthConfig = { + resourceUrl: string; + issuerUrl: string; + clientId: string; + clientSecret: string; + exchangeAudience: string; + addressClaim: string; +}; + +/** Preserve exact issuer/resource identifiers while checking their transport and shape. */ +export function oauthUrl(value: string, label: string, allowQuery = false): string { + let url: URL; + try { url = new URL(value); } catch { throw new Error(`${label} must be an absolute HTTPS URL`); } + if (url.username || url.password || (!allowQuery && url.search) || url.hash || + (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname)))) { + throw new Error(`${label} requires HTTPS (HTTP only on loopback), without credentials${allowQuery ? "" : ", query"} or fragment`); + } + return value; +} + +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"); + 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"); + return undefined; + } + if (transport !== "http") throw new Error("OAuth mode requires HTTP; use API-key mode for stdio"); + const required = (suffix: string) => { + const name = `FMSG_MCP_OAUTH_${suffix}`; + const value = env[name]?.trim(); + if (!value) throw new Error(`${name} is required in OAuth mode`); + return value; + }; + const resourceUrl = oauthUrl(required("RESOURCE_URL"), "MCP resource URL"); + const issuerUrl = oauthUrl(required("ISSUER_URL"), "OAuth issuer URL"); + const exchangeAudience = required("EXCHANGE_AUDIENCE"); + if (exchangeAudience === resourceUrl) throw new Error("OAuth exchange audience must differ from the MCP resource URL"); + return { + resourceUrl, issuerUrl, exchangeAudience, + clientId: required("CLIENT_ID"), clientSecret: required("CLIENT_SECRET"), + addressClaim: env.FMSG_MCP_OAUTH_ADDRESS_CLAIM?.trim() || "sub", + }; +} diff --git a/src/oauth/errors.ts b/src/oauth/errors.ts new file mode 100644 index 0000000..a2e8773 --- /dev/null +++ b/src/oauth/errors.ts @@ -0,0 +1,36 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { FmsgHttpError } from "../client/errors.js"; + +export type OAuthRequestState = { scopes: string[]; error?: OAuthRequestError }; +export const oauthRequestState = new AsyncLocalStorage(); + +/** Preserve authentication failures when a tool/resource maps them to an MCP result. */ +export function recordOAuthFailure(error: unknown): void { + const state = oauthRequestState.getStore(); + if (!state) return; + if (error instanceof OAuthRequestError) state.error = error; + else if (error instanceof FmsgHttpError && error.status === 401) state.error = invalidToken(); + else if (error instanceof FmsgHttpError && error.insufficientScope) state.error = new OAuthRequestError(403, "insufficient_scope", + "The Web API requires additional scope or refuses this route for delegated tokens.", state.scopes); +} + +/** Safe, server-authored authentication failures; never include issuer response bodies or credentials. */ +export class OAuthRequestError extends Error { + constructor(readonly status: 401 | 403 | 500 | 503, readonly code: string, message: string, readonly scopes: string[] = []) { + super(message); + this.name = "OAuthRequestError"; + } +} + +export const invalidToken = () => new OAuthRequestError(401, "invalid_token", "The OAuth connection must be refreshed or reconnected."); +export const insufficientScope = (scopes: string[]) => new OAuthRequestError(403, "insufficient_scope", "The OAuth connection does not authorize this operation.", scopes); + +export function oauthErrorResponse(error: OAuthRequestError, metadataUrl: string): Response { + const headers = new Headers({ "cache-control": "no-store" }); + if (error.status === 401 || error.status === 403) { + let challenge = `Bearer resource_metadata=${JSON.stringify(metadataUrl)}, error=${JSON.stringify(error.code)}`; + if (error.scopes.length) challenge += `, scope=${JSON.stringify(error.scopes.join(" "))}`; + headers.set("www-authenticate", challenge); + } + return Response.json({ error: error.code, error_description: error.message }, { status: error.status, headers }); +} diff --git a/src/oauth/issuer.ts b/src/oauth/issuer.ts new file mode 100644 index 0000000..9acaeb7 --- /dev/null +++ b/src/oauth/issuer.ts @@ -0,0 +1,147 @@ +import { createRemoteJWKSet, customFetch, decodeJwt, decodeProtectedHeader, jwtVerify, errors } from "jose"; +import { normalizeFmsgAddress } from "../address.js"; +import { readBytes } from "../client/stream.js"; +import type { AccessToken } from "../client/types.js"; +import { oauthUrl, type OAuthConfig } from "./config.js"; +import { invalidToken, OAuthRequestError } from "./errors.js"; +import { parseScopes } from "./scopes.js"; + +export type OAuthIdentity = { address: string; expiresAtMs: number; scopes: string[]; clientId: string }; +type Metadata = { token_endpoint: string; jwks_uri: string }; +export const EXCHANGE_CACHE_MAX_MS = 300_000; + +/** One configured authorization server. No endpoint is taken from an incoming JWT. */ +export class OAuthIssuer { + private metadata?: { value: Metadata; expiresAt: number }; + private loading?: Promise; + private jwks?: ReturnType; + private jwksUrl?: string; + private readonly lifetime = new AbortController(); + + constructor(private readonly config: OAuthConfig, private readonly log: (line: string) => void) {} + + private async fetch(url: string, init: RequestInit = {}): Promise { + this.lifetime.signal.throwIfAborted(); + const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(5000), ...(init.signal ? [init.signal] : [])]); + try { + const response = await fetch(url, { ...init, signal, redirect: "error" }); + const { data } = await readBytes(response.body, 65_536); + return new Response(data as Uint8Array, { status: response.status, headers: response.headers }); + } catch { + if (init.signal?.aborted) throw init.signal.reason; + throw new OAuthRequestError(503, "temporarily_unavailable", "The authorization service is unavailable."); + } + } + + private configurationError(code: string): OAuthRequestError { + this.log(`OAuth operator action required: ${code}; check issuer and exchange configuration`); + return new OAuthRequestError(500, "server_error", "The OAuth server configuration needs operator attention."); + } + + async discover(): Promise { + this.lifetime.signal.throwIfAborted(); + if (this.metadata && this.metadata.expiresAt > Date.now()) return this.metadata.value; + if (this.loading) return this.loading; + this.loading = (async () => { + const issuer = new URL(this.config.issuerUrl); + const path = issuer.pathname === "/" ? "" : issuer.pathname; + let response = await this.fetch(`${issuer.origin}/.well-known/oauth-authorization-server${path}`); + if (response.status === 404) response = await this.fetch(`${this.config.issuerUrl.replace(/\/$/u, "")}/.well-known/openid-configuration`); + if (!response.ok) throw new OAuthRequestError(503, "temporarily_unavailable", "OAuth discovery is unavailable."); + let value: Record; + try { value = await response.json() as Record; } catch { throw this.configurationError("invalid discovery document"); } + if (!value || value.issuer !== this.config.issuerUrl || typeof value.jwks_uri !== "string" || typeof value.token_endpoint !== "string") { + throw this.configurationError("discovery issuer or endpoints do not match"); + } + let result: Metadata; + try { result = { token_endpoint: oauthUrl(value.token_endpoint, "token endpoint", true), jwks_uri: oauthUrl(value.jwks_uri, "JWKS endpoint", true) }; } + catch { throw this.configurationError("invalid discovery endpoint URL"); } + if (result.jwks_uri !== this.jwksUrl) { + this.jwks = createRemoteJWKSet(new URL(result.jwks_uri), { + cacheMaxAge: 300_000, cooldownDuration: 5000, + [customFetch]: (url, init) => this.fetch(String(url), init), + }); + this.jwksUrl = result.jwks_uri; + } + this.metadata = { value: result, expiresAt: Date.now() + 300_000 }; + return result; + })().finally(() => { this.loading = undefined; }); + return this.loading; + } + + async verify(token: string): Promise { + if (token.length > 16_384) throw invalidToken(); + try { + const header = decodeProtectedHeader(token); + if (header.alg !== "EdDSA" || header.typ !== "at+jwt" || typeof header.kid !== "string" || !header.kid) throw invalidToken(); + } catch { throw invalidToken(); } + await this.discover(); + try { + const { payload } = await jwtVerify(token, this.jwks!, { + algorithms: ["EdDSA"], typ: "at+jwt", issuer: this.config.issuerUrl, + audience: this.config.resourceUrl, requiredClaims: ["iss", "aud", "exp", "sub"], + }); + const audience = Array.isArray(payload.aud) && payload.aud.length === 1 ? payload.aud[0] : payload.aud; + const claim = payload[this.config.addressClaim]; + const address = typeof claim === "string" ? normalizeFmsgAddress(claim) : undefined; + const scopes = parseScopes(payload.scope); + if (audience !== this.config.resourceUrl || !address || !scopes || !Number.isFinite(payload.exp)) throw invalidToken(); + return { address, scopes, expiresAtMs: payload.exp! * 1000, clientId: typeof payload.client_id === "string" ? payload.client_id : "oauth-client" }; + } catch (error) { + if (error instanceof OAuthRequestError) throw error; + if (error instanceof errors.JOSEError) throw invalidToken(); + throw new OAuthRequestError(503, "temporarily_unavailable", "OAuth token verification is unavailable."); + } + } + + async exchange(subjectToken: string, identity: OAuthIdentity, signal: AbortSignal): Promise { + if (identity.expiresAtMs <= Date.now()) throw invalidToken(); + const metadata = await this.discover(); + signal.throwIfAborted(); + const startedAt = Date.now(); + const requestedScopes: string[] = identity.scopes.filter(scope => scope === "fmsg:read" || scope === "fmsg:write"); + // RFC 6749 section 2.3.1: form-encode each component before HTTP Basic. + const encode = (value: string) => new URLSearchParams({ x: value }).toString().slice(2); + const basic = Buffer.from(`${encode(this.config.clientId)}:${encode(this.config.clientSecret)}`).toString("base64"); + const response = await this.fetch(metadata.token_endpoint, { + method: "POST", signal, + headers: { authorization: `Basic ${basic}`, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: subjectToken, subject_token_type: "urn:ietf:params:oauth:token-type:access_token", + audience: this.config.exchangeAudience, + scope: requestedScopes.join(" "), + }), + }); + let body: Record; + try { body = await response.json() as Record; } catch { throw this.configurationError("invalid exchange response"); } + if (!body || !response.ok) { + if (body?.error === "invalid_grant") throw invalidToken(); + if (body?.error === "invalid_client" || body?.error === "invalid_target") throw this.configurationError(body.error); + throw new OAuthRequestError(503, "temporarily_unavailable", "OAuth token exchange is unavailable."); + } + try { + if (typeof body.access_token !== "string" || body.access_token === subjectToken || + typeof body.expires_in !== "number" || !Number.isFinite(body.expires_in) || body.expires_in <= 0 || + typeof body.token_type !== "string" || body.token_type.toLowerCase() !== "bearer" || + body.issued_token_type !== "urn:ietf:params:oauth:token-type:access_token" || body.refresh_token !== undefined) throw new Error(); + // The authenticated token endpoint is trusted to issue the token. Check its + // contract before use; the Web API verifies its signature, issuer and rights. + const claims = decodeJwt(body.access_token); + const header = decodeProtectedHeader(body.access_token); + const scopes = parseScopes(body.scope); + const tokenScopes = parseScopes(claims.scope); + const audience = Array.isArray(claims.aud) && claims.aud.length === 1 ? claims.aud[0] : claims.aud; + if (header.alg !== "EdDSA" || header.typ !== "at+jwt" || audience !== this.config.exchangeAudience || + typeof claims.sub !== "string" || normalizeFmsgAddress(claims.sub) !== identity.address || + typeof claims.exp !== "number" || !Number.isFinite(claims.exp) || + !scopes || !tokenScopes || scopes.some(scope => !requestedScopes.includes(scope)) || + tokenScopes.some(scope => !scopes.includes(scope)) || scopes.some(scope => !tokenScopes.includes(scope))) throw new Error(); + const expiresAtMs = Math.min(startedAt + body.expires_in * 1000, claims.exp * 1000, identity.expiresAtMs, startedAt + EXCHANGE_CACHE_MAX_MS); + if (expiresAtMs <= Date.now()) throw new Error(); + return { accessToken: body.access_token, address: identity.address, expiresAtMs }; + } catch { throw this.configurationError("exchange returned an unexpected token or scope"); } + } + + close(): void { this.lifetime.abort(); this.config.clientSecret = ""; this.metadata = undefined; this.jwks = undefined; } +} diff --git a/src/oauth/provider.ts b/src/oauth/provider.ts new file mode 100644 index 0000000..7a7784d --- /dev/null +++ b/src/oauth/provider.ts @@ -0,0 +1,96 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { AuthInfo } from "@modelcontextprotocol/server"; +import { FmsgClient } from "../client/client.js"; +import type { Config } from "../config.js"; +import type { Caller, CallerProvider } from "../context.js"; +import { invalidToken } from "./errors.js"; +import { OAuthIssuer, EXCHANGE_CACHE_MAX_MS, type OAuthIdentity } from "./issuer.js"; + +type Entry = { key: string; caller: Caller; identity: OAuthIdentity; active: number; lastUsed: number }; + +/** Each incoming token owns its own client/cache. Opaque request leases survive SDK cloning. */ +export class OAuthCallerProvider implements CallerProvider { + readonly issuer: OAuthIssuer; + private readonly entries = new Map(); + private readonly leases = new Map(); + private readonly timer: NodeJS.Timeout; + private closed = false; + + constructor(private readonly config: Config, log: (line: string) => void) { + this.issuer = new OAuthIssuer({ ...config.oauth! }, log); + this.timer = setInterval(() => this.evict(), 30_000).unref(); + } + + private drop(entry: Entry): void { + if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key); + if (!entry.active) entry.caller.client.close(); + } + + private evict(): void { + for (const entry of this.entries.values()) { + if (entry.identity.expiresAtMs <= Date.now() || Date.now() - entry.lastUsed >= EXCHANGE_CACHE_MAX_MS) this.drop(entry); + } + while (this.entries.size > this.config.http.keyCacheMax) this.drop(this.entries.values().next().value!); + } + + async verifyAccessToken(token: string): Promise { + if (this.closed) throw invalidToken(); + const identity = await this.issuer.verify(token); + if (this.closed) throw invalidToken(); + this.evict(); + const key = createHash("sha256").update(token).digest("hex"); + let entry = this.entries.get(key); + if (!entry) { + let subject = token; + const client = new FmsgClient(this.config.apiUrl, { + getToken: ({ signal }) => this.issuer.exchange(subject, identity, signal), + close: () => { subject = ""; }, + }, { allowInsecureHttp: this.config.allowInsecureHttp, reconnectOnTokenExpiry: true }); + entry = { key, identity, active: 0, lastUsed: Date.now(), caller: { + client, address: identity.address, tokenExpiresAt: async () => (await client.getToken()).expiresAtMs, + } }; + this.entries.set(key, entry); + } + entry.active++; + entry.lastUsed = Date.now(); + this.evict(); + const lease = randomUUID(); + this.leases.set(lease, entry); + return { token: key, clientId: identity.clientId, scopes: identity.scopes, expiresAt: identity.expiresAtMs / 1000, + extra: { callerLease: lease, address: identity.address } }; + } + + private entryFor(auth: AuthInfo | undefined): Entry | undefined { + const lease = auth?.extra?.callerLease; + const entry = typeof lease === "string" ? this.leases.get(lease) : undefined; + return entry && auth?.token === entry.key && auth.clientId === entry.identity.clientId && auth.extra?.address === entry.identity.address ? entry : undefined; + } + + async forRequest(auth: AuthInfo | undefined): Promise { + const entry = this.entryFor(auth); + if (this.closed || !entry || entry.identity.expiresAtMs <= Date.now()) throw invalidToken(); + return entry.caller; + } + + release(auth: AuthInfo): void { + const entry = this.entryFor(auth); + if (!entry) return; + this.leases.delete(auth.extra!.callerLease as string); + entry.active--; + if (!entry.active && this.entries.get(entry.key) !== entry) entry.caller.client.close(); + } + + invalidate(caller: Caller): void { + for (const entry of this.entries.values()) if (entry.caller === caller) this.drop(entry); + } + + close(): void { + this.closed = true; + clearInterval(this.timer); + this.issuer.close(); + for (const entry of [...this.entries.values(), ...this.leases.values()]) entry.caller.client.close(); + this.entries.clear(); this.leases.clear(); + } + + get size(): number { return this.entries.size; } +} diff --git a/src/oauth/scopes.ts b/src/oauth/scopes.ts new file mode 100644 index 0000000..e35df88 --- /dev/null +++ b/src/oauth/scopes.ts @@ -0,0 +1,31 @@ +export const READ_SCOPE = "fmsg:read"; +export const WRITE_SCOPE = "fmsg:write"; +export const MESSAGING_SCOPES = [READ_SCOPE, WRITE_SCOPE]; + +// Explicit tool classification, tested against the registered surface. Tools +// that read before writing request both scopes in one challenge. +export const TOOL_SCOPES: Record = { + whoami: [READ_SCOPE], resolve_address: [READ_SCOPE], + list_messages: [READ_SCOPE], list_sent: [READ_SCOPE], get_message: [READ_SCOPE], + get_thread: [READ_SCOPE], delivery_status: [READ_SCOPE], download_attachment: [READ_SCOPE], + save_attachment: [READ_SCOPE], wait_for_message: [READ_SCOPE], + send_message: [WRITE_SCOPE], reply: MESSAGING_SCOPES, + mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE], +}; + +export function parseScopes(value: unknown): string[] | undefined { + if (typeof value !== "string") return undefined; + if (value !== "" && !/^[\x21\x23-\x5b\x5d-\x7e]+(?: [\x21\x23-\x5b\x5d-\x7e]+)*$/u.test(value)) return undefined; + return [...new Set(value.split(" ").filter(Boolean))]; +} + +export function requestScopes(body: unknown): string[] { + if (!body || typeof body !== "object" || Array.isArray(body)) return []; + const request = body as { method?: unknown; params?: { name?: unknown } }; + if (request.method === "tools/call") { + const name = request.params?.name; + // Unknown tools remain the SDK's method/parameter error, never a callable surface. + return typeof name === "string" && Object.hasOwn(TOOL_SCOPES, name) ? TOOL_SCOPES[name]! : []; + } + return request.method === "resources/read" ? [READ_SCOPE] : []; +} diff --git a/src/thread.ts b/src/thread.ts index 880c40d..1dccbf4 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -104,7 +104,7 @@ async function fromPidWalk( try { msg = await client.getMessage(id, signal); } catch (error) { - if (error instanceof FmsgHttpError && (error.status === 404 || error.status === 403)) { + if (error instanceof FmsgHttpError && (error.status === 404 || (error.status === 403 && !error.insufficientScope))) { complete = false; break; } diff --git a/src/tools/common.ts b/src/tools/common.ts index c2b4cb3..192bf7f 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -4,6 +4,7 @@ import type { FmsgMessage, RecipientDelivery } from "../client/types.js"; import type { Config } from "../config.js"; import { type Caller, type CallerProvider, callerFor } from "../context.js"; import { toolError } from "../errors.js"; +import { OAuthRequestError } from "../oauth/errors.js"; import { FmsgHttpError } from "../client/client.js"; import { isoTime, preview } from "../render.js"; @@ -99,7 +100,7 @@ export async function withCaller( try { return await body(caller, ctx.mcpReq.signal); } catch (error) { - if (error instanceof FmsgHttpError && (error.status === 401 || (error.path === "/fmsg/token" && [400, 403].includes(error.status)))) { + if ((error instanceof OAuthRequestError && error.status === 401) || (error instanceof FmsgHttpError && (error.status === 401 || (error.path === "/fmsg/token" && [400, 403].includes(error.status))))) { deps.provider.invalidate?.(caller); } return toolError(error, caller.address); diff --git a/src/tools/identity.ts b/src/tools/identity.ts index 9293418..efa03b8 100644 --- a/src/tools/identity.ts +++ b/src/tools/identity.ts @@ -10,7 +10,7 @@ export const registerIdentityTools: Register = (server, deps) => { { title: "Show fmsg identity", description: - "Report the fmsg address this server acts as (derived from the API key), the fmsg Web API URL, " + + "Report the fmsg address this server acts as (from the authenticated connection), the fmsg Web API URL, " + "when the current access token expires (it is renewed automatically; no action needed), and the " + "address-resolution defaults. Call this first if unsure who you are sending as.", outputSchema: z.object({ diff --git a/src/wait.ts b/src/wait.ts index 641078c..d2609f7 100644 --- a/src/wait.ts +++ b/src/wait.ts @@ -4,7 +4,8 @@ import { FmsgClient, FmsgHttpError } from "./client/client.js"; import { compareMessageIds, maxMessageId, minMessageId } from "./client/message-id.js"; import type { FmsgMessage } from "./client/types.js"; import { safeErrorMessage } from "./client/redact.js"; -import { openFmsgWebSocket, parseWsEvent } from "./client/ws.js"; +import { openFmsgWebSocket, parseWsEvent, webSocketTokenExpiresAt } from "./client/ws.js"; +import { OAuthRequestError } from "./oauth/errors.js"; export type WaitOptions = { /** Only messages with an id greater than this qualify. Default: the newest inbox id at call time. */ @@ -73,8 +74,8 @@ export async function waitForMessage( let finished = false; const retryStop = new AbortController(); const retrySignal = signal ? AbortSignal.any([signal, retryStop.signal]) : retryStop.signal; - const authorizationFailure = (error: unknown) => error instanceof FmsgHttpError && - ([401, 403].includes(error.status) || error.path === "/fmsg/token"); + const authorizationFailure = (error: unknown) => error instanceof OAuthRequestError || (error instanceof FmsgHttpError && + ([401, 403].includes(error.status) || error.path === "/fmsg/token")); const retryRead = async (read: () => Promise, attempts = 3): Promise => { for (let attempt = 0; ; attempt++) { retrySignal.throwIfAborted(); @@ -88,12 +89,13 @@ export async function waitForMessage( const rootCache = new Map(); const lookupRoot = async (id: string): Promise => { try { - return (await client.getThreadMessages(id, signal)).root_id; + return (await client.getThreadMessages(id, retrySignal)).root_id; } catch (error) { + if (authorizationFailure(error)) throw error; // Fall back to a bounded pid walk; any failure here propagates as "unknown". let cur = id; for (let i = 0; i < 100; i++) { - const m = await client.getMessage(cur, signal); + const m = await client.getMessage(cur, retrySignal); if (!m.pid) return m.id; cur = m.pid; } @@ -117,7 +119,8 @@ export async function waitForMessage( if (options.threadOf) { try { targetRoot = await rootOf(options.threadOf, 1); - } catch { + } catch (error) { + if (authorizationFailure(error)) throw error; throw new Error(`could not determine the thread of message ${options.threadOf}`); } } @@ -136,6 +139,16 @@ export async function waitForMessage( let pollTimer: NodeJS.Timeout | undefined; let settleTimer: NodeJS.Timeout | undefined; let recoveryTimer: NodeJS.Timeout | undefined; + let openTimer: NodeJS.Timeout | undefined; + let expiryTimer: NodeJS.Timeout | undefined; + let reconnectTimer: NodeJS.Timeout | undefined; + let reconnectDelay = 1000; + const disposeSocket = (ws: WebSocket) => { + ws.removeAllListeners(); + // terminate/close during CONNECTING can emit an asynchronous error. + ws.on("error", () => undefined); + ws.terminate(); + }; return new Promise((resolve, reject) => { const cleanup = () => { @@ -144,17 +157,13 @@ export async function waitForMessage( clearTimeout(deadlineTimer); clearTimeout(settleTimer); clearTimeout(recoveryTimer); + clearTimeout(openTimer); + clearTimeout(expiryTimer); + clearTimeout(reconnectTimer); clearInterval(pollTimer); clearInterval(tickTimer); signal?.removeEventListener("abort", onAbort); - if (socket) { - socket.removeAllListeners(); - try { - socket.close(); - } catch { - /* ignore */ - } - } + if (socket) disposeSocket(socket); }; const finish = () => { if (finished) return; @@ -219,6 +228,7 @@ export async function waitForMessage( try { root = await rootOf(m.id); } catch (error) { + if (authorizationFailure(error)) return fail(error); if (!finished) unclassified.push({ id: m.id, from: m.from, error: safeErrorMessage(error) }); return; } finally { @@ -265,7 +275,7 @@ export async function waitForMessage( try { // A socket was authorized at its handshake. Re-read through a protected // route so an old connection cannot bypass upstream grant revocation. - const message = await retryRead(() => client.getMessage(id, signal)); + const message = await retryRead(() => client.getMessage(id, retrySignal)); inflight.delete(id); await consider(message); } catch (error) { @@ -285,40 +295,61 @@ export async function waitForMessage( }; const open = deps.openSocket ?? openFmsgWebSocket; - open(client, retrySignal) - .then((ws) => { - if (finished) { - ws.close(); - return; - } + const reconnect = (immediate = false) => { + if (finished || reconnectTimer || !client.reconnectOnTokenExpiry) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = undefined; + connect(); + }, immediate ? 0 : reconnectDelay); + if (!immediate) reconnectDelay = Math.min(reconnectDelay * 2, 10_000); + }; + const connect = () => { + if (finished) return; + open(client, retrySignal).then(ws => { + if (finished) { disposeSocket(ws); return; } socket = ws; - const openTimer = setTimeout(() => { - if (ws.readyState !== ws.OPEN) { - ws.removeAllListeners(); - ws.terminate(); - socket = undefined; - startPolling("WebSocket did not open; polling instead"); + const expiresAt = webSocketTokenExpiresAt(ws); + const disconnected = (why: string) => { + if (socket !== ws) return; + clearTimeout(openTimer); + clearTimeout(expiryTimer); + socket = undefined; + disposeSocket(ws); + if (finished) return; + if (client.reconnectOnTokenExpiry && expiresAt !== undefined && expiresAt <= Date.now() + 1000) { + reconnect(true); + } else { + startPolling(why); + reconnect(); } - }, options.wsOpenTimeoutMs ?? 10_000); + }; + openTimer = setTimeout(() => disconnected("WebSocket did not open; polling instead"), options.wsOpenTimeoutMs ?? 10_000); ws.on("open", () => { clearTimeout(openTimer); + reconnectDelay = 1000; + clearInterval(pollTimer); + pollTimer = undefined; + transport = "websocket"; + note = null; + if (expiresAt !== undefined) { + expiryTimer = setTimeout(() => disconnected("WebSocket credential expired"), Math.max(0, expiresAt - Date.now())); + } + // Keep the original floor, seen set, batch and deadline across renewal. void catchUp(); }); - ws.on("message", (raw) => { + ws.on("message", raw => { const event = parseWsEvent(raw); if (event?.type === "new_msg" && event.data) void considerPushed(event.data.id); }); - ws.on("error", () => { - clearTimeout(openTimer); - socket = undefined; - startPolling("WebSocket failed; polling instead"); - }); - ws.on("close", () => { - clearTimeout(openTimer); - socket = undefined; - if (!finished) startPolling("WebSocket closed; polling instead"); - }); - }) - .catch(() => startPolling("WebSocket unavailable; polling instead")); + ws.on("error", () => disconnected("WebSocket failed; polling instead")); + ws.on("close", () => disconnected("WebSocket closed; polling instead")); + }).catch(error => { + if (finished) return; + if (authorizationFailure(error)) { fail(error); return; } + startPolling("WebSocket unavailable; polling instead"); + reconnect(); + }); + }; + connect(); }); } diff --git a/test/fake-fmsg-server.ts b/test/fake-fmsg-server.ts index 6ee766a..61e1acd 100644 --- a/test/fake-fmsg-server.ts +++ b/test/fake-fmsg-server.ts @@ -37,7 +37,7 @@ export type SeedInput = Partial; }; -export type LoggedRequest = { method: string; path: string; body?: unknown; rawBody?: string }; +export type LoggedRequest = { method: string; path: string; body?: unknown; rawBody?: string; authorization?: string; actAs?: string }; const ID_FIELDS = /"(id|pid|batch_id|root_id|trigger_id)":"([0-9]+)"/gu; @@ -84,7 +84,7 @@ export class FakeFmsgServer { /** Registered provider fixtures only; this does not simulate JWT verification or OAuth. */ readonly providerTokens = new Map(); /** Fail the next request whose path matches, with this status and message. */ - failNext: { match: RegExp; status: number; error: string; code?: string } | undefined; + failNext: { match: RegExp; status: number; error: string; code?: string; challenge?: string } | undefined; /** Force the next protected request to answer 401 (expired JWT simulation). */ rejectNextProtected = false; /** Make thread/messages answer 422 thread_too_deep. */ @@ -100,6 +100,7 @@ export class FakeFmsgServer { const url = new URL(req.url ?? "/", "http://localhost"); if (url.pathname !== "/fmsg/ws") return socket.destroy(); const bearer = req.headers.authorization?.replace(/^Bearer\s+/iu, ""); + this.requests.push({ method: "GET", path: "/fmsg/ws", authorization: req.headers.authorization, actAs: req.headers["x-fmsg-act-as"] as string | undefined }); const subject = this.authenticatedSubject(bearer ?? url.searchParams.get("access_token") ?? undefined); if (!subject) { socket.write("HTTP/1.1 401 Unauthorized\r\ncontent-type: application/json\r\n\r\n{\"error\":\"unauthorized\"}"); @@ -271,10 +272,13 @@ export class FakeFmsgServer { const path = url.pathname; const log: LoggedRequest = { method, path }; this.requests.push(log); + log.authorization = req.headers.authorization; + log.actAs = req.headers["x-fmsg-act-as"] as string | undefined; if (this.failNext && this.failNext.match.test(`${method} ${path}`)) { const f = this.failNext; this.failNext = undefined; + if (f.challenge) res.setHeader("www-authenticate", f.challenge); await readBody(req); return this.json(res, f.status, { error: f.error, ...(f.code ? { code: f.code } : {}) }); } diff --git a/test/fake-oauth-server.ts b/test/fake-oauth-server.ts new file mode 100644 index 0000000..b338994 --- /dev/null +++ b/test/fake-oauth-server.ts @@ -0,0 +1,87 @@ +/** Local RFC 8414/8693 fixture with real signatures, independent OAuth/API keys. */ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { randomUUID } from "node:crypto"; +import { decodeJwt, exportJWK, generateKeyPair, SignJWT, type JWTPayload, type JWTHeaderParameters } from "jose"; +import type { OAuthConfig } from "../src/oauth/config.js"; +import type { FakeFmsgServer } from "./fake-fmsg-server.js"; +import { ALICE } from "./helpers.js"; + +export class FakeOAuthServer { + private readonly server = createServer((req, res) => void this.handle(req, res).catch(() => { + res.writeHead(500); res.end(); + })); + keys!: Awaited>; + apiKeys!: Awaited>; + baseUrl = ""; + kid = "oauth-key"; + exchangeTtl = 300; + exchangeError?: string; + discoveryOverride: Record = {}; + responseOverride: Record = {}; + exchangedClaims: JWTPayload = {}; + readonly revoked = new Set(); + readonly requests: string[] = []; + readonly exchanges: Array<{ form: URLSearchParams; authorization?: string; token?: string }> = []; + onExchange?: () => Promise; + + constructor(private readonly api: FakeFmsgServer) {} + + get config(): OAuthConfig { + return { resourceUrl: "https://mcp.example.com/mcp", issuerUrl: `${this.baseUrl}/oauth`, clientId: "mcp-server", + clientSecret: "fixture secret:+&", exchangeAudience: "https://api.example.com/fmsg", addressClaim: "sub" }; + } + + async start(): Promise { + this.keys = await generateKeyPair("EdDSA"); + this.apiKeys = await generateKeyPair("EdDSA"); + await new Promise(resolve => this.server.listen(0, "127.0.0.1", resolve)); + this.baseUrl = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`; + } + async stop(): Promise { + await new Promise(resolve => { this.server.close(() => resolve()); this.server.closeAllConnections(); }); + } + + async token(claims: JWTPayload = {}, header: Partial = {}, key = this.keys.privateKey): Promise { + return new SignJWT({ iss: this.config.issuerUrl, aud: this.config.resourceUrl, sub: ALICE, + exp: Math.floor(Date.now() / 1000) + 600, nbf: Math.floor(Date.now() / 1000) - 1, + scope: "fmsg:read fmsg:write", client_id: "test-agent", jti: randomUUID(), ...claims, + }).setProtectedHeader({ alg: "EdDSA", typ: "at+jwt", kid: this.kid, ...header }).sign(key); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + this.requests.push(req.url!); + const path = new URL(req.url!, this.baseUrl).pathname; + const json = (status: number, body: unknown) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; + if (path === "/.well-known/oauth-authorization-server/oauth") { + return json(200, { issuer: this.config.issuerUrl, jwks_uri: `${this.baseUrl}/oauth/jwks.json`, + token_endpoint: `${this.baseUrl}/oauth/token`, ...this.discoveryOverride }); + } + if (path === "/oauth/jwks.json") return json(200, { keys: [{ ...await exportJWK(this.keys.publicKey), kid: this.kid, use: "sig", alg: "EdDSA" }] }); + if (path === "/.well-known/jwks.json") return json(200, { keys: [{ ...await exportJWK(this.apiKeys.publicKey), kid: "api-key" }] }); + if (path !== "/oauth/token" || req.method !== "POST") return json(404, {}); + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const form = new URLSearchParams(Buffer.concat(chunks).toString()); + const entry: (typeof this.exchanges)[number] = { form, authorization: req.headers.authorization }; + this.exchanges.push(entry); + await this.onExchange?.(); + const encode = (value: string) => new URLSearchParams({ x: value }).toString().slice(2); + const expected = `Basic ${Buffer.from(`${encode(this.config.clientId)}:${encode(this.config.clientSecret)}`).toString("base64")}`; + if (entry.authorization !== expected || form.has("client_secret")) return json(401, { error: "invalid_client" }); + if (this.exchangeError) return json(400, { error: this.exchangeError, error_description: `${this.config.clientSecret} ${form.get("subject_token")}` }); + if (form.get("audience") !== this.config.exchangeAudience) return json(400, { error: "invalid_target" }); + const subjectToken = form.get("subject_token")!; + if (this.revoked.has(subjectToken)) return json(400, { error: "invalid_grant" }); + const subject = decodeJwt(subjectToken); + const exp = Math.min(Date.now() / 1000 + this.exchangeTtl, subject.exp!); + const scope = form.get("scope") ?? subject.scope; + const claims = { iss: "https://issuer.example.com", aud: this.config.exchangeAudience, sub: subject.sub, exp, + scope, act: { sub: this.config.clientId }, jti: randomUUID(), ...this.exchangedClaims }; + const token = await new SignJWT(claims).setProtectedHeader({ alg: "EdDSA", typ: "at+jwt", kid: "api-key" }).sign(this.apiKeys.privateKey); + entry.token = token; + this.api.providerTokens.set(token, { accessToken: token, address: subject.sub!, expiresAtMs: exp * 1000 }); + return json(200, { access_token: token, issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", expires_in: exp - Date.now() / 1000, scope, ...this.responseOverride }); + } +} diff --git a/test/oauth.test.ts b/test/oauth.test.ts new file mode 100644 index 0000000..602fd55 --- /dev/null +++ b/test/oauth.test.ts @@ -0,0 +1,375 @@ +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { generateKeyPair } from "jose"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { createHttpServer, type HttpServerHandle } from "../src/http.js"; +import { OAuthIssuer } from "../src/oauth/issuer.js"; +import { OAuthCallerProvider } from "../src/oauth/provider.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"; + +describe("HTTP OAuth", () => { + let api: FakeFmsgServer; + let idp: FakeOAuthServer; + let http: HttpServerHandle; + let base: string; + let issuer: OAuthIssuer; + let provider: OAuthCallerProvider; + let logs: string[]; + const clients: Client[] = []; + beforeEach(async () => { + api = new FakeFmsgServer(); await api.start(); + idp = new FakeOAuthServer(api); await idp.start(); + logs = []; + const config = { ...configFor(api, "http"), oauth: idp.config }; + http = createHttpServer(config, line => logs.push(line)); + provider = http.provider as OAuthCallerProvider; + issuer = provider.issuer; + 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 = {}, extra: Record = {}) { + return fetch(`${base}/mcp`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", + ...(token ? { authorization: `Bearer ${token}` } : {}), ...extra }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) }); + } + async function connect(token: string): Promise { + const client = new Client({ name: "oauth-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 exact configured protected-resource metadata at root and resource paths", async () => { + 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(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(await response.json()).toMatchObject({ resource: idp.config.resourceUrl, authorization_servers: [idp.config.issuerUrl], + scopes_supported: ["fmsg:read", "fmsg:write"], bearer_methods_supported: ["header"] }); + } + for (const token of [undefined, "fmsgk_alice_secret", "invalid"]) { + const response = await post(token); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain('resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"'); + await response.body?.cancel(); + } + expect(idp.requests).toHaveLength(0); + }); + + it("validates signed JWTs using the discovered OAuth JWKS and caches discovery", async () => { + const token = await idp.token(); + expect(await issuer.verify(token)).toMatchObject({ address: ALICE, scopes: ["fmsg:read", "fmsg:write"], clientId: "test-agent" }); + await issuer.verify(token); + expect(idp.requests).toEqual(["/.well-known/oauth-authorization-server/oauth", "/oauth/jwks.json"]); + expect(idp.exchanges).toHaveLength(0); + }); + + it("preserves queries on discovered token and JWKS endpoints", async () => { + idp.discoveryOverride = { jwks_uri: `${idp.baseUrl}/oauth/jwks.json?tenant=example`, token_endpoint: `${idp.baseUrl}/oauth/token?tenant=example` }; + const client = await connect(await idp.token()); + expect(structured(await call(client, "whoami"))).toMatchObject({ address: ALICE }); + expect(idp.requests).toContain("/oauth/jwks.json?tenant=example"); + expect(idp.requests).toContain("/oauth/token?tenant=example"); + }); + + it("rejects the wrong signature, type, key, issuer, audience, lifetime, address and scope", async () => { + const other = await generateKeyPair("EdDSA"); + const bad = [ + await idp.token({}, {}, other.privateKey), await idp.token({}, { typ: "JWT" }), await idp.token({}, { kid: "api-key" }), + await idp.token({ iss: "https://foreign.example.com/oauth" }), await idp.token({ aud: idp.config.exchangeAudience }), + await idp.token({ aud: [idp.config.resourceUrl, "owner"] }), await idp.token({ aud: `${idp.config.resourceUrl}/` }), + await idp.token({ exp: 1 }), await idp.token({ exp: undefined }), await idp.token({ nbf: Date.now() / 1000 + 60 }), + await idp.token({ sub: "account-id" }), await idp.token({ scope: ["fmsg:read"] }), + await idp.token({ scope: "fmsg:read\nfmsg:write" }), + `${Buffer.from(JSON.stringify({ alg: "none", typ: "at+jwt", kid: idp.kid })).toString("base64url")}.e30.`, + ]; + for (const token of bad) { + const response = await post(token); + expect(response.status).toBe(401); + expect(await response.text()).not.toContain(token); + } + expect(idp.exchanges).toHaveLength(0); + expect(api.requests).toHaveLength(0); + expect(idp.requests).not.toContain("/.well-known/jwks.json"); + }); + + it("fails closed on mismatched discovery and insecure endpoints without exposing secrets", async () => { + idp.discoveryOverride = { issuer: "https://foreign.example.com" }; + const token = await idp.token(); + const response = await post(token); + expect(response.status).toBe(500); + expect(await response.text()).not.toContain(token); + expect(logs.join()).toContain("operator action required"); + expect(logs.join()).not.toContain(idp.config.clientSecret); + idp.discoveryOverride = { token_endpoint: "http://external.example.com/token" }; + expect((await post(token)).status).toBe(500); + expect(api.requests).toHaveLength(0); + }); + + it("refreshes the discovered keys on an unknown kid after the fetch cooldown", async () => { + await issuer.verify(await idp.token()); + idp.keys = await generateKeyPair("EdDSA"); + idp.kid = "rotated-oauth-key"; + const token = await idp.token({}, { jku: "https://untrusted.example.com/keys" }); + const now = Date.now(); + const clock = vi.spyOn(Date, "now").mockReturnValue(now + 6000); + try { expect(await issuer.verify(token)).toMatchObject({ address: ALICE }); } + finally { clock.mockRestore(); } + expect(idp.requests).toEqual(["/.well-known/oauth-authorization-server/oauth", "/oauth/jwks.json", "/oauth/jwks.json"]); + }); + + it("does not let method headers or JSON batches bypass scope checks", async () => { + const token = await idp.token({ scope: "fmsg:read" }); + const spoofed = await post(token, "tools/call", { name: "send_message", arguments: { to: [BOB], topic: "x", body: "x" } }, + { "mcp-method": "tools/list", "mcp-name": "whoami" }); + expect(spoofed.status).toBe(403); + const batch = await fetch(`${base}/mcp`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify([{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "send_message" } }]) }); + expect(batch.status).toBe(400); + expect(idp.exchanges).toHaveLength(0); + expect(api.requests).toHaveLength(0); + }); + + it("accepts the configured public origin behind a proxy and exposes auth challenges to allowed browsers", async () => { + const origin = new URL(idp.config.resourceUrl).origin; + const response = await post(undefined, "tools/list", {}, { origin }); + expect(response.status).toBe(401); + expect(response.headers.get("access-control-allow-origin")).toBe(origin); + expect(response.headers.get("access-control-expose-headers")).toContain("WWW-Authenticate"); + expect((await post(undefined, "tools/list", {}, { origin: "https://untrusted.example.com" })).status).toBe(403); + }); + + it("classifies every HTTP tool and blocks insufficient scope before token exchange", async () => { + const client = await connect(await idp.token({ scope: "" })); + const { tools } = await client.listTools(); + expect(tools.length).toBeGreaterThan(10); + for (const tool of tools) { + expect(Object.hasOwn(TOOL_SCOPES, tool.name)).toBe(true); + const scopes = TOOL_SCOPES[tool.name]!; + expect(scopes).toContain(tool.annotations?.readOnlyHint ? "fmsg:read" : "fmsg:write"); + const token = await idp.token({ scope: tool.annotations?.readOnlyHint ? "fmsg:write" : "fmsg:read" }); + const response = await post(token, "tools/call", { name: tool.name, arguments: {} }); + expect(response.status, tool.name).toBe(403); + expect(response.headers.get("www-authenticate")).toContain(`scope="${scopes.join(" ")}"`); + await response.body?.cancel(); + } + const resource = await post(await idp.token({ scope: "fmsg:write" }), "resources/read", { uri: "fmsg://message/1" }); + expect(resource.status).toBe(403); + expect(idp.exchanges).toHaveLength(0); + expect(api.requests).toHaveLength(0); + }); + + it("exchanges using Basic and form fields, never forwarding the incoming token or act-as", async () => { + const token = await idp.token(); + const client = await connect(token); + expect(client.getInstructions()).toContain(ALICE); + 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((await call(client, "list_messages")).isError).toBeFalsy(); + expect(idp.exchanges).toHaveLength(1); + const exchange = idp.exchanges[0]!; + expect(Object.fromEntries(exchange.form)).toEqual({ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token_type: "urn:ietf:params:oauth:token-type:access_token", subject_token: token, + audience: idp.config.exchangeAudience, scope: "fmsg:read fmsg:write" }); + expect(api.requests.length).toBeGreaterThan(1); + for (const req of api.requests) { + expect(req.authorization).toBe(`Bearer ${exchange.token}`); + expect(req.actAs).toBeUndefined(); + expect(req.path).not.toBe("/fmsg/token"); + } + }); + + it("isolates caches per incoming token even for the same address", async () => { + const tokens = [await idp.token(), await idp.token(), await idp.token({ sub: BOB })]; + const connected = await Promise.all(tokens.map(connect)); + const identities = await Promise.all(connected.map(client => call(client, "whoami"))); + expect(identities.map(result => structured<{ address: string }>(result).address)).toEqual([ALICE, ALICE, BOB]); + expect(new Set(idp.exchanges.map(entry => entry.token)).size).toBe(3); + expect(idp.exchanges.map(entry => entry.form.get("subject_token")).sort()).toEqual(tokens.sort()); + await Promise.all(connected.map(client => call(client, "list_messages"))); + expect(idp.exchanges).toHaveLength(3); + }); + + it.each([["invalid_grant", 401], ["invalid_client", 500], ["invalid_target", 500], ["temporarily_unavailable", 503]] as const)( + "maps exchange %s to HTTP %i without exposing IdP error descriptions", async (code, status) => { + idp.exchangeError = code; + const token = await idp.token(); + const response = await post(token, "tools/call", { name: "list_messages" }); + expect(response.status).toBe(status); + const text = await response.text(); + expect(text).not.toContain(token); expect(text).not.toContain(idp.config.clientSecret); + expect(logs.join()).not.toContain(token); expect(logs.join()).not.toContain(idp.config.clientSecret); + if (status === 401) expect(response.headers.get("www-authenticate")).toContain('error="invalid_token"'); + if (status === 500) expect(logs.join()).toContain("operator action required"); + expect(api.requests).toHaveLength(0); + }, + ); + + it("caps cache expiry by response, JWT, subject and five minutes", async () => { + idp.exchangeTtl = 900; + const token = await idp.token({ exp: Date.now() / 1000 + 1800 }); + const identity = await issuer.verify(token); + const before = Date.now(); + const exchanged = await issuer.exchange(token, identity, new AbortController().signal); + expect(exchanged.expiresAtMs).toBeGreaterThan(before + 290_000); + expect(exchanged.expiresAtMs).toBeLessThanOrEqual(Date.now() + 300_000); + idp.responseOverride = { expires_in: 20 }; + expect((await issuer.exchange(token, identity, new AbortController().signal)).expiresAtMs).toBeLessThanOrEqual(Date.now() + 20_000); + idp.responseOverride = {}; + idp.exchangedClaims = { exp: Date.now() / 1000 + 10 }; + expect((await issuer.exchange(token, identity, new AbortController().signal)).expiresAtMs).toBeLessThanOrEqual(Date.now() + 10_000); + idp.exchangedClaims = {}; + const short = await idp.token({ exp: Date.now() / 1000 + 5 }); + const shortIdentity = await issuer.verify(short); + expect((await issuer.exchange(short, shortIdentity, new AbortController().signal)).expiresAtMs).toBeLessThanOrEqual(shortIdentity.expiresAtMs); + }); + + it("refuses exchange contract violations before making a Web API call", async () => { + const token = await idp.token(); const identity = await issuer.verify(token); + for (const override of [{ refresh_token: "not-allowed" }, { access_token: token }, { expires_in: 0 }, { scope: "owner" }]) { + idp.responseOverride = override; + await expect(issuer.exchange(token, identity, new AbortController().signal)).rejects.toMatchObject({ status: 500 }); + } + idp.responseOverride = {}; + for (const claims of [{ aud: "owner" }, { aud: [idp.config.exchangeAudience, "owner"] }, { sub: BOB }, { scope: "owner" }]) { + idp.exchangedClaims = claims; + await expect(issuer.exchange(token, identity, new AbortController().signal)).rejects.toMatchObject({ status: 500 }); + } + expect(api.requests).toHaveLength(0); + }); + + it("returns upstream insufficient-scope challenges and does not retry 403", async () => { + const token = await idp.token(); + api.failNext = { match: /^GET \/fmsg$/u, status: 403, error: "route unavailable", challenge: 'Bearer error="insufficient_scope"' }; + const response = await post(token, "tools/call", { name: "list_messages" }); + expect(response.status).toBe(403); + expect(response.headers.get("www-authenticate")).toContain('error="insufficient_scope"'); + expect(idp.exchanges).toHaveLength(1); + expect(api.requests).toHaveLength(1); + }); + + it("re-exchanges after upstream 401 and returns 401 if the grant was revoked", async () => { + const token = await idp.token(); + const client = await connect(token); + await call(client, "list_messages"); + idp.revoked.add(token); + api.rejectNextProtected = true; + const response = await post(token, "tools/call", { name: "list_messages" }); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain('error="invalid_token"'); + expect(idp.exchanges).toHaveLength(2); + }); + + it("renews an expiring socket and catches up within the same wait", async () => { + idp.exchangeTtl = 0.8; + const token = await idp.token(); + const client = await connect(token); + const waiting = call(client, "wait_for_message", { after_id: "0", timeout_seconds: 5, settle_seconds: 0, include_thread: false }); + await vi.waitFor(() => expect(api.connectedSockets(ALICE)).toBe(1)); + let id = ""; + idp.onExchange = async () => { + // Arrives during credential exchange, without a socket announcement. + id = api.seed({ from: BOB, to: [ALICE], data: "across renewal" }).id; + }; + const result = structured<{ after_id: string; messages: unknown[]; transport: string }>(await waiting); + expect(result).toMatchObject({ after_id: id, transport: "websocket" }); + expect(result.messages).toHaveLength(1); + expect(idp.exchanges.length).toBeGreaterThanOrEqual(2); + await vi.waitFor(() => expect(api.connectedSockets(ALICE)).toBe(0)); + const sockets = api.requests.filter(req => req.path === "/fmsg/ws"); + expect(sockets.length).toBeGreaterThanOrEqual(2); + expect(new Set(sockets.map(req => req.authorization)).size).toBeGreaterThanOrEqual(2); + }); + + it("stops a wait when renewal reports revocation and releases the socket", async () => { + idp.exchangeTtl = 0.5; + const token = await idp.token(); + const auth = await provider.verifyAccessToken(token); + const caller = await provider.forRequest(auth); + try { + const waiting = waitForMessage(caller.client, ALICE, { afterId: "0", timeoutMs: 5000, settleMs: 0 }); + const rejected = expect(waiting).rejects.toMatchObject({ status: 401 }); + await vi.waitFor(() => expect(api.connectedSockets(ALICE)).toBe(1)); + idp.revoked.add(token); + await rejected; + await vi.waitFor(() => expect(api.connectedSockets(ALICE)).toBe(0)); + } finally { provider.release(auth); } + }); + + it("returns 401 when the incoming token expires during a wait before streaming begins", async () => { + const token = await idp.token({ exp: Date.now() / 1000 + 1 }); + const response = await post(token, "tools/call", { name: "wait_for_message", arguments: { after_id: "0", timeout_seconds: 5 } }); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain('error="invalid_token"'); + expect(await response.json()).toMatchObject({ error: "invalid_token" }); + expect(idp.exchanges).toHaveLength(1); + await vi.waitFor(() => expect(api.connectedSockets(ALICE)).toBe(0)); + }); + + it("finishes an already streaming wait with an auth error and challenges the next call", async () => { + // Accelerate only the wait's progress interval; network and token clocks stay real. + const interval = globalThis.setInterval; + vi.spyOn(globalThis, "setInterval").mockImplementation(((fn: () => void, ms?: number, ...args: unknown[]) => + interval(fn, ms === 20_000 ? 50 : ms, ...args)) as typeof setInterval); + idp.exchangeTtl = 0.5; + const token = await idp.token(); + const response = await post(token, "tools/call", { name: "wait_for_message", + arguments: { after_id: "0", timeout_seconds: 5 }, _meta: { progressToken: "waiting" } }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + idp.revoked.add(token); + const events = await response.text(); + expect(events).toContain("notifications/progress"); + expect(events).toContain("refreshed or reconnected"); + expect(events).not.toContain(token); + const next = await post(token, "tools/call", { name: "list_messages" }); + expect(next.status).toBe(401); + expect(await next.json()).toMatchObject({ error: "invalid_token" }); + await vi.waitFor(() => expect(api.connectedSockets(ALICE)).toBe(0)); + }); + + it("cancels a wait during exchange and does not open a late socket", async () => { + const auth = await provider.verifyAccessToken(await idp.token()); + const caller = await provider.forRequest(auth); + const controller = new AbortController(); + let release!: () => void; + idp.onExchange = () => new Promise(resolve => { release = resolve; }); + try { + const waiting = waitForMessage(caller.client, ALICE, { afterId: "0", timeoutMs: 5000, settleMs: 0 }, controller.signal); + await vi.waitFor(() => expect(release).toBeTypeOf("function")); + controller.abort(); + expect(await waiting).toMatchObject({ note: "cancelled" }); + release(); + await vi.waitFor(() => expect(idp.exchanges[0]?.token).toBeTypeOf("string")); + expect(api.connectedSockets(ALICE)).toBe(0); + } finally { release?.(); provider.release(auth); } + }); +}); + +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", + FMSG_MCP_OAUTH_CLIENT_SECRET: "secret", FMSG_MCP_OAUTH_EXCHANGE_AUDIENCE: "fmsg-webapi" }; + it("keeps API keys the default and requires explicit complete HTTP 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, 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"); + } + for (const url of ["http://mcp.example.com/mcp", "https://user:secret@mcp.example.com/mcp", "https://mcp.example.com/mcp?x=1", "https://mcp.example.com/mcp#fragment"]) { + expect(() => loadOAuthConfig({ ...env, FMSG_MCP_OAUTH_RESOURCE_URL: url }, "http")).toThrow(); + } + }); +}); From 37228c74d75f444e0fa3a0f9d9b6020139c2ce59 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 17 Sep 2026 18:45:00 +0800 Subject: [PATCH 2/4] Return 401 when the subject expires during token exchange --- src/oauth/issuer.ts | 13 ++++++++++--- test/oauth.test.ts | 7 +++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/oauth/issuer.ts b/src/oauth/issuer.ts index 9acaeb7..cb038f9 100644 --- a/src/oauth/issuer.ts +++ b/src/oauth/issuer.ts @@ -85,8 +85,9 @@ export class OAuthIssuer { const claim = payload[this.config.addressClaim]; const address = typeof claim === "string" ? normalizeFmsgAddress(claim) : undefined; const scopes = parseScopes(payload.scope); - if (audience !== this.config.resourceUrl || !address || !scopes || !Number.isFinite(payload.exp)) throw invalidToken(); - return { address, scopes, expiresAtMs: payload.exp! * 1000, clientId: typeof payload.client_id === "string" ? payload.client_id : "oauth-client" }; + const expiresAtMs = Math.floor(payload.exp! * 1000); + if (audience !== this.config.resourceUrl || !address || !scopes || !Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) throw invalidToken(); + return { address, scopes, expiresAtMs, clientId: typeof payload.client_id === "string" ? payload.client_id : "oauth-client" }; } catch (error) { if (error instanceof OAuthRequestError) throw error; if (error instanceof errors.JOSEError) throw invalidToken(); @@ -113,6 +114,9 @@ export class OAuthIssuer { scope: requestedScopes.join(" "), }), }); + // The subject can expire during discovery/exchange or response transfer. + // That is a normal reconnect, not an operator configuration failure. + if (identity.expiresAtMs <= Date.now()) throw invalidToken(); let body: Record; try { body = await response.json() as Record; } catch { throw this.configurationError("invalid exchange response"); } if (!body || !response.ok) { @@ -140,7 +144,10 @@ export class OAuthIssuer { const expiresAtMs = Math.min(startedAt + body.expires_in * 1000, claims.exp * 1000, identity.expiresAtMs, startedAt + EXCHANGE_CACHE_MAX_MS); if (expiresAtMs <= Date.now()) throw new Error(); return { accessToken: body.access_token, address: identity.address, expiresAtMs }; - } catch { throw this.configurationError("exchange returned an unexpected token or scope"); } + } catch { + if (identity.expiresAtMs <= Date.now()) throw invalidToken(); + throw this.configurationError("exchange returned an unexpected token or scope"); + } } close(): void { this.lifetime.abort(); this.config.clientSecret = ""; this.metadata = undefined; this.jwks = undefined; } diff --git a/test/oauth.test.ts b/test/oauth.test.ts index 602fd55..30a382b 100644 --- a/test/oauth.test.ts +++ b/test/oauth.test.ts @@ -234,6 +234,13 @@ describe("HTTP OAuth", () => { expect((await issuer.exchange(short, shortIdentity, new AbortController().signal)).expiresAtMs).toBeLessThanOrEqual(shortIdentity.expiresAtMs); }); + it("returns 401 if the incoming token expires while exchange is in flight", async () => { + const token = await idp.token(); + const identity = await issuer.verify(token); + idp.onExchange = async () => { vi.spyOn(Date, "now").mockReturnValue(identity.expiresAtMs + 1); }; + await expect(issuer.exchange(token, identity, new AbortController().signal)).rejects.toMatchObject({ status: 401 }); + }); + it("refuses exchange contract violations before making a Web API call", async () => { const token = await idp.token(); const identity = await issuer.verify(token); for (const override of [{ refresh_token: "not-allowed" }, { access_token: token }, { expires_in: 0 }, { scope: "owner" }]) { From b7677153cd3da1055672194d8571c9365608f467 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 17 Sep 2026 18:46:47 +0800 Subject: [PATCH 3/4] Assert expiry behavior independently of renewal timing --- test/oauth.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/oauth.test.ts b/test/oauth.test.ts index 30a382b..e8abda0 100644 --- a/test/oauth.test.ts +++ b/test/oauth.test.ts @@ -319,7 +319,8 @@ describe("HTTP OAuth", () => { expect(response.status).toBe(401); expect(response.headers.get("www-authenticate")).toContain('error="invalid_token"'); expect(await response.json()).toMatchObject({ error: "invalid_token" }); - expect(idp.exchanges).toHaveLength(1); + // An early renewal can start just before subject expiry. Both paths must + // return the same challenge and release the socket, regardless of timing. await vi.waitFor(() => expect(api.connectedSockets(ALICE)).toBe(0)); }); From a621fca6b25175598e2615e935934b9f47e39773 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 17 Sep 2026 18:49:10 +0800 Subject: [PATCH 4/4] Cover scope enforcement with mismatched tool-routing headers --- test/oauth.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/oauth.test.ts b/test/oauth.test.ts index e8abda0..eae4075 100644 --- a/test/oauth.test.ts +++ b/test/oauth.test.ts @@ -133,10 +133,14 @@ describe("HTTP OAuth", () => { const spoofed = await post(token, "tools/call", { name: "send_message", arguments: { to: [BOB], topic: "x", body: "x" } }, { "mcp-method": "tools/list", "mcp-name": "whoami" }); expect(spoofed.status).toBe(403); + const mismatched = await post(token, "tools/call", { name: "whoami", arguments: { to: [BOB], topic: "x", body: "x" } }, + { "mcp-method": "tools/call", "mcp-name": "send_message", "mcp-protocol-version": "2026-07-28" }); + expect(mismatched.status).toBe(400); const batch = await fetch(`${base}/mcp`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify([{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "send_message" } }]) }); expect(batch.status).toBe(400); - expect(idp.exchanges).toHaveLength(0); + // A valid read scope can preflight an exchange, but mismatched dispatch + // headers must never turn the read into a write at the Web API. expect(api.requests).toHaveLength(0); });