mcp-oauth-test-server
by hercemer42
README.md
# mcp-oauth-test-server
A small, configurable **OAuth 2.0 authorization server** + **OAuth-protected MCP server** for testing
OAuth-protected MCP flows **end to end** — discovery, interactive consent (PKCE), token refresh,
refresh-token rotation, access-token expiry, revocation, and deliberate error injection.
It exists so you can exercise an OAuth-MCP client or integration against a provider you fully control
— including the failure modes a real provider won't let you trigger on demand (rotate the refresh
token, expire/revoke the access token, force `invalid_grant`, deny consent, return `403 insufficient_scope`).
> ⚠️ **Test only.** It auto-approves every consent request, keeps all state in memory, and performs no
> real authentication. Never expose it as a real authorization server.
## What's in the box
Two HTTP servers, started together by `npm start`:
| Server | Default | Role |
|---|---|---|
| **OAuth authorization server** | `:9100` | discovery, `/authorize`, `/token`, `/introspect`, `/register`, control plane |
| **OAuth-protected MCP server** | `:9101` | protected-resource discovery, `401 WWW-Authenticate`, Streamable-HTTP `/mcp`, control plane |
The MCP server validates bearer tokens by calling the OAuth server's `/introspect`, so the two talk
over HTTP and can run on different hosts.
## Run
```bash
npm install
npm start
# [oauth] authorization server http://localhost:9100 (http)
# [mcp] protected MCP server http://localhost:9101/mcp
```
### HTTPS (for clients that connect directly)
The servers default to plain HTTP. A client that connects to the sim **directly over HTTPS** needs a
cert. (A client that can't reach loopback — e.g. one behind an SSRF-guarded proxy — needs a public
tunnel instead; see [Using it behind a public tunnel](#using-it-behind-a-public-tunnel). With a tunnel
the sim can stay HTTP.)
```bash
npm run certs # generates a self-signed cert in ./certs (gitignored)
npm start # auto-detects the cert and serves HTTPS; base URLs switch to https://
```
The cert is self-signed, so the connecting client must trust it — macOS keychain:
`sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./certs/localhost.pem`;
Node clients: `NODE_EXTRA_CA_CERTS=…/certs/localhost.pem` (or `NODE_TLS_REJECT_UNAUTHORIZED=0`).
Configure via env (see `.env.example`): `OAUTH_PORT`, `MCP_PORT`, `OAUTH_BASE_URL`, `MCP_BASE_URL`,
`TLS_CERT_FILE`, `TLS_KEY_FILE`, `SEED_REFRESH_TOKEN`, `TOKEN_TTL_SECONDS`, `SCOPES`.
`*_BASE_URL` are the URLs embedded in the discovery documents — they must be reachable by whoever does
discovery + token exchange. For direct local use that's `localhost`; behind a tunnel, set them to the
public URLs (see below).
## The discovery chain
A compliant MCP/OAuth client can bootstrap from just the MCP server URL:
1. Client hits `POST /mcp` with no token → `401` + `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"`.
2. Client fetches `/.well-known/oauth-protected-resource` (RFC 9728) → `authorization_servers: ["http://localhost:9100"]`.
3. Client fetches `http://localhost:9100/.well-known/oauth-authorization-server` (RFC 8414) → `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, …
4. Client (optionally) registers via `/register` (RFC 7591), then runs the authorization-code + PKCE flow.
## Endpoints
**OAuth server (`:9100`)**
- `GET /.well-known/oauth-authorization-server` — RFC 8414 metadata
- `GET /authorize` — authorization-code flow, **auto-approves**, validates/echoes PKCE `code_challenge` (S256)
- `POST /token` — `authorization_code` (PKCE-verified) and `refresh_token` grants
- `GET /introspect?token=…` — `{ active, scope }`
- `POST /register` — dynamic client registration (RFC 7591)
**MCP server (`:9101`)**
- `GET /.well-known/oauth-protected-resource` — RFC 9728 metadata
- `POST /mcp` — Streamable-HTTP MCP; bearer-validated. Tools: `echo`, `add`, `whoami`
- `GET /mcp` — `405` (stateless server)
## Control plane (inject failures)
```bash
# OAuth server
curl -XPOST localhost:9100/control/rotate -d '{"on":true}' -H content-type:application/json # rotate refresh token each refresh
curl -XPOST localhost:9100/control/expires-in -d '{"seconds":5}' -H content-type:application/json # short access-token TTL
curl -XPOST localhost:9100/control/revoke-access # invalidate all access tokens (→ MCP 401)
curl -XPOST localhost:9100/control/invalid-grant -d '{"on":true}' -H content-type:application/json # refresh-grant → invalid_grant
curl -XPOST localhost:9100/control/deny-consent -d '{"on":true}' -H content-type:application/json # /authorize → access_denied
curl -XPOST localhost:9100/control/seed-refresh -d '{"token":"…"}' -H content-type:application/json # mark a refresh token valid
curl -XPOST localhost:9100/control/reset # clear all state
curl localhost:9100/control/state
# MCP server
curl -XPOST localhost:9101/control/force-403 -d '{"on":true}' -H content-type:application/json # tool access → 403 insufficient_scope
curl -XPOST localhost:9101/control/reset
```
### Scenarios these unlock
| Scenario | How |
|---|---|
| First-time consent + PKCE | run the authorize-code flow; `code_challenge` is verified at `/token` |
| Transparent refresh | deposit a credential with the seeded refresh token; call a tool |
| Refresh-token rotation persisted | `rotate on` → each refresh returns a new refresh token |
| Access-token expiry / skew cache | `expires-in 5` → token refreshes when near expiry |
| Upstream 401 (revoked) → re-auth | `revoke-access` → MCP returns `401` → client force-refreshes |
| Dead refresh token → re-consent | `invalid-grant on` → refresh fails → client routes to re-consent |
| Consent failure | `deny-consent on` → `/authorize` returns `access_denied` |
| Insufficient scope | `force-403 on` → tool calls return `403` (a terminal error, not a re-auth loop) |
### Driving a scenario (`npm run scenario`)
`scripts/scenario.sh` flips the control plane by name, so you don't have to remember the raw curls
(it talks to the local control ports — HTTPS when `./certs` exists, else HTTP):
```bash
npm run scenario -- state # show control-plane state (token counts, refresh tokens)
npm run scenario -- rotate on # refresh-token rotation on every refresh-grant
npm run scenario -- expire-access 5 # hand clients a 5s access-token TTL
npm run scenario -- revoke # revoke live access tokens (next call → upstream 401)
npm run scenario -- dead-refresh on # refresh grant → invalid_grant
npm run scenario -- deny-consent on # /authorize → access_denied
npm run scenario -- bad-scope on # tool calls → 403 insufficient_scope
npm run scenario -- reset # ⚠ wipes state — invalidates deposited creds (re-consent)
# direct: ./scripts/scenario.sh <scenario> [on|off|secs]
```
**Loop:** set a scenario → trigger a tool call from your client (run the action that uses a tool, or
refresh the client's tool list) → watch the sim console + `scenario state` to confirm:
| To test | Set | Trigger | Expected |
|---|---|---|---|
| Access-token refresh on expiry | `expire-access 5` | tool call, wait >5s, tool call again | transparent refresh; both succeed (`tokenCalls` climbs) |
| Revoked token → recover | `revoke` | tool call | force-refresh, then succeeds |
| Refresh-token rotation | `rotate on` | repeated tool calls (force refreshes) | each rotated refresh token is persisted + used; calls keep succeeding |
| Dead/expired refresh token | `dead-refresh on` + `revoke` | tool call | `409 needs-oauth-reauth` → client prompts re-consent (`dead-refresh off` to recover) |
| Consent denied | `deny-consent on` | (re)authorize in the client | `/authorize` returns `access_denied` |
| Insufficient scope | `bad-scope on` | tool call | `403` — terminal error, not a re-auth loop |
## Using it behind a public tunnel
Some OAuth-MCP clients enforce **SSRF protection**: they require HTTPS and **block `localhost`,
loopback, and private IPs**. To drive consent from one, the sim must be reachable at a **public HTTPS
URL** — a tunnel is the simplest way, and it terminates TLS for you (the sim itself can stay on plain
HTTP, no local cert needed).
### 1. Expose both servers with cloudflared
cloudflared "quick tunnels" need no account. The sim is two servers, so run two (each prints a
`https://<random>.trycloudflare.com` URL):
```bash
brew install cloudflared # once
cloudflared tunnel --url http://localhost:9101 # MCP → https://<mcp-host>.trycloudflare.com
cloudflared tunnel --url http://localhost:9100 # OAuth → https://<oauth-host>.trycloudflare.com
```
(If you're running the sim over HTTPS instead, add `--no-tls-verify` to each.)
### 2. Point the sim's discovery at the public URLs
Discovery docs must advertise the public URLs, so restart the sim with them as base URLs:
```bash
MCP_BASE_URL=https://<mcp-host>.trycloudflare.com \
OAUTH_BASE_URL=https://<oauth-host>.trycloudflare.com \
npm start
```
> **⚠ Quick-tunnel URLs are ephemeral — restart the sim whenever they change.** Every `cloudflared`
> (re)start mints a new random URL. `OAUTH_BASE_URL` is used not just for discovery but for the MCP
> server's token **introspection** (it calls `OAUTH_BASE_URL/introspect`), so if you rebuild the
> tunnels and *don't* restart the sim with the new URLs, refresh still succeeds but the MCP call fails
> with `401 (introspection failed) fetch failed` — the sim is introspecting against the dead old URL.
> Repointing only the client's stored endpoints is **not** enough. And because restarting the sim
> resets its in-memory token state (`validRefresh`), any previously-issued refresh token is
> invalidated → the client must re-consent.
### 3. Point your client at it
Give your OAuth-MCP client the public MCP URL (`https://<mcp-host>.trycloudflare.com/mcp`) with
**OAuth 2.0** auth. The client auto-discovers the authorization server (no need to hand-enter the
authorize/token URLs), and since the sim supports dynamic client registration it obtains client
credentials automatically. Then toggle the control plane to exercise the refresh / rotation / revoke
/ re-auth paths.
> Quick-tunnel URLs are **ephemeral** — redo steps 2–3 if you restart the tunnels. A
> `localhost`/loopback URL will fail any client that enforces an SSRF/HTTPS check (typically a
> `422 "URL must use HTTPS"` or a blocked-host error).
## License
Set your organization's standard license before publishing (currently `UNLICENSED`).
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues