Phoenix OAuth2 Authorization Server#
Overview#
Phoenix v19.0.0 adds a built-in OAuth2 authorization server — Phoenix now issues tokens to clients, not just delegates to external identity providers . The primary consumers are the Phoenix CLI (px auth login) and MCP clients, which can now obtain bearer tokens through a standards-compliant flow instead of manually provisioned API keys.
RFCs implemented:
| RFC | Role |
|---|---|
| RFC 8414 | Authorization Server Metadata (discovery) |
| RFC 7009 | Token Revocation |
| RFC 7591 | Dynamic Client Registration (DCR) |
| RFC 7636 | PKCE (S256 only) |
| RFC 9728 | Protected Resource Metadata |
| RFC 8707 | Resource Indicators |
This feature is separate from Phoenix's existing relying-party OAuth2 (external IdP sign-in via PHOENIX_OAUTH2_* env vars). Both coexist: the existing IdP login lives in src/phoenix/server/api/routers/oauth2.py and src/phoenix/server/oauth2.py; the new authorization server lives in src/phoenix/server/api/routers/oauth2_authorization_server.py and src/phoenix/server/oauth2_authorization_server.py .
Endpoints#
All endpoints are mounted only when PHOENIX_ENABLE_OAUTH2_AUTHORIZATION_SERVER=true (default). A disabled server returns 404 rather than falling through to the SPA, which is the signal the CLI uses to detect "no OAuth support" .
| Method | Path | Purpose |
|---|---|---|
GET | /.well-known/oauth-authorization-server | RFC 8414 metadata |
GET | /.well-known/openid-configuration | Same metadata at OIDC discovery location for MCP client compatibility |
GET | /oauth2/authorize | Validates client + redirect URI; redirects authenticated users to consent page |
POST | /oauth2/authorize/decision | Processes consent approval; mints authorization code |
POST | /oauth2/token | Exchanges code or refresh token for access + refresh tokens |
POST | /oauth2/revoke | Revokes either token of a pair; ends entire grant; always 200 per RFC 7009 |
POST | /oauth2/register | Dynamic client registration (RFC 7591) |
Metadata document advertises: response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], and registration_endpoint when DCR is enabled .
Rate limits: /oauth2/token and /oauth2/revoke share a limiter (0.2 req/s per IP, 60-second window). /oauth2/register has its own limiter configurable via PHOENIX_OAUTH2_DCR_RATE_LIMIT_PER_HOUR (default 10/hour) .
Authorization Code + PKCE Flow#
Client → GET /oauth2/authorize?client_id=…&code_challenge=…&code_challenge_method=S256&…
← 302 → consent page (SPA)
User approves consent
Client → POST /oauth2/authorize/decision {approved: true, code_challenge, …}
← {redirect_to: "…?code=…"}
Client → POST /oauth2/token {grant_type=authorization_code, code=…, code_verifier=…}
← {access_token, refresh_token, expires_in}
PKCE: S256 only — no plain support . The verifier pattern enforces ^[A-Za-z0-9._~-]{43,128}$. The S256 challenge is computed as base64url(sha256(verifier)) and verified with hmac.compare_digest to prevent timing attacks .
Authorization code security: Codes are stored as SHA-256 hashes (hash_authorization_code) so a DB leak yields no redeemable codes . Codes expire in 5 minutes and are atomically claimed via conditional DELETE … RETURNING; concurrent redemptions have exactly one winner .
Grants are database rows (oauth2_grants) created at code redemption, not at browser approval — abandoned consent flows leave no grant behind . Grant lifetime defaults to 90 days (PHOENIX_OAUTH2_GRANT_EXPIRY_DAYS); both access and refresh token lifetimes are clamped to the grant's remaining expiry at mint time .
Refresh token rotation + replay detection: Spending a refresh token mints a new token pair and marks the old refresh token as consumed (not deleted). Presenting a consumed token signals that two parties hold it, triggering full grant revocation per RFC 9700 §4.14.2 .
Public clients only: token_endpoint_auth_method: none across all clients — no client secrets or client_credentials grant .
Resource indicators (RFC 8707): The resource parameter is accepted at /authorize and /token, persisted through the authorization code and grant, and embedded in token claims. This allows per-resource enforcement to be added later without a schema migration .
Dynamic Client Registration#
POST /oauth2/register (RFC 7591) is governed by a three-position policy dial: PHOENIX_OAUTH2_DYNAMIC_CLIENT_REGISTRATION = disabled | local_only | enabled (default: enabled for out-of-the-box MCP client compatibility) .
Redirect URI classes — validated differently per dial position :
| Class | Scheme | Match rule | Enabled when |
|---|---|---|---|
| Loopback | http://127.0.0.1, ::1, localhost | Port-flexible; path + host must match | local_only or enabled |
| Private-use scheme | cursor://, vscode://, etc. | Exact URI match; deny-set excludes javascript:, data:, file:, etc. | local_only or enabled |
| HTTPS registered | https://… | Exact URI match; optional host allowlist | enabled only |
HTTPS redirect hosts can be further pinned with PHOENIX_OAUTH2_ALLOWED_REDIRECT_HOSTS (comma-separated, unset = all hosts) .
Abuse controls: Cap of 50 unconsumed clients per IP per day; rate limited by PHOENIX_OAUTH2_DCR_RATE_LIMIT_PER_HOUR (default 10/hour). Dynamically registered client IDs are prefixed px_dcr_ . Abandoned DCR clients (no grant ever created) are cleaned up automatically .
Key Files and Configuration#
Primary Implementation Files#
| File | Description |
|---|---|
src/phoenix/server/oauth2_authorization_server.py | Auth-server helpers: PKCE (verify_pkce, create_code_challenge, hash_authorization_code), redirect URI validation (validate_redirect_uri, RedirectUriKind, RedirectUriDialPosition), canonical resource identifier |
src/phoenix/server/api/routers/oauth2_authorization_server.py | Route handlers for all 7 endpoints: authorize, authorize/decision, token, revoke, register, and both well-known discovery routes |
src/phoenix/server/api/routers/oauth2.py | Existing relying-party login (external IdP callbacks) — separate from authorization server |
src/phoenix/server/oauth2.py | OAuth2Client / OAuth2Clients for external IdP configuration |
src/phoenix/server/api/types/OAuth2Grant.py | GraphQL OAuth2Grant type |
src/phoenix/server/api/mutations/oauth2_grant_mutations.py | GraphQL mutations for grant management |
Environment Variables#
| Variable | Default | Effect |
|---|---|---|
PHOENIX_ENABLE_OAUTH2_AUTHORIZATION_SERVER | true | Enables/disables the entire authorization server |
PHOENIX_OAUTH2_GRANT_EXPIRY_DAYS | 90 | Hard grant lifetime ceiling; tokens cannot outlive the grant |
PHOENIX_OAUTH2_DYNAMIC_CLIENT_REGISTRATION | enabled | DCR policy dial: disabled / local_only / enabled |
PHOENIX_OAUTH2_ALLOWED_REDIRECT_HOSTS | (unset) | Comma-separated HTTPS redirect host allowlist for DCR |
PHOENIX_OAUTH2_CONSENT_ORIGIN_CHECK | strict | Origin-header CSRF check on consent decisions; set off only if a proxy strips the header |
PHOENIX_OAUTH2_DCR_RATE_LIMIT_PER_HOUR | 10 | Max DCR registrations per IP per hour |
Database#
Migration 132d988c5bef_add_oauth2_authorization_server_tables adds the oauth2_clients, oauth2_authorization_codes, and oauth2_grants tables .
CLI and MCP Integration#
px auth login (Authorization Code + PKCE)#
The CLI implements the full authorization code + PKCE flow :
- Binds an ephemeral
http://127.0.0.1:<port>/callbacklistener to receive the authorization code. - Opens the authorization URL in a browser; falls back to printing the URL for
--no-browser/ SSH use. - Validates state (minimum 22 characters) to prevent CSRF.
- Persists access and refresh tokens into the active profile under a settings lock.
- Subsequent commands silently refresh when within 60 seconds of expiry, with rotation awareness.
px auth logout revokes the refresh token (ending the whole grant). px auth status reports the authenticated user and token expiry .
The CLI pre-flights /.well-known/oauth-authorization-server before starting the browser flow, failing fast if the server has the authorization server disabled .
MCP Server#
The MCP server mounts at /mcp when PHOENIX_ENABLE_MCP_SERVER=true (default). It uses Phoenix bearer-token authentication and serves as the reference end-to-end proof of the OAuth2 chain . Clients (e.g., Cursor) register via DCR at /oauth2/register, then complete the authorization code + PKCE flow to obtain tokens scoped to the /mcp resource indicator .