Skip to content
Merged
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
87 changes: 76 additions & 11 deletions docs/core/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ Enable it on the server you already run:
authorizer --url https://auth.example.com --mcp-enabled # ...your other flags
```

`--url` is **required** with `--mcp-enabled`, and the server refuses to start without it.
Every token presented at `/mcp` is checked against this deployment's canonical resource
identifier, `<url>/mcp`. Without `--url` that identifier would be derived from request
`--url` is already required to start the server at all; with `--mcp-enabled` it must also
be a usable `http(s)` origin, and startup refuses anything else (no scheme, userinfo, a
non-http scheme). Every token presented at `/mcp` is checked against this deployment's
canonical resource identifier, `<url>/mcp` — scheme + host only, with any path, query or
trailing slash stripped. Without `--url` that identifier would be derived from request
headers, which would let a caller name the audience their own token has to match — no
check at all.

Expand All @@ -52,7 +54,8 @@ check at all.
exactly `<url>/mcp`. An ordinary login token — the kind that works at `/graphql`,
`/v1/*` and gRPC — is rejected here, and an MCP token is rejected there. Neither rule
has an "or" in it: a token you hand to a semi-trusted agent cannot become a full API
credential.
credential. The mapping from audience to surface is a bijection, and that holds for
[delegated tokens](#agent-delegation-rfc-8693) too.
- **Bearer only.** No cookie, no admin secret, and no admin operation reaches this
surface, so it is safe to expose to the public internet and exempt from CSRF.
- **Shared middleware.** Because it is mounted on the main listener, it inherits CORS,
Expand All @@ -73,7 +76,9 @@ needs nothing configured beyond the URL:
"resource": "https://auth.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["openid", "email", "profile", "phone", "offline_access"]
"scopes_supported": ["openid", "email", "profile", "phone", "offline_access"],
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"resource_documentation": "https://docs.authorizer.dev/core/mcp"
}
```

Expand All @@ -97,6 +102,7 @@ happens rather than what the specs allow.
| Client | Works | How |
| --- | --- | --- |
| **Claude Code, VS Code** — static token | **yes, verified** | Mint a token bound to `<url>/mcp` and pass it as a fixed header (below) |
| **Claude Code** — [delegated token](#agent-delegation-rfc-8693) | **yes, verified** | Same fixed-header setup, with an RFC 8693 token bound to `<url>/mcp`. Verified on Claude Code 2.1.233: `✔ Connected`, and a `check_permissions` tool call returned the agent's own intersected answer |
| **Claude Code** — OAuth | with `--enable-dynamic-client-registration` | Claude Code's released version predates CIMD: it reads `client_id_metadata_document_supported`, ignores it, and refuses unless a `registration_endpoint` is advertised. With DCR enabled it registers itself (verified: `POST /oauth/register` → 201, public client, loopback callback) and runs the flow |
| **Claude.ai custom connector** — pasted client ID | unverified | Anthropic documents an OAuth Client ID field under *Advanced settings*; not confirmed here |
| Any client that needs to self-register | yes | Enable `--enable-client-id-metadata-document` (preferred) or `--enable-dynamic-client-registration` (RFC 7591, for clients that predate CIMD) |
Expand Down Expand Up @@ -146,8 +152,10 @@ can identify itself with an HTTPS URL pointing at a JSON document, instead of a
}
```

The `client_id` must equal the URL the document is served from — that equality is
what stops any host claiming to be any client. Authorizer fetches it through an
The `client_id` must be an **https URL with a path** (a bare origin like
`https://app.example.com` is not treated as a document URL and falls through to a
registry lookup), and it must equal the URL the document is served from — that equality
is what stops any host claiming to be any client. Authorizer fetches it through an
SSRF-hardened client (one-shot DNS, dial pinned to the validated IP, private and
loopback addresses refused), validates the presented `redirect_uri` against the
document's list, and caches it with a clamped TTL.
Expand Down Expand Up @@ -238,6 +246,57 @@ curl -X POST https://auth.example.com/oauth/register \
# No client_secret is ever issued: these are public clients.
```

### Agent delegation (RFC 8693)

`/mcp` accepts **delegated** access tokens — the "agent X acting for user Y" kind minted
by [token exchange](../enterprise/token-exchange) — as well as ordinary first-party ones.
This is what lets an agent ask Authorizer about *its own* authority through the tools it
was granted, rather than needing a credential that speaks for the whole user.

Mint one by naming the MCP server as the `resource`:

```sh
curl -s -X POST https://auth.example.com/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d client_id=$AGENT_ID -d client_secret=$AGENT_SECRET \
-d subject_token=$USER_ACCESS_TOKEN \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d actor_token=$AGENT_ACCESS_TOKEN \
-d actor_token_type=urn:ietf:params:oauth:token-type:access_token \
-d resource=https://auth.example.com/mcp
```

`resource=<url>/mcp`, not `<url>`. The two are different audiences and therefore
different surfaces: a delegated token bound to the bare URL authenticates GraphQL, REST
and gRPC and is **refused** at `/mcp`; this one is the exact mirror. A 401 from `/mcp`
is far more often this than a permissions problem — check the `aud` claim first.

**The answers are the agent's, not the user's.** `check_permissions` and
`list_permissions` evaluate `perms(agent) ∩ perms(user)`, so an agent that was never
granted a document is denied it even when the delegating user can read it, and
enumeration omits it rather than leaking the name. Supplying an explicit `user` cannot
shed the agent half, and a delegated caller naming any *other* subject is refused
outright.

Two limits worth planning around:

- **A delegated token lives 5 minutes and has no refresh token.** The `401` an expired
one gets carries the same discovery challenge as any other, but an MCP client cannot
refresh its way out — the agent has to redo the exchange. Non-interactive agents that
re-exchange on demand fit this well; a long-lived chat session does not.
- **Revocation still flows through the user's session.** Logout, password reset and
admin revoke all take the agent's access down with them, because the token names the
originating session and that session is checked on every call.

Verified against a real Claude Code client (2.1.233): a delegated token bound to
`<url>/mcp` reports `✔ Connected` and its `check_permissions` calls come back with the
agent's intersected answer — `allowed: false` for a document the delegating user *can*
read but the agent was never granted. The same token bound to the bare `<url>` fails the
connection with `invalid_token`, which is the audience boundary doing its job.

The [`with-agent-permissions`](https://github.com/authorizerdev/examples/tree/main/with-agent-permissions)
example runs this end to end and asserts the intersection through the real tool surface.

## Exposed tools

| Tool | Auth required | Description |
Expand Down Expand Up @@ -273,14 +332,18 @@ authorizer mcp \
--database-type=sqlite \
--database-url=auth.db \
--url=http://localhost:8080 \
--jwt-type=HS256 \
--jwt-secret=your-jwt-secret \
--encryption-key=your-encryption-key \
--mcp-bearer="$USER_ACCESS_TOKEN"
```

With a SQLite/Postgres/MySQL `--database-type`, FGA reuses the main database
With a SQLite/Postgres/MySQL/MariaDB `--database-type`, FGA reuses the main database
automatically — no `--fga-store` flag needed (see
[Enabling FGA](./authorization#1-enabling-fga)). Only pass `--fga-store` /
`--fga-store-url` when the main database is NoSQL (MongoDB, DynamoDB, …) or
[Enabling FGA](./authorization#1-enabling-fga)). Postgres- and MySQL-compatible
variants beyond those (CockroachDB, YugabyteDB, libSQL, PlanetScale) are *not*
auto-mapped and need an explicit `--fga-store`. Only pass `--fga-store` /
`--fga-store-url` when the main database is NoSQL (MongoDB, DynamoDB, …), SQL Server, or
you want FGA on a separate store; `--fga-store` takes one of `sqlite`,
`postgres`, `mysql`, or `memory` — not a URI.

Expand Down Expand Up @@ -326,6 +389,8 @@ Most MCP hosts read a JSON config that declares the command to spawn. For
"--client-id", "YOUR_CLIENT_ID",
"--database-type", "sqlite",
"--database-url", "auth.db",
"--jwt-type", "HS256",
"--jwt-secret", "your-jwt-secret",
"--encryption-key", "your-encryption-key",
"--url", "https://auth.example.com",
"--mcp-bearer", "USER_ACCESS_TOKEN"
Expand All @@ -344,7 +409,7 @@ When a tool call fails — bad arguments, an unauthenticated call, or a permissi
the server returns an MCP tool result with `isError: true` and the error message as text,
so the host surfaces it to the model as a recoverable failure (not a protocol abort).
Typical messages mirror the gRPC status: `Unauthenticated`, `PermissionDenied`,
`FailedPrecondition` (e.g. *fga is not enabled*).
`FailedPrecondition` (e.g. *fine-grained authorization is not enabled*).

## Authorizer as the authorization server protecting *your own* MCP server

Expand Down
3 changes: 3 additions & 0 deletions docs/core/rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ services:
- --database-url=postgres://user:pass@db:5432/authorizer
- --url=https://auth.example.com
- --encryption-key=your-encryption-key
- --jwt-type=HS256
- --jwt-secret=your-jwt-secret
- --admin-secret=your-admin-secret
- --redis-url=redis://redis:6379
- --rate-limit-rps=30
- --rate-limit-burst=20
Expand Down
8 changes: 7 additions & 1 deletion docs/core/sso-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ authorizer \
--smtp-username "auth@yourcompany.com" \
--smtp-password "..." \
--smtp-sender-email "auth@yourcompany.com" \
--encryption-key your-encryption-key
--jwt-type RS256 \
--jwt-private-key "$(cat jwt-private.pem)" \
--jwt-public-key "$(cat jwt-public.pem)" \
--encryption-key your-encryption-key \
--client-id YOUR_CLIENT_ID \
--client-secret YOUR_CLIENT_SECRET \
--admin-secret YOUR_ADMIN_SECRET
```

Key flags for SSO:
Expand Down
26 changes: 26 additions & 0 deletions docs/deployment/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,32 @@ docker run -p 8080:8080 quay.io/authorizer/authorizer:latest \
--client-secret=secret
```

### Persisting data across restarts

The command above writes `test.db` inside the container, so **every restart
starts from an empty database**. Mount a named volume and put SQLite on it:

```bash
docker run -p 8080:8080 -u root \
-v authorizer_data:/authorizer/data \
quay.io/authorizer/authorizer \
--database-type=sqlite \
--database-url=/authorizer/data/data.db \
--url=http://localhost:8080 \
--client-id=123456 \
--client-secret=secret \
--admin-secret=admin \
--jwt-type=HS256 \
--jwt-secret=test \
--encryption-key=test-encryption-key
```

`-u root` is needed because the image runs as uid 1000 (`authorizer`), and a
named volume mounted at a path the image does not already own is created
root-owned — without it the process cannot create the database file. Drop it
once you `chown` the volume, or use a managed database instead.


Then open `http://localhost:8080/app` for the built-in login UI.

---
Expand Down
2 changes: 1 addition & 1 deletion docs/integrations/hasura.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ You can also deploy Authorizer instance using

> **Note:** If you are trying out with one click deployment options like railway then template is configured in a way that it will also deploy postgres + redis for you. For other deployment options, start the server with the required CLI flags:
> ```bash
> ./authorizer --database-type=sqlite --database-url=test.db --jwt-type=HS256 --jwt-secret=test --encryption-key=test-encryption-key --admin-secret=admin --client-id=123456 --client-secret=secret
> ./authorizer --database-type=sqlite --database-url=test.db --url=http://localhost:8080 --jwt-type=HS256 --jwt-secret=test --encryption-key=test-encryption-key --admin-secret=admin --client-id=123456 --client-secret=secret
> ```
> You can also configure `--redis-url` to have persisted sessions. For more information check [Server Configuration](/core/server-config).

Expand Down