exoDocs
Decisions (ADRs)

Decisions and roadmap

ADR 0010: Exo Key OAuth for third-party MCP clients (CIMD, DCR, resource indicators)

Context

Construct (https://construct.page/mcp, staging https://staging.construct.page/mcp) and Exo (<exo convex site>/mcp) are remote MCP servers. Users connect them from their own agents: Claude (web/Desktop), Claude Code, ChatGPT, Codex, Hermes, OpenCode, Cursor, VS Code, Muse. Until now an agent could only connect with a pasted Exo Key. Every one of those clients implements MCP authorization (OAuth 2.1): it reads the server's protected-resource metadata (RFC 9728), discovers the authorization server (RFC 8414), identifies itself with a Client ID Metadata Document (CIMD) or Dynamic Client Registration (RFC 7591), and runs an authorization code + PKCE flow with an RFC 8707 resource. Exo Key stays the ONLY authorization server for all apps (ADR 0004); the issuer string never changes.

Client facts this design follows (fetched 2026-10-08):

ClientIdentificationRedirectToken auth
Claude (web)CIMD if client_id_metadata_document_supported and none are advertised, else DCRhttps://claude.ai/api/mcp/auth_callbacknone
Claude CodeCIMD https://claude.ai/oauth/claude-code-client-metadata, else DCRhttp://localhost/callback, http://127.0.0.1/callback, any portnone
ChatGPTCIMD https://chatgpt.com/oauth/client.json, else DCRhttps://chatgpt.com/connector_platform_oauth_redirect (with RFC 9207 iss)private_key_jwt (jwks https://chatgpt.com/oauth/jwks.json) or none
CodexCIMD https://chatgpt.com/oauth/codex/client.json, else DCRhttp://127.0.0.1/callback, http://localhost/callback, any portnone
HermesCIMD https://nousresearch.github.io/hermes-agent/docs/oauth/client-metadata.json, else DCR; device flowloopback ports 27890-27894none
OpenCode, Cursor, VS Code, Muse CodeDCRloopback / https://vscode.dev/redirect / https://www.cursor.com/...none

Decision

Discovery

One metadata document is served as RFC 8414 AS metadata and OIDC discovery, byte-identical at:

  • <issuer>/.well-known/oauth-authorization-server and <issuer>/.well-known/openid-configuration
  • the RFC 8414 §3.1 path-inserted forms <origin>/.well-known/oauth-authorization-server/exo-id and <origin>/.well-known/openid-configuration/exo-id
  • the same four under the /exo-key endpoint prefix.

It advertises issuer (<site>/exo-id), authorization_endpoint (EXO_ID_AUTHORIZE_URL, the frontend /id/authorize), token_endpoint, registration_endpoint, revocation_endpoint, jwks_uri, device_authorization_endpoint, response_types_supported [code], grant_types_supported [authorization_code, refresh_token, device_code, token-exchange], code_challenge_methods_supported [S256], token_endpoint_auth_methods_supported [none, private_key_jwt], client_id_metadata_document_supported: true, authorization_response_iss_parameter_supported: true, scopes_supported [read, write, ai, usage:read, offline_access]. Client secrets are not offered: every MCP client above is public or uses private_key_jwt.

Clients

First-party registry apps (convex/lib/exoIdApps.ts, app=) are unchanged. Third-party clients send client_id instead of app:

  • DCR POST {prefix}/v1/register: open registration of public clients (token_endpoint_auth_method absent or none; secrets rejected), any client_name, 1-20 redirect URIs (https; loopback http://127.0.0.1|localhost|[::1]; private-use schemes such as cursor://; never javascript:/data:/file:, never fragments). Unsupported grant types are dropped (RFC 7591 §3.2.1). Rows live in exoKeyOAuthClients (exo_dcr_<random>). Rate-limited per client IP (rightmost X-Forwarded-For); clients idle 30 days are deleted by a daily cron.
  • CIMD: a client_id that is an https URL is fetched server-side and must contain client_id equal to the URL. SSRF guard: https only, default port, real DNS name (IP literals, localhost, single-label and internal suffixes refused), no query/fragment/userinfo/dot segments, redirects not followed, 3 s timeout, 10 KB streamed cap, JSON only, global and per-host fetch rate limits. Results (and failures, for 60 s) are cached 1 h in exoKeyOAuthFetchCache. Client secrets in a document are refused. Allowed token auth methods = the intersection of token_endpoint_auth_method + token_endpoint_auth_methods_supported (SEP-3149) with none/private_key_jwt.
  • private_key_jwt (RFC 7523): the assertion is verified against the document's jwks_uri (must share the client_id origin) or inline jwks; RS256/ES256; iss = sub = client_id; aud = the token endpoint (either prefix) or the issuer; exp and jti required, lifetime at most 10 minutes, jti single-use (exoKeyAssertionJtis). Unknown kid refetches the key set once. A presented assertion must verify; without one the client must allow none.

DNS resolution is not available in the Convex runtime, so a public name pointing at a private address is not detected; the remaining controls (https certificate for the name, no redirects, small budget, caching) bound what such a fetch can do. Revisit if Convex exposes resolution.

Redirect URIs

Exact string match, except loopback http redirects, which match on any port (RFC 8252 §7.3); host, path and query must still match, so localhost and 127.0.0.1 are separate entries (every client lists both).

Authorization endpoint

/id/authorize?response_type=code&client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&state=…&scope=…&resource=…

exoKeyOAuth.describeAuthorize (public action) resolves the client and checks the redirect. An unknown client or unregistered redirect renders an error page and never redirects. Everything else that is wrong (response_type, missing/plain PKCE, missing or unknown resource) redirects to the client with error, state and iss. The consent screen shows the client name (and CIMD host), the redirect host, the scopes and the resource (Construct · staging.construct.page). A person without a session signs in (or signs up) in place. exoKeyOAuth.decideAuthorize (session-gated, rate-limited per user) re-validates, then on approve stores a 60 s single-use code in exoIdCodes bound to client, redirect URI, PKCE challenge, scope, resource and user, and redirects with code, state, iss (RFC 9207); on deny, error=access_denied + iss.

Scopes: unknown values (e.g. openid) are dropped; no recognised scope grants read write offline_access. Refresh tokens are always issued.

Resources and audience (RFC 8707)

EXO_PROTECTED_RESOURCES (JSON {"<url>": {"app": "construct", "label": "Construct"}}) lists the protected resources; defaults are https://staging.construct.page/mcp and https://construct.page/mcp (app construct). Exo's own <site>/mcp (app exo) is always included. Resources compare as exact strings. Unknown resources are invalid_target at authorize and token time.

Access tokens for third-party clients carry:

{ "iss": "<site>/exo-id", "sub": "<exo user id>", "aud": ["<resource>", "<app>"],
  "client_id": "<cimd url | exo_dcr_…>", "scope": "read write offline_access",
  "email": "…", "email_verified": true, "name": "…", "iat": 0, "exp": 0 }

RS256, kid from the Exo Key JWKS, 15 minutes. The resource gives MCP servers RFC 8707 audience binding; the app id keeps Convex customJwt providers (applicationID = <app>) working, since Convex accepts any aud array that contains the application ID (convex-backend crates/authentication). A token for one resource is refused by the other resource server (Exo's MCP verifies aud ∈ {exo, <site>/mcp}; Construct's MCP must require its own resource URL).

Token endpoint

POST {prefix}/v1/token with client_id (and no app), form-urlencoded or JSON:

  • authorization_code: code, redirect_uri (exact match with the authorize request), code_verifier (PKCE S256), optional resource (must equal the code's). Any attempt consumes the code. First-party app= requests can never redeem a client's code, and vice versa.
  • refresh_token: rotation with reuse detection exactly as ADR 0004/#165 (families, 30 s grace, family revoke on reuse); the session is bound to clientId + resource, which every successor copies. Another client, a first-party app, or a different resource cannot use it. scope may narrow, never widen.
  • Response: access_token, token_type, expires_in, refresh_token, scope (no user).
  • Errors are RFC 6749 JSON (invalid_client is 401 with WWW-Authenticate).

POST {prefix}/v1/revoke accepts RFC 7009 token (+ client_id) and revokes the whole family.

Exo MCP

convex/mcpHttp.ts accepts access tokens whose aud contains exo or <site>/mcp; its protected-resource metadata resource is exactly <site>/mcp and authorization_servers is [<issuer>].

Consequences

  • Every listed agent can show a "sign in" button for Exo and Construct with no pasted key.
  • Construct's MCP must serve RFC 9728 metadata pointing at the Exo issuer and require aud ∋ <its resource URL> on MCP requests (its Convex customJwt provider keeps working).
  • New tables: exoKeyOAuthClients, exoKeyOAuthFetchCache, exoKeyAssertionJtis; new optional fields on exoIdCodes and exoIdSessions (clientId, resource, scope).
  • Follow-ups: a "Connected agents" list with per-client revoke on /id/keys; ID tokens / id_token_hint for ChatGPT; DNS-resolution SSRF check if the runtime allows it.

Source: docs/adr/0010-exo-key-mcp-oauth.md

On this page