built-in-authorization-server
by rinormaloku
README.md
# built-in-authorization-server
## Install & run
```bash
npm install
PORT=3005 MCP_FAKE_AUTH=1 MCP_FAKE_IDP=1 npm start
```
Server URL is `http://localhost:3005`; `apiPrefix` is `auth`.
## Two modes
### REAL mode
Provide real provider credentials in the environment and the real GitHub
provider is wired exactly as documented:
```bash
GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=yyy JWT_SECRET=<32+ chars> PORT=3014 npm start
```
The interactive browser leg (`/auth/authorize` → GitHub → `/auth/callback`)
requires a live GitHub App and is only exercised in this mode.
### FAKE mode (offline, `MCP_FAKE_AUTH=1`)
When `MCP_FAKE_AUTH=1` is set and no real credentials are present, dummy
`clientId`/`clientSecret` are supplied so the module constructs without ever
contacting an IdP. Every offline-reachable feature works:
- Discovery: `/.well-known/oauth-authorization-server`,
`/.well-known/oauth-protected-resource`
- Dynamic Client Registration (RFC 7591): `POST /auth/register` (deprecated in
MCP revision `2026-07-28`, still fully supported here)
- PKCE required and advertised (`code_challenge_methods_supported: ["S256"]`)
- JWT validation of locally-signed tokens by the `/mcp` middleware
- Guarded MCP calls with a locally-minted JWT
Mint a local token (signed with the same `jwtSecret`, so `validateToken` accepts
it without any IdP call):
```bash
TOKEN=$(npx ts-node scripts/mint-jwt.ts)
bunx @modelcontextprotocol/inspector --cli http://localhost:3014/mcp \
--transport http --header "Authorization: Bearer $TOKEN" --method tools/list
```
With `MCP_FAKE_AUTH=1` alone the `authorize → callback` interactive leg still
needs the external IdP and is **not** runnable. Add `MCP_FAKE_IDP=1` (below) to
get an offline stand-in for it.
## What was verified (FAKE mode)
- Discovery JSON shape matches the doc.
- `POST /auth/register` returns a registered client (`client_id`).
- PKCE methods advertised.
- `/mcp` accepts a valid locally-minted JWT (`whoami` → `Hello, Ada Lovelace!`),
rejects missing / malformed / wrong-secret tokens with `401`.
- `disableEndpoints` disables a discovery route (→ `404`) while keeping the other.
- `@McpUser()` projects `req.user`.
---
## Tier 4 walkthrough: consent screen and Client ID Metadata Documents
Three **opt-in** environment flags, all default off. With none of them set this
example behaves exactly as it did before they existed — same routes, same
metadata document, same GitHub provider, same log output.
| Flag | Effect |
|---|---|
| `MCP_CONSENT=1` | Turns on the interactive consent screen (`consent: { enabled: true }`). |
| `MCP_CIMD=1` | Accepts URL-shaped `client_id`s, and hosts `GET /client-metadata.json` + `GET /demo-callback` so the *client* side of the demo runs locally too. Implies consent — `McpAuthModule` forces it on. |
| `MCP_FAKE_IDP=1` | ⚠️ Replaces GitHub with a local stub that authenticates **everyone** as "Ada Lovelace", with no network call. Implies `MCP_FAKE_AUTH=1`. |
### FAKE-mode shortcuts, stated plainly
Everything below except `MCP_FAKE_IDP=1` is real production behaviour. Two things
are shortcuts and neither is safe outside a demo:
1. **`MCP_FAKE_IDP=1`** — there is no authentication. A real deployment sends the
user to GitHub/Google/Keycloak here. It exists only so the browser leg (and
therefore the consent screen) can be walked through offline.
2. **`allowInsecureClientIdScheme: true`**, which `MCP_CIMD=1` sets — this
accepts `http://` `client_id` URLs *and* disables the SSRF guard, which is the
only reason `http://localhost:3014/client-metadata.json` works as a
`client_id`. With it on, an unauthenticated caller can make this server fetch
e.g. `https://169.254.169.254/...` (the cloud instance-metadata endpoint). In
production the document must live on a public `https` origin and this option
must stay `false`.
### A. Consent screen only
```bash
PORT=3014 MCP_FAKE_IDP=1 MCP_CONSENT=1 npm start
```
Register a client and start an authorization request in a browser:
```bash
CLIENT_ID=$(curl -s -X POST http://localhost:3014/auth/register \
-H 'content-type: application/json' \
-d '{"client_name":"DCR Consent Demo","redirect_uris":["http://127.0.0.1:33418/callback"]}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["client_id"])')
echo "http://localhost:3014/auth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback&code_challenge=WMlOi-4SP-ouuMs3uOb7jhkkMKK5SSJ9Z8Eagvs1wmk&code_challenge_method=S256&state=demo-state"
```
Open that URL. The fake IdP bounces straight back to `/auth/callback`, which now
answers with the **consent screen** instead of redirecting. It shows:
- the client name, and who you are signed in as ("Ada Lovelace"),
- **`127.0.0.1` — the redirect URI hostname**, as its own prominent field
(`draft/basic/authorization/security-considerations` makes displaying this a
MUST for a CIMD-capable server),
- an **orange loopback warning**, because the code will be delivered to a port on
this machine that any local process could have bound (a SHOULD),
- the scopes that will actually be granted — already narrowed, so you see what
you are approving rather than what was requested.
Click **Approve** and the browser lands on
`http://127.0.0.1:33418/callback?code=…&state=demo-state&iss=http://localhost:3014`.
Nothing is listening on 33418, so the browser shows a connection error — the
`code` in the address bar is the point. Click **Deny** instead and you get
`?error=access_denied&…` on the same URI (RFC 6749 §4.1.2.1).
The `code_challenge` above is the S256 hash of the verifier
`sSQKjtmhf3TsxI14yDaGqZpyHg7l1hzoYGMHvRtknJQ`, so you can redeem the code:
```bash
curl -s -X POST http://localhost:3014/auth/token \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=authorization_code \
--data-urlencode "code=<the code from the address bar>" \
--data-urlencode code_verifier=sSQKjtmhf3TsxI14yDaGqZpyHg7l1hzoYGMHvRtknJQ \
--data-urlencode redirect_uri=http://127.0.0.1:33418/callback \
--data-urlencode "client_id=$CLIENT_ID"
```
Approving is remembered for the same (user, client, scope) triple, so a second
authorization request goes straight through with no screen. Start the server with
`MCP_CONSENT=1` and `consent.rememberForMs: 0` in `main.ts` to prompt every time.
### B. Client ID Metadata Documents
```bash
PORT=3014 MCP_FAKE_IDP=1 MCP_CIMD=1 npm start
```
The server now advertises support, and hosts a document *as if it were the
client*:
```bash
curl -s http://localhost:3014/.well-known/oauth-authorization-server \
| grep -o '"client_id_metadata_document_supported":true'
curl -s http://localhost:3014/client-metadata.json
```
```jsonc
{
"client_id": "http://localhost:3014/client-metadata.json", // MUST match this URL exactly
"client_name": "CIMD Demo Client",
"client_uri": "http://localhost:3014",
"redirect_uris": [
"http://localhost:3014/demo-callback", // a viewable stand-in
"http://127.0.0.1:33418/callback" // what a real MCP client uses
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none" // CIMD clients are public
}
```
**There is no registration step.** The URL *is* the `client_id`. Open:
```
http://localhost:3014/auth/authorize?response_type=code&client_id=http%3A%2F%2Flocalhost%3A3014%2Fclient-metadata.json&redirect_uri=http%3A%2F%2Flocalhost%3A3014%2Fdemo-callback&code_challenge=WMlOi-4SP-ouuMs3uOb7jhkkMKK5SSJ9Z8Eagvs1wmk&code_challenge_method=S256&state=demo-state&scope=offline_access
```
The server fetches the document, validates it, and shows the consent screen —
with one extra sentence in the loopback warning, because a name that came from a
document *anyone may reference* deserves less trust than a registered one:
```
Authorization code will be sent to
localhost
http://localhost:3014/demo-callback
This client receives the authorization code on this computer.
localhost is a loopback address, so any program running locally could have
opened that port and be impersonating CIMD Demo Client, whose name and logo
come from a document that anyone may reference. Approve only if you just
started this application yourself.
Client identifier http://localhost:3014/client-metadata.json
Client website http://localhost:3014
Requested access offline_access
```
Click **Approve** and this time the browser lands on a page that *shows* you the
`code`, `state` and `iss`, because `/demo-callback` is served by this example.
Redeem it with the same URL as `client_id` (and no secret — it is a public
client):
```bash
curl -s -X POST http://localhost:3014/auth/token \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=authorization_code \
--data-urlencode "code=<the code>" \
--data-urlencode code_verifier=sSQKjtmhf3TsxI14yDaGqZpyHg7l1hzoYGMHvRtknJQ \
--data-urlencode redirect_uri=http://localhost:3014/demo-callback \
--data-urlencode client_id=http://localhost:3014/client-metadata.json
```
```json
{ "access_token": "eyJhbGciOiJIUzI1NiIs…", "token_type": "bearer", "expires_in": 86400, "refresh_token": "…" }
```
That token works against `/mcp` like any other:
```bash
bunx @modelcontextprotocol/inspector --cli http://localhost:3014/mcp \
--transport http --header "Authorization: Bearer <access_token>" --method tools/list
```
Redemption used the document **snapshot** taken at `/authorize`, not a second
fetch — so the grant is pinned to exactly the metadata the user saw.
### C. Rejections worth seeing
Every one of these is an HTTP 400 with a message naming the reason ("if the
authorization server fails to retrieve the client metadata document, it SHOULD
abort the authorization request"). Run with `MCP_CIMD=1` and vary `client_id`:
```bash
Q='response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A3014%2Fdemo-callback&code_challenge=WMlOi-4SP-ouuMs3uOb7jhkkMKK5SSJ9Z8Eagvs1wmk&code_challenge_method=S256&state=s'
# nothing hosted there
curl -s "http://localhost:3014/auth/authorize?$Q&client_id=http%3A%2F%2Flocalhost%3A3014%2Fnope.json"
# → client metadata document request returned HTTP 404
# no path component
curl -s "http://localhost:3014/auth/authorize?$Q&client_id=http%3A%2F%2Flocalhost%3A3014"
# → client_id must contain a path component, e.g. https://example.com/client.json
# dot segments
curl -s "http://localhost:3014/auth/authorize?$Q&client_id=http%3A%2F%2Flocalhost%3A3014%2Fa%2F..%2Fclient-metadata.json"
# → client_id must not contain single-dot or double-dot path segments
# a URI the document does not declare
curl -s "http://localhost:3014/auth/authorize?response_type=code&client_id=http%3A%2F%2Flocalhost%3A3014%2Fclient-metadata.json&redirect_uri=http%3A%2F%2Fevil.example%2Fcb&code_challenge=x&code_challenge_method=S256"
# → Invalid redirect_uri
```
The **SSRF refusals** (private/loopback addresses, non-`https` schemes) are not
visible in this demo, because `allowInsecureClientIdScheme: true` is exactly the
switch that disables them. Drop `MCP_CIMD=1`'s hatch (edit `main.ts`) and
`http://…` and `https://127.0.0.1/…` are both refused before any connection is
attempted. `tests/mcp-oauth-cimd.e2e.spec.ts` covers that posture.
### D. Everything at once
```bash
PORT=3014 MCP_FAKE_IDP=1 MCP_CONSENT=1 MCP_CIMD=1 npm start
```
> **Running against the workspace build?** When this example is installed with a
> `file:` dependency (`scripts/use-examples.sh local`) the symlinked packages
> resolve a second `@nestjs/core` from the workspace root, which Nest's injector
> rejects. Add `NODE_OPTIONS=--preserve-symlinks` — this is a linking artifact,
> not a product issue, and is what `e2e/harness.ts` does. A normal `npm install`
> of the published packages needs nothing.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues