diff --git a/.github/workflows/live-tests.yml b/.github/workflows/live-tests.yml index ecd9aa0..be5b40e 100644 --- a/.github/workflows/live-tests.yml +++ b/.github/workflows/live-tests.yml @@ -22,6 +22,21 @@ jobs: name: Unit tests + build runs-on: ubuntu-latest timeout-minutes: 20 + # Durable OAuth store (#155): the Postgres store test and the remote + # container smoke run against this throwaway database. + services: + postgres: + image: postgres:16.4-alpine + env: + POSTGRES_PASSWORD: ci-only-not-a-secret + POSTGRES_DB: mcp_oauth_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -33,6 +48,8 @@ jobs: - run: npm audit --audit-level=low - run: npm run verify:release-metadata - run: npm test + env: + MCP_OAUTH_TEST_DATABASE_URL: postgres://postgres:ci-only-not-a-secret@127.0.0.1:5432/mcp_oauth_test - run: npm run build - run: npm run check:readme-tools - run: npm run generate:live-contracts @@ -55,9 +72,17 @@ jobs: - name: Remote MCP container smoke (no account credential) run: | docker build -f Dockerfile.remote -t opa-openai-plugin:ci . - docker run --detach --name opa-remote-ci --publish 127.0.0.1:18080:8080 --env OILPRICEAPI_MCP_PUBLIC_URL=https://127.0.0.1:18080 opa-openai-plugin:ci - trap 'docker rm -f opa-remote-ci >/dev/null 2>&1' EXIT + # Production fails closed without a durable store and key (#155); + # host networking reaches the service Postgres on 127.0.0.1. + docker run --detach --name opa-remote-ci --network host \ + --env PORT=18080 \ + --env OILPRICEAPI_MCP_PUBLIC_URL=https://127.0.0.1:18080 \ + --env OILPRICEAPI_MCP_OAUTH_DATABASE_URL=postgres://postgres:ci-only-not-a-secret@127.0.0.1:5432/mcp_oauth_test \ + --env OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY="$(openssl rand -base64 32)" \ + opa-openai-plugin:ci + trap 'docker logs opa-remote-ci 2>&1 | tail -20; docker rm -f opa-remote-ci >/dev/null 2>&1' EXIT node scripts/smoke-remote-container.mjs http://127.0.0.1:18080/mcp + docker logs opa-remote-ci 2>&1 | grep -q '"event":"oauth_store_ready","durable":true' - run: npm run smoke:registry-verifier - run: npm run smoke:npm-verifier - run: npm run smoke:release-provenance diff --git a/package-lock.json b/package-lock.json index 6b670eb..c4ad934 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "express": "5.3.0", + "pg": "8.23.1", "zod": "^4.4.3" }, "bin": { @@ -19,6 +20,7 @@ "devDependencies": { "@types/express": "5.0.6", "@types/node": "^26.1.1", + "@types/pg": "8.23.1", "typescript": "^7.0.2", "vitest": "^5.0.3" }, @@ -501,6 +503,18 @@ "undici-types": "~8.9.0" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -2107,6 +2121,95 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2167,6 +2270,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -2445,6 +2587,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2794,6 +2945,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/package.json b/package.json index 047fbf4..789ec30 100644 --- a/package.json +++ b/package.json @@ -73,11 +73,13 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "express": "5.3.0", + "pg": "8.23.1", "zod": "^4.4.3" }, "devDependencies": { "@types/express": "5.0.6", "@types/node": "^26.1.1", + "@types/pg": "8.23.1", "typescript": "^7.0.2", "vitest": "^5.0.3" } diff --git a/src/__tests__/oauthStore.test.ts b/src/__tests__/oauthStore.test.ts new file mode 100644 index 0000000..4cbe7b5 --- /dev/null +++ b/src/__tests__/oauthStore.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { createHash, randomBytes } from "node:crypto"; +import { PluginOAuth } from "../pluginOAuth.js"; +import { + MemoryOAuthStore, + PostgresOAuthStore, + Sealer, + oauthPersistenceFromEnv, + type OAuthStore, +} from "../oauthStore.js"; +import { startRemote } from "../remote.js"; + +const origin = "https://mcp.example"; +const resource = new URL(origin + "/mcp"); +const verifier = "a".repeat(43); +const challenge = createHash("sha256").update(verifier).digest("base64url"); +const apiKey = "caller-key-value-1234"; +const sha = (v: string) => createHash("sha256").update(v).digest("base64url"); +const masterKey = randomBytes(32); + +const accountOk = () => + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ status: "success", data: { usage: {} } })), + ); + +/** A fresh provider instance over the given store: a process restart. */ +function boot(store: OAuthStore & { durable: boolean }, key = masterKey) { + return new PluginOAuth(origin, accountOk(), { + persistence: { store, sealer: new Sealer(key) }, + }); +} + +async function connect(provider: PluginOAuth, confidential = false) { + vi.spyOn(console, "log").mockImplementation(() => {}); + const client = await provider.clientsStore.registerClient({ + redirect_uris: ["https://claude.ai/api/mcp/auth_callback"], + token_endpoint_auth_method: confidential ? "client_secret_post" : "none", + ...(confidential ? { client_secret: "sdk-issued-secret-value" } : {}), + }); + let flow = ""; + const res = { + cookie: (_n: string, v: string) => { + flow = v; + }, + setHeader: vi.fn(), + type: () => res, + send: vi.fn(), + }; + await provider.authorize( + client, + { + resource, + codeChallenge: challenge, + redirectUri: client.redirect_uris[0], + scopes: ["energy:read"], + state: "s", + }, + res as any, + ); + const redirect = new URL(await provider.completeLink(flow, flow, apiKey)); + const code = redirect.searchParams.get("code")!; + const tokens = await provider.exchangeAuthorizationCode( + client, + code, + verifier, + client.redirect_uris[0], + resource, + ); + return { client, flow, code, redirect, tokens }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("OAuth state survives a restart (#155)", () => { + it("refreshes successfully on a new provider instance over the same store", async () => { + const store = new MemoryOAuthStore(); + const first = boot(store); + const { client, tokens } = await connect(first); + first.close(); + + const second = boot(store); + try { + expect(await second.credentialForAccessToken(tokens.access_token)).toBe( + apiKey, + ); + const rotated = await second.exchangeRefreshToken( + client, + tokens.refresh_token!, + ["energy:read"], + resource, + ); + expect(await second.credentialForAccessToken(rotated.access_token)).toBe( + apiKey, + ); + // Rotation is single-use across restarts too. + await expect( + second.exchangeRefreshToken( + client, + tokens.refresh_token!, + ["energy:read"], + resource, + ), + ).rejects.toThrow("Invalid refresh grant"); + } finally { + second.close(); + } + }); + + it("keeps a dynamically registered client, including its secret", async () => { + const store = new MemoryOAuthStore(); + const first = boot(store); + const { client } = await connect(first, true); + first.close(); + + const second = boot(store); + try { + const found = await second.clientsStore.getClient(client.client_id); + expect(found?.client_id).toBe(client.client_id); + expect(found?.redirect_uris).toEqual([ + "https://claude.ai/api/mcp/auth_callback", + ]); + expect(found?.client_secret).toBe("sdk-issued-secret-value"); + expect(await second.clientsStore.getClient("unknown")).toBeUndefined(); + } finally { + second.close(); + } + }); + + it("rejects expired refresh and access tokens after a restart", async () => { + vi.useFakeTimers(); + const store = new MemoryOAuthStore(); + const first = boot(store); + const { client, tokens } = await connect(first); + first.close(); + vi.advanceTimersByTime(7 * 86400000 + 1); + const second = boot(store); + try { + await expect( + second.verifyAccessToken(tokens.access_token), + ).rejects.toThrow(); + await expect( + second.exchangeRefreshToken( + client, + tokens.refresh_token!, + ["energy:read"], + resource, + ), + ).rejects.toThrow("Invalid refresh grant"); + } finally { + second.close(); + } + }); + + it("rejects revoked tokens after a restart", async () => { + const store = new MemoryOAuthStore(); + const first = boot(store); + const { client, tokens } = await connect(first); + await first.revokeToken(client, { token: tokens.access_token }); + first.close(); + const second = boot(store); + try { + await expect( + second.verifyAccessToken(tokens.access_token), + ).rejects.toThrow(); + await expect( + second.exchangeRefreshToken( + client, + tokens.refresh_token!, + ["energy:read"], + resource, + ), + ).rejects.toThrow(); + } finally { + second.close(); + } + }); + + it("rejects every stored grant when the encryption key changes", async () => { + const store = new MemoryOAuthStore(); + const first = boot(store); + const { client, tokens } = await connect(first); + first.close(); + const second = boot(store, randomBytes(32)); + try { + expect(await second.clientsStore.getClient(client.client_id)).toBe( + undefined, + ); + await expect( + second.credentialForAccessToken(tokens.access_token), + ).rejects.toThrow(); + await expect( + second.exchangeRefreshToken( + client, + tokens.refresh_token!, + ["energy:read"], + resource, + ), + ).rejects.toThrow(); + } finally { + second.close(); + } + }); + + it("lets only one of two concurrent refreshes with the same token succeed", async () => { + const store = new MemoryOAuthStore(); + const a = boot(store); + const b = boot(store); + const { client, tokens } = await connect(a); + try { + const results = await Promise.allSettled([ + a.exchangeRefreshToken(client, tokens.refresh_token!, [], resource), + b.exchangeRefreshToken(client, tokens.refresh_token!, [], resource), + ]); + expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); + } finally { + a.close(); + b.close(); + } + }); +}); + +describe("what sits at rest", () => { + it("stores only hashes of bearer values and never the API key or client secret", async () => { + const store = new MemoryOAuthStore(); + const provider = boot(store); + try { + const { client, flow, code, tokens } = await connect(provider, true); + const rows = store.snapshot(); + const ids = rows.map((r) => `${r.kind}:${r.id}`); + expect(ids).toEqual( + expect.arrayContaining([ + `client:${sha(client.client_id)}`, + `linked:${sha(flow)}`, + `access:${sha(tokens.access_token)}`, + `refresh:${sha(tokens.refresh_token!)}`, + ]), + ); + // The code was consumed by the exchange. + expect(ids).not.toContain(`code:${sha(code)}`); + const raw = JSON.stringify(rows); + for (const secret of [ + apiKey, + "sdk-issued-secret-value", + flow, + code, + tokens.access_token, + tokens.refresh_token!, + verifier, + "auth_callback", + ]) + expect(raw).not.toContain(secret); + // Revocation subject is a keyed HMAC, not a bare hash of the key. + expect(raw).not.toContain(sha(apiKey)); + for (const row of rows) expect(row.data).toMatch(/^v1\./); + } finally { + provider.close(); + } + }); + + it("refuses a sealed payload moved onto another row", () => { + const sealer = new Sealer(masterKey); + const sealed = sealer.seal("access:one", { key: apiKey }); + expect(sealer.open("access:one", sealed)).toEqual({ key: apiKey }); + expect(() => sealer.open("access:two", sealed)).toThrow(); + expect(() => sealer.open("refresh:one", sealed)).toThrow(); + }); +}); + +describe("fails closed on missing configuration", () => { + const key = randomBytes(32).toString("base64"); + it("requires an encryption key in production", async () => { + await expect( + oauthPersistenceFromEnv({ + NODE_ENV: "production", + OILPRICEAPI_MCP_OAUTH_DATABASE_URL: "postgres://x/y", + }), + ).rejects.toThrow("OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY"); + }); + it("requires an encryption key whenever a durable store is configured", async () => { + const connect = vi.fn(); + await expect( + oauthPersistenceFromEnv( + { OILPRICEAPI_MCP_OAUTH_DATABASE_URL: "postgres://x/y" }, + connect, + ), + ).rejects.toThrow("OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY"); + expect(connect).not.toHaveBeenCalled(); + }); + it("requires a durable store in production", async () => { + await expect( + oauthPersistenceFromEnv({ + NODE_ENV: "production", + OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY: key, + }), + ).rejects.toThrow("OILPRICEAPI_MCP_OAUTH_DATABASE_URL"); + }); + it("rejects a key that is not 32 bytes", async () => { + await expect( + oauthPersistenceFromEnv({ + OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY: "c2hvcnQ=", + }), + ).rejects.toThrow("32 bytes"); + }); + it("uses memory only outside production with no store configured", async () => { + const { store } = await oauthPersistenceFromEnv({ NODE_ENV: "test" }); + expect(store.durable).toBe(false); + }); + it("connects the durable store with the bound CA", async () => { + const fake = Object.assign(new MemoryOAuthStore(), { durable: true }); + const connect = vi.fn().mockResolvedValue(fake); + const { store } = await oauthPersistenceFromEnv( + { + NODE_ENV: "production", + OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY: key, + OILPRICEAPI_MCP_OAUTH_DATABASE_URL: "postgres://u:p@db/mcp", + OILPRICEAPI_MCP_OAUTH_DATABASE_CA_CERT: "PEM", + }, + connect, + ); + expect(store).toBe(fake); + expect(connect).toHaveBeenCalledWith({ + connectionString: "postgres://u:p@db/mcp", + caCert: "PEM", + }); + }); + it("refuses to start the remote service in production without the key", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("OILPRICEAPI_KEY", ""); + vi.stubEnv("OIL_PRICE_API_KEY", ""); + vi.stubEnv("OILPRICEAPI_MCP_PUBLIC_URL", origin); + vi.stubEnv("OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY", ""); + vi.stubEnv("OILPRICEAPI_MCP_OAUTH_DATABASE_URL", ""); + await expect(startRemote()).rejects.toThrow( + "OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY", + ); + }); +}); + +// Runs against a real Postgres when MCP_OAUTH_TEST_DATABASE_URL is set, +// e.g. postgres://localhost/mcp_oauth_test. Skipped otherwise. +const pgUrl = process.env.MCP_OAUTH_TEST_DATABASE_URL; +describe.skipIf(!pgUrl)("Postgres store", () => { + it("survives a restart on a new connection pool and consumes refresh atomically", async () => { + const storeA = await PostgresOAuthStore.connect({ + connectionString: pgUrl!, + }); + await storeA.prune(Number.MAX_SAFE_INTEGER); + const first = boot(storeA); + const { client, tokens } = await connect(first); + first.close(); + await storeA.close(); + + const storeB = await PostgresOAuthStore.connect({ + connectionString: pgUrl!, + }); + const second = boot(storeB); + const third = boot(storeB); + try { + expect( + (await second.clientsStore.getClient(client.client_id))?.client_id, + ).toBe(client.client_id); + expect(await second.credentialForAccessToken(tokens.access_token)).toBe( + apiKey, + ); + const results = await Promise.allSettled([ + second.exchangeRefreshToken( + client, + tokens.refresh_token!, + [], + resource, + ), + third.exchangeRefreshToken(client, tokens.refresh_token!, [], resource), + ]); + const ok = results.filter((r) => r.status === "fulfilled"); + expect(ok).toHaveLength(1); + const rotated = ( + ok[0] as PromiseFulfilledResult<{ access_token: string }> + ).value; + expect(await second.credentialForAccessToken(rotated.access_token)).toBe( + apiKey, + ); + await second.revokeToken(client, { token: rotated.access_token }); + await expect( + second.verifyAccessToken(rotated.access_token), + ).rejects.toThrow(); + await expect( + second.verifyAccessToken(tokens.access_token), + ).rejects.toThrow(); + } finally { + second.close(); + third.close(); + await storeB.prune(Number.MAX_SAFE_INTEGER); + await storeB.close(); + } + }); +}); diff --git a/src/__tests__/pluginOAuth.test.ts b/src/__tests__/pluginOAuth.test.ts index f9ae4b6..c756b83 100644 --- a/src/__tests__/pluginOAuth.test.ts +++ b/src/__tests__/pluginOAuth.test.ts @@ -5,7 +5,7 @@ import { PluginOAuth } from "../pluginOAuth.js"; const origin = "https://mcp.example"; const verifier = "a".repeat(43); const challenge = createHash("sha256").update(verifier).digest("base64url"); -function setup(status = 200) { +async function setup(status = 200) { const fetchImpl = vi.fn().mockResolvedValue( new Response( JSON.stringify({ @@ -16,7 +16,7 @@ function setup(status = 200) { ), ); const provider = new PluginOAuth(origin, fetchImpl); - const client = provider.clientsStore.registerClient({ + const client = await provider.clientsStore.registerClient({ redirect_uris: ["https://chatgpt.com/connector_platform_oauth_redirect"], token_endpoint_auth_method: "none", }); @@ -24,7 +24,7 @@ function setup(status = 200) { } async function authorize( provider: PluginOAuth, - client: ReturnType, + client: Awaited>, override = {}, ) { let cookie = ""; @@ -55,7 +55,7 @@ async function authorize( return cookie; } async function linked() { - const { provider, client, fetchImpl } = setup(); + const { provider, client, fetchImpl } = await setup(); const flow = await authorize(provider, client); const redirect = new URL( await provider.completeLink(flow, flow, "caller-key-value-1234"), @@ -71,7 +71,7 @@ async function linked() { afterEach(() => vi.useRealTimers()); describe("plugin OAuth boundary", () => { it("rejects unregistered redirect destinations before rendering consent", async () => { - const { provider, client } = setup(); + const { provider, client } = await setup(); try { await expect( authorize(provider, client, { @@ -94,7 +94,7 @@ describe("plugin OAuth boundary", () => { ), ); try { - const client = provider.clientsStore.registerClient({ + const client = await provider.clientsStore.registerClient({ redirect_uris: [ "https://chatgpt.com/connector_platform_oauth_redirect", ], @@ -121,7 +121,7 @@ describe("plugin OAuth boundary", () => { ); const provider = new PluginOAuth(origin, fetchImpl as typeof fetch); try { - const client = provider.clientsStore.registerClient({ + const client = await provider.clientsStore.registerClient({ redirect_uris: [ "https://chatgpt.com/connector_platform_oauth_redirect", ], @@ -179,11 +179,11 @@ describe("plugin OAuth boundary", () => { "http://evil.example/callback", "https://user:password@evil.example/callback", "https://evil.example/callback#fragment", - ])("rejects unsafe callback %s", (redirect) => { - const { provider } = setup(); - expect(() => + ])("rejects unsafe callback %s", async (redirect) => { + const { provider } = await setup(); + await expect( provider.clientsStore.registerClient({ redirect_uris: [redirect] }), - ).toThrow(); + ).rejects.toThrow(); }); it.each([ "https://claude.ai/api/mcp/auth_callback/extra", @@ -191,14 +191,14 @@ describe("plugin OAuth boundary", () => { "https://claude.ai.evil.example/api/mcp/auth_callback", "http://claude.ai/api/mcp/auth_callback", "https://evil.example/api/mcp/auth_callback", - ])("rejects look-alike Claude callback %s", (redirect) => { - const { provider } = setup(); - expect(() => + ])("rejects look-alike Claude callback %s", async (redirect) => { + const { provider } = await setup(); + await expect( provider.clientsStore.registerClient({ redirect_uris: [redirect], token_endpoint_auth_method: "none", }), - ).toThrow("Use the OpenAI or Claude callback"); + ).rejects.toThrow("Use the OpenAI or Claude callback"); }); it("links a Claude connection, names the return host and marks the platform", async () => { const fetchImpl = vi @@ -210,7 +210,7 @@ describe("plugin OAuth boundary", () => { ); const provider = new PluginOAuth(origin, fetchImpl); try { - const client = provider.clientsStore.registerClient({ + const client = await provider.clientsStore.registerClient({ redirect_uris: ["https://claude.ai/api/mcp/auth_callback"], token_endpoint_auth_method: "none", }); @@ -258,7 +258,7 @@ describe("plugin OAuth boundary", () => { } }); it("keeps marking ChatGPT links as the OpenAI plugin", async () => { - const { provider, client, fetchImpl } = setup(); + const { provider, client, fetchImpl } = await setup(); try { const flow = await authorize(provider, client); await provider.completeLink(flow, flow, "caller-key-value-1234"); @@ -270,7 +270,7 @@ describe("plugin OAuth boundary", () => { } }); it("requires the exact protected resource and read-only scope", async () => { - const { provider, client } = setup(); + const { provider, client } = await setup(); await expect( authorize(provider, client, { resource: new URL("https://other.example/mcp"), @@ -281,7 +281,7 @@ describe("plugin OAuth boundary", () => { ).rejects.toThrow(); }); it("rejects cross-browser linking and consumes failed forms", async () => { - const { provider, client, fetchImpl } = setup(401); + const { provider, client, fetchImpl } = await setup(401); const flow = await authorize(provider, client); await expect( provider.completeLink(flow, "different", "caller-key-value-1234"), @@ -298,7 +298,7 @@ describe("plugin OAuth boundary", () => { it.each([403, 429, 500])( "fails closed when account validation returns %s", async (status) => { - const { provider, client } = setup(status); + const { provider, client } = await setup(status); const flow = await authorize(provider, client); await expect( provider.completeLink(flow, flow, "caller-key-value-1234"), @@ -377,7 +377,7 @@ describe("plugin OAuth boundary", () => { }); it("expires forms and access tokens", async () => { vi.useFakeTimers(); - const { provider, client } = setup(); + const { provider, client } = await setup(); const flow = await authorize(provider, client); vi.advanceTimersByTime(300_001); await expect( @@ -398,11 +398,11 @@ describe("plugin OAuth boundary", () => { describe("client registration", () => { afterEach(() => vi.restoreAllMocks()); - it("accepts client_secret_post and keeps the SDK-issued secret", () => { + it("accepts client_secret_post and keeps the SDK-issued secret", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); const provider = new PluginOAuth(origin, vi.fn()); try { - const client = provider.clientsStore.registerClient({ + const client = await provider.clientsStore.registerClient({ redirect_uris: ["https://claude.ai/api/mcp/auth_callback"], token_endpoint_auth_method: "client_secret_post", client_secret: "sdk-issued-secret", @@ -420,11 +420,11 @@ describe("client registration", () => { provider.close(); } }); - it("strips any secret from public clients", () => { + it("strips any secret from public clients", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); const provider = new PluginOAuth(origin, vi.fn()); try { - const client = provider.clientsStore.registerClient({ + const client = await provider.clientsStore.registerClient({ redirect_uris: ["https://claude.ai/api/mcp/auth_callback"], token_endpoint_auth_method: "none", client_secret: "should-not-survive", @@ -435,22 +435,22 @@ describe("client registration", () => { provider.close(); } }); - it("logs why a registration was rejected, without the redirect path", () => { + it("logs why a registration was rejected, without the redirect path", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); const provider = new PluginOAuth(origin, vi.fn()); try { - expect(() => + await expect( provider.clientsStore.registerClient({ redirect_uris: ["https://claude.ai/api/mcp/auth_callback"], token_endpoint_auth_method: "client_secret_basic", }), - ).toThrow("client_secret_post"); - expect(() => + ).rejects.toThrow("client_secret_post"); + await expect( provider.clientsStore.registerClient({ redirect_uris: ["https://evil.example/secret-path?token=x"], token_endpoint_auth_method: "none", }), - ).toThrow("Claude callback"); + ).rejects.toThrow("Claude callback"); const events = log.mock.calls.map((c) => JSON.parse(c[0])); expect(events[0]).toMatchObject({ event: "oauth_registration_rejected", @@ -508,7 +508,7 @@ describe("/link outcome logging", () => { describe("double-submitted link form", () => { it("returns the same redirect for a resubmit with the same key, after the cookie is cleared", async () => { - const { provider, client } = setup(); + const { provider, client } = await setup(); try { const flow = await authorize(provider, client); const first = await provider.completeLink( @@ -539,7 +539,7 @@ describe("double-submitted link form", () => { }); it("stops replaying the redirect after 60 seconds", async () => { vi.useFakeTimers(); - const { provider, client } = setup(); + const { provider, client } = await setup(); try { const flow = await authorize(provider, client); await provider.completeLink(flow, flow, "caller-key-value-1234"); @@ -553,7 +553,7 @@ describe("double-submitted link form", () => { } }); it("opens help links in a new tab", async () => { - const { provider, client } = setup(); + const { provider, client } = await setup(); let html = ""; const res = { cookie: vi.fn(), diff --git a/src/oauthStore.ts b/src/oauthStore.ts new file mode 100644 index 0000000..d38d1be --- /dev/null +++ b/src/oauthStore.ts @@ -0,0 +1,341 @@ +import { + createCipheriv, + createDecipheriv, + createHmac, + hkdfSync, + randomBytes, +} from "node:crypto"; + +/** + * Durable state for the remote MCP OAuth provider (issue #155). + * + * Rows are keyed by `kind` and a SHA-256 hash of the bearer value (flow, + * code, access token, refresh token) or of the client_id. The raw bearer value + * is never stored. Everything else about a row (PKCE parameters, redirect, + * client metadata and secret, the customer's OilPriceAPI key) lives in `data`, + * which the provider seals with AES-256-GCM before it reaches the store. + */ +export type RecordKind = + "client" | "form" | "code" | "linked" | "access" | "refresh"; + +export interface StoredRecord { + clientId: string; + /** HMAC of the customer's API key; lets revocation find sibling grants. */ + subject?: string; + /** Sealed payload. Opaque to the store. */ + data: string; + /** Epoch milliseconds. Rows at or past this instant are never returned. */ + expires: number; +} + +export interface OAuthStore { + get( + kind: RecordKind, + id: string, + now: number, + ): Promise; + put(kind: RecordKind, id: string, record: StoredRecord): Promise; + /** Atomic read-and-delete, so a code or refresh token is single-use even across instances. */ + take( + kind: RecordKind, + id: string, + now: number, + ): Promise; + delete(kind: RecordKind, id: string): Promise; + count(kind: RecordKind, now: number): Promise; + revokeSubject( + clientId: string, + subject: string, + kinds: RecordKind[], + ): Promise; + prune(now: number): Promise; + close(): Promise; +} + +/** Process-local store for tests and local development. Lost on restart. */ +export class MemoryOAuthStore implements OAuthStore { + readonly durable = false; + private rows = new Map(); + private key = (kind: RecordKind, id: string) => `${kind}\u0000${id}`; + async get(kind: RecordKind, id: string, now: number) { + const row = this.rows.get(this.key(kind, id)); + return row && row.expires > now ? { ...row } : undefined; + } + async put(kind: RecordKind, id: string, record: StoredRecord) { + this.rows.set(this.key(kind, id), { ...record }); + } + async take(kind: RecordKind, id: string, now: number) { + // No await between read and delete: the take is atomic on the event loop. + const key = this.key(kind, id); + const row = this.rows.get(key); + this.rows.delete(key); + return row && row.expires > now ? { ...row } : undefined; + } + async delete(kind: RecordKind, id: string) { + this.rows.delete(this.key(kind, id)); + } + async count(kind: RecordKind, now: number) { + let n = 0; + for (const [key, row] of this.rows) + if (key.startsWith(kind + "\u0000") && row.expires > now) n++; + return n; + } + async revokeSubject(clientId: string, subject: string, kinds: RecordKind[]) { + for (const [key, row] of this.rows) + if ( + row.clientId === clientId && + row.subject === subject && + kinds.some((kind) => key.startsWith(kind + "\u0000")) + ) + this.rows.delete(key); + } + async prune(now: number) { + for (const [key, row] of this.rows) + if (row.expires <= now) this.rows.delete(key); + } + async close() {} + /** Test helper: raw rows as they sit at rest. */ + snapshot() { + return [...this.rows.entries()].map(([key, row]) => ({ + kind: key.split("\u0000")[0] as RecordKind, + id: key.split("\u0000")[1], + ...row, + })); + } +} + +interface PgPoolLike { + query( + text: string, + values?: unknown[], + ): Promise<{ rows: Record[]; rowCount: number | null }>; + end(): Promise; +} + +const TABLE = "mcp_oauth_records"; +const SCHEMA = [ + `CREATE TABLE IF NOT EXISTS ${TABLE} ( + kind text NOT NULL, + id text NOT NULL, + client_id text NOT NULL, + subject text, + data text NOT NULL, + expires_at bigint NOT NULL, + PRIMARY KEY (kind, id) + )`, + `CREATE INDEX IF NOT EXISTS ${TABLE}_subject_idx ON ${TABLE} (client_id, subject)`, + `CREATE INDEX IF NOT EXISTS ${TABLE}_expires_idx ON ${TABLE} (expires_at)`, +]; + +const toRecord = (row: Record): StoredRecord => ({ + clientId: String(row.client_id), + subject: row.subject == null ? undefined : String(row.subject), + data: String(row.data), + expires: Number(row.expires_at), +}); + +/** Postgres-backed store. One table, created on first start. */ +export class PostgresOAuthStore implements OAuthStore { + readonly durable = true; + private constructor(private pool: PgPoolLike) {} + static async connect(options: { + connectionString: string; + caCert?: string; + pool?: PgPoolLike; + }) { + let pool = options.pool; + if (!pool) { + const { default: pg } = await import("pg"); + let connectionString = options.connectionString; + let ssl: { ca: string; rejectUnauthorized: true } | undefined; + if (options.caCert) { + // pg lets sslmode in the URL override an explicit ssl object, which + // would drop the CA. Verify against the bound cluster CA instead. + const url = new URL(connectionString); + url.searchParams.delete("sslmode"); + connectionString = url.href; + ssl = { ca: options.caCert, rejectUnauthorized: true }; + } + pool = new pg.Pool({ + connectionString, + ssl, + max: 4, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + statement_timeout: 5_000, + }) as unknown as PgPoolLike; + } + for (const statement of SCHEMA) await pool.query(statement); + return new PostgresOAuthStore(pool); + } + async get(kind: RecordKind, id: string, now: number) { + const { rows } = await this.pool.query( + `SELECT client_id, subject, data, expires_at FROM ${TABLE} + WHERE kind = $1 AND id = $2 AND expires_at > $3`, + [kind, id, now], + ); + return rows[0] ? toRecord(rows[0]) : undefined; + } + async put(kind: RecordKind, id: string, record: StoredRecord) { + await this.pool.query( + `INSERT INTO ${TABLE} (kind, id, client_id, subject, data, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (kind, id) DO UPDATE SET client_id = EXCLUDED.client_id, + subject = EXCLUDED.subject, data = EXCLUDED.data, expires_at = EXCLUDED.expires_at`, + [ + kind, + id, + record.clientId, + record.subject ?? null, + record.data, + record.expires, + ], + ); + } + async take(kind: RecordKind, id: string, now: number) { + const { rows } = await this.pool.query( + `DELETE FROM ${TABLE} WHERE kind = $1 AND id = $2 + RETURNING client_id, subject, data, expires_at`, + [kind, id], + ); + const row = rows[0] ? toRecord(rows[0]) : undefined; + return row && row.expires > now ? row : undefined; + } + async delete(kind: RecordKind, id: string) { + await this.pool.query(`DELETE FROM ${TABLE} WHERE kind = $1 AND id = $2`, [ + kind, + id, + ]); + } + async count(kind: RecordKind, now: number) { + const { rows } = await this.pool.query( + `SELECT count(*)::int AS n FROM ${TABLE} WHERE kind = $1 AND expires_at > $2`, + [kind, now], + ); + return Number(rows[0]?.n ?? 0); + } + async revokeSubject(clientId: string, subject: string, kinds: RecordKind[]) { + await this.pool.query( + `DELETE FROM ${TABLE} WHERE client_id = $1 AND subject = $2 AND kind = ANY($3::text[])`, + [clientId, subject, kinds], + ); + } + async prune(now: number) { + await this.pool.query(`DELETE FROM ${TABLE} WHERE expires_at <= $1`, [now]); + } + async close() { + await this.pool.end(); + } +} + +/** + * Seals record payloads with AES-256-GCM and derives revocation subjects with + * HMAC-SHA256. Both keys are HKDF subkeys of one 32-byte master key. The row's + * kind and id are bound as associated data, so a sealed payload cannot be + * moved onto another row. + */ +export class Sealer { + private sealKey: Buffer; + private subjectKey: Buffer; + constructor(masterKey: Buffer) { + if (masterKey.length !== 32) + throw new Error("OAuth encryption key must be 32 bytes"); + const derive = (info: string) => + Buffer.from(hkdfSync("sha256", masterKey, Buffer.alloc(0), info, 32)); + this.sealKey = derive("opa-mcp-oauth/seal/v1"); + this.subjectKey = derive("opa-mcp-oauth/subject/v1"); + } + static fromEnv(value: string | undefined) { + if (!value) return undefined; + const trimmed = value.trim(); + const key = /^[0-9a-fA-F]{64}$/.test(trimmed) + ? Buffer.from(trimmed, "hex") + : Buffer.from(trimmed, "base64"); + if (key.length !== 32) + throw new Error( + "OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY must be 32 bytes, base64 or hex encoded", + ); + return new Sealer(key); + } + static ephemeral() { + return new Sealer(randomBytes(32)); + } + seal(aad: string, payload: unknown): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", this.sealKey, iv); + cipher.setAAD(Buffer.from(aad)); + const body = Buffer.concat([ + cipher.update(JSON.stringify(payload), "utf8"), + cipher.final(), + ]); + return [ + "v1", + iv.toString("base64url"), + body.toString("base64url"), + cipher.getAuthTag().toString("base64url"), + ].join("."); + } + open(aad: string, sealed: string): T { + const [version, iv, body, tag] = sealed.split("."); + if (version !== "v1" || !iv || body === undefined || !tag) + throw new Error("Unsupported sealed record"); + const decipher = createDecipheriv( + "aes-256-gcm", + this.sealKey, + Buffer.from(iv, "base64url"), + ); + decipher.setAAD(Buffer.from(aad)); + decipher.setAuthTag(Buffer.from(tag, "base64url")); + return JSON.parse( + Buffer.concat([ + decipher.update(Buffer.from(body, "base64url")), + decipher.final(), + ]).toString("utf8"), + ) as T; + } + subject(apiKey: string) { + return createHmac("sha256", this.subjectKey) + .update(apiKey) + .digest("base64url"); + } +} + +export interface OAuthPersistence { + store: OAuthStore & { durable: boolean }; + sealer: Sealer; +} + +/** + * Chooses the store from the environment. Fails closed: production requires + * both a durable store and an encryption key, and a durable store always + * requires the key, so a misconfigured deploy refuses to start instead of + * silently falling back to memory or plaintext. + */ +export async function oauthPersistenceFromEnv( + env: NodeJS.ProcessEnv = process.env, + connect: typeof PostgresOAuthStore.connect = (o) => + PostgresOAuthStore.connect(o), +): Promise { + const production = env.NODE_ENV === "production"; + const databaseUrl = env.OILPRICEAPI_MCP_OAUTH_DATABASE_URL; + const sealer = Sealer.fromEnv(env.OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY); + if (!sealer && (production || databaseUrl)) + throw new Error( + "OILPRICEAPI_MCP_OAUTH_ENCRYPTION_KEY is required to store OAuth connections", + ); + if (!databaseUrl) { + if (production) + throw new Error( + "OILPRICEAPI_MCP_OAUTH_DATABASE_URL is required in production; in-memory OAuth state is lost on every restart", + ); + return { + store: new MemoryOAuthStore(), + sealer: sealer ?? Sealer.ephemeral(), + }; + } + const store = await connect({ + connectionString: databaseUrl, + caCert: env.OILPRICEAPI_MCP_OAUTH_DATABASE_CA_CERT || undefined, + }); + return { store, sealer: sealer! }; +} diff --git a/src/pluginOAuth.ts b/src/pluginOAuth.ts index 20ea2c3..e054f33 100644 --- a/src/pluginOAuth.ts +++ b/src/pluginOAuth.ts @@ -18,91 +18,135 @@ import { InvalidRequestError, TemporarilyUnavailableError, } from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import { + MemoryOAuthStore, + Sealer, + type OAuthPersistence, + type OAuthStore, + type RecordKind, + type StoredRecord, +} from "./oauthStore.js"; const opaque = () => randomBytes(32).toString("base64url"); const hash = (value: string) => createHash("sha256").update(value).digest("base64url"); const equal = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b)); +const CLIENT_TTL = 30 * 86400000; +const ACCESS_TTL = 3_600_000; +const REFRESH_TTL = 7 * 86400000; +interface StoredParams { + state?: string; + scopes?: string[]; + codeChallenge: string; + redirectUri: string; + resource?: string; +} interface Grant { - clientId: string; - params: AuthorizationParams; - expires: number; + params: StoredParams; key?: string; } interface Token { - clientId: string; key: string; - expires: number; resource: string; } +const storeParams = (params: AuthorizationParams): StoredParams => ({ + state: params.state, + scopes: params.scopes, + codeChallenge: params.codeChallenge, + redirectUri: params.redirectUri, + resource: params.resource?.href, +}); +export interface PluginOAuthOptions { + /** Durable store plus the sealer that encrypts every row's payload. */ + persistence?: OAuthPersistence; + /** Maximum live rows per kind before new authorizations are refused. */ + capacity?: number; +} export class PluginOAuth implements OAuthServerProvider { - private clients = new Map(); - private forms = new Map(); - private codes = new Map(); - // Completed links, kept briefly so a double-submitted form (Enter + click) - // gets the same redirect instead of a "session mismatch" error page that - // replaces the successful one. - private linked = new Map< - string, - { redirect: string; keyHash: string; expires: number } - >(); - private tokens = new Map(); - private refreshTokens = new Map(); + private store: OAuthStore; + private sealer: Sealer; + private capacityLimit: number; private cleanupTimer: ReturnType; readonly resource: string; readonly origin: string; constructor( publicUrl: string, private fetchImpl: typeof fetch = fetch, + options: PluginOAuthOptions = {}, ) { this.origin = new URL(publicUrl).origin; this.resource = this.origin + "/mcp"; - this.cleanupTimer = setInterval(() => this.prune(), 30_000); + this.store = options.persistence?.store ?? new MemoryOAuthStore(); + this.sealer = options.persistence?.sealer ?? Sealer.ephemeral(); + this.capacityLimit = options.capacity ?? 1000; + this.cleanupTimer = setInterval(() => { + this.store.prune(Date.now()).catch((error) => + console.log( + JSON.stringify({ + event: "oauth_store_prune_failed", + error: error instanceof Error ? error.constructor.name : "unknown", + }), + ), + ); + }, 30_000); this.cleanupTimer.unref(); } + /** Stops background pruning. Closing the store is its owner's job. */ close() { clearInterval(this.cleanupTimer); - for (const map of [ - this.forms, - this.codes, - this.tokens, - this.refreshTokens, - this.clients, - ]) - map.clear(); } - private prune() { - const now = Date.now(); - for (const [key, client] of this.clients) - if ((client.client_id_issued_at ?? 0) * 1000 + 30 * 86400000 <= now) - this.clients.delete(key); - for (const map of [ - this.forms, - this.codes, - this.tokens, - this.refreshTokens, - this.linked, - ]) - for (const [key, value] of map) if (value.expires <= now) map.delete(key); + private async put( + kind: RecordKind, + id: string, + clientId: string, + payload: unknown, + expires: number, + subject?: string, + ) { + await this.store.put(kind, id, { + clientId, + subject, + expires, + data: this.sealer.seal(`${kind}:${id}`, payload), + }); + } + private open(kind: RecordKind, id: string, record?: StoredRecord) { + if (!record) return undefined; + try { + return { + ...record, + payload: this.sealer.open(`${kind}:${id}`, record.data), + }; + } catch { + // Wrong key or tampered row: treat as absent, never as valid. + return undefined; + } } - private capacity(map: Map) { - this.prune(); - if (map.size >= 1000) + private async load(kind: RecordKind, id: string) { + return this.open(kind, id, await this.store.get(kind, id, Date.now())); + } + private async take(kind: RecordKind, id: string) { + return this.open(kind, id, await this.store.take(kind, id, Date.now())); + } + private async capacity(kind: RecordKind) { + if ((await this.store.count(kind, Date.now())) >= this.capacityLimit) throw new TemporarilyUnavailableError( "Authorization capacity reached. Retry later.", ); } get clientsStore() { return { - getClient: (id: string) => this.clients.get(id), - registerClient: ( + getClient: async (id: string) => + (await this.load("client", hash(id))) + ?.payload, + registerClient: async ( input: Omit< OAuthClientInformationFull, "client_id" | "client_id_issued_at" >, ) => { - this.capacity(this.clients); + await this.capacity("client"); const reject = (reason: string): never => { logRegistration("oauth_registration_rejected", input, reason); throw new InvalidClientMetadataError(reason); @@ -166,7 +210,15 @@ export class PluginOAuth implements OAuthServerProvider { delete client.client_secret; delete client.client_secret_expires_at; } else if (!client.client_secret) reject("Client secret unavailable"); - this.clients.set(client.client_id, client); + // The SDK compares client_secret directly, so the record (secret + // included) is sealed rather than hashed. + await this.put( + "client", + hash(client.client_id), + client.client_id, + client, + Date.now() + CLIENT_TTL, + ); logRegistration("oauth_client_registered", input); return client; }, @@ -187,13 +239,15 @@ export class PluginOAuth implements OAuthServerProvider { throw new InvalidScopeError("Only energy:read is supported"); if (!/^[A-Za-z0-9_-]{43}$/.test(params.codeChallenge)) throw new InvalidRequestError("S256 PKCE is required"); - this.capacity(this.forms); + await this.capacity("form"); const flow = opaque(); - this.forms.set(hash(flow), { - clientId: client.client_id, - params, - expires: Date.now() + 300_000, - }); + await this.put( + "form", + hash(flow), + client.client_id, + { params: storeParams(params) } satisfies Grant, + Date.now() + 300_000, + ); res.cookie("opa_oauth_flow", flow, { httpOnly: true, secure: true, @@ -212,7 +266,7 @@ export class PluginOAuth implements OAuthServerProvider { res .type("html") .send( - `Connect OilPriceAPI

Connect OilPriceAPI

Returning to ${escapeHtml(new URL(params.redirectUri).host)} after you connect.

Allow this connection to read supported energy datasets using your account's existing entitlements. No write access is requested.

Paste your OilPriceAPI API key. It stays on the server and is never returned to the model. Connections expire and may require reconnecting after a service restart.

Find your API key · Privacy · Support

`, + `Connect OilPriceAPI

Connect OilPriceAPI

Returning to ${escapeHtml(new URL(params.redirectUri).host)} after you connect.

Allow this connection to read supported energy datasets using your account's existing entitlements. No write access is requested.

Paste your OilPriceAPI API key. It is stored encrypted on the server and is never returned to the model. Connections expire after 7 days without use and then need reconnecting.

Find your API key · Privacy · Support

`, ); } async completeLink( @@ -220,15 +274,17 @@ export class PluginOAuth implements OAuthServerProvider { cookieFlow: string | undefined, key: string, ) { - this.prune(); - const done = flow ? this.linked.get(hash(flow)) : undefined; - if (done && equal(done.keyHash, hash(key))) return done.redirect; + const done = flow + ? await this.load<{ redirect: string }>("linked", hash(flow)) + : undefined; + if (done?.subject && equal(done.subject, this.sealer.subject(key))) + return done.payload.redirect; if (!cookieFlow || !equal(flow, cookieFlow)) throw new InvalidRequestError("Authorization session mismatch"); - const grant = this.forms.get(hash(flow)); - if (!grant) + const form = await this.take("form", hash(flow)); + if (!form) throw new InvalidGrantError("Authorization session expired. Reconnect."); - this.forms.delete(hash(flow)); + const grant = form.payload; if (!/^[A-Za-z0-9_.-]{16,256}$/.test(key)) throw new InvalidGrantError( "Invalid OilPriceAPI key. Reconnect with a valid key.", @@ -267,53 +323,82 @@ export class PluginOAuth implements OAuthServerProvider { } finally { clearTimeout(timer); } - this.capacity(this.codes); + await this.capacity("code"); + const subject = this.sealer.subject(key); const code = opaque(); - this.codes.set(hash(code), { ...grant, key, expires: Date.now() + 60_000 }); + await this.put( + "code", + hash(code), + form.clientId, + { params: grant.params, key } satisfies Grant, + Date.now() + 60_000, + subject, + ); const redirect = new URL(grant.params.redirectUri); redirect.searchParams.set("code", code); if (grant.params.state) redirect.searchParams.set("state", grant.params.state); redirect.searchParams.set("iss", this.origin + "/"); - this.capacity(this.linked); - this.linked.set(hash(flow), { - redirect: redirect.href, - keyHash: hash(key), - expires: Date.now() + 60_000, - }); + await this.capacity("linked"); + // The redirect carries the live code, so it is sealed like everything else. + await this.put( + "linked", + hash(flow), + form.clientId, + { redirect: redirect.href }, + Date.now() + 60_000, + subject, + ); return redirect.href; } async challengeForAuthorizationCode( client: OAuthClientInformationFull, code: string, ) { - this.prune(); - const grant = this.codes.get(hash(code)); + const grant = await this.load("code", hash(code)); if (!grant || grant.clientId !== client.client_id) throw new InvalidGrantError("Invalid authorization code"); - return grant.params.codeChallenge; + return grant.payload.params.codeChallenge; } - private issue(clientId: string, key: string): OAuthTokens { - this.capacity(this.tokens); - this.capacity(this.refreshTokens); + private async issue( + client: OAuthClientInformationFull, + key: string, + ): Promise { + await this.capacity("access"); + await this.capacity("refresh"); const access = opaque(); const refresh = opaque(); - this.tokens.set(hash(access), { - clientId, - key, - resource: this.resource, - expires: Date.now() + 3_600_000, - }); - this.refreshTokens.set(hash(refresh), { - clientId, - key, - resource: this.resource, - expires: Date.now() + 7 * 86400000, - }); + const subject = this.sealer.subject(key); + const token: Token = { key, resource: this.resource }; + await this.put( + "access", + hash(access), + client.client_id, + token, + Date.now() + ACCESS_TTL, + subject, + ); + await this.put( + "refresh", + hash(refresh), + client.client_id, + token, + Date.now() + REFRESH_TTL, + subject, + ); + // A client in active use outlives its refresh tokens, so a cached DCR + // client_id keeps working while the connection does. + await this.put( + "client", + hash(client.client_id), + client.client_id, + client, + Date.now() + Math.max(CLIENT_TTL, REFRESH_TTL), + ); return { access_token: access, token_type: "Bearer", - expires_in: 3600, + expires_in: ACCESS_TTL / 1000, refresh_token: refresh, scope: "energy:read", }; @@ -325,20 +410,22 @@ export class PluginOAuth implements OAuthServerProvider { redirect?: string, resource?: URL, ) { - this.prune(); - const grant = this.codes.get(hash(code)); + const found = await this.load("code", hash(code)); + const grant = found?.payload; if ( - !grant || - !grant.key || - grant.clientId !== client.client_id || + !found || + !grant?.key || + found.clientId !== client.client_id || resource?.href !== this.resource || redirect !== grant.params.redirectUri || (verifier !== undefined && !equal(hash(verifier), grant.params.codeChallenge)) ) throw new InvalidGrantError("Invalid authorization grant"); - this.codes.delete(hash(code)); - return this.issue(client.client_id, grant.key); + // Atomic consume: of two concurrent exchanges only one gets the row. + if (!(await this.store.take("code", hash(code), Date.now()))) + throw new InvalidGrantError("Invalid authorization grant"); + return this.issue(client, grant.key); } async exchangeRefreshToken( client: OAuthClientInformationFull, @@ -346,50 +433,52 @@ export class PluginOAuth implements OAuthServerProvider { scopes?: string[], resource?: URL, ) { - this.prune(); - const token = this.refreshTokens.get(hash(refresh)); + const found = await this.load("refresh", hash(refresh)); if ( - !token || - token.clientId !== client.client_id || + !found || + found.clientId !== client.client_id || resource?.href !== this.resource || scopes?.some((scope) => scope !== "energy:read") ) throw new InvalidGrantError("Invalid refresh grant. Reconnect."); - this.refreshTokens.delete(hash(refresh)); - return this.issue(client.client_id, token.key); + if (!(await this.store.take("refresh", hash(refresh), Date.now()))) + throw new InvalidGrantError("Invalid refresh grant. Reconnect."); + return this.issue(client, found.payload.key); } - async verifyAccessToken(access: string) { - this.prune(); - const token = this.tokens.get(hash(access)); + private async accessToken(access: string) { + const token = await this.load("access", hash(access)); if (!token) throw new InvalidTokenError("Invalid or expired connection. Reconnect."); + return token; + } + async verifyAccessToken(access: string) { + const token = await this.accessToken(access); return { token: access, clientId: token.clientId, scopes: ["energy:read"], expiresAt: Math.floor(token.expires / 1000), - resource: new URL(token.resource), + resource: new URL(token.payload.resource), }; } async credentialForAccessToken(access: string) { - await this.verifyAccessToken(access); - return this.tokens.get(hash(access))!.key; + return (await this.accessToken(access)).payload.key; } async revokeToken( client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest, ) { - const key = hash(request.token); + const id = hash(request.token); // Disconnect removes the associated credential from all active grants. - const entry = this.tokens.get(key) ?? this.refreshTokens.get(key); - if (entry?.clientId !== client.client_id) return; - for (const [id, grant] of this.codes) - if (grant.clientId === entry.clientId && grant.key === entry.key) - this.codes.delete(id); - for (const map of [this.tokens, this.refreshTokens]) - for (const [id, token] of map) - if (token.clientId === entry.clientId && token.key === entry.key) - map.delete(id); + const entry = + (await this.store.get("access", id, Date.now())) ?? + (await this.store.get("refresh", id, Date.now())); + if (entry?.clientId !== client.client_id || !entry.subject) return; + await this.store.revokeSubject(entry.clientId, entry.subject, [ + "code", + "access", + "refresh", + ]); } } export function createOAuthApp(provider: PluginOAuth) { diff --git a/src/remote.ts b/src/remote.ts index c868920..c50e8cb 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -8,6 +8,7 @@ import { randomBytes, createHmac } from "node:crypto"; import { pathToFileURL } from "node:url"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { PluginOAuth, createOAuthApp } from "./pluginOAuth.js"; +import { oauthPersistenceFromEnv } from "./oauthStore.js"; import { ACCOUNT_TOOLS, createEnergyServer, @@ -304,7 +305,7 @@ export function createRemoteServer(options: RemoteOptions) { httpServer.maxHeadersCount = 40; return httpServer; } -export function startRemote() { +export async function startRemote() { if (process.env.OILPRICEAPI_KEY || process.env.OIL_PRICE_API_KEY) throw new Error( "Remote service must not have a shared OilPriceAPI credential", @@ -312,7 +313,19 @@ export function startRemote() { const publicUrl = process.env.OILPRICEAPI_MCP_PUBLIC_URL; if (!publicUrl || new URL(publicUrl).protocol !== "https:") throw new Error("OILPRICEAPI_MCP_PUBLIC_URL must be stable HTTPS"); - const oauth = new PluginOAuth(publicUrl); + // Fails closed: production refuses to start without a durable store and an + // encryption key, so connections survive deploys (#155). + const persistence = await oauthPersistenceFromEnv(); + console.log( + JSON.stringify({ + event: "oauth_store_ready", + durable: persistence.store.durable, + }), + ); + const oauth = new PluginOAuth(publicUrl, fetch, { + persistence, + capacity: persistence.store.durable ? 50_000 : 1000, + }); const server = createRemoteServer({ publicUrl, oauth, @@ -323,9 +336,15 @@ export function startRemote() { writeEvent: (event) => console.log(JSON.stringify(event)), }); server.listen(Number(process.env.PORT ?? 8080), "0.0.0.0"); - const shutdown = () => server.close(() => process.exit(0)); + const shutdown = () => + server.close(() => + persistence.store.close().finally(() => process.exit(0)), + ); process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) - startRemote(); + startRemote().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + });