Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
184167d
feat(cimd): add the Client ID Metadata Document resolver
lakhansamani Aug 11, 2026
e0b58c9
feat(cimd): add the consent screen and pending-consent flow
lakhansamani Aug 11, 2026
049351e
feat(cimd): wire metadata-document clients into authorize and token
lakhansamani Aug 11, 2026
c7299a6
docs(cimd): changelog, and record why the browser leg has no e2e test
lakhansamani Aug 11, 2026
1067512
fix(cimd): unblock the consent POST and resume via redirect
lakhansamani Aug 11, 2026
9204364
test(cimd): add the e2e TLS fixture and browser spec
lakhansamani Aug 11, 2026
1924b5b
test(cimd): assert the consent flow in Go, scope Playwright to the UI
lakhansamani Aug 11, 2026
bc6ce8e
fix(cimd): honour prompt=none, and never resolve the reserved client …
lakhansamani Aug 11, 2026
a1c8578
security(cimd): bind consent grants to the request, and consume atomi…
lakhansamani Aug 11, 2026
4616b2e
feat(oauth): add opt-in RFC 7591 dynamic client registration
lakhansamani Aug 12, 2026
40895bd
refactor(oauth): simplify registration validation
lakhansamani Aug 12, 2026
11dc998
test(oauth): cover refresh for a self-registered public client
lakhansamani Aug 12, 2026
d5ddc3e
fix(oauth): validate redirect_uri against the client on the login page
lakhansamani Aug 12, 2026
34508ce
test(e2e): gate releases on the OAuth round trip and SCIM
lakhansamani Aug 12, 2026
ddd7d43
fix(consent): allow the approved redirect_uri in the consent page CSP
lakhansamani Aug 13, 2026
4cc58cd
feat(consent): match the login UI, and render errors as pages
lakhansamani Aug 13, 2026
168a65c
fix(oauth): harden three details found in review
lakhansamani Aug 13, 2026
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id

### Added

- **Client ID Metadata Documents (`--enable-client-id-metadata-document`)**: accepts an HTTPS-URL `client_id` that resolves to a JSON document describing the client ([CIMD draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00)), so a client with no pre-registration can authenticate. This is what makes the OAuth path to `/mcp` reachable: without it Claude Code refuses the server outright ("Incompatible auth server: does not support dynamic client registration"). The document is fetched through the SSRF-hardened client (one-shot DNS, dial pinned to the validated IP), its `client_id` must equal its own URL, `redirect_uri` is validated against the document's list, and responses are size-capped and cached with a clamped TTL. Advertised as `client_id_metadata_document_supported` only when enabled. Optional host allow-list via `--client-id-metadata-allowed-domains`. Off by default — it changes the authorization endpoint's trust model for every client, not just MCP.
- **Dynamic Client Registration (`--enable-dynamic-client-registration`)**: implements [RFC 7591](https://www.rfc-editor.org/rfc/rfc7591.html) at `POST /oauth/register` and advertises `registration_endpoint`, for MCP clients that cannot yet use CIMD — which today includes Claude Code, whose released version reads `client_id_metadata_document_supported` and still refuses without a registration endpoint. CIMD remains preferred: the MCP client priority order is pre-registered → CIMD → DCR, so enabling this cannot downgrade a CIMD-capable client. Registers PUBLIC clients only (`token_endpoint_auth_method` must be `none`; `client_credentials` is refused), limits `redirect_uris` to https or loopback http, requires S256 PKCE at `/authorize` and refuses implicit response types, shows the same consent screen CIMD clients get ([RFC 7591 §5](https://www.rfc-editor.org/rfc/rfc7591.html#section-5) asks for exactly that warning), and bounds abuse with the per-IP rate limiter plus a registry ceiling. RFC 7592 client management is not implemented. **Off by default** — it is an unauthenticated write endpoint, matching how Keycloak, Ory Hydra and Auth0 all ship it.
- **Consent screen for self-registered clients**: a CIMD client asserts its own identity — it chooses its `client_name`, and anyone who can host a JSON file can claim any name — so `/authorize` now shows a consent page for those clients before issuing a code. It leads with the **redirect host**, the only fact about such a client the server has verified, and warns explicitly when a client's redirect URIs are loopback-only (any local process can bind the same port and present the same document; the MCP spec requires both). Pre-registered and first-party clients are unaffected and keep today's silent approval. Consent is single-use, pinned to the session it was shown to, and a refusal redirects to the client with `access_denied` per RFC 6749 §4.1.2.1.

- **Remote MCP server (`--mcp-enabled`)**: Authorizer serves its MCP tool surface over Streamable HTTP at `POST <url>/mcp`, acting as an OAuth 2.1 resource server for itself. Implements RFC 9728 protected resource metadata at `/.well-known/oauth-protected-resource/mcp`, answers an unauthenticated or expired credential with `401` + `WWW-Authenticate: Bearer resource_metadata="…"` (the header MCP clients follow to discover the authorization server, and the status they refresh on), and enforces RFC 8707 audience binding — a token is accepted only when its `aud` is this deployment's canonical `<url>/mcp`, which is the audience every other Authorizer endpoint rejects. Each request carries its own bearer token, so one server serves every caller under their own identity. The surface runs on a dedicated in-process gRPC server that accepts no cookie, no admin secret and no admin operation, so no token can cross between MCP and GraphQL/gRPC/REST. **Requires `--url`**; startup refuses the combination without it, because the audience comparison must not take input from the caller. Off by default.
- **RFC 8252 §7.3 loopback redirect URIs**: `redirect_uri` validation now ignores the port when both the registered and presented URIs are loopback (`127.0.0.1`, `[::1]`, `localhost`). Native apps bind an ephemeral port at run time and cannot register it in advance, so exact matching made loopback redirects unusable. Scheme, host, path and query must still match exactly, and non-loopback redirects are unchanged.
- **Unified OAuth Client registry (machine & agent identity foundation)**: All clients (human, machine, agent) are registered in a single `authorizer_clients` table with a `kind` discriminator (`interactive` | `service_account`). Service accounts can use the `client_credentials` grant for machine-to-machine authentication, while agents can participate in delegation chains. Admin GraphQL/gRPC operations manage clients with secret generation (32-byte crypto/rand, bcrypt-12 at rest), scope-subset enforcement, and one-time secret reveal ([#648](https://github.com/authorizerdev/authorizer/pull/648)).
Expand Down Expand Up @@ -100,7 +104,7 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id
### Removed

- **`authorizer_client_id_not_found_total`**: replaced by **`authorizer_client_id_header_missing_total`**, which matches the actual behavior (header omitted, request still allowed). Update dashboards and alerts accordingly.
- **OIDC Discovery — `registration_endpoint`**: previously pointed to the signup UI rather than an RFC 7591 dynamic client registration endpoint. It will return when RFC 7591 is implemented.
- **OIDC Discovery — `registration_endpoint`**: previously pointed to the signup UI rather than an RFC 7591 dynamic client registration endpoint. (It returns in 2.4.0, pointing at a real RFC 7591 endpoint and only when `--enable-dynamic-client-registration` is set.)

## [2.2.1-rc.0] - 2026-04-06

Expand Down
11 changes: 6 additions & 5 deletions ROADMAP_V2.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,10 +291,11 @@
- [ ] **Authorization Server Metadata (RFC 8414)**
- `/.well-known/oauth-authorization-server` endpoint
- Publishes supported grant types, scopes, response types, token endpoint auth methods
- [ ] **Dynamic Client Registration (RFC 7591)**
- [x] **Dynamic Client Registration (RFC 7591)** -- delivered in 2.4.0 behind `--enable-dynamic-client-registration` (off by default)
- `POST /oauth/register` -- MCP clients register programmatically
- Returns `client_id` (and optionally `client_secret` for confidential clients)
- Registration access token for subsequent client management
- Returns `client_id` only: PUBLIC clients exclusively, so no `client_secret` is ever issued to an anonymous caller
- Registration access tokens (RFC 7592 client management) deliberately NOT implemented -- a self-registered client is disposable
- CIMD remains the preferred mechanism; this exists for clients that predate it
- [ ] **Resource Indicators (RFC 8707)**
- `resource` parameter in authorization and token requests
- Token audience (`aud`) set to the target MCP server URL
Expand Down Expand Up @@ -366,7 +367,7 @@ Human approval and safe third-party access.
- *Unlocks:* human-in-the-loop, agents calling Google/Slack/etc. on a user's behalf.

### Wave 4 — Enterprise hardening
- [x] **MCP authorization** (OAuth 2.1 + RFC 9728 + RFC 8707) (4.1) — delivered in 2.4.0 as `--mcp-enabled`: Authorizer is both the authorization server and the resource server for its own MCP surface. Still open: RFC 7591 dynamic client registration / CIMD for zero-touch client onboarding (and the `/authorize` consent screen CIMD requires), plus **ID-JAG / Cross-App Access** for enterprise-managed MCP.
- [x] **MCP authorization** (OAuth 2.1 + RFC 9728 + RFC 8707) (4.1) — delivered in 2.4.0 as `--mcp-enabled`: Authorizer is both the authorization server and the resource server for its own MCP surface. Zero-touch client onboarding also landed in 2.4.0: CIMD (`--enable-client-id-metadata-document`, preferred) and RFC 7591 DCR (`--enable-dynamic-client-registration`, for clients that predate CIMD), both gated behind the `/authorize` consent screen. Still open: **ID-JAG / Cross-App Access** for enterprise-managed MCP.
- [ ] **JIT / time-bound grants** (TTL tuples), **per-agent guardrails** (spend/rate limits), **consent management**.
- *Unlocks:* enterprise-managed agent deployments at scale.

Expand Down Expand Up @@ -558,7 +559,7 @@ Phase 5 (can partially parallelize with Phase 3-4)
|---|---|---|
| OAuth 2.1 (draft) | 4 | Modern OAuth baseline (PKCE mandatory, no implicit) |
| RFC 7636 (PKCE) | Done | Proof Key for Code Exchange |
| RFC 7591 (DCR) | 4 | Dynamic Client Registration for MCP |
| RFC 7591 (DCR) | Done | Dynamic Client Registration for MCP (opt-in; CIMD preferred) |
| RFC 8414 (AS Metadata) | 4 | Authorization Server discovery |
| RFC 8693 (Token Exchange) | 5 | Agent delegation, cross-service tokens |
| RFC 8707 (Resource Indicators) | 4 | Audience-restricted tokens for MCP |
Expand Down
51 changes: 38 additions & 13 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/authorizerdev/authorizer/internal/audit"
"github.com/authorizerdev/authorizer/internal/authenticators"
"github.com/authorizerdev/authorizer/internal/authenticators/webauthn"
"github.com/authorizerdev/authorizer/internal/clientmetadata"
"github.com/authorizerdev/authorizer/internal/config"
"github.com/authorizerdev/authorizer/internal/constants"
"github.com/authorizerdev/authorizer/internal/email"
Expand Down Expand Up @@ -143,6 +144,18 @@ func init() {
// as the OAuth metadata clients discover it through, and mounting it on the
// main router gives it the existing CORS, security-header, rate-limit and
// logging middleware.
f.BoolVar(&rootArgs.config.EnableClientIDMetadataDocument, "enable-client-id-metadata-document", false,
"Accept HTTPS-URL client_ids that resolve to a Client ID Metadata Document (CIMD), so clients "+
"with no prior relationship can authenticate — required for the OAuth path to the MCP surface. "+
"Clients registered this way are self-asserted, so a consent screen is shown for them. Off by default")
f.StringSliceVar(&rootArgs.config.ClientIDMetadataAllowedDomains, "client-id-metadata-allowed-domains", nil,
"Restrict which hosts may serve a client metadata document (e.g. claude.ai). Empty accepts any HTTPS host")
f.BoolVar(&rootArgs.config.EnableDynamicClientRegistration, "enable-dynamic-client-registration", false,
"Serve the RFC 7591 dynamic client registration endpoint at POST <url>/oauth/register, for MCP "+
"clients that cannot use CIMD. This is an UNAUTHENTICATED write endpoint: prefer "+
"--enable-client-id-metadata-document where the client supports it. Clients registered this "+
"way are self-asserted, so a consent screen is shown for them. Off by default")

f.BoolVar(&rootArgs.config.MCPEnabled, "mcp-enabled", false,
"Serve the MCP tool surface over HTTP at POST <url>/mcp as an OAuth 2.1 resource server. "+
"Requires --url: tokens are accepted only when their audience equals <url>/mcp, and that "+
Expand Down Expand Up @@ -701,20 +714,32 @@ func runRoot(c *cobra.Command, args []string) {
log.Fatal().Err(err).Msg("failed to create service provider")
}

// CIMD resolver. nil when disabled, which is what switches the feature off
// everywhere downstream — the authorize handler and the client-auth resolver
// both treat a nil provider as "URL client_ids are not a thing here".
var clientMetadataProvider *clientmetadata.Provider
if rootArgs.config.EnableClientIDMetadataDocument {
clientMetadataProvider = clientmetadata.New(&log, rootArgs.config.ClientIDMetadataAllowedDomains,
rootArgs.config.Env == constants.E2EEnv)
log.Info().Strs("allowed_domains", rootArgs.config.ClientIDMetadataAllowedDomains).
Msg("Client ID Metadata Documents enabled")
}

httpProvider, err := http_handlers.New(&rootArgs.config, &http_handlers.Dependencies{
Log: &log,
AuditProvider: auditProvider,
AuthenticatorProvider: authenticatorProvider,
EmailProvider: emailProvider,
EventsProvider: eventsProvider,
MemoryStoreProvider: memoryStoreProvider,
SMSProvider: smsProvider,
StorageProvider: storageProvider,
TokenProvider: tokenProvider,
OAuthProvider: oauthProvider,
RateLimitProvider: rateLimitProvider,
ServiceProvider: serviceProvider,
AuthzEngine: authzEngine,
ClientMetadataProvider: clientMetadataProvider,
Log: &log,
AuditProvider: auditProvider,
AuthenticatorProvider: authenticatorProvider,
EmailProvider: emailProvider,
EventsProvider: eventsProvider,
MemoryStoreProvider: memoryStoreProvider,
SMSProvider: smsProvider,
StorageProvider: storageProvider,
TokenProvider: tokenProvider,
OAuthProvider: oauthProvider,
RateLimitProvider: rateLimitProvider,
ServiceProvider: serviceProvider,
AuthzEngine: authzEngine,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create http provider")
Expand Down
43 changes: 42 additions & 1 deletion e2e-playground/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,19 @@ services:
# presented at /mcp is checked against <url>/mcp, so the server refuses
# to start with --mcp-enabled and no --url.
- "--mcp-enabled"
# Accept https client_ids that resolve to a Client ID Metadata Document,
# so a client with no pre-registration can authenticate.
- "--enable-client-id-metadata-document"
# RFC 7591 self-registration, for clients that predate CIMD. Both are on
# here so tests/dcr.spec.ts can prove they coexist: a client that supports
# CIMD selects it first (the MCP priority order is pre-registered → CIMD →
# DCR), so advertising the registration endpoint cannot downgrade one.
# Off by default in the product — it is an unauthenticated write endpoint.
- "--enable-dynamic-client-registration"
- "--app-cookie-secure=false"
- "--admin-cookie-secure=false"
- "--app-cookie-same-site=lax"
- "--allowed-origins=http://localhost:8080,http://authorizer:8080"
- "--allowed-origins=http://localhost:8080,http://authorizer:8080,https://cimd-client:4300"
- "--google-client-id=mock-client-id"
- "--google-client-secret=mock-client-secret"
- "--github-client-id=mock-client-id"
Expand Down Expand Up @@ -64,12 +73,22 @@ services:
# correctness.
- "--rate-limit-rps=1000"
- "--rate-limit-burst=1000"
environment:
# Go reads the system trust store from SSL_CERT_FILE. The bundle appends
# our e2e CA to the public roots (see mocks/tls-certs/generate.sh) rather
# than replacing them, so this adds trust for the CIMD mock without
# removing it for anything else.
SSL_CERT_FILE: /certs/bundle.crt
volumes:
- certs:/certs:ro
depends_on:
mock-oauth: { condition: service_started }
mock-saml-idp: { condition: service_started }
mailpit: { condition: service_started }
sms-sink: { condition: service_started }
webhook-sink: { condition: service_started }
tls-certs: { condition: service_completed_successfully }
cimd-client: { condition: service_started }
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 2s
Expand Down Expand Up @@ -559,6 +578,24 @@ services:
build: ./mocks/sms-sink
ports: ["4100:4100"]

# One-shot: generates a CA and a leaf certificate into the shared `certs`
# volume, then exits. Everything that needs TLS waits on it completing.
tls-certs:
build: ./mocks/tls-certs
volumes:
- certs:/certs

# Hosts a Client ID Metadata Document over HTTPS. A compose service rather
# than a fixture file because the AUTHORIZER SERVER fetches the client_id URL
# — that fetch is what CIMD is.
cimd-client:
build: ./mocks/cimd-client
depends_on:
tls-certs: { condition: service_completed_successfully }
volumes:
- certs:/certs
ports: ["4300:4300"]

webhook-sink:
build: ./mocks/webhook-sink
ports: ["4200:4200"]
Expand Down Expand Up @@ -590,6 +627,7 @@ services:
AUTHORIZER_REPLICA_A_BASE_URL: http://authorizer-replica-a:8080
AUTHORIZER_REPLICA_B_BASE_URL: http://authorizer-replica-b:8080
MOCK_OAUTH_BASE_URL: http://mock-oauth:4000
CIMD_CLIENT_BASE_URL: https://cimd-client:4300
# https, not http: mock-saml-idp terminates TLS (see its server.ts) so
# Authorizer's idp_sso_url validation (https-only, no test bypass)
# accepts the stored SSO URL. Callers must ignore the self-signed cert
Expand Down Expand Up @@ -686,3 +724,6 @@ services:
mailpit: { condition: service_started }
sms-sink: { condition: service_started }
webhook-sink: { condition: service_started }

volumes:
certs:
7 changes: 7 additions & 0 deletions e2e-playground/mocks/cimd-client/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY server.ts tsconfig.json ./
EXPOSE 4300
CMD ["npm", "start"]
8 changes: 8 additions & 0 deletions e2e-playground/mocks/cimd-client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "cimd-client",
"private": true,
"version": "1.0.0",
"scripts": { "start": "ts-node server.ts" },
"dependencies": { "express": "^4.21.0" },
"devDependencies": { "ts-node": "^10.9.2", "typescript": "^5.6.0", "@types/express": "^4.17.21", "@types/node": "^22.7.0" }
}
55 changes: 55 additions & 0 deletions e2e-playground/mocks/cimd-client/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Stands in for an MCP client that identifies itself with a Client ID Metadata
// Document instead of a pre-registered client_id.
//
// Serves HTTPS, and that is not incidental: the spec requires a CIMD client_id
// to use the https scheme, and the AUTHORIZER SERVER — not the test process —
// is what fetches it. So this needs a certificate the server trusts, which the
// tls-certs service generates into a shared volume.
//
// `client_id` MUST equal the URL it is served from. That equality is the spec's
// central requirement and the only thing stopping any host from serving a
// document claiming to be some other client.
import express from 'express';
import https from 'node:https';
import fs from 'node:fs';

const BASE = process.env.SELF_BASE_URL || 'https://cimd-client:4300';
const app = express();

app.get('/client.json', (_req, res) => {
res.json({
client_id: `${BASE}/client.json`,
client_name: 'E2E Playground Client',
client_uri: BASE,
redirect_uris: [`${BASE}/callback`],
grant_types: ['authorization_code'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
});
});

// A document whose client_id names a DIFFERENT URL — the impersonation case.
// Accepting this would let any host claim to be any client.
app.get('/mismatched.json', (_req, res) => {
res.json({
client_id: `${BASE}/client.json`,
client_name: 'Impersonator',
redirect_uris: [`${BASE}/callback`],
});
});

// Where the authorization code lands. Echoes the query so the test can read it.
app.get('/callback', (req, res) => {
// HTML, not JSON: this is a NAVIGATION target, and a browser handles a
// document far more predictably than an application/json body.
res.type('html').send(`<!doctype html><title>callback</title><pre id="q">${JSON.stringify(req.query)}</pre>`);
});

app.get('/healthz', (_req, res) => res.sendStatus(204));

https
.createServer(
{ key: fs.readFileSync('/certs/server.key'), cert: fs.readFileSync('/certs/server.crt') },
app,
)
.listen(4300, () => console.log(`cimd-client listening on ${BASE}`));
12 changes: 12 additions & 0 deletions e2e-playground/mocks/cimd-client/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
}
}
Loading
Loading