ADR-017 — Our own OAuth authorization server for the MCP endpoint
Status: Accepted · Date: 2026-09-29 · Feature: mcp.md § Authentication
Context
“Connect TextStack to Claude/ChatGPT” meant creating a connect key (tsk_…) and pasting it — as a
header for Claude, inside a URL (/mcp/k/<key>) for ChatGPT, whose connectors offer only “No
authentication” or OAuth. Readwise’s connector is the bar: paste https://…/mcp (or pick it from the
directory), sign in, approve. Both directories require OAuth for user data, and both clients speak the
MCP authorization spec: 401 + WWW-Authenticate: Bearer resource_metadata=…, Protected Resource
Metadata (RFC 9728), AS metadata (RFC 8414), authorization code + PKCE S256, public clients registered
by CIMD (preferred) or DCR, resource (RFC 8707) → audience, refresh rotation. These client requirements were verified on 2026-09-29 against Readwise’s live
endpoints (curl) and the Claude and OpenAI connector docs; the resulting contract is in mcp.md.
What already existed: a web login (email/Google/Apple), a device-flow consent page, hashed tsk_ keys
resolved per request by McpKeyAuthMiddleware, and an MCP host that forwarded any bearer and answered
200 without one (public tools worked anonymously, private ones failed as tool errors — so an OAuth
client would never have started a sign-in).
Decision
- A mini authorization server inside the API, not OpenIddict or Duende. We need one grant
(authorization code + PKCE) plus refresh, one client type (public), one scope. Login and sessions
exist. That is ~400 lines in
OAuthEndpoints.csand three tables. OpenIddict (Apache-2.0) would bring its own schema and still needs DCR and CIMD written by hand; Duende is commercially licensed above $1M revenue and is built for a problem we do not have. Issuerhttps://textstack.app(App:BaseUrl). - Opaque, hashed tokens, not JWTs. Access
tso_(1 h), refreshtsr_(90 days, sliding, rotated on every use; a reused one matches no row →invalid_grant). Our JWT is validated withValidateAudience = falseand cannot be revoked; the spec requires an audience and the product requires instant revocation. Thetsk_machinery already does “prefix → SHA-256 → row → not revoked” in one indexed query;tso_addsexpires > nowandresource = https://textstack.app/mcpto the same query (OAuth.LiveAccessToken). Revoking a grant is therefore effective on the next request, with no cache. - The bridge does not validate tokens. The MCP host and the API are one trust domain; the token
was issued by our AS for the MCP resource, and the API enforces that audience. Formally the
bridge forwards the token it received, which the spec calls token passthrough — the concern there
is a server replaying a token to a third party, which does not happen here. One exception: for a
tso_bearer the host asksGET /oauth/token-statusand answers an expired/revoked token with401 error="invalid_token", because that 401 — not a 200 whose tools fail — is what makes clients refresh. It fails open on a transport error, so an API outage is not a sign-in loop. - Login required for all of
/mcp, like Readwise. No bearer →401+ challenge. This removes anonymous use of the three catalog tools over MCP; accepted by the owner.tsk_keys and the/mcp/k/<key>URL (rewritten into a bearer before the check) keep working unchanged. - CIMD and DCR both. CIMD is fetched with an SSRF guard (https, default port, a path, every
resolved address public — checked in the socket connect callback so DNS rebinding cannot swap it —
no redirects, 5 s, 16 KB) and cached 24 h in
oauth_clients. DCR rows land in the same table, so authorize resolves both with one lookup. Redirect URIs must be https on an allowlisted host (OAuth:AllowedRedirectHosts: claude.ai, claude.com, chatgpt.com) or loopback on any port (Claude Code); the loopback port is ignored when matching (RFC 8252 §7.3). - Account required to authorize — a guest’s approve is
403 account_required, the same sign-up-vs-sign-in distinctionRequireAiAccountuses. One scope,library(“read and write your library”), plusoffline_access; unknown scopes are dropped, not refused. - Pending requests and codes live in a table, not
IMemoryCache: a deploy mid-consent would otherwise lose the request, and code single-use is a conditionalUPDATE … WHERE consumed_at IS NULLthat wins exactly once. Rows older than a day are swept on each authorize.
Consequences
- Claude (web, desktop, mobile, Code) and ChatGPT can connect by URL alone; directory submission is
unblocked on the auth side (tool
title+readOnlyHint/destructiveHint/openWorldHintwere added at the same time). - A
tso_token reaches the library, never the account (owner decision). Account-management route groups carry.RejectOAuthTokens()(Api/Extensions/OAuthTokenPolicy.cs) and answer403 insufficient_scope:/me/account,/me/profile,/auth,/auth/device,/me/mcp/keys,/me/oauth/grants, and OAuth approve/deny (a token must not mint a broader credential). It is a deny-list on whole groups, so an endpoint added to one of them is covered; a NEW account group must add the call.tsk_keys are unchanged. - One grant per app: approving again for the same (user, client name, redirect host) revokes the previous grant in the same save — not matched on client_id, which DCR re-mints per connect.
- Refresh-token reuse revokes the whole grant (RFC 9700 §4.14.2): the grant remembers the refresh
token it rotated away (
previous_refresh_token_hash); presenting it revokes access and refresh. One generation back only — an older token is refused but not treated as reuse. - nginx must route
/.well-known/oauth-protected-resource[/mcp]→ MCP host and/.well-known/oauth-authorization-server+/oauth/→ API; without that they answer the SPA’s HTML.deploy.yml(“Sync nginx config”) appliesinfra/nginx/textstack.confon deploy.