Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions OAUTH_TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# Testing OAuth Authentication with Keycloak

This guide explains how to test the MCP server's OAuth authentication locally using Keycloak as the identity provider.

## Architecture

```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ MCP Client │────>│ MCP-JS │────>│ Keycloak │
│ (Claude Code │ │ :3000 │ │ :8080 │
│ or curl) │ │ │ │ │
│ │ │ validates │ │ issues │
│ │────>│ bearer token │ │ tokens │
└──────────────┘ └──────────────┘ └──────────────┘
│ ▲
└────── OAuth flow (authorize/token) ────┘
```

- **Keycloak** is the OAuth authorization server (issues tokens)
- **MCP-JS** is the resource server (validates tokens via Keycloak introspection)
- **MCP clients** discover OAuth endpoints via `/.well-known/oauth-authorization-server` on the MCP server, then authenticate directly with Keycloak

## Quick Start

### 1. Start the stack

```bash
docker compose -f docker-compose.oauth.yml up --build
```

Wait for both services to be healthy. Keycloak takes ~30 seconds to start.

### 2. Verify Keycloak is running

Open http://localhost:8080 and log in with `admin` / `admin`.
The `mcp` realm is pre-configured with:

| Resource | Value |
|-------------------|----------------------|
| Realm | `mcp` |
| Public client | `mcp-client` |
| Server client | `mcp-server` |
| Server secret | `mcp-server-secret` |
| Test user | `testuser` |
| Test password | `testpassword` |

### 3. Verify MCP server OAuth discovery

```bash
curl -s http://localhost:3000/.well-known/oauth-authorization-server | jq .
```

Expected output shows Keycloak's endpoints:
```json
{
"issuer": "http://keycloak:8080/realms/mcp",
"authorization_endpoint": "http://keycloak:8080/realms/mcp/protocol/openid-connect/auth",
"token_endpoint": "http://keycloak:8080/realms/mcp/protocol/openid-connect/token",
...
}
```

### 4. Verify unauthenticated requests are rejected

```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/mcp
# Expected: 401
```

## Testing with curl

### Get a token using the Resource Owner Password Grant

For testing purposes, you can use Keycloak's direct access grant (password flow):

```bash
# Get an access token
TOKEN=$(curl -s -X POST http://localhost:8080/realms/mcp/protocol/openid-connect/token \
-d "grant_type=password" \
-d "client_id=mcp-client" \
-d "username=testuser" \
-d "password=testpassword" \
-d "scope=openid" | jq -r '.access_token')

echo "Token: $TOKEN"
```

### Use the token with the MCP server

```bash
# This should succeed (200)
curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/executions | jq .

# Execute JavaScript via the HTTP API
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "1 + 1"}' \
http://localhost:3000/api/exec | jq .
```

### Verify token introspection works

```bash
# Introspect the token directly with Keycloak (this is what MCP-JS does internally)
curl -s -X POST http://localhost:8080/realms/mcp/protocol/openid-connect/token/introspect \
-u "mcp-server:mcp-server-secret" \
-d "token=$TOKEN" | jq .
```

## Testing with Claude Code

### Option A: Use the MCP server's built-in OAuth (no Keycloak)

For simpler testing, you can run the MCP server with its built-in OAuth provider:

```bash
# Run locally (no Docker needed for the server)
cd server && cargo run -- --http-port 3000 --stateless --oauth --oauth-issuer http://localhost:3000
```

Then configure Claude Code's MCP settings (`.cursor/mcp.json` or equivalent):

```json
{
"mcpServers": {
"mcp-js": {
"url": "http://localhost:3000/mcp"
}
}
}
```

Claude Code will discover the OAuth endpoints and handle the authorization flow automatically.

### Option B: Use Keycloak (full stack)

1. Start the Docker stack:
```bash
docker compose -f docker-compose.oauth.yml up --build
```

2. The MCP server at `http://localhost:3000` will advertise Keycloak's OAuth endpoints.
Configure Claude Code to connect to `http://localhost:3000/mcp`.

3. When Claude Code discovers the OAuth requirement, it will redirect you to
Keycloak's login page. Log in with `testuser` / `testpassword`.

## Pre-configured Keycloak Clients

### mcp-client (public)

Used by MCP client applications (Claude Code, MCP Inspector, curl, etc.).

- **Client ID**: `mcp-client`
- **Type**: Public (no secret required)
- **Flows**: Authorization Code, Direct Access Grant (password)
- **Redirect URIs**: `http://localhost:*`, `http://127.0.0.1:*`

### mcp-server (confidential)

Used by the MCP server internally to introspect tokens.

- **Client ID**: `mcp-server`
- **Client Secret**: `mcp-server-secret`
- **Type**: Confidential (service account)
- **Purpose**: Token introspection only

## Troubleshooting

### "401 Unauthorized" when using a token

1. Check that the token hasn't expired (default: 1 hour)
2. Verify Keycloak is reachable from the MCP server container:
```bash
docker compose -f docker-compose.oauth.yml exec mcp-js \
curl -s http://keycloak:8080/realms/mcp/.well-known/openid-configuration | head -5
```
3. Check MCP server logs for introspection errors:
```bash
docker compose -f docker-compose.oauth.yml logs mcp-js
```

### Keycloak not starting

Keycloak needs ~30 seconds to initialize. Check its health:
```bash
docker compose -f docker-compose.oauth.yml ps
docker compose -f docker-compose.oauth.yml logs keycloak
```

### Realm not imported

If the `mcp` realm doesn't appear in Keycloak, verify the volume mount:
```bash
docker compose -f docker-compose.oauth.yml exec keycloak ls -la /opt/keycloak/data/import/
```
49 changes: 49 additions & 0 deletions docker-compose.oauth.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# MCP-JS with Keycloak OAuth authentication.
#
# Usage:
# docker compose -f docker-compose.oauth.yml up --build
#
# Services:
# - keycloak: http://localhost:8080 (admin: admin / admin)
# - mcp-js: http://localhost:3000 (MCP endpoint: /mcp, protected by OAuth)
#
# Pre-configured Keycloak realm "mcp":
# - Client "mcp-client" (public) — for MCP client apps
# - Client "mcp-server" (confidential, secret: mcp-server-secret) — for token introspection
# - Test user: testuser / testpassword

services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
command:
- start-dev
- --import-realm
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
volumes:
- ./keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\n\r\n' >&3 && cat <&3 | grep -q '200'"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s

mcp-js:
build: .
command:
- --http-port=3000
- --stateless
- --oauth
- --oauth-issuer=http://localhost:3000
- --oauth-provider-url=http://keycloak:8080/realms/mcp
- --oauth-client-id=mcp-server
- --oauth-client-secret=mcp-server-secret
ports:
- "3000:3000"
depends_on:
keycloak:
condition: service_healthy
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
# so we can inject our fixed replace-workspace-values script.
cargoDeps = (rustPlatform.fetchCargoVendor {
src = ./server;
hash = "sha256-rg4+cjAQ1VC5Oxk68FJMJmU8WIPnbPq0nLN70RicoIk=";
hash = "sha256-rzc4gYyK3SfHZIq84wCmaUMVcloQ99C7yDsrFppKh28=";
}).overrideAttrs (old: {
nativeBuildInputs = map (dep:
if (dep.name or "") == "replace-workspace-values"
Expand Down
80 changes: 80 additions & 0 deletions keycloak/realm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"realm": "mcp",
"enabled": true,
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"bruteForceProtected": false,
"sslRequired": "none",
"accessTokenLifespan": 3600,
"roles": {
"realm": [
{
"name": "user",
"description": "Default user role"
}
]
},
"clients": [
{
"clientId": "mcp-server",
"name": "MCP Server (Resource Server)",
"description": "Confidential client used by the MCP server for token introspection",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "mcp-server-secret",
"publicClient": false,
"serviceAccountsEnabled": true,
"directAccessGrantsEnabled": false,
"standardFlowEnabled": false,
"protocol": "openid-connect"
},
{
"clientId": "mcp-client",
"name": "MCP Client",
"description": "Public client for MCP client applications (e.g. Claude Code)",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"serviceAccountsEnabled": false,
"protocol": "openid-connect",
"redirectUris": [
"http://localhost:*",
"http://127.0.0.1:*",
"urn:ietf:wg:oauth:2.0:oob"
],
"webOrigins": [
"http://localhost",
"http://127.0.0.1"
],
"defaultClientScopes": [
"openid",
"profile"
]
}
],
"users": [
{
"username": "testuser",
"enabled": true,
"emailVerified": true,
"email": "testuser@example.com",
"firstName": "Test",
"lastName": "User",
"credentials": [
{
"type": "password",
"value": "testpassword",
"temporary": false
}
],
"realmRoles": [
"user"
]
}
]
}
1 change: 1 addition & 0 deletions server/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ http-body-util = "0.1"
regorus = "0.5"
reqwest = { version = "0.12", features = ["json"] }
url = "2"
serde_urlencoded = "0.7"
futures = "0.3"
wasmparser = "0.225"
uuid = { version = "1.0", features = ["v4"] }
Expand Down
1 change: 1 addition & 0 deletions server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ pub mod engine;
pub mod mcp;
pub mod api;
pub mod session;
pub mod oauth;
Loading
Loading