Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions documentation/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand All @@ -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
Expand Down Expand Up @@ -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`**.
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
24 changes: 24 additions & 0 deletions src/auth/downstream-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthClientProvider,
OAuthDiscoveryState,
OAuthTokens,
} from "@modelcontextprotocol/client";
import type { KVStorage } from "../types.js";
Expand Down Expand Up @@ -34,6 +35,7 @@ const OAUTH_VALUE_KEYS = [
"oauth:pending",
"oauth:verifier",
"oauth:state",
"oauth:discovery",
] as const;
const MAX_CLEANUP_BACKLOG = 1_000;

Expand Down Expand Up @@ -807,6 +809,23 @@ export class KvOAuthProvider implements OAuthClientProvider {
);
}

async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
return (
await this.readValue(
"oauth:discovery",
(raw) => JSON.parse(raw) as OAuthDiscoveryState,
)
)?.value;
}

async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
await this.writeValue(
"oauth:discovery",
state,
(value) => JSON.stringify(value),
);
}

async tokens(
ctx?: OAuthClientInformationContext,
): Promise<OAuthTokens | undefined> {
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
2 changes: 1 addition & 1 deletion templates/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@zackbart/connecta": "0.22.2",
"@zackbart/connecta": "0.22.3",
"quickjs-emscripten": "0.32.0"
},
"devDependencies": {
Expand Down
111 changes: 111 additions & 0 deletions test/downstream-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
FetchLike,
OAuthClientInformationContext,
OAuthClientInformationFull,
OAuthDiscoveryState,
OAuthTokens,
Transport,
} from "@modelcontextprotocol/client";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -381,6 +475,7 @@ describe("KvOAuthProvider over memoryStorage", () => {
"oauth:pending",
"oauth:verifier",
"oauth:state",
"oauth:discovery",
]) {
await backing.set(key, key);
}
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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();
});
});
Expand Down