Decisions and roadmap
ADR 0010: Exo Key OAuth for third-party MCP clients (CIMD, DCR, resource indicators)
- Status: proposed
- Date: 2026-10-08
- Extends: 0004-exo-id-central-identity-provider.md, 0009-exo-key-headless-sign-in.md
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):
| Client | Identification | Redirect | Token auth |
|---|---|---|---|
| Claude (web) | CIMD if client_id_metadata_document_supported and none are advertised, else DCR | https://claude.ai/api/mcp/auth_callback | none |
| Claude Code | CIMD https://claude.ai/oauth/claude-code-client-metadata, else DCR | http://localhost/callback, http://127.0.0.1/callback, any port | none |
| ChatGPT | CIMD https://chatgpt.com/oauth/client.json, else DCR | https://chatgpt.com/connector_platform_oauth_redirect (with RFC 9207 iss) | private_key_jwt (jwks https://chatgpt.com/oauth/jwks.json) or none |
| Codex | CIMD https://chatgpt.com/oauth/codex/client.json, else DCR | http://127.0.0.1/callback, http://localhost/callback, any port | none |
| Hermes | CIMD https://nousresearch.github.io/hermes-agent/docs/oauth/client-metadata.json, else DCR; device flow | loopback ports 27890-27894 | none |
| OpenCode, Cursor, VS Code, Muse Code | DCR | loopback / 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-serverand<issuer>/.well-known/openid-configuration- the RFC 8414 §3.1 path-inserted forms
<origin>/.well-known/oauth-authorization-server/exo-idand<origin>/.well-known/openid-configuration/exo-id - the same four under the
/exo-keyendpoint 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_methodabsent ornone; secrets rejected), anyclient_name, 1-20 redirect URIs (https; loopbackhttp://127.0.0.1|localhost|[::1]; private-use schemes such ascursor://; neverjavascript:/data:/file:, never fragments). Unsupported grant types are dropped (RFC 7591 §3.2.1). Rows live inexoKeyOAuthClients(exo_dcr_<random>). Rate-limited per client IP (rightmostX-Forwarded-For); clients idle 30 days are deleted by a daily cron. - CIMD: a
client_idthat is an https URL is fetched server-side and must containclient_idequal 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 inexoKeyOAuthFetchCache. Client secrets in a document are refused. Allowed token auth methods = the intersection oftoken_endpoint_auth_method+token_endpoint_auth_methods_supported(SEP-3149) withnone/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 inlinejwks; RS256/ES256;iss = sub = client_id;aud= the token endpoint (either prefix) or the issuer;expandjtirequired, lifetime at most 10 minutes,jtisingle-use (exoKeyAssertionJtis). Unknownkidrefetches the key set once. A presented assertion must verify; without one the client must allownone.
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), optionalresource(must equal the code's). Any attempt consumes the code. First-partyapp=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 toclientId+resource, which every successor copies. Another client, a first-partyapp, or a differentresourcecannot use it.scopemay narrow, never widen.- Response:
access_token,token_type,expires_in,refresh_token,scope(nouser). - Errors are RFC 6749 JSON (
invalid_clientis 401 withWWW-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 ConvexcustomJwtprovider keeps working). - New tables:
exoKeyOAuthClients,exoKeyOAuthFetchCache,exoKeyAssertionJtis; new optional fields onexoIdCodesandexoIdSessions(clientId,resource,scope). - Follow-ups: a "Connected agents" list with per-client revoke on
/id/keys; ID tokens /id_token_hintfor ChatGPT; DNS-resolution SSRF check if the runtime allows it.