diff --git a/CHANGELOG.md b/CHANGELOG.md index c2582e0..1600741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this package are documented here. ## Unreleased +## 0.26.3 — 2026-09-28 + +This patch lets downstream OAuth connections use a public client metadata +URL when a server supports URL-based client IDs but restricts dynamic +registration. Existing OAuth configurations keep their current behavior. +The Node template now pins 0.26.3. + +### Added + +- `remoteMcp` OAuth auth accepts `clientMetadataUrl` and default `scope`. + The SDK negotiates URL-based client IDs, falling back to dynamic registration + when unsupported. Existing PKCE, issuer binding, encrypted storage, refresh, + and disconnect remain in use. Invalid settings fail at construction. + ## 0.26.2 — 2026-09-27 This patch restores named client tokens as an optional auth module. Deployments diff --git a/documentation/auth.md b/documentation/auth.md index b6ee28a..be98726 100644 --- a/documentation/auth.md +++ b/documentation/auth.md @@ -615,3 +615,26 @@ present, must match that owner and may then manage the connector, while an interactive provider's explicit 403 still refuses the flow. See [meta-tools](./meta-tools.md#authorization-recovery) for the recovery shapes a caller actually receives. + +## URL-based downstream OAuth clients + +`remoteMcp` accepts `auth: { type: "oauth", clientMetadataUrl, scope }`. +`clientMetadataUrl` names a public HTTPS OAuth client metadata document with a +non-root path. It must not contain credentials or a fragment. The document +must include its own URL as `client_id`, the deployment's exact +`/oauth/callback/` in `redirect_uris`, authorization-code and +refresh-token grants, and `token_endpoint_auth_method: "none"`. Hosting that +public document belongs to the deployment; Connecta does not expose a public +route through inbound authentication. + +The SDK uses the URL as the client ID only when the authorization server +advertises `client_id_metadata_document_supported`. Otherwise it keeps the +existing dynamic registration flow. State validation, PKCE, issuer binding, +encrypted token storage, refresh, and disconnect follow the same code paths. +The metadata URL and scopes participate in the saved-client configuration +binding, so restarting after either changes cannot reuse an old registration. + +`scope` supplies space-separated default OAuth scopes through client metadata. +A downstream challenge or protected-resource scope declaration takes precedence, +and the SDK adds `offline_access` when advertised for refresh-token grants. +Omitting both settings preserves the existing discovery and registration flow. diff --git a/package-lock.json b/package-lock.json index c263cdd..47b1f39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@zackbart/connecta", - "version": "0.26.2", + "version": "0.26.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@zackbart/connecta", - "version": "0.26.2", + "version": "0.26.3", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.1.1", diff --git a/package.json b/package.json index fd00cbd..17d66d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zackbart/connecta", - "version": "0.26.2", + "version": "0.26.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 eight meta-tools.", diff --git a/src/auth/downstream-oauth.ts b/src/auth/downstream-oauth.ts index d0786da..22774d0 100644 --- a/src/auth/downstream-oauth.ts +++ b/src/auth/downstream-oauth.ts @@ -1093,6 +1093,7 @@ function retirementTimes(raw: string | null): Map { * drives transport.finishAuth(code). */ export class KvOAuthProvider implements OAuthClientProvider { + readonly clientMetadataUrl?: string; /** * The reset generation this provider's flow started under. Every OAuth value * it writes carries this epoch, so a late write can land after a reset without @@ -1128,7 +1129,12 @@ export class KvOAuthProvider implements OAuthClientProvider { private readonly clientBinding?: string, /** Request-local observer so the operator route drains every reset it began. */ private readonly onReset?: (reset: Promise) => void, - ) {} + /** SDK uses this only when the authorization server advertises support. */ + clientMetadataUrl?: string, + private readonly scope?: string, + ) { + if (clientMetadataUrl !== undefined) this.clientMetadataUrl = clientMetadataUrl; + } /** * Stamp the force-reauth generation the current connect flow started under. @@ -1730,6 +1736,7 @@ export class KvOAuthProvider implements OAuthClientProvider { grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", + ...(this.scope !== undefined ? { scope: this.scope } : {}), }; } diff --git a/src/connectors/remote-mcp.ts b/src/connectors/remote-mcp.ts index f4dae10..3fb66e4 100644 --- a/src/connectors/remote-mcp.ts +++ b/src/connectors/remote-mcp.ts @@ -79,7 +79,13 @@ interface RemoteMcpCredentialAuth { export type RemoteMcpAuth = | { type: "headers"; headers: Record } | RemoteMcpCredentialAuth - | { type: "oauth" }; + | { + type: "oauth"; + /** Public HTTPS client metadata document, for servers supporting URL-based client IDs. */ + clientMetadataUrl?: string; + /** Space-separated default scopes when the resource server does not advertise them. */ + scope?: string; + }; /** * Apply a maintained provider's slot copy and header framing to credential @@ -674,6 +680,20 @@ interface ConnectionState { * server or hide other connectors). */ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector { + const clientMetadataUrl = opts.auth?.type === "oauth" ? opts.auth.clientMetadataUrl : undefined; + const oauthScope = opts.auth?.type === "oauth" ? opts.auth.scope : undefined; + if (oauthScope !== undefined && !/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(oauthScope)) { + throw new Error(`[connecta] connector "${id}" OAuth scope must contain space-separated scope tokens.`); + } + if (clientMetadataUrl !== undefined) { + let valid = false; + try { + const url = new URL(clientMetadataUrl); + valid = url.protocol === "https:" && url.pathname !== "/" && + !url.username && !url.password && !url.hash; + } catch { /* Invalid configuration is rejected below without echoing its value. */ } + if (!valid) throw new Error(`[connecta] connector "${id}" clientMetadataUrl must be an HTTPS URL with a non-root path, without credentials or a fragment.`); + } if (opts.authScope === "personal" && opts.auth?.type === "headers") { throw new Error( `[connecta] connector "${id}" cannot combine authScope "personal" ` + @@ -943,12 +963,16 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector { grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", + ...(oauthScope !== undefined ? { scope: oauthScope } : {}), }, authScope: opts.authScope ?? "shared", versionNegotiation: opts.versionNegotiation ?? "auto", redirects: opts.redirects ?? "none", + clientMetadataUrl, }), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), + clientMetadataUrl, + oauthScope, ); if (state) state.provider = provider; return provider; diff --git a/src/version.ts b/src/version.ts index d12961f..e6a0f1d 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.26.2"; +export const CONNECTA_VERSION = "0.26.3"; diff --git a/templates/node/package.json b/templates/node/package.json index df217f6..45395bf 100644 --- a/templates/node/package.json +++ b/templates/node/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@zackbart/connecta": "0.26.2", + "@zackbart/connecta": "0.26.3", "quickjs-emscripten": "0.32.0" }, "devDependencies": { diff --git a/test/downstream-oauth.test.ts b/test/downstream-oauth.test.ts index 7494797..23e4ead 100644 --- a/test/downstream-oauth.test.ts +++ b/test/downstream-oauth.test.ts @@ -149,12 +149,18 @@ describe("KvOAuthProvider over memoryStorage", () => { await expect(callback.discoveryState()).resolves.toEqual(discovery); }); - it("finishes OAuth from a non-default protected-resource metadata URL", async () => { + it.each([false, true])("finishes OAuth from non-default metadata, URL client ID: %s", async (urlClient) => { 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 clientMetadataUrl = "https://connecta.test/oauth-client.json"; + const makeProvider = () => new KvOAuthProvider( + "svc", storage, REDIRECT, undefined, true, undefined, undefined, undefined, undefined, + urlClient ? clientMetadataUrl : undefined, + "full mcp", + ); const fetchStub: FetchLike = async (input, init = {}) => { const url = new URL(input); if (url.href === metadataUrl) { @@ -166,6 +172,8 @@ describe("KvOAuthProvider over memoryStorage", () => { if (url.href === `${issuer}/.well-known/oauth-authorization-server`) { return Response.json({ issuer, + client_id_metadata_document_supported: urlClient, + scopes_supported: ["full", "mcp", "offline_access"], authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, registration_endpoint: `${issuer}/register`, @@ -176,6 +184,7 @@ describe("KvOAuthProvider over memoryStorage", () => { }); } if (url.href === `${issuer}/register`) { + expect(urlClient).toBe(false); expect(init.method).toBe("POST"); return Response.json({ client_id: "registered-client", @@ -189,6 +198,7 @@ describe("KvOAuthProvider over memoryStorage", () => { expect(init.method).toBe("POST"); expect(init.body).toBeInstanceOf(URLSearchParams); expect((init.body as URLSearchParams).get("code")).toBe("auth-code"); + expect((init.body as URLSearchParams).get("client_id")).toBe(urlClient ? clientMetadataUrl : "registered-client"); return Response.json({ access_token: "access-token", token_type: "Bearer", @@ -197,7 +207,7 @@ describe("KvOAuthProvider over memoryStorage", () => { throw new Error(`Unexpected OAuth test request: ${url.href}`); }; - const start = new KvOAuthProvider("svc", storage, REDIRECT); + const start = makeProvider(); await expect( auth(start, { serverUrl: mcpUrl, @@ -207,7 +217,9 @@ describe("KvOAuthProvider over memoryStorage", () => { ).resolves.toBe("REDIRECT"); const pending = new URL((await start.pendingAuthorizationUrl())!); - const callback = new KvOAuthProvider("svc", storage, REDIRECT); + expect(pending.searchParams.get("client_id")).toBe(urlClient ? clientMetadataUrl : "registered-client"); + expect(pending.searchParams.get("scope")).toBe("full mcp offline_access"); + const callback = makeProvider(); expect(await callback.verifyState(pending.searchParams.get("state"))).toBe( true, ); diff --git a/test/remote-mcp.test.ts b/test/remote-mcp.test.ts index 2497bcb..9889463 100644 --- a/test/remote-mcp.test.ts +++ b/test/remote-mcp.test.ts @@ -888,6 +888,14 @@ describe("downstream session termination", () => { }); describe("remoteMcp() destination guard", () => { + it.each(["", " full", "full ", "read\nwrite", 'read"write', "read\\write"])("rejects invalid OAuth scope %s", (scope) => { + expect(() => remoteMcp("svc", { url: "https://example.com/mcp", auth: { type: "oauth", scope } })).toThrow(/OAuth scope/); + }); + + it.each(["", "not-a-url", "http://example.com/client.json", "https://example.com/", "https://user:secret@example.com/client.json", "https://example.com/client.json#fragment"])("rejects invalid client metadata URL %s", (clientMetadataUrl) => { + expect(() => remoteMcp("svc", { url: "https://example.com/mcp", auth: { type: "oauth", clientMetadataUrl } })).toThrow(/clientMetadataUrl/); + }); + it.each([ ["warns when static headers auth would travel over http://", "cleartext", "http://example.com/mcp", true, true], ["does not warn for headers auth over https://", "secure", "https://example.com/mcp", true, false],