--- title: "ADR 0019 — Hosted OAuth for remote MCP clients" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:41:01.967Z" --- # ADR 0019 — Hosted OAuth for remote MCP clients **Status** Accepted · **Date** 2026-09-16 · **Supersedes** ADR 0002 **Relates to** - ADR 0006 (rate limits), ADR 0009 (reach), ADR 0010 (a record that a person can read), ADR 0013 (one Worker for all orgs) - `apps/worker/src/routes/oauth.ts`, `apps/worker/src/services/oauth.ts`, `apps/worker/src/storage/oauth.ts` - `packages/db/migrations/0020_oauth.sql`, `packages/db/migrations/0021_oauth_consent.sql` ## Context ADR 0002 chose bearer tokens and rejected OAuth. It gave two reasons. OAuth costs a week of work and a permanent surface for bugs. And it gives nothing for adoption, because every client then accepted a bearer token. The second reason is no longer true. - ChatGPT cannot send a custom API key. Its developer mode connects to a remote MCP server with OAuth, or with no authentication. - Claude connects a custom connector with OAuth. The connector callback is `https://claude.ai/api/mcp/auth_callback` on web, desktop and mobile. A static request header is an administrator-only beta, and a connector cannot use it together with OAuth. - Both clients find the authorization server from the MCP server's `401` (RFC 9728). Both register themselves with dynamic client registration (RFC 7591), because kmem does not offer a client ID metadata document. A person on ChatGPT or Claude had no way to connect kmem except Quick Connect. The owner asked for sign-in. The first server (PR #3) had the parts, and a green suite, and it could not finish in a real browser. A review on 2026-09-16 found four defects, and each one alone stopped the flow: 1. The server sent the consent page with `Referrer-Policy: no-referrer`. Under that policy a browser posts a form with `Origin: null` and no `Referer`. The CSRF check refused "Allow connection" with a `403`. The test set `Origin` by hand. 2. The page had the global CSP `form-action 'self'`. Chromium and Safari apply `form-action` to the redirects that follow a form post, so the browser stopped at the `302` to `claude.ai` or `chatgpt.com`. 3. Registration refused `client_secret_post` and any grant type other than `authorization_code`. Claude sends both, and ChatGPT's published metadata names `refresh_token`. 4. The authorize step accepted one scope word. Claude and the MCP SDK send `scope=read suggest`. The suite also ran on the self-hosted host with an organization whose id was `default`. Production refuses that state, so no test covered the path the owner uses. ## Decision kmem is an OAuth 2.1 authorization server for its own MCP endpoint, on each org's own host. A token that OAuth issues is an ordinary `session_tokens` row with an `oauth_grants` row next to it. Connect lists it, a person revokes it, and the audit log records it like any other connection. ### 1. Hosted only OAuth answers only on an org's own host, such as `https://acme.kmem.app`. On the apex and on a self-hosted instance, every OAuth path answers `404` and writes nothing. Those hosts resolve to the self-hosted default project, which has no organization and no membership to consent with. `servesHostedOAuth` in `services/oauth.ts` is the one rule, and the MCP router and the OAuth routes both read it. `http/static.ts` reserves `/oauth` and `/.well-known`, so the app shell never answers there. ### 2. Discovery - `/.well-known/oauth-protected-resource` and `/.well-known/oauth-protected-resource/mcp` give the resource `${origin}/mcp` and the authorization server `${origin}`. - `/.well-known/oauth-authorization-server` gives RFC 8414 metadata. It lists `grant_types_supported: ["authorization_code"]`, `token_endpoint_auth_methods_supported: ["none"]`, S256 only, `scopes_supported: ["read", "suggest"]` and `authorization_response_iss_parameter_supported: true`. - The MCP `401` is one challenge with three parameters: `Bearer realm="kmem", resource_metadata="…/.well-known/oauth-protected-resource/mcp", scope="read suggest"`. A `401` for an invalid token also carries `error="invalid_token"`. - The `403` for a missing scope keeps `error="insufficient_scope"` and `scope="suggest"`, and adds `resource_metadata`, so a client can step up. - On a host that does not serve OAuth, the challenge is `Bearer realm="kmem"` only. - Where OAuth answers, the body of the scope `403` adds one sentence to `fix`: a client that connected through sign-in connects again and chooses "Read and propose". The refusal otherwise tells a person to create a connection in Connect, which does not help ChatGPT or Claude. - On the apex, the `401` body adds one sentence to `fix`: use the org's own MCP endpoint, which has the form `https://your-org.kmem.app/mcp`. No org lives on the apex, so no token and no sign-in works there. A self-hosted instance has no apex, and its `401` stays as it was. - The OAuth `404` on those hosts gives the same sentence on a hosted deployment. On a self-hosted instance it says that the instance has no sign-in for MCP clients, and names a token from Connect. `signInAddressFix` in `services/oauth.ts` is the one place for both sentences. ### 3. Registration - Registration takes no credential. It accepts any `grant_types`, `response_types` and `token_endpoint_auth_method`, as RFC 7591 §3.2.1 allows. It registers every client as public and answers with what it stored: `token_endpoint_auth_method: "none"`, `grant_types: ["authorization_code"]` and `response_types: ["code"]`. - `redirect_uris` is strict: 1 to 5 entries, each at most 512 characters, HTTPS or plain HTTP to `localhost`, `127.0.0.1` or `[::1]`, with no fragment and no user information. - The route keeps the first 60 characters of `client_name` and puts them through `redactSecrets` before insert. - Registration has its own limiter bucket, keyed by org and client address. The token exchange and the consent page share a second bucket, also keyed by org and client address. One anonymous caller can no longer use up an org's budget. - Registration never refuses a client because of a count of unused clients. The first version did: at 200 clients with no grant it answered `429`. One anonymous address could fill that in about four minutes and lock every member out of ChatGPT and Claude until the nightly sweep. Now each client row records its registrant: a SHA-256 digest of the address it came from, never the address. For an IPv6 address the digest covers only the /64. Before the insert, in the same D1 batch: - when the registrant holds 20 unused clients in the org, its own oldest unused client goes; - when the org holds 200 unused clients, the org's oldest unused client goes. - "Unused" means no grant and no live code. A client whose person pressed Allow a moment ago is about to connect, so it stays. One address can hold 20 slots and cannot push out anybody else's client. A caller with at least ten addresses can push out the oldest unused clients of other registrants. That costs a person a retry, not a day. The route writes `oauth_unused_clients_removed` at `warn` with the count, so a flood shows in the logs. - Claude and ChatGPT register from their own servers, so all members of an org share the same small set of registrants. A refusal per registrant would lock out such an org. The registrant limit removes their oldest abandoned attempts instead. ### 4. Consent - The consent page sends `Referrer-Policy: same-origin` and carries ``. The browser then posts the real origin to this origin only. The `302` that follows keeps `no-referrer`. - The page's CSP has `form-action 'self'` plus the origin of the one callback that this request validated (`admitFormTarget` in `http/security.ts`). The route admits the origin only after it matched the callback against the registration and checked the request. Every other page keeps `form-action 'self'`. - When the callback is a plain-HTTP loopback address, the page's CSP leaves out `upgrade-insecure-requests`. No TLS answers on the client's port, and a browser that upgraded the redirect to `https://localhost` would stop there. The page loads nothing over HTTP. - Every response from `/oauth/authorize` sends `Cross-Origin-Opener-Policy: unsafe-none`: the page, the `302` to sign-in, the `302` to the callback and a refusal. So does `/sign-in` or `/sign-up` when `next` is the consent page. A client can open the flow in a popup and watch it. Under `same-origin`, the opener reads the popup as closed at once. A browser applies the opener policy of each redirect in a navigation, so one `302` with `same-origin` was enough to break the popup. The redirects keep the rest of the app's list, `no-referrer` included. The page keeps `frame-ancestors 'none'` and `X-Frame-Options: DENY`. - The page shows the callback host first. A host other than `claude.ai` or `chatgpt.com` gets a warning that kmem does not know it. A loopback callback gets a warning that a program on this computer receives the connection. Anybody can register a client and call it "Claude", so the host is what identifies the client. - The connection name field is empty and required. The client's name is only the placeholder. The name must pass the label rule of a mint (`sessionLinkLabelSchema` in `services/tokens.ts`). When the page asks again, it does not write a credential-shaped name back into the field. - The page offers the permissions that the request asked for, and selects the widest. - Some refusals come before the route knows the client and the callback: an unknown client, or an unregistered callback. The route shows these to the person as JSON and never sends them to a callback. - A refusal after that, and before the person decides, goes to the callback with `error`, `error_description`, `state` and `iss`. It goes there only when the callback is `claude.ai`, `chatgpt.com` or a loopback address. The errors are `unsupported_response_type`, `invalid_request` for PKCE, `invalid_target` and `invalid_scope`. Registration takes no credential, so anybody can register `https://evil.example/cb`. A redirect there would make this org's own host an open redirector, which RFC 9700 §4.11.2 forbids, and the person would never see the "unknown host" warning. So for any other callback the route shows the refusal as JSON with no `Location`, and names the host that it did not send the refusal to. - Cancel always goes to the callback with `access_denied`, because the person saw the page and its warning first. - The route sends a person with no session to `/sign-in?next=…`. - The web app loads a `next` that no screen draws, such as `/oauth/authorize`, as a full page (`goToDestination` in `apps/web/src/shell/AuthRedirect.tsx`). A person who signs in with an emailed link in another tab then still reaches the consent page. ### 5. Scope - `scope` is a space-delimited set. `read` and `suggest` are the only values. An unknown word, `write` and `offline_access` included, refuses the request with `invalid_scope`. - No scope means `read suggest`, the same value the `401` names (RFC 6749 §3.3). - The token response reports `read suggest` for a suggest grant and `read` for a read grant. - OAuth never grants `write`. A consent page that a client opened is not the place to let an assistant change the knowledge base with no person in the loop. - A connection reaches `open` docs only, the same default that Connect gives a minted token. ### 6. Callback matching A requested `redirect_uri` must equal a registered one. For a loopback callback, the match ignores the port (RFC 8252 §7.3), because a native client binds a free port when it starts. The scheme, the host, the path and the query must still match, so `localhost` and `127.0.0.1` are two different callbacks. The exchange requires the exact `redirect_uri` that the authorize request used. ### 7. The code, and the exchange that mints the connection - A code is `kmc_` and 32 base62 characters, so nobody can mistake it for an `sp_` credential. D1 stores only its SHA-256. It lives five minutes. - The code row holds the consent: the label, the scope, the expiry as a mint spells it (`30d`, `never`) and the account that consented (migration `0021`). Consent mints nothing. A code that nobody exchanges leaves no connection, no list entry and no `token_minted` row. - `POST /oauth/token` composes the session token with `composeToken` in `services/tokens.ts`, the function that `mintToken` uses. So the row, the expiry arithmetic and the audit detail have one implementation (AGENTS.md rule 5). The exchange adds `via: "oauth"` and the client id to the audit detail. A scan in `oauth.test.ts` holds the route to that. - The exchange writes the session token, its grant and its `token_minted` audit row in the same D1 batch that consumes the code. The consume is a conditional update with a fresh exchange id, and each insert runs only when that update claimed the code. A replay, a concurrent exchange, an expired code and a wrong binding write nothing. - The consume also requires that the person who consented is still a member of the org. A person who left between Allow and the exchange gets `invalid_grant`, and no row says that a non-member connected. - The expiry counts from the exchange. The choices are 7 days, 30 days, 90 days and Never. - `resource` is optional at both steps. It defaults to `${origin}/mcp`, and any other value is `invalid_target`. - The token endpoint accepts a client that sends `client_id` in HTTP Basic. It ignores any secret. ### 8. What the connection may open - `validateToken` refuses an OAuth token at every door except the resource of its grant. It also refuses the token when a caller passes no resource at all. `/api/reindex`, the Connect preview and Quick Connect therefore refuse it. - The token stops when the person who consented leaves the org. ### 9. No refresh tokens, and no client ID metadata documents kmem issues no refresh token. When a connection expires, the person connects again in the client. The consent page says so. kmem does not advertise `client_id_metadata_document_supported`, so Claude and ChatGPT use registration. ### 10. The nightly sweep The `scheduled()` handler in `apps/worker/src/index.ts` runs `sweepOAuth` after the index sweep, over the same projects. Through `CommitService` (`commitOAuthChange`, op `sweep`), it deletes expired codes, and then clients that are older than 24 hours with no grant and no live code. It writes the totals as `oauth_sweep` at `warn`, and a failure as `oauth_sweep_failed` at `error`. ### 11. The Connections list `GET /api/tokens` gives each row `via: "oauth" | "minted"` and `oauth_client_name: string | null`. One query over the page's grants, a left join to the clients, finds both. The revoke and rename responses carry the same two fields. ## Alternatives rejected - **Keep bearer tokens only (ADR 0002).** ChatGPT and most Claude plans then have no way to connect except Quick Connect. Bearer tokens stay for harnesses such as Claude Code, Cursor and Codex. - **Answer Allow with a same-origin page that goes to the callback by a link or a meta refresh.** This avoids the `form-action` rule. It adds a second page, a second click on some browsers, and a navigation that a client cannot tell from a person who left. The CSP change keeps one redirect. - **Refresh tokens with rotation.** MCP 2025-11-25 requires rotation and reuse detection for a public client. That is a token family, a revocation cascade and a second credential in storage. Neither client requires a refresh token. Claude asks the person to connect again. Revisit when a live run shows that expiry is a real problem. - **Client ID metadata documents.** Both clients prefer them. They need an outbound fetch with SSRF controls, a size limit and a cache. The sweep and the unused-client limits bound what registration costs. - **A refusal at a count of unused clients.** A count for the whole org let one address lock the org out. A count for each address locks out an org whose members all connect through the same Claude servers. Removing the oldest unused client bounds the rows and refuses nobody. - **Send every error to a registered callback, as RFC 6749 §4.1.2.1 reads alone.** Registration is open, so that turns the org's host into an open redirector (RFC 9700 §4.11.2). - **One grant for more than one org, from an apex issuer.** A grant for one org is one resource, one membership and one row in that org's Connections. A person with two orgs adds two connectors, one for each org. - **Ignore unknown scope words.** kmem tells a client that asks for more than kmem grants, and the challenge names the scopes to ask for. - **Mint the token at consent with a five-minute expiry, and extend it at exchange.** Each abandoned consent then leaves a row in Connections, and an audit row for a mint that nobody used. ## Consequences - **Deploy order.** `validateToken` reads `oauth_grants` for every valid token. Apply `0020` and `0021` before the Worker goes live. `pnpm release` does this. A bare `wrangler deploy` does not. - **`0021` drops and recreates the three OAuth tables.** Production held no row in them, because `0020` never reached production. A development database loses its test registrations. Before the drop, `0021` revokes every live session token that a `0020` grant names. Without its grant, such a token would open Quick Connect and `/api`, and it would outlive its creator's membership. `packages/db/test/oauth-consent-migration.test.ts` holds this. - **An IPv6 loopback callback** (`http://[::1]:port`) goes into the CSP as it is. A browser that does not parse an IPv6 source ignores it, and that one callback then stops at the redirect. - **No live client has tested this yet.** The suite models the browser. The post's origin comes from the page's own policy. The CSP must name the callback origin. Each redirect must carry the opener policy. Connect real ChatGPT and a real Claude connector to a deployed org host. Record each run in `docs/COMPATIBILITY.md`. - **Nobody has tested Claude Desktop or mobile.** They use the same `claude.ai` callback. That callback can answer with a redirect to a `claude:` link. The consent page's `form-action` then stops the redirect, because the policy admits only `https://claude.ai`. A live run decides whether to admit `claude:`. - **Sign-in with another provider inside the popup** goes through that provider's pages, and their opener policy is not kmem's. A popup-based client can then read the popup as closed. - **ChatGPT developer mode** needs Plus, Pro, Business, Enterprise or Edu. Claude Free allows one custom connector. On Claude Team and Enterprise, an owner adds the connector first. - **What the tests hold.** `apps/worker/test/routes/oauth.test.ts` runs on a tenant host with the exact Claude and ChatGPT bodies, a browser-shaped consent post, and the apex and the self-hosted host. `apps/worker/test/invariants/token-lifecycle.test.ts` holds invariant I6 for an OAuth token.