Repository navigation
fix(oauth): persist remote MCP OAuth state in Postgres, hashed and encrypted (#155) - #174
Conversation
…crypted (#155) Clients, consent forms, codes, link replays and access/refresh tokens moved from in-process Maps to an OAuthStore. Rows are keyed by SHA-256 of the bearer value; every payload (PKCE params, redirect, client secret, customer API key) is sealed with AES-256-GCM bound to its row. Revocation finds sibling grants by an HMAC subject, not the key. Codes and refresh tokens are consumed with an atomic take, so rotation stays single-use across instances. PostgresOAuthStore is used when OILPRICEAPI_MCP_OAUTH_DATABASE_URL is set; MemoryOAuthStore remains for tests and local dev. Production refuses to start without both the database URL and OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…155) The remote image now refuses to start in production without a durable store and encryption key, so the container smoke supplies both from a throwaway service Postgres and asserts the durable store came up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Karl explicitly approved merging and deploying all remaining OPA PRs in this session. Reviewed encrypted storage with kind/id AAD, independently derived sealing/subject keys, hashed bearer lookup, parameterized queries, atomic code/refresh consumption, revocation, TLS CA verification, expiry and fail-closed startup. The refreshed head includes the reviewed futures/marine changes; all CI gates are green. Local full suite against isolated PostgreSQL 15 passed (612 tests before the latest tool merges); current CI repeats the PostgreSQL restart/concurrency tests and remote container smoke. Production preparation: dedicated Deployment remains gated on the reconciled MCP app spec: bind this database, inject its CA and the backed-up SECRET, preserve the existing domain/origins, and pin the reviewed immutable image. The app is not configured to auto-deploy on merge. Local network access to the managed cluster was unavailable; actual bound-CA and reconnect-free refresh verification will run against the isolated app after deployment. Existing memory-only sessions must reconnect once on this first migration. |
Fixes #155. Remote MCP OAuth state (DCR clients, consent forms, codes, link replays, access and refresh tokens) moves from in-process Maps to a durable store, so a deploy or DO restart no longer disconnects every Claude and ChatGPT user.
Do not deploy this image until the prerequisites below are in place. Production now fails closed: without the database URL and encryption key the container exits 1 at boot, the DO deploy fails its health check, and the previous deployment stays active.
What changed
src/oauthStore.ts(new)OAuthStoreinterface,MemoryOAuthStore(tests/dev),PostgresOAuthStore(one tablemcp_oauth_records, created on boot),Sealer(AES-256-GCM + HMAC),oauthPersistenceFromEnvsrc/pluginOAuth.tsregisterClientis async; client TTL slides on token issue; consent copy no longer says connections drop on restartsrc/remote.tsstartRemoteis async, builds persistence from env, logsoauth_store_ready {durable}, closes the pool on shutdown; capacity 50,000 rows/kind with a durable store (1,000 in memory).github/workflows/live-tests.ymlpostgres:16.4-alpineservice;npm testruns the real-Postgres store test; the remote container smoke now supplies a DB URL and key and assertsoauth_store_ready {durable:true}package.jsonpg@8.23.1(exact, published 2026-09-30),@types/pg@8.23.1devStore choice: Postgres (
opa-2025-nyc-db), dedicated database and userRead-only inspection on 2026-10-15:
oilpriceapi-openai-mcp(4a89cb34) has no database bound and is not a trusted source on either cluster (doctl databases firewalls list).opa-2025-nyc-db(PG 15, 2 nodes) already hosts per-app databases (entityapi,verifycarrier), so amcp_oauthdatabase with its own user follows existing practice and keeps the MCP app out of the API's schema.opa-2025-nyc-redis(Valkey 8,noeviction, RDB persistence) was the alternative. Rejected: it is the API's Sidekiq/cache instance, RDB snapshots can lose the last writes on failover (a lost refresh-token rotation logs a user out), and it gives no per-app isolation comparable to a separate Postgres database and user.Security notes
client_secret, API key) is sealed with AES-256-GCM, key fromOILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEYvia HKDF. Associated data bindskind:id, so a payload copied onto another row fails to open. A row that fails to decrypt is treated as absent (reject), never as valid.client_secretin plaintext at/token, so the client record is sealed rather than hashed.DELETE ... RETURNING; a test runs two concurrent refreshes with one token and exactly one succeeds (memory and Postgres).NODE_ENV=productionwithout the key, or without the database URL, refuses to start. A database URL without a key refuses to start in any environment. Key must decode to 32 bytes (base64 or hex).OILPRICEAPI_MCP_OAUTH_DATABASE_CA_CERTis set, the pool verifies the server against that CA (rejectUnauthorized: true) and dropssslmodefrom the URL, becausepglets the URL override an explicitsslobject.Deploy prerequisites (Karl action; nothing here was changed)
opa-2025-nyc-db:doadmin, give the user ownership of its database only:ALTER DATABASE mcp_oauth OWNER TO mcp_oauth;and inmcp_oauth:GRANT ALL ON SCHEMA public TO mcp_oauth;. The app creates its table on first boot.4a89cb34(this also adds the app as a trusted source). PR againstdeployment/openai-mcp.yaml, then apply:services[energy-mcp].envs:MCP_ALLOWED_ORIGINSand themcp.oilpriceapi.comdomain, whichdeployment/openai-mcp.yamldoes not; reconcile the file withdoctl apps spec getbefore applying so they are not dropped.instance_count: 1).Verification
npm test: 562/563 pass. The one failure isenergyPlugin > cancels an upstream that stalls after opening its response body, which also fails onorigin/mainin the same run and is tracked in [P2][test] energyPlugin 'cancels an upstream that stalls' times out intermittently (2/5 local runs on origin/main) #172; it passes in isolation.oauthStore.test.tsran 16/16 including the Postgres test; container smoke logged{"event":"oauth_store_ready","durable":true}.MCP_OAUTH_TEST_DATABASE_URL=postgres://localhost/mcp_oauth_test: 60/60 pass, including the real-Postgres restart test (also runs in CI against the service Postgres).v1.ciphertext; payload moved to another row fails; missing key / missing DB in production fail closed, includingstartRemote().tsc --noEmit,npm run build, prettier on changed files,check:capability-ledger,smoke:package,smoke:public-claims,smoke:product-facts,verify:release-metadata,npm audit(0 vulnerabilities): pass.Dockerfile.remoteimage, local: production with no key exits 1 withOILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY is required; key without DB exits 1; key + local Postgres boots,/health200,oauth_store_ready {"durable":true},/registerrow stored asv1.ciphertext and present afterdocker restart.CA_CERT, and the full Claude reconnect-free refresh across a real deploy. Both need the prerequisites above; checkdoctl apps logs 4a89cb34-72a3-4daf-a893-0628699ded26 --tail=50foroauth_store_readywithdurable: trueafter the first deploy.🤖 Generated with Claude Code