From 046e6607c908e25ab7b51970569223e2d3e83a96 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 14 Aug 2026 17:14:05 +0530 Subject: [PATCH 1/4] fix(docs): add mandatory flags missing from runnable examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three blocks could not boot as written: - sso-guide: no --jwt-*, --client-id/secret or --admin-secret. Fails on `missing jwt type` after --encryption-key passes. - rate-limiting compose: no --jwt-* or --admin-secret. - mcp: no --jwt-*. `authorizer mcp` validates the bearer itself, so it needs the same JWT settings as the server that minted it — without them every tool call returns Unauthenticated. Verified the corrected sso-guide flag set boots against a build of main. Also documents persisting SQLite across restarts with a named volume; the Docker quick start writes the database inside the container, so every restart began from an empty one. --- docs/core/mcp.md | 4 ++++ docs/core/rate-limiting.md | 3 +++ docs/core/sso-guide.md | 8 +++++++- docs/deployment/docker.md | 26 ++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/core/mcp.md b/docs/core/mcp.md index d4ecdc2..44cfbac 100644 --- a/docs/core/mcp.md +++ b/docs/core/mcp.md @@ -273,6 +273,8 @@ 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" ``` @@ -326,6 +328,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" diff --git a/docs/core/rate-limiting.md b/docs/core/rate-limiting.md index 16c26ec..a3f8589 100644 --- a/docs/core/rate-limiting.md +++ b/docs/core/rate-limiting.md @@ -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 diff --git a/docs/core/sso-guide.md b/docs/core/sso-guide.md index b3804f8..72c7aed 100644 --- a/docs/core/sso-guide.md +++ b/docs/core/sso-guide.md @@ -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: diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 177c539..1b954e9 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -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. --- From 434adfe683880c4611cb85e32948168e60aba209 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 14 Aug 2026 18:53:35 +0530 Subject: [PATCH 2/4] fix: add --url, required since authorizer 2.4.0 The server exits at boot without --url (authorizerdev/authorizer#764). These commands could not start as written. --- docs/integrations/hasura.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations/hasura.md b/docs/integrations/hasura.md index 913ece8..207789a 100644 --- a/docs/integrations/hasura.md +++ b/docs/integrations/hasura.md @@ -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). From 896f58321ec5a367e9f00e45945509c80fad186f Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 15 Aug 2026 12:57:13 +0530 Subject: [PATCH 3/4] docs(mcp): document delegated tokens, correct verified claims Adds the RFC 8693 section: /mcp accepts delegated tokens, the resource must be /mcp rather than the bare URL, answers are the agent's authority and not the user's, and the 5-minute TTL has no refresh. Corrections from checking the page against main: - protected-resource metadata also carries jwks_uri and resource_documentation - --url is required to start the server at all, not only with --mcp-enabled; MCP additionally requires a usable http(s) origin - a CIMD client_id must be an https URL WITH a path; a bare origin falls through to a registry lookup - FGA auto-reuse covers MariaDB; CockroachDB, YugabyteDB, libSQL, PlanetScale and SQL Server need an explicit --fga-store - the FailedPrecondition message is "fine-grained authorization is not enabled" --- docs/core/mcp.md | 76 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/docs/core/mcp.md b/docs/core/mcp.md index 44cfbac..169aa01 100644 --- a/docs/core/mcp.md +++ b/docs/core/mcp.md @@ -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, `/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, `/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. @@ -52,7 +54,8 @@ check at all. exactly `/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, @@ -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" } ``` @@ -146,8 +151,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. @@ -238,6 +245,51 @@ 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=/mcp`, not ``. 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. + +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 | @@ -279,10 +331,12 @@ authorizer mcp \ --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. @@ -348,7 +402,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 From d46234470cf2e7d859a5ed6b42a1146a30cb6979 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 15 Aug 2026 13:32:42 +0530 Subject: [PATCH 4/4] docs(mcp): record real-client verification of delegated tokens Verified on Claude Code 2.1.233: connects, calls check_permissions, and gets the agent's intersected answer. A token bound to the bare instead of /mcp fails with invalid_token. --- docs/core/mcp.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/core/mcp.md b/docs/core/mcp.md index 169aa01..4336a91 100644 --- a/docs/core/mcp.md +++ b/docs/core/mcp.md @@ -102,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 `/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 `/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) | @@ -287,6 +288,12 @@ Two limits worth planning around: 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 +`/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 `` 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.