Skip to main content
Glama
hercemer42

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`).