Skip to content

RFC: OAuth 2.1 Authorization Server for MCP #516

@lakhansamani

Description

@lakhansamani

RFC: OAuth 2.1 Authorization Server for MCP

Phase: 4 — AI-Era Auth
Priority: P2 — High
Estimated Effort: High
Depends on: OIDC Provider (#514), M2M Auth (#509)


Problem Statement

The Model Context Protocol (MCP) requires an OAuth 2.1-compliant authorization server for tool-server authorization. MCP clients (AI agents, IDEs, chat interfaces) need to authenticate with MCP servers through standard OAuth flows. WorkOS, Keycloak 26.4, and Clerk all support MCP auth. This is the fastest-growing auth use case.


Current Architecture Context

  • /authorize endpoint exists with PKCE support
  • /oauth/token supports authorization_code and refresh_token grants
  • /.well-known/openid-configuration exists (basic)
  • No /.well-known/oauth-authorization-server (RFC 8414)
  • No Dynamic Client Registration (RFC 7591)
  • No Resource Indicators (RFC 8707)
  • No refresh token rotation

Proposed Solution

1. OAuth 2.1 Compliance

OAuth 2.1 (draft-ietf-oauth-v2-1) consolidates best practices:

Mandatory changes:

  • PKCE required on ALL authorization code flows (S256 only, remove plain method)
  • Remove implicit grant — currently response_type=token is supported; deprecate and remove
  • Refresh token rotation — each refresh issues a new refresh token, old one invalidated
  • Refresh token sender-constrained — bind to client_id, reject if different client presents it
  • Exact redirect URI matching — no wildcard or partial matching

Implementation in existing handlers:

// In authorize.go — reject implicit grant:
if responseType == "token" {
    return oauthError(c, "unsupported_response_type", 
        "Implicit grant (response_type=token) is not supported. Use authorization_code with PKCE.")
}

// In authorize.go — require PKCE:
if codeChallenge == "" || codeChallengeMethod != "S256" {
    return oauthError(c, "invalid_request", "code_challenge with S256 method is required")
}

// In token.go — refresh token rotation:
case "refresh_token":
    // Validate old refresh token
    // Issue new access_token AND new refresh_token
    // Invalidate old refresh token immediately
    // If old refresh token is reused after rotation → revoke entire token family (breach detection)

2. Authorization Server Metadata (RFC 8414)

New endpoint: GET /.well-known/oauth-authorization-server

{
    "issuer": "https://auth.example.com",
    "authorization_endpoint": "https://auth.example.com/authorize",
    "token_endpoint": "https://auth.example.com/oauth/token",
    "registration_endpoint": "https://auth.example.com/oauth/register",
    "revocation_endpoint": "https://auth.example.com/oauth/revoke",
    "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
    "scopes_supported": ["openid", "profile", "email", "offline_access", "mcp:tool:*"],
    "response_types_supported": ["code"],
    "grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
    "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
    "code_challenge_methods_supported": ["S256"],
    "revocation_endpoint_auth_methods_supported": ["client_secret_basic"],
    "service_documentation": "https://docs.authorizer.dev",
    "resource_indicators_supported": true
}

MCP clients discover Authorizer's capabilities via this endpoint.

3. Dynamic Client Registration (RFC 7591)

MCP clients need to register programmatically — they can't go through a manual admin setup.

New endpoint: POST /oauth/register

// Request
{
    "client_name": "Claude Desktop",
    "redirect_uris": ["http://localhost:8765/callback"],
    "grant_types": ["authorization_code"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none",
    "application_type": "native",
    "scope": "openid profile mcp:tool:read_file mcp:tool:search"
}

// Response
{
    "client_id": "dyn_a1b2c3d4...",
    "client_name": "Claude Desktop",
    "redirect_uris": ["http://localhost:8765/callback"],
    "grant_types": ["authorization_code"],
    "token_endpoint_auth_method": "none",
    "registration_access_token": "rat_xyz...",      // for subsequent client management
    "registration_client_uri": "https://auth.example.com/oauth/register/dyn_a1b2c3d4...",
    "client_id_issued_at": 1711800000
}

Registration access token: Used to read/update/delete the client registration via GET/PUT/DELETE /oauth/register/:client_id.

Security controls:

  • --enable-dynamic-client-registration=false (off by default)
  • --dcr-require-initial-access-token=true — require a pre-issued token to register (prevents open registration)
  • Rate limit on registration endpoint

4. Resource Indicators (RFC 8707)

Tokens are scoped to a specific MCP server (resource) to prevent token reuse across servers.

In authorization request:

GET /authorize?
    client_id=dyn_abc&
    response_type=code&
    scope=mcp:tool:read_file&
    resource=https://mcp-server.example.com&    ← target MCP server
    code_challenge=...&
    code_challenge_method=S256

In token request:

POST /oauth/token
    grant_type=authorization_code&
    code=...&
    resource=https://mcp-server.example.com

Token includes audience restriction:

{
    "aud": "https://mcp-server.example.com",
    "scope": "mcp:tool:read_file",
    "sub": "user_123"
}

MCP servers validate that aud matches their own URL before accepting the token.

5. Protected Resource Metadata

MCP servers publish metadata pointing to Authorizer as their authorization server:

Endpoint on MCP server side: GET /.well-known/oauth-protected-resource

{
    "resource": "https://mcp-server.example.com",
    "authorization_servers": ["https://auth.example.com"],
    "scopes_supported": ["mcp:tool:read_file", "mcp:tool:execute_query", "mcp:tool:search"]
}

Authorizer doesn't serve this endpoint (the MCP server does), but Authorizer needs to:

  • Accept resource parameter in authorize/token requests
  • Set aud claim to the resource URL
  • Validate that the resource URL is in an allowlist (optional)

6. Tool-Level Permission Scopes

Define MCP tool permissions as OAuth scopes:

mcp:tool:read_file          → Read files from the MCP server
mcp:tool:write_file         → Write/modify files
mcp:tool:execute_query      → Execute database queries
mcp:tool:search             → Search operations
mcp:tool:admin              → Administrative operations
mcp:*                       → All MCP permissions

Scope registration: MCP server operators register their tool scopes via the admin API:

type Mutation {
    _register_mcp_scopes(params: RegisterMCPScopesInput!): Response!
}

input RegisterMCPScopesInput {
    resource_url: String!           # MCP server URL
    scopes: [MCPScopeInput!]!
}

input MCPScopeInput {
    name: String!                   # e.g., "mcp:tool:read_file"
    description: String!            # Human-readable for consent screen
}

Consent screen shows which tools the agent is requesting access to:

"Claude Desktop" wants to:
☑ Read files from your MCP server
☑ Search your data
☐ Execute database queries
☐ Write files

[Allow]  [Deny]

MCP Auth Flow (End-to-End)

1. MCP Client discovers MCP Server's protected resource metadata
   GET https://mcp-server.example.com/.well-known/oauth-protected-resource
   → learns authorization_server = https://auth.example.com

2. MCP Client discovers Authorizer's capabilities
   GET https://auth.example.com/.well-known/oauth-authorization-server
   → learns endpoints, supported scopes, DCR endpoint

3. MCP Client registers dynamically (if not already registered)
   POST https://auth.example.com/oauth/register
   → gets client_id

4. MCP Client initiates authorization
   GET https://auth.example.com/authorize?
       client_id=...&resource=https://mcp-server.example.com&scope=mcp:tool:read_file&...
   → user authenticates and consents

5. MCP Client exchanges code for token
   POST https://auth.example.com/oauth/token
       grant_type=authorization_code&code=...&resource=https://mcp-server.example.com

6. MCP Client uses token with MCP Server
   MCP Server validates: signature (via JWKS), aud (matches own URL), scope, expiry

CLI Configuration Flags

--enable-dynamic-client-registration=false     # Enable RFC 7591 DCR
--dcr-require-initial-access-token=true        # Require token to register
--dcr-initial-access-token=                    # Pre-shared token for DCR
--enable-resource-indicators=true              # Enable RFC 8707
--allowed-resources=                           # Allowlist of resource URLs (empty = any)
--enforce-pkce=true                            # Require PKCE on all auth code flows
--enable-refresh-token-rotation=true           # Rotate refresh tokens

Migration Strategy

  1. Add /.well-known/oauth-authorization-server endpoint
  2. Enforce PKCE on all authorization code flows (breaking: clients without PKCE will fail)
  3. Deprecate implicit grant (response_type=token) with warning, then remove
  4. Add refresh token rotation (existing refresh tokens continue to work but get rotated on next use)
  5. Add DCR endpoint (disabled by default)
  6. Add resource indicator support to authorize/token endpoints

Breaking changes: Implicit grant removal and mandatory PKCE. Provide migration period with warnings.


Testing Plan

  • Test OAuth 2.1 compliance (PKCE mandatory, no implicit)
  • Test refresh token rotation and breach detection
  • Test DCR registration and client management
  • Test resource indicator audience restriction
  • Test MCP tool scope consent flow
  • Test AS metadata endpoint
  • Test backward compatibility with existing clients during migration

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions