# OAuth Minimal OAuth 2.1 implementation for MCP client authentication. Supports Dynamic Client Registration (RFC 7591) and Authorization Code flow with PKCE (RFC 7636). --- ## Table of Contents 1. [Overview](#overview) 2. [Features](#features) 3. [Configuration](#configuration) 4. [Usage](#usage) 5. [API Reference](#api-reference) 6. [Internals](#internals) 7. [Troubleshooting](#troubleshooting) 8. [Related Documentation](#related-documentation) --- ## Overview `OAuth` provides a standards-compliant authentication flow for MCP clients (like Claude Code) to authenticate with the server without manual token generation. It implements the minimum subset of OAuth 2.1 needed for the MCP use case: 1. **Client registers itself** via Dynamic Client Registration 2. **Client redirects user** to `/authorize` for login and consent 3. **Server issues authorization code** and redirects back to client 4. **Client exchanges code for access token** via `/token` endpoint All state (clients, auth codes) is stored in a single ETS table (`:oauth_store`). This is transient — data is lost on restart, which is appropriate for dev/pre-alpha usage. ### Design Decisions - **ETS over SQLite** — OAuth clients and auth codes are short-lived and don't need persistence. ETS provides fast, concurrent access without disk I/O. - **Reuses existing bearer token encryption** — access tokens are encrypted using the same `Plug.Crypto.MessageEncryptor` mechanism as cookie-based auth, so the router's existing bearer token validation works without changes. - **No refresh tokens** — access tokens are long-lived (30 days). Simplifies the flow at the cost of requiring re-authentication after expiry. - **PKCE required when provided** — if the client sends a `code_challenge`, the server enforces verification. If omitted, PKCE is skipped (for simpler clients). - **OAuth metadata endpoint disabled** — `/.well-known/oauth-authorization-server` is implemented but commented out in the router. Claude Code auto-discovers it and bypasses the static Bearer token in `.mcp.json`, which breaks the current setup. ## Features | Feature | Description | |---------|-------------| | Dynamic Client Registration | RFC 7591 — clients register via `POST /register` (JSON) | | Authorization Code + PKCE | RFC 7636 — S256 code challenge method | | Server Metadata | RFC 8414 — discovery endpoint (currently disabled) | | Login + Consent Pages | Dark-themed HTML pages for browser-based authorization | | Token Generation | Encrypted bearer tokens compatible with existing auth | | Single-Use Auth Codes | Codes deleted after exchange, expire after 600 seconds | ## Configuration No module-level configuration. The module reads application config at runtime for token encryption: | Config Key | Source | Purpose | |------------|--------|---------| | `:secret_key_base` | `Application.get_env(:mcp, :secret_key_base)` | Key derivation for token encryption | | `:cookie_salt` | `Application.get_env(:mcp, :cookie_salt)` | Salt for key generation and encryption | ### Module Constants | Constant | Value | Description | |----------|-------|-------------| | `@table` | `:oauth_store` | ETS table name | | `@code_ttl_seconds` | `600` | Authorization code lifetime (10 minutes) | ## Usage ### Full OAuth Flow (MCP Client Perspective) ``` 1. Client → POST /register (JSON) → Gets client_id + client_secret 2. Client → GET /authorize?client_id=... → User sees login page 3. User → POST /authorize (credentials) → Server redirects with ?code=... 4. Client → POST /token (code + verifier) → Gets access_token 5. Client → MCP requests with Bearer token ``` ### Dynamic Client Registration ```elixir # Client sends JSON POST to /register Req.post("https://server:8443/register", json: %{ "client_name" => "My MCP Client", "redirect_uris" => ["http://localhost:3000/callback"] }, headers: [{"content-type", "application/json"}] ) #=> {:ok, %Req.Response{status: 201, body: %{ #=> "client_id" => "client_abc123...", #=> "client_secret" => "xyz789...", #=> "redirect_uris" => ["http://localhost:3000/callback"], #=> "client_name" => "My MCP Client", #=> "token_endpoint_auth_method" => "client_secret_post" #=> }}} ``` ### Authorization Code Exchange ```elixir # After user authorizes, client receives code in redirect URL callback # Client exchanges code for access token Req.post("https://server:8443/token", form: [ grant_type: "authorization_code", code: "code_abc123...", client_id: "client_abc123...", redirect_uri: "http://localhost:3000/callback", code_verifier: "original_verifier_string" ] ) #=> {:ok, %Req.Response{status: 200, body: %{ #=> "access_token" => "encrypted_bearer_token...", #=> "token_type" => "bearer", #=> "expires_in" => 2592000 #=> }}} ``` ### Direct API Usage (IEx) ```elixir # Register a client client = OAuth.register_client(%{ "client_name" => "Test Client", "redirect_uris" => ["http://localhost:3000/callback"] }) #=> %{client_id: "client_...", client_secret: "...", ...} # Look up a client OAuth.get_client(client.client_id) #=> {:ok, %{client_secret: "...", client_name: "Test Client", ...}} # Create an auth code (normally done by router after login) code = OAuth.create_auth_code(client.client_id, 1, "http://localhost:3000/callback", "challenge") #=> "code_..." # Exchange code for user_id (normally done by router at /token) OAuth.exchange_code(code, client.client_id, "http://localhost:3000/callback", "verifier") #=> {:ok, 1} or {:error, "invalid_code_verifier"} # Generate a bearer token for a user token = OAuth.generate_access_token(1) #=> "encrypted_string..." # Get server metadata OAuth.metadata("https://server:8443") #=> %{issuer: "https://server:8443", authorization_endpoint: "...", ...} ``` ## API Reference ### `metadata/1` Returns OAuth 2.1 Authorization Server Metadata (RFC 8414). **Parameters:** - `base_url` (string) — the server's base URL (e.g., `"https://server:8443"`) **Returns:** map with standard OAuth metadata fields ```elixir OAuth.metadata("https://server:8443") #=> %{ #=> issuer: "https://server:8443", #=> authorization_endpoint: "https://server:8443/authorize", #=> token_endpoint: "https://server:8443/token", #=> registration_endpoint: "https://server:8443/register", #=> response_types_supported: ["code"], #=> grant_types_supported: ["authorization_code"], #=> code_challenge_methods_supported: ["S256"], #=> token_endpoint_auth_methods_supported: ["client_secret_post"] #=> } ``` --- ### `register_client/1` Registers a new OAuth client via Dynamic Client Registration (RFC 7591). Generates a unique `client_id` and `client_secret`, stores client data in ETS. **Parameters:** - `params` (map) — client registration request - `"client_name"` (string, optional) — human-readable name. Default: `"MCP Client"` - `"redirect_uris"` (list of strings, optional) — allowed redirect URIs. Default: `[]` **Returns:** map with client credentials ```elixir OAuth.register_client(%{ "client_name" => "Claude Code", "redirect_uris" => ["http://127.0.0.1:9876/callback"] }) #=> %{ #=> client_id: "client_abc123...", #=> client_secret: "xyz789...", #=> redirect_uris: ["http://127.0.0.1:9876/callback"], #=> client_name: "Claude Code", #=> token_endpoint_auth_method: "client_secret_post" #=> } ``` --- ### `get_client/1` Looks up a registered client by ID. **Parameters:** - `client_id` (string) — the client identifier **Returns:** `{:ok, client_data}` or `{:error, :not_found}` ```elixir OAuth.get_client("client_abc123...") #=> {:ok, %{ #=> client_secret: "xyz789...", #=> redirect_uris: ["http://127.0.0.1:9876/callback"], #=> client_name: "Claude Code", #=> created_at: 1740643200 #=> }} OAuth.get_client("nonexistent") #=> {:error, :not_found} ``` --- ### `create_auth_code/4` Creates a single-use authorization code after user login and consent. The code expires after 600 seconds. **Parameters:** - `client_id` (string) — the requesting client's ID - `user_id` (integer) — the authenticated user's ID - `redirect_uri` (string) — must match during code exchange - `code_challenge` (string or nil, optional) — PKCE S256 challenge. Default: `nil` **Returns:** string authorization code ```elixir code = OAuth.create_auth_code("client_abc...", 1, "http://localhost:3000/callback") #=> "code_def456..." # With PKCE challenge challenge = :crypto.hash(:sha256, "my_verifier") |> Base.url_encode64(padding: false) code = OAuth.create_auth_code("client_abc...", 1, "http://localhost:3000/callback", challenge) #=> "code_ghi789..." ``` --- ### `exchange_code/4` Exchanges an authorization code for a user ID. The code is deleted after use (single-use). Validates expiry, client_id, redirect_uri, and PKCE verifier. **Parameters:** - `code` (string) — the authorization code - `client_id` (string) — must match the client that requested the code - `redirect_uri` (string) — must match the URI used when creating the code - `code_verifier` (string or nil, optional) — PKCE verifier. Required if code was created with a challenge. Default: `nil` **Returns:** `{:ok, user_id}` or `{:error, reason}` | Error | Cause | |-------|-------| | `"invalid_code"` | Code not found (already used or never existed) | | `"expired_code"` | Code older than 600 seconds | | `"invalid_client"` | `client_id` doesn't match | | `"invalid_redirect_uri"` | `redirect_uri` doesn't match | | `"invalid_code_verifier"` | PKCE verification failed | ```elixir OAuth.exchange_code("code_def456...", "client_abc...", "http://localhost:3000/callback") #=> {:ok, 1} # Code already used (single-use) OAuth.exchange_code("code_def456...", "client_abc...", "http://localhost:3000/callback") #=> {:error, "invalid_code"} # With PKCE OAuth.exchange_code("code_ghi789...", "client_abc...", "http://localhost:3000/callback", "my_verifier") #=> {:ok, 1} ``` --- ### `generate_access_token/1` Generates an encrypted bearer token for a user. Uses the same encryption as cookie-based authentication, so the router's existing bearer token validation works without changes. **Parameters:** - `user_id` (integer) — the user to generate a token for **Returns:** encrypted token string (used as `Authorization: Bearer `) ```elixir token = OAuth.generate_access_token(1) #=> "SFMyNTY.g2gDaAJhAW4IAOr..." # Token is compatible with existing bearer auth in the router # Client uses: Authorization: Bearer ``` > **Note:** Tokens are long-lived (30 days, set by the router's `/token` response). There is no refresh token mechanism. --- ### `render_login_page/3` Renders the HTML login page shown when an unauthenticated user hits `/authorize`. **Parameters:** - `client_name` (string) — displayed to the user as the requesting application - `params` (map) — OAuth parameters carried through the form as hidden fields (`client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method`) - `error` (string or nil, optional) — error message to display. Default: `nil` **Returns:** HTML string ```elixir OAuth.render_login_page("Claude Code", %{"client_id" => "abc", "redirect_uri" => "http://..."}) #=> "..." # With error OAuth.render_login_page("Claude Code", params, "Invalid email or password") #=> "..." # includes red error banner ``` --- ### `render_consent_page/2` Renders the HTML consent page shown when an authenticated user hits `/authorize`. Displays "Allow Access" and "Deny" buttons. **Parameters:** - `client_name` (string) — displayed to the user as the requesting application - `params` (map) — OAuth parameters carried through forms as hidden fields **Returns:** HTML string ```elixir OAuth.render_consent_page("Claude Code", %{"client_id" => "abc", "redirect_uri" => "http://..."}) #=> "..." ``` ## Internals ### ETS Table Structure The `:oauth_store` table uses composite tuple keys to store different entity types in a single table: | Key Pattern | Value | Description | |-------------|-------|-------------| | `{:client, client_id}` | `%{client_secret, redirect_uris, client_name, created_at}` | Registered OAuth client | | `{:code, code}` | `%{client_id, user_id, redirect_uri, code_challenge, expires_at}` | Authorization code (single-use) | The table is created lazily by `ensure_table/0` on first access — `:named_table`, `:set`, `:public`. ### Authorization Flow ``` Client Server User │ │ │ ├── POST /register (JSON) ─────►│ │ │◄── 201 {client_id, secret} ──┤ │ │ │ │ ├── GET /authorize?... ────────►│ │ │ ├── render_login_page() ───────►│ │ │◄── POST /authorize (creds) ──┤ │ │ authenticate_by_email() │ │ │ create_auth_code() │ │◄── 302 ?code=...&state=... ──┤ │ │ │ │ ├── POST /token ───────────────►│ │ │ {code, client_id, │ exchange_code() │ │ redirect_uri, verifier} │ generate_access_token() │ │◄── 200 {access_token} ───────┤ │ │ │ │ ├── MCP request ───────────────►│ │ │ Authorization: Bearer ... │ (standard bearer auth) │ ``` ### PKCE Verification When a `code_challenge` is provided during authorization: 1. Client generates a random `code_verifier` string 2. Client computes `code_challenge = BASE64URL(SHA256(code_verifier))` 3. Client sends `code_challenge` with the `/authorize` request 4. Server stores `code_challenge` with the auth code 5. Client sends `code_verifier` with the `/token` request 6. Server computes `SHA256(code_verifier)`, base64url-encodes it, and compares with stored challenge using `Plug.Crypto.secure_compare/2` (constant-time comparison) ### Token Encryption Access tokens are generated using the same mechanism as cookie-based auth: ```elixir secret = Plug.Crypto.KeyGenerator.generate(secret_key_base, cookie_salt, length: 32) token = Plug.Crypto.MessageEncryptor.encrypt(to_string(user_id), secret, cookie_salt) ``` This means OAuth-issued tokens are indistinguishable from manually generated Bearer tokens. The router's `try_bearer_auth/1` function decrypts both the same way. ### ID Generation Client IDs and auth codes are prefixed random strings: ```elixir # client_abc123... (24 bytes of crypto-random, base64url-encoded) # code_def456... :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false) ``` Client secrets use 32 bytes of randomness (256 bits of entropy). ## Troubleshooting | Problem | Cause | Fix | |---------|-------|-----| | `{:error, "invalid_code"}` | Code already used or never existed | Auth codes are single-use; request a new one | | `{:error, "expired_code"}` | Code older than 10 minutes | Complete the flow within 600 seconds | | `{:error, "invalid_code_verifier"}` | PKCE challenge/verifier mismatch | Ensure verifier matches the challenge sent to `/authorize` | | Clients lost after restart | ETS is in-memory only | Expected behavior; clients must re-register | | Claude Code bypasses static Bearer token | It auto-discovers `/.well-known/oauth-authorization-server` | Metadata endpoint is disabled for this reason | | `ArgumentError` on ETS access | Race condition during table creation | Handled by `rescue` in `ensure_table/0` | ## Related Documentation - [Authentication](AUTHENTICATION.md) — cookie and Bearer token auth in the router - [Access Control](access_control.md) — permission system that gates MCP tools - [Application](APPLICATION.md) — supervision tree and module overview --- *Source: `lib/oauth.ex` — Last updated: 2026-02-27*