# Manual OAuth testing

When an MCP client's OAuth integration goes wrong, exercising the gateway's
endpoints by hand is the fastest way to figure out where. This guide walks every
step of the downstream OAuth flow using `curl`, `openssl`, and `jq`. Each step
shows the request, the shape of the response, and what to look for.

The flow being tested is the standard MCP authorization handshake: discovery →
registration → authorize → token → MCP request → refresh. Read the
[authentication overview](./overview.mdx) for the conceptual model first.

:::note

The user-consent step is browser-based — there's no scriptable way to complete
it from a terminal. Steps 4 through 6 show the URL to open in a browser and the
redirect to inspect; the rest of the flow runs in your terminal.

:::

## Prerequisites

- `curl`, `jq`, `openssl`, and a Bash-compatible shell.
- A deployed MCP Gateway with an MCP OAuth policy
  ([Auth0](./configuring-auth0.mdx) or [generic OIDC](./configuring-okta.mdx))
  configured and at least one `/mcp/{slug}` route.
- A browser to complete the user-consent step.

Throughout this guide, replace:

- `GATEWAY` with your gateway origin (e.g., `https://gateway.example.com`).
- `SLUG` with the route slug (e.g., `linear-v1`).
- `REDIRECT_URI` with a redirect URL that you can monitor — for testing,
  `http://localhost:8765/callback` works because the URL only needs to capture
  the `code` query parameter.

```bash
GATEWAY="https://gateway.example.com"
SLUG="linear-v1"
REDIRECT_URI="http://localhost:8765/callback"
```

## Step 1: Discover the protected resource

An unauthenticated request to an MCP route should return a `401` with a
`WWW-Authenticate` header that points at the per-route Protected Resource
Metadata document.

```bash
curl -i -X POST "${GATEWAY}/mcp/${SLUG}" \
  -H "content-type: application/json" \
  -H "accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":"1","method":"ping"}'
```

Expected response:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="OAuth",
  resource_metadata="https://gateway.example.com/.well-known/oauth-protected-resource/mcp/linear-v1"
```

If you get a 200 instead, the route isn't protected. Check that the MCP OAuth
policy is attached to the route in `routes.oas.json`.

Now fetch the PRM document:

```bash
curl -s "${GATEWAY}/.well-known/oauth-protected-resource/mcp/${SLUG}" | jq
```

Expected response shape:

```json
{
  "resource": "https://gateway.example.com/mcp/linear-v1",
  "resource_name": "Linear MCP Proxy",
  "authorization_servers": ["https://gateway.example.com/mcp/linear-v1"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["mcp:tools"],
  "mcp_protocol_version": "2025-11-25"
}
```

The `authorization_servers` array tells the client where to find the AS
metadata. For the gateway, the AS lives under the same origin.

## Step 2: Discover the authorization server

Fetch the per-route AS metadata document.

```bash
curl -s "${GATEWAY}/.well-known/oauth-authorization-server/mcp/${SLUG}" | jq
```

Expected response shape (truncated to the fields you care about):

```json
{
  "issuer": "https://gateway.example.com/mcp/linear-v1",
  "authorization_endpoint": "https://gateway.example.com/oauth/authorize/mcp/linear-v1",
  "token_endpoint": "https://gateway.example.com/oauth/token",
  "registration_endpoint": "https://gateway.example.com/oauth/register",
  "revocation_endpoint": "https://gateway.example.com/oauth/revoke",
  "scopes_supported": ["mcp:tools"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": [
    "none",
    "client_secret_basic",
    "client_secret_post",
    "private_key_jwt"
  ],
  "client_id_metadata_document_supported": true
}
```

Capture the URLs you'll need:

```bash
AS_METADATA=$(curl -s "${GATEWAY}/.well-known/oauth-authorization-server/mcp/${SLUG}")
AUTH_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.authorization_endpoint')
TOKEN_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.token_endpoint')
REGISTRATION_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.registration_endpoint')
```

If `code_challenge_methods_supported` doesn't include `S256`, something is wrong
with the gateway configuration. The spec requires `S256` and the gateway always
advertises it.

## Step 3: Register a client (DCR)

For this test, register a public client with
`token_endpoint_auth_method: "none"`. This is the simplest mode and matches what
a CLI client would use.

```bash
DCR_RESPONSE=$(curl -s -X POST "${REGISTRATION_ENDPOINT}" \
  -H "content-type: application/json" \
  -d "{
    \"client_name\": \"Manual OAuth Test\",
    \"redirect_uris\": [\"${REDIRECT_URI}\"],
    \"grant_types\": [\"authorization_code\", \"refresh_token\"],
    \"response_types\": [\"code\"],
    \"token_endpoint_auth_method\": \"none\",
    \"scope\": \"mcp:tools\"
  }")

echo "$DCR_RESPONSE" | jq
CLIENT_ID=$(echo "$DCR_RESPONSE" | jq -r '.client_id')
```

Expected response shape:

```json
{
  "client_id": "dcr:abc123...",
  "client_id_issued_at": 1747958400,
  "client_id_metadata_document_supported": true,
  "client_name": "Manual OAuth Test",
  "redirect_uris": ["http://localhost:8765/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "mcp:tools"
}
```

The client ID is opaque. DCR clients expire 90 days after issuance.

## Step 4: Build the authorize URL with PKCE

Generate a PKCE verifier and S256 challenge, plus a state value for CSRF.

```bash
CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '\n=+/' | cut -c1-128)
CODE_CHALLENGE=$(printf "%s" "$CODE_VERIFIER" | openssl dgst -sha256 -binary \
  | openssl base64 | tr '/+' '_-' | tr -d '=')
STATE=$(openssl rand -hex 16)
RESOURCE=$(echo "$AS_METADATA" | jq -r '.issuer')

echo "CODE_VERIFIER: $CODE_VERIFIER"
echo "CODE_CHALLENGE: $CODE_CHALLENGE"
echo "STATE: $STATE"
echo "RESOURCE: $RESOURCE"
```

Build the authorize URL. The `resource` parameter is **required** by the MCP
spec on every authorization and token request.

```bash
AUTH_URL="${AUTH_ENDPOINT}?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256&state=${STATE}&scope=mcp:tools&resource=$(printf %s "$RESOURCE" | jq -sRr @uri)"

echo "Open this URL in a browser:"
echo "$AUTH_URL"
```

Open the URL in a browser. The flow is:

1. The gateway redirects you to your IdP's login page.
2. You authenticate at the IdP.
3. The IdP redirects back to the gateway's `/oauth/callback`.
4. The gateway renders the consent setup page.
5. You click **Authorize**.
6. The gateway redirects to your `redirect_uri` with `?code=...&state=...`.

Capture the `code` value from the final redirect URL. There's no listener on
`http://localhost:8765`, so the browser shows a connection-refused page — that's
expected. Copy the `code` value out of the address bar.

:::warning

The authorization code is single-use and short-lived (typically 30 seconds). Run
the next step immediately after copying it.

:::

```bash
read -p "Enter the authorization code from the redirect URL: " AUTH_CODE
```

## Step 5: Exchange the code for tokens

`POST /oauth/token` with the authorization-code grant. Public clients send
`client_id` in the form body; confidential clients use HTTP Basic.

```bash
TOKEN_RESPONSE=$(curl -s -X POST "${TOKEN_ENDPOINT}" \
  -H "content-type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=${AUTH_CODE}" \
  --data-urlencode "redirect_uri=${REDIRECT_URI}" \
  --data-urlencode "code_verifier=${CODE_VERIFIER}" \
  --data-urlencode "client_id=${CLIENT_ID}" \
  --data-urlencode "resource=${RESOURCE}")

echo "$TOKEN_RESPONSE" | jq

ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')
REFRESH_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.refresh_token')
```

Expected response shape:

```json
{
  "access_token": "at_...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "rt_...",
  "scope": "mcp:tools",
  "resource": "https://gateway.example.com/mcp/linear-v1"
}
```

A common failure mode here is `invalid_grant` because the authorization code
expired or was already used. Re-run from step 4.

Another common one is `invalid_request` if you forget the `code_verifier` or
omit the `resource` parameter.

## Step 6: Call the MCP endpoint with the access token

Now the access token can be presented as a bearer credential on the MCP route.

```bash
curl -s -X POST "${GATEWAY}/mcp/${SLUG}" \
  -H "authorization: Bearer ${ACCESS_TOKEN}" \
  -H "content-type: application/json" \
  -H "accept: application/json, text/event-stream" \
  -H "mcp-protocol-version: 2025-11-25" \
  -d '{
    "jsonrpc": "2.0",
    "id": "1",
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-11-25",
      "capabilities": {},
      "clientInfo": {
        "name": "manual-test",
        "version": "0.0.0"
      }
    }
  }' | jq
```

Expected response is a JSON-RPC result with the upstream's `serverInfo` and
`capabilities`:

```json
{
  "jsonrpc": "2.0",
  "id": "1",
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "tools": {}
    },
    "serverInfo": {
      "name": "linear",
      "version": "..."
    }
  }
}
```

If you see a JSON-RPC error with `code: -32042` (`URLElicitationRequiredError`),
the **upstream** MCP server requires OAuth and the user hasn't connected to it
yet. Open the `authUrl` in the error payload's `data` field in a browser. See
[Per-user OAuth to upstream MCP servers](./upstream-oauth.mdx) for the full
flow.

If you see a `401`, the bearer token is missing, expired, revoked, or bound to a
different route — the response `WWW-Authenticate` header includes a reason code
via `error="..."`.

If you see a `403` with `error="insufficient_scope"`, the token has the wrong
scope. The gateway only issues `mcp:tools` today.

## Step 7: Refresh the access token

The access token expires in 15 minutes by default. Exchange the refresh token
for a new pair using the `refresh_token` grant.

```bash
REFRESH_RESPONSE=$(curl -s -X POST "${TOKEN_ENDPOINT}" \
  -H "content-type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=${REFRESH_TOKEN}" \
  --data-urlencode "client_id=${CLIENT_ID}" \
  --data-urlencode "resource=${RESOURCE}")

echo "$REFRESH_RESPONSE" | jq

ACCESS_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.access_token')
NEW_REFRESH_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.refresh_token')
```

The refresh token rotates on every use. Presenting the old refresh token again
will **revoke the entire grant** — that's the spec's defense against
refresh-token replay. Always use the most recently issued refresh token.

The new access token can be used immediately on subsequent `/mcp/{slug}`
requests.

## Step 8: Revoke the tokens (optional cleanup)

When you're done testing, revoke the grant.

```bash
curl -s -i -X POST "${GATEWAY}/oauth/revoke" \
  -H "content-type: application/x-www-form-urlencoded" \
  --data-urlencode "token=${NEW_REFRESH_TOKEN}" \
  --data-urlencode "token_type_hint=refresh_token" \
  --data-urlencode "client_id=${CLIENT_ID}"
```

Per RFC 7009, the gateway responds with `200 OK` and an empty body for both
successful revocations and unknown tokens. Subsequent MCP requests with the
revoked access token return `401`.

## Putting it all together

Here's a single Bash script that runs every step except the browser-based
authorize redirect. Save it as `test-oauth.sh` and run it after editing the
configuration block at the top.

```bash
#!/usr/bin/env bash
# Manual OAuth flow test for the Zuplo MCP Gateway.
# Walks discovery → DCR → authorize URL → code exchange → MCP request → refresh.
# The authorize step is browser-based; the script pauses for you to paste the code.

set -euo pipefail

# ----- Configuration -----
GATEWAY="https://gateway.example.com"
SLUG="linear-v1"
REDIRECT_URI="http://localhost:8765/callback"
# -------------------------

echo "==> Step 1: discover protected resource"
PRM_URL="${GATEWAY}/.well-known/oauth-protected-resource/mcp/${SLUG}"
echo "PRM: ${PRM_URL}"
curl -s "${PRM_URL}" | jq -r '{authorization_servers, scopes_supported, mcp_protocol_version}'

echo
echo "==> Step 2: fetch AS metadata"
AS_METADATA=$(curl -s "${GATEWAY}/.well-known/oauth-authorization-server/mcp/${SLUG}")
AUTH_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.authorization_endpoint')
TOKEN_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.token_endpoint')
REGISTRATION_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.registration_endpoint')
RESOURCE=$(echo "$AS_METADATA" | jq -r '.issuer')
echo "issuer: $RESOURCE"
echo "authorize: $AUTH_ENDPOINT"
echo "token: $TOKEN_ENDPOINT"

echo
echo "==> Step 3: register client (DCR)"
DCR_RESPONSE=$(curl -s -X POST "${REGISTRATION_ENDPOINT}" \
  -H "content-type: application/json" \
  -d "{
    \"client_name\": \"Manual OAuth Test\",
    \"redirect_uris\": [\"${REDIRECT_URI}\"],
    \"grant_types\": [\"authorization_code\", \"refresh_token\"],
    \"response_types\": [\"code\"],
    \"token_endpoint_auth_method\": \"none\",
    \"scope\": \"mcp:tools\"
  }")
CLIENT_ID=$(echo "$DCR_RESPONSE" | jq -r '.client_id')
echo "client_id: $CLIENT_ID"

echo
echo "==> Step 4: build authorize URL with PKCE"
CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '\n=+/' | cut -c1-128)
CODE_CHALLENGE=$(printf "%s" "$CODE_VERIFIER" | openssl dgst -sha256 -binary \
  | openssl base64 | tr '/+' '_-' | tr -d '=')
STATE=$(openssl rand -hex 16)
RESOURCE_ENC=$(printf "%s" "$RESOURCE" | jq -sRr @uri)
AUTH_URL="${AUTH_ENDPOINT}?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256&state=${STATE}&scope=mcp:tools&resource=${RESOURCE_ENC}"
echo
echo "Open this URL in a browser:"
echo "$AUTH_URL"
echo
echo "After completing login and consent, copy the 'code' query parameter from the redirect URL."
read -r -p "Enter the authorization code: " AUTH_CODE

echo
echo "==> Step 5: exchange code for tokens"
TOKEN_RESPONSE=$(curl -s -X POST "${TOKEN_ENDPOINT}" \
  -H "content-type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=${AUTH_CODE}" \
  --data-urlencode "redirect_uri=${REDIRECT_URI}" \
  --data-urlencode "code_verifier=${CODE_VERIFIER}" \
  --data-urlencode "client_id=${CLIENT_ID}" \
  --data-urlencode "resource=${RESOURCE}")
echo "$TOKEN_RESPONSE" | jq
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')
REFRESH_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.refresh_token')

echo
echo "==> Step 6: call MCP endpoint with the access token"
curl -s -X POST "${GATEWAY}/mcp/${SLUG}" \
  -H "authorization: Bearer ${ACCESS_TOKEN}" \
  -H "content-type: application/json" \
  -H "accept: application/json, text/event-stream" \
  -H "mcp-protocol-version: 2025-11-25" \
  -d '{
    "jsonrpc": "2.0",
    "id": "1",
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-11-25",
      "capabilities": {},
      "clientInfo": { "name": "manual-test", "version": "0.0.0" }
    }
  }' | jq

echo
echo "==> Step 7: refresh"
REFRESH_RESPONSE=$(curl -s -X POST "${TOKEN_ENDPOINT}" \
  -H "content-type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=${REFRESH_TOKEN}" \
  --data-urlencode "client_id=${CLIENT_ID}" \
  --data-urlencode "resource=${RESOURCE}")
echo "$REFRESH_RESPONSE" | jq

echo
echo "Done."
```

Make it executable and run it:

```bash
chmod +x test-oauth.sh
./test-oauth.sh
```

## Common issues

- **`401` on every MCP request after token exchange.** Token bound to a
  different route than the one you're calling. Each token binds to the
  `operationId` of the route used during authorize. Either re-run for the
  intended route or call the route you authorized for.
- **`401` with `error="invalid_token"` after a token reuse.** Refresh tokens
  rotate on every use — presenting an old one revokes the entire grant. Re-run
  the full flow.
- **`invalid_request` at the token endpoint.** Most often a missing `resource`
  parameter or a missing `code_verifier`. Both are required.
- **`invalid_grant` at the token endpoint.** The authorization code expired or
  was already redeemed. Re-run from step 4.
- **`invalid_audience`.** The bearer token is being used at a route whose
  canonical resource URI doesn't match the token's `resource` claim. The gateway
  derives the canonical URI from the request origin — a misconfigured custom
  domain or proxy can cause this.
- **The browser shows the gateway's consent page but the **Authorize** button is
  disabled.** The route has an upstream that hasn't been connected yet. Click
  the per-upstream **Connect** button first. See
  [upstream OAuth](./upstream-oauth.mdx).
- **JSON-RPC error `-32042` (`URLElicitationRequiredError`).** The downstream
  OAuth succeeded but the upstream MCP server requires OAuth and the user hasn't
  connected. Open the `authUrl` in the error payload's `data` field in a
  browser.

## Related

- [Authentication overview](./overview.mdx)
- [Configuring Auth0](./configuring-auth0.mdx)
- [Configuring Okta (generic OIDC)](./configuring-okta.mdx)
- [Per-user OAuth to upstream MCP servers](./upstream-oauth.mdx)
- [MCP authorization spec, revision 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
