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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id

### Added

- **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)).
- **Machine-to-machine (service-to-service) authentication**: Service account clients use the RFC 6749 §4.4 `client_credentials` grant at `/oauth/token` to mint access tokens for autonomous workloads. Tokens carry `login_method: service_account` and resolve to `service_account:<client_id>` FGA subjects instead of users. Scope-subset enforcement and timing-safe authentication prevent privilege escalation ([#641](https://github.com/authorizerdev/authorizer/pull/641), [#642](https://github.com/authorizerdev/authorizer/pull/642), [#644](https://github.com/authorizerdev/authorizer/pull/644), [#645](https://github.com/authorizerdev/authorizer/pull/645), [#647](https://github.com/authorizerdev/authorizer/pull/647)).
- **Secretless workload identity (RFC 7523 + SPIFFE JWT-SVID + Kubernetes TokenReview)**: Service accounts can authenticate via `client_assertion` (JWT-bearer) with `private_key_jwt` or `jwt-spiffe` assertion types. Trusted issuers validate assertion signatures, pin subject claims, and prevent replay via single-use `jti` in a bounded-TTL cache. When enabled, Kubernetes TokenReview API validates projected ServiceAccount tokens before issuance. All authentication paths share constant-time comparison and SSRF-hardened external fetch for JWKS ([#654](https://github.com/authorizerdev/authorizer/pull/654), [#659](https://github.com/authorizerdev/authorizer/pull/659)).
Expand Down Expand Up @@ -62,8 +64,16 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id
- **BREAKING: `/userinfo` now strictly filters claims by scope per OIDC Core §5.4.** The endpoint returns only `sub` plus the claims permitted by the standard scope groups (`profile`, `email`, `phone`, `address`) encoded in the access token. Previously, `/userinfo` returned the full user object regardless of scopes. Clients that request only the `openid` scope but read profile/email claims from `/userinfo` **must** now request those scopes explicitly. See https://docs.authorizer.dev/core/oauth2-oidc for the full scope→claim mapping.
- **OAuth 2.1 standards compliance**: refresh-token reuse detection revokes the user's entire session family on replay (RFC 8707 compliance); `resource` parameter binding on authorization code flow (binds access token `aud` claim); new `--oauth21-strict` flag (default off) gates implicit-grant and PKCE-plain removal behind opt-in. New `GET /.well-known/oauth-authorization-server` thin alias of OIDC discovery for MCP compliance ([#693](https://github.com/authorizerdev/authorizer/pull/693)).

### Deprecated

- **`authorizer mcp` (stdio transport)** — superseded by `--mcp-enabled`, removed in 2.5.0. The stdio subcommand ran a second copy of every provider (storage, memory store, embedded FGA engine) alongside the real server, and its identity was a single process-wide `--mcp-bearer`, so one process could only ever serve one user. Both are gone with the HTTP transport: the MCP surface shares the running server's providers, and every request carries its own token. The subcommand keeps working and now prints a deprecation notice.

### Security

- **RFC 8707 resource binding now survives token refresh.** An access token minted with a `resource` indicator carries that resource as its `aud`, which is what stops it being replayed at a different resource server. That binding previously survived exactly one token: the refresh grant did not carry the resource forward, so the rotated access token fell back to the client id as its audience and came back **unbound** — usable at Authorizer's own API, which is precisely what the restriction exists to prevent. The resource is now stamped on the refresh token (a reserved claim, so `CUSTOM_ACCESS_TOKEN_SCRIPT` cannot forge it) and carried across every rotation. A refresh request that names a *different* resource is rejected with `invalid_target` (RFC 8707 §2.2 permits restricting the resource, never switching it). Grants that never used a resource indicator are unaffected — their tokens keep the client id as the audience and their claim set is unchanged.

- **BEHAVIOUR CHANGE — deactivating a service account now revokes its live access tokens.** Token validation resolved a token's subject as a *user* only and treated "no such user" as "not revoked". A `client_credentials` token's `sub` is the service account's row id, never a user id, so that lookup missed every time and reported the caller live: setting a service account inactive blocked new token issuance but did nothing to tokens already outstanding, which kept working at GraphQL, gRPC and REST until they expired. Subject liveness now resolves user-then-client and fails closed when the subject is neither, so revoking a compromised machine identity takes effect immediately. Tokens belonging to active service accounts and live users are unaffected. Service accounts, `client_credentials` and workload identity are all new in this release, so no previously-released behaviour changes.

- **OIDC/OAuth2 specification compliance for Enterprise IdP integration**: `/authorize` now returns RFC 6749 error codes (invalid_request, unauthorized_client, unsupported_response_type) instead of freeform strings; errors after `redirect_uri` validation redirect to the RP per spec instead of returning JSON. ID tokens now include `auth_time` claim on all issuance paths (OIDC Core §2 requirement for `max_age`). Discovery endpoint advertises `"none"` in `token_endpoint_auth_methods_supported` for PKCE-only public clients. `token_type` normalized to `"Bearer"` (capitalized). In-memory state store enforced with 10-minute TTL; DB state store enforces 600-second read-time TTL. `Cache-Control` caching added to discovery endpoint ([#604](https://github.com/authorizerdev/authorizer/pull/604)).
- **RFC-compliant PKCE and redirect_uri security hardening**: S256 `code_challenge` now tolerates base64url padding (Auth0 compatibility). `client_secret` validation enforced whenever provided, even when PKCE is used (prevents secret bypass). `code_verifier` rejected when no `code_challenge` was registered (prevents PKCE bypass). `redirect_uri` URL-encoded in state to prevent `@@`-delimiter injection. `/oauth/token` now validates `redirect_uri` matches the `/authorize` registration (RFC 6749 §4.1.3). Authorize state removal is synchronous (prevents code reuse). Constant-time `redirect_uri` comparison ([#603](https://github.com/authorizerdev/authorizer/pull/603)).
- **Introspection authentication & backchannel SSRF hardening**: `/oauth/introspect` now requires `client_secret` when configured (previously omitting secret bypassed auth entirely). Timing-safe `crypto/subtle.ConstantTimeCompare` used for all secret validation. Backchannel logout SSRF fixed by routing through `SafeHTTPClient` (upfront DNS, IP pinning, rejects private/loopback). Session rollover goroutine errors now logged instead of silently discarded ([#606](https://github.com/authorizerdev/authorizer/pull/606)).
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ For production builds, tests, and Docker, see [Getting Started](#getting-started
- ✅ SCIM 2.0 user and group provisioning with RFC 7644 compliance
- ✅ Multi-tenant / org-scoped admin roles and isolation
- ✅ GraphQL, REST, gRPC, and MCP APIs (all transports share the same service layer for public auth operations)
- ✅ Remote MCP server for AI agents — OAuth 2.1 protected, RFC 9728 discovery, RFC 8707 audience-bound tokens
- ✅ Admin API — user management, webhooks, email templates, audit logs, and FGA model/tuples over GraphQL, gRPC, and REST transports
- ✅ Rate limiting and security hardening (CSRF, CORS, HSTS, CSP, trusted proxies)
- ✅ Prometheus metrics and health/readiness endpoints
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP_V2.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,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
- [ ] **MCP authorization** (OAuth 2.1 + RFC 9728 + RFC 8707) (4.1) and **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. 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.
- [ ] **JIT / time-bound grants** (TTL tuples), **per-agent guardrails** (spend/rate limits), **consent management**.
- *Unlocks:* enterprise-managed agent deployments at scale.

Expand Down
35 changes: 28 additions & 7 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,36 @@ var mcpArgs struct {
// `(authorizer.v1.mcp_tool).exposed` option; the MCP server discovers
// them at startup.
//
// Transport: STDIO ONLY. The MCP server has no auth/rate-limit interceptors
// of its own — the security model relies on the OS-level trust boundary of
// the subprocess. See internal/mcp/server.go's Server type comment.
// DEPRECATED, removed in 2.5.0. Superseded by --mcp-enabled on the server,
// which shares the running providers and authenticates every request
// separately. This transport has no auth of its own — it relies on the
// OS-level trust boundary of the subprocess. See internal/mcp.Server.
var mcpCmd = &cobra.Command{
Use: "mcp",
Short: "Serve Authorizer's MCP tool surface over stdio",
Long: "Exposes a subset of Authorizer's gRPC methods (those marked " +
"(authorizer.v1.mcp_tool).exposed=true in proto) as MCP " +
"tools, suitable for use with Claude Code or any MCP-compatible " +
"host. Stdio is the only supported transport.",
Long: "DEPRECATED — use --mcp-enabled on the server instead; this " +
"subcommand is removed in 2.5.0.\n\n" +
"Exposes a subset of Authorizer's gRPC methods (those marked " +
"(authorizer.v1.mcp_tool).exposed=true in proto) as MCP tools over " +
"stdio. It runs a second copy of every provider and serves a single " +
"user per process (--mcp-bearer), which is why it cannot be deployed. " +
"The server's own MCP surface at POST <url>/mcp shares the running " +
"providers and authenticates each request separately.",
Run: runMCP,
}

func init() {
// Cobra prints this above the command's output on every invocation.
//
// The stdio transport cannot be deployed: it runs a second copy of every
// provider (storage, memory store, FGA engine) and its identity is one
// process-wide --mcp-bearer, so a process serves exactly one user forever.
// `--mcp-enabled` replaces both properties — the MCP surface shares the
// running server's providers, and every request carries its own token.
mcpCmd.Deprecated = "the stdio MCP transport will be removed in 2.5.0. " +
"Run the server with --mcp-enabled and connect to POST <url>/mcp instead. " +
"See https://docs.authorizer.dev/core/mcp"

mcpCmd.Flags().StringVar(&mcpArgs.bearer, "mcp-bearer", "",
"Bearer token to attach to every outgoing gRPC call (carries the "+
"user identity for tools like Profile / Permissions / Session). "+
Expand All @@ -82,6 +98,11 @@ func runMCP(_ *cobra.Command, _ []string) {
// JSON-RPC framing on stdout.
log := zerolog.New(os.Stderr).With().Timestamp().Logger()

// Cobra's deprecation notice goes to the terminal; this puts it where an
// operator running under a supervisor will actually see it.
log.Warn().Msg("`authorizer mcp` (stdio) is deprecated and will be removed in 2.5.0 — " +
"run the server with --mcp-enabled and connect to POST <url>/mcp instead")

// Wire all subsystems an MCP-exposed tool might need. As more ops
// migrate into internal/service, this list stays the same — the
// service-provider dependencies don't change per op, only the methods
Expand Down
57 changes: 57 additions & 0 deletions cmd/mcp_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package cmd

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/authorizerdev/authorizer/internal/config"
)

// TestValidateMCPConfig guards the startup refusal that MCP's entire audience
// model rests on.
//
// A token is accepted at /mcp only if its `aud` equals this deployment's
// canonical <url>/mcp. Derive that identifier from request headers — which is
// what parsers.GetHost does when --url is unset, falling back to
// X-Authorizer-URL, X-Forwarded-Host, then Host — and the caller supplies both
// sides of the comparison. Refusing to start is what makes the check mean
// something.
//
// The unusable-URL cases matter as much as the empty one: MCPResource() returns
// "" for a scheme-less or non-http --url too, so starting anyway would produce a
// surface that is enabled, advertises nothing (the metadata handler 404s) and
// rejects every token — broken in the direction that reports success.
func TestValidateMCPConfig(t *testing.T) {
t.Run("MCP disabled never fails, whatever --url says", func(t *testing.T) {
for _, u := range []string{"", "not-a-url", "https://auth.example.com"} {
require.NoError(t, validateMCPConfig(&config.Config{MCPEnabled: false, AuthorizerURL: u}),
"the guard must not affect deployments that do not run MCP")
}
})

t.Run("MCP enabled with a usable --url starts", func(t *testing.T) {
for _, u := range []string{
"https://auth.example.com",
"https://auth.example.com/", // trailing slash is normalised away
"http://localhost:8080", // local development
} {
assert.NoError(t, validateMCPConfig(&config.Config{MCPEnabled: true, AuthorizerURL: u}), u)
}
})

t.Run("MCP enabled without a usable --url refuses to start", func(t *testing.T) {
for _, u := range []string{
"", // the shipped default
"auth.example.com", // scheme omitted — the likely mistake
"ftp://auth.example.com", // not an http origin
"https://user:pw@auth.example.com", // userinfo
} {
err := validateMCPConfig(&config.Config{MCPEnabled: true, AuthorizerURL: u})
require.Error(t, err, "--url %q cannot yield a resource identifier, so MCP must not start", u)
assert.Contains(t, err.Error(), "--url",
"the message must name the flag an operator has to set")
}
})
}
Loading
Loading