diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f60a1..cedd657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to this package are documented here. +## 0.22.3 — 2026-09-03 + +This patch carries OAuth discovery across the browser redirect. It matters for +downstream MCP servers whose authentication challenge names a protected-resource +metadata URL outside the RFC 9728 default path, including Cloudflare Access +Managed OAuth. Existing grants and connectors using the default path require no +configuration or storage migration. + +### Fixed + +- **Non-default OAuth discovery survives callbacks.** The KV OAuth provider now + persists the SDK's discovery state in the authorization generation, restores + it in the callback's fresh request scope, and deletes it through discovery, + all-credential, and generation cleanup. A callback therefore exchanges its + code against the same validated authorization-server issuer that registered + the client instead of invalidating that client after rediscovery (#523). + ## 0.22.2 — 2026-09-01 This patch makes the maintained-provider release check credential-free and diff --git a/documentation/upgrading.md b/documentation/upgrading.md index 6ccddb0..65b7564 100644 --- a/documentation/upgrading.md +++ b/documentation/upgrading.md @@ -57,7 +57,7 @@ exist so far: | --- | --- | --- | | **pre-template** | before 0.10.2 | no `connecta init` existed; hand-written, or copied from the retired `examples/node` | | **A** | 0.10.2 – 0.15.1 | `.env.example`, `.gitignore`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `package.json`, `src/index.ts`, `tsconfig.json` | -| **B** | 0.16.0 – 0.22.2 | adds `.dockerignore`, `Dockerfile`, `docker-compose.yml`, and `src/file-activity.ts`; `src/index.ts` grows the four commented operator blocks; `.env.example` ships `CONNECTA_TOKEN=` empty | +| **B** | 0.16.0 – 0.22.3 | adds `.dockerignore`, `Dockerfile`, `docker-compose.yml`, and `src/file-activity.ts`; `src/index.ts` grows the four commented operator blocks; `.env.example` ships `CONNECTA_TOKEN=` empty | Generation A is a decade in template years and identifying it precisely does not matter, because you are about to reconstruct it exactly rather than guess @@ -106,7 +106,7 @@ know what to preserve, once to know what to re-verify at the end. ### Bump the pin and install ```sh -npm pkg set dependencies.@zackbart/connecta=0.22.2 +npm pkg set dependencies.@zackbart/connecta=0.22.3 npm install ``` @@ -130,7 +130,7 @@ Generate the *current* template beside the base you already made, into the same `$SCRATCH`: ```sh -(cd "$SCRATCH" && npx @zackbart/connecta@0.22.2 init current) +(cd "$SCRATCH" && npx @zackbart/connecta@0.22.3 init current) ``` You now have a three-way merge with a real base: `$SCRATCH/base` is what this @@ -186,7 +186,7 @@ A deployment older than 0.10.2 has no base to diff against. Do not try to manufacture one. Instead: 1. `SCRATCH=$(mktemp -d)`, then - `(cd "$SCRATCH" && npx @zackbart/connecta@0.22.2 init current)` — there is no + `(cd "$SCRATCH" && npx @zackbart/connecta@0.22.3 init current)` — there is no `base` leg here, only the current template to read from. 2. Copy `$SCRATCH/current` into the deployment file by file, **skipping `src/index.ts`**. @@ -207,7 +207,7 @@ first, so cross them bottom-up: start at the oldest one still above this deployment's pin and work back up the page, because each boundary assumes the older ones are already done. -### 0.21.2 → 0.22.2 +### 0.21.2 → 0.22.3 Connector and user policy remain config-as-code. If `identity.connectorAccess` is configured, every interactive human may now manage the authentication of diff --git a/package-lock.json b/package-lock.json index 5221d9d..2e0d90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@zackbart/connecta", - "version": "0.22.2", + "version": "0.22.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@zackbart/connecta", - "version": "0.22.2", + "version": "0.22.3", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.1.1", diff --git a/package.json b/package.json index 5c53704..898d457 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zackbart/connecta", - "version": "0.22.2", + "version": "0.22.3", "type": "module", "sideEffects": false, "description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.", diff --git a/src/auth/downstream-oauth.ts b/src/auth/downstream-oauth.ts index ad63fa1..af65c64 100644 --- a/src/auth/downstream-oauth.ts +++ b/src/auth/downstream-oauth.ts @@ -4,6 +4,7 @@ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, + OAuthDiscoveryState, OAuthTokens, } from "@modelcontextprotocol/client"; import type { KVStorage } from "../types.js"; @@ -34,6 +35,7 @@ const OAUTH_VALUE_KEYS = [ "oauth:pending", "oauth:verifier", "oauth:state", + "oauth:discovery", ] as const; const MAX_CLEANUP_BACKLOG = 1_000; @@ -807,6 +809,23 @@ export class KvOAuthProvider implements OAuthClientProvider { ); } + async discoveryState(): Promise { + return ( + await this.readValue( + "oauth:discovery", + (raw) => JSON.parse(raw) as OAuthDiscoveryState, + ) + )?.value; + } + + async saveDiscoveryState(state: OAuthDiscoveryState): Promise { + await this.writeValue( + "oauth:discovery", + state, + (value) => JSON.stringify(value), + ); + } + async tokens( ctx?: OAuthClientInformationContext, ): Promise { @@ -1028,6 +1047,7 @@ export class KvOAuthProvider implements OAuthClientProvider { oauthValueStorageKey("oauth:client", generation), oauthValueStorageKey("oauth:tokens", generation), oauthValueStorageKey("oauth:verifier", generation), + oauthValueStorageKey("oauth:discovery", generation), ]); } else if (scope === "client") { await this.storage.delete( @@ -1041,6 +1061,10 @@ export class KvOAuthProvider implements OAuthClientProvider { await this.storage.delete( oauthValueStorageKey("oauth:verifier", generation), ); + } else if (scope === "discovery") { + await this.storage.delete( + oauthValueStorageKey("oauth:discovery", generation), + ); } if (endsRefresh) { this.failRefreshFlight( diff --git a/src/version.ts b/src/version.ts index bf4b3e1..ee8ab24 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,4 +4,4 @@ * a bump that forgets this file fails the build rather than shipping a stale * version to `/health` and to downstream MCP handshakes. */ -export const CONNECTA_VERSION = "0.22.2"; +export const CONNECTA_VERSION = "0.22.3"; diff --git a/templates/node/package.json b/templates/node/package.json index 95a5123..e10dffd 100644 --- a/templates/node/package.json +++ b/templates/node/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@zackbart/connecta": "0.22.2", + "@zackbart/connecta": "0.22.3", "quickjs-emscripten": "0.32.0" }, "devDependencies": { diff --git a/test/downstream-oauth.test.ts b/test/downstream-oauth.test.ts index 7782668..a7cc2f7 100644 --- a/test/downstream-oauth.test.ts +++ b/test/downstream-oauth.test.ts @@ -3,6 +3,7 @@ import type { FetchLike, OAuthClientInformationContext, OAuthClientInformationFull, + OAuthDiscoveryState, OAuthTokens, Transport, } from "@modelcontextprotocol/client"; @@ -92,6 +93,99 @@ describe("KvOAuthProvider over memoryStorage", () => { expect(await p.tokens()).toEqual(tokens); }); + it("persists discovery state across OAuth callback request scopes", async () => { + const storage = memoryStorage(); + const start = new KvOAuthProvider("svc", storage, REDIRECT); + const discovery: OAuthDiscoveryState = { + authorizationServerUrl: "https://auth.example", + resourceMetadataUrl: + "https://downstream.example/.well-known/custom-protected-resource", + resourceMetadata: { + resource: "https://downstream.example/mcp", + authorization_servers: ["https://auth.example"], + }, + }; + + await start.saveDiscoveryState(discovery); + + const callback = new KvOAuthProvider("svc", storage, REDIRECT); + await expect(callback.discoveryState()).resolves.toEqual(discovery); + }); + + it("finishes OAuth from a non-default protected-resource metadata URL", async () => { + const storage = memoryStorage(); + const issuer = "https://auth.example"; + const mcpUrl = "https://downstream.example/mcp"; + const metadataUrl = + "https://downstream.example/.well-known/custom-protected-resource/mcp"; + const fetchStub: FetchLike = async (input, init = {}) => { + const url = new URL(input); + if (url.href === metadataUrl) { + return Response.json({ + resource: mcpUrl, + authorization_servers: [issuer], + }); + } + if (url.href === `${issuer}/.well-known/oauth-authorization-server`) { + return Response.json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }); + } + if (url.href === `${issuer}/register`) { + expect(init.method).toBe("POST"); + return Response.json({ + client_id: "registered-client", + redirect_uris: [REDIRECT], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }); + } + if (url.href === `${issuer}/token`) { + expect(init.method).toBe("POST"); + expect(init.body).toBeInstanceOf(URLSearchParams); + expect((init.body as URLSearchParams).get("code")).toBe("auth-code"); + return Response.json({ + access_token: "access-token", + token_type: "Bearer", + }); + } + throw new Error(`Unexpected OAuth test request: ${url.href}`); + }; + + const start = new KvOAuthProvider("svc", storage, REDIRECT); + await expect( + auth(start, { + serverUrl: mcpUrl, + resourceMetadataUrl: new URL(metadataUrl), + fetchFn: fetchStub, + }), + ).resolves.toBe("REDIRECT"); + const pending = new URL((await start.pendingAuthorizationUrl())!); + + const callback = new KvOAuthProvider("svc", storage, REDIRECT); + expect(await callback.verifyState(pending.searchParams.get("state"))).toBe( + true, + ); + await expect( + auth(callback, { + serverUrl: mcpUrl, + authorizationCode: "auth-code", + fetchFn: fetchStub, + }), + ).resolves.toBe("AUTHORIZED"); + await expect(callback.tokens()).resolves.toMatchObject({ + access_token: "access-token", + }); + }); + it("binds client registration and tokens to the validated authorization issuer", async () => { const storage = memoryStorage(); const p = new KvOAuthProvider("svc", storage, REDIRECT); @@ -381,6 +475,7 @@ describe("KvOAuthProvider over memoryStorage", () => { "oauth:pending", "oauth:verifier", "oauth:state", + "oauth:discovery", ]) { await backing.set(key, key); } @@ -396,12 +491,14 @@ describe("KvOAuthProvider over memoryStorage", () => { "oauth:pending", "oauth:verifier", "oauth:state", + "oauth:discovery", ]); expect(await backing.get("oauth:client")).toBeNull(); expect(await backing.get("oauth:tokens")).toBe("oauth:tokens"); expect(await backing.get("oauth:pending")).toBeNull(); expect(await backing.get("oauth:verifier")).toBeNull(); expect(await backing.get("oauth:state")).toBeNull(); + expect(await backing.get("oauth:discovery")).toBeNull(); // The surviving physical token is legacy residue outside the active // generation namespace, so it is no longer a usable credential. expect(await p.tokens()).toBeUndefined(); @@ -713,6 +810,9 @@ describe("KvOAuthProvider over memoryStorage", () => { }); await p.saveTokens({ access_token: "at", token_type: "Bearer" }); await p.saveCodeVerifier("v-123"); + await p.saveDiscoveryState({ + authorizationServerUrl: "https://auth.example", + }); }; const pTokens = provider(); @@ -721,24 +821,35 @@ describe("KvOAuthProvider over memoryStorage", () => { expect(await pTokens.tokens()).toBeUndefined(); expect(await pTokens.clientInformation()).toBeDefined(); expect(await pTokens.codeVerifier()).toBe("v-123"); + expect(await pTokens.discoveryState()).toBeDefined(); const pClient = provider(); await seed(pClient); await pClient.invalidateCredentials("client"); expect(await pClient.clientInformation()).toBeUndefined(); expect(await pClient.tokens()).toBeDefined(); + expect(await pClient.discoveryState()).toBeDefined(); const pVerifier = provider(); await seed(pVerifier); await pVerifier.invalidateCredentials("verifier"); await expect(pVerifier.codeVerifier()).rejects.toThrow(); expect(await pVerifier.tokens()).toBeDefined(); + expect(await pVerifier.discoveryState()).toBeDefined(); + + const pDiscovery = provider(); + await seed(pDiscovery); + await pDiscovery.invalidateCredentials("discovery"); + expect(await pDiscovery.discoveryState()).toBeUndefined(); + expect(await pDiscovery.clientInformation()).toBeDefined(); + expect(await pDiscovery.tokens()).toBeDefined(); const pAll = provider(); await seed(pAll); await pAll.invalidateCredentials("all"); expect(await pAll.clientInformation()).toBeUndefined(); expect(await pAll.tokens()).toBeUndefined(); + expect(await pAll.discoveryState()).toBeUndefined(); await expect(pAll.codeVerifier()).rejects.toThrow(); }); });