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

https://exo.now/docs/adr/0010-exo-key-mcp-oauth

> Status: proposed · Date: 2026-10-08



# ADR 0010: Exo Key OAuth for third-party MCP clients (CIMD, DCR, resource indicators) [#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](0004-exo-id-central-identity-provider.md),
  [0009-exo-key-headless-sign-in.md](0009-exo-key-headless-sign-in.md)

## Context [#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 [#decision]

### Discovery [#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 [#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 [#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 [#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) [#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:

```json
{ "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 [#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 [#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 [#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.
