Skip to content

Commit 82cc221

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents 1ccc846 + 8ad1441 commit 82cc221

9 files changed

Lines changed: 600 additions & 32 deletions

File tree

‎changes/unreleased/4211.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<!-- changes-log: packages/coding-agent/src/core/extensions/builtin/mcp/changes.md -->
2+
## 2026-10-24 - Validate OAuth discovery issuers before credential use (senpi#4194)
3+
4+
### What changed
5+
6+
- `auth/oauth-issuer.ts` validates metadata against the discovered authorization server, preserving exact PRM identifiers and the SDK's origin fallback for path-served resources without PRM.
7+
- `auth/oauth-provider.ts` validates both cached and new discovery, deletes credentials bound to another issuer before re-registration, stamps newly stored credentials with the validated issuer, and declares native registration for loopback redirects.
8+
- `auth/oauth.ts` checks the issuer of pasted error responses before exposing their contents and persists injected discovery through the same validation boundary.
9+
- `auth/oauth-refresh.ts` checks the client registration's issuer as well as the token's issuer before sending a refresh request.
10+
- Credential stamps use one canonical form that removes only an origin-only URL's final slash. Existing root-fallback sign-ins refresh without registering again, and grants without AS metadata are stamped from the validated discovery issuer.
11+
- Invalid cached metadata is rediscovered rather than requiring logout. A callback without trusted discovery is rejected before exchange, and redirect-less clients omit `application_type`.
12+
13+
### Why
14+
15+
- SDK discovery parsed metadata without comparing its issuer. Direct client-credentials grants and refresh could retain or use registrations belonging to a different issuer.
16+
17+
### Why an extension could not handle it
18+
19+
- Discovery persistence and credential use are owned by the builtin's SDK OAuth provider.
20+
21+
### Expected merge conflict zones
22+
23+
- LOW: discovery persistence in `auth/oauth-provider.ts`, pasted redirect parsing in `auth/oauth.ts`, and grant validation in `auth/oauth-refresh.ts`.
24+
25+
<!-- changes-log: packages/coding-agent/CHANGELOG.md -->
26+
### Fixed
27+
28+
- MCP OAuth validates metadata and response issuers before using credentials, discards registrations and tokens bound to another issuer, and declares native loopback registration. Metadata and callback issuers remain exact identifiers; credential stamps tolerate only the final slash of an origin-only URL, preserving existing root-fallback sign-ins and grants without AS metadata. Configured client IDs remain reusable configuration ([#4211](https://github.com/code-yeongyu/senpi/pull/4211)).
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { OAuthServerInfo } from "@modelcontextprotocol/sdk/client/auth.js";
2+
import { OAuthFlowError } from "./oauth-errors.ts";
3+
import type { McpStoredAuth } from "./token-store.ts";
4+
5+
// Credential stamps tolerate only the slash spelling of an origin-only URL, not URL normalization.
6+
export function canonicalAuthorizationServerIssuer(issuer: string): string {
7+
return issuer.replace(/^(https?:\/\/[^/?#]+)\/$/, "$1");
8+
}
9+
10+
// RFC 8414 section 3.3: identifiers from metadata are compared verbatim, not as normalized URLs.
11+
export function validatedAuthorizationServerIssuer(info: OAuthServerInfo, serverName: string): string {
12+
// The SDK's legacy fallback discovers root metadata and spells the origin with a trailing slash.
13+
// Only that configured-resource fallback is normalized; PRM-provided identifiers remain exact.
14+
const origin = new URL(info.authorizationServerUrl).origin;
15+
const issuer =
16+
!info.resourceMetadata?.authorization_servers?.length && info.authorizationServerUrl === `${origin}/`
17+
? canonicalAuthorizationServerIssuer(info.authorizationServerUrl)
18+
: info.authorizationServerUrl;
19+
if (info.authorizationServerMetadata !== undefined && info.authorizationServerMetadata.issuer !== issuer) {
20+
throw new OAuthFlowError(
21+
"needs_auth",
22+
`MCP server ${serverName} authorization server metadata issuer mismatch; restart authorization after correcting the server metadata.`,
23+
{ serverName },
24+
);
25+
}
26+
return issuer;
27+
}
28+
29+
export function credentialsMatchIssuer(record: McpStoredAuth | undefined, issuer: string): boolean {
30+
const grantIssuer = record?.issuer ?? record?.discoveryState?.authorizationServerUrl;
31+
const clientIssuer = record?.clientInfo?.issuer ?? grantIssuer;
32+
return [grantIssuer, clientIssuer].every(
33+
(stamp) =>
34+
stamp === undefined ||
35+
canonicalAuthorizationServerIssuer(stamp) === canonicalAuthorizationServerIssuer(issuer),
36+
);
37+
}

‎packages/coding-agent/src/core/extensions/builtin/mcp/auth/oauth-provider.ts‎

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import type {
99
import type { McpLogger } from "../log.ts";
1010
import { fingerprintSecret } from "../log.ts";
1111
import { OAuthFlowError } from "./oauth-errors.ts";
12+
import {
13+
canonicalAuthorizationServerIssuer,
14+
credentialsMatchIssuer,
15+
validatedAuthorizationServerIssuer,
16+
} from "./oauth-issuer.ts";
1217
import type { McpStoredAuth, McpTokenStore } from "./token-store.ts";
1318

1419
// Buffer before absolute token expiry at which a refresh is considered due.
@@ -31,19 +36,6 @@ export function tokenExpiresAt(tokens: OAuthTokens, now = Date.now()): number |
3136
return now + tokens.expires_in * 1000;
3237
}
3338

34-
/** Compares authorization server identifiers the way the SDK does: parsed, tolerating one trailing `/`. */
35-
export function sameAuthorizationServer(a: string, b: string): boolean {
36-
const normalize = (value: string): string => {
37-
try {
38-
return new URL(value).href.replace(/\/$/, "");
39-
} catch (error) {
40-
if (error instanceof TypeError) return value.replace(/\/$/, "");
41-
throw error;
42-
}
43-
};
44-
return normalize(a) === normalize(b);
45-
}
46-
4739
/**
4840
* The authorization server a stored grant belongs to (senpi#2940, GHSA-6qxp-vccf-f47h): its `issuer` stamp, or
4941
* for a record saved before the stamp existed, the authorization server its sign-in discovered. A record with
@@ -127,9 +119,18 @@ export class McpOAuthProvider implements OAuthClientProvider {
127119
return this.#options.clientMetadataUrl;
128120
}
129121

130-
get clientMetadata(): OAuthClientMetadata {
122+
get clientMetadata(): OAuthClientMetadata & { application_type?: "native" | "web" } {
131123
const redirect = this.#options.redirectUrl;
132124
return {
125+
...(redirect === undefined
126+
? {}
127+
: {
128+
application_type:
129+
!["http:", "https:"].includes(new URL(redirect).protocol) ||
130+
["127.0.0.1", "localhost", "[::1]"].includes(new URL(redirect).hostname)
131+
? "native"
132+
: "web",
133+
}),
133134
redirect_uris: redirect === undefined ? [] : [String(redirect)],
134135
grant_types: ["authorization_code", "refresh_token"],
135136
response_types: ["code"],
@@ -158,24 +159,59 @@ export class McpOAuthProvider implements OAuthClientProvider {
158159
}
159160

160161
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
161-
// The SDK stamps `info.issuer`; it is persisted with the rest so the binding survives a restart.
162-
await this.#store.update((current) => ({ ...(current ?? {}), clientInfo: info }));
162+
const discovery = this.discoveryState();
163+
const issuer =
164+
discovery === undefined
165+
? info.issuer && canonicalAuthorizationServerIssuer(info.issuer)
166+
: validatedAuthorizationServerIssuer(discovery, this.serverName);
167+
await this.#store.update((current) => ({ ...(current ?? {}), clientInfo: { ...info, issuer } }));
163168
}
164169

165170
async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
166-
await this.#store.update((current) => ({ ...freezeGrantAttribution(current), discoveryState: state }));
171+
const issuer = validatedAuthorizationServerIssuer(state, this.serverName);
172+
const discoveryState = { ...state, authorizationServerUrl: issuer };
173+
await this.#store.update((current) => {
174+
const attributed = freezeGrantAttribution(current);
175+
if (!credentialsMatchIssuer(attributed, issuer)) return { discoveryState };
176+
const clientIssuer = attributed.clientInfo?.issuer ?? storedGrantIssuer(attributed);
177+
return {
178+
...attributed,
179+
// Unattributed legacy registrations cannot be sent to the server discovered now.
180+
clientInfo:
181+
clientIssuer === undefined || attributed.clientInfo === undefined
182+
? undefined
183+
: { ...attributed.clientInfo, issuer },
184+
discoveryState,
185+
};
186+
});
167187
}
168188

169189
discoveryState(): OAuthDiscoveryState | undefined {
170-
return this.#store.read()?.discoveryState;
190+
const current = this.#store.read();
191+
const state = current?.discoveryState;
192+
if (state !== undefined) {
193+
try {
194+
const issuer = validatedAuthorizationServerIssuer(state, this.serverName);
195+
if (!credentialsMatchIssuer(current, issuer)) return undefined;
196+
} catch (error) {
197+
if (error instanceof OAuthFlowError) return undefined;
198+
throw error;
199+
}
200+
}
201+
return state;
171202
}
172203

173204
tokens(): OAuthTokens | undefined {
174205
return storedAuthToTokens(this.#store.read());
175206
}
176207

177208
async saveTokens(tokens: OAuthTokens): Promise<void> {
178-
await this.#store.update((current) => mergeTokensIntoStoredAuth(current, tokens, this.serverUrl));
209+
const discovery = this.discoveryState();
210+
const issuer =
211+
discovery === undefined
212+
? tokens.issuer && canonicalAuthorizationServerIssuer(tokens.issuer)
213+
: validatedAuthorizationServerIssuer(discovery, this.serverName);
214+
await this.#store.update((current) => mergeTokensIntoStoredAuth(current, { ...tokens, issuer }, this.serverUrl));
179215
this.#logFingerprint("saveTokens", tokens.access_token);
180216
}
181217

‎packages/coding-agent/src/core/extensions/builtin/mcp/auth/oauth-refresh.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { loadMcpSdkAuth } from "../sdk.lazy.ts";
55
import { safeDelay } from "../wrap.ts";
66
import { isInvalidGrant, isTransientTokenError, OAuthFlowError } from "./oauth-errors.ts";
77
import { oauthFetch } from "./oauth-fetch.ts";
8+
import { credentialsMatchIssuer, validatedAuthorizationServerIssuer } from "./oauth-issuer.ts";
89
import {
910
type McpOAuthProvider,
1011
mergeTokensIntoStoredAuth,
1112
REFRESH_LEEWAY_MS,
12-
sameAuthorizationServer,
1313
storedAuthToTokens,
1414
storedGrantIssuer,
1515
} from "./oauth-provider.ts";
@@ -91,8 +91,8 @@ export class McpRefreshManager {
9191
// elsewhere, or one that cannot be attributed at all, is a continuity break: drop it and require a fresh
9292
// sign-in rather than refreshing silently against whatever server discovery now names.
9393
const issuer = storedGrantIssuer(current);
94-
const authorizationServer = String(info.authorizationServerUrl);
95-
if (issuer === undefined || !sameAuthorizationServer(issuer, authorizationServer)) {
94+
const authorizationServer = validatedAuthorizationServerIssuer(info, this.#provider.serverName);
95+
if (issuer === undefined || !credentialsMatchIssuer(current, authorizationServer)) {
9696
this.#provider.store.writeUnlocked(undefined);
9797
throw new OAuthFlowError(
9898
"needs_auth",

‎packages/coding-agent/src/core/extensions/builtin/mcp/auth/oauth.ts‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
88
import { loadMcpSdkAuth } from "../sdk.lazy.ts";
99
import { isInvalidGrant, OAuthFlowError } from "./oauth-errors.ts";
1010
import { oauthFetch } from "./oauth-fetch.ts";
11+
import { validatedAuthorizationServerIssuer } from "./oauth-issuer.ts";
1112
import type { McpOAuthProvider } from "./oauth-provider.ts";
1213
import { assertS256Supported } from "./oauth-refresh.ts";
1314

@@ -51,7 +52,7 @@ export async function completeAuthorization(
5152
redirectInput: string,
5253
options: OAuthFlowOptions = {},
5354
): Promise<void> {
54-
const { code, state, iss } = parseRedirect(redirectInput, provider.serverName);
55+
const { code, state, iss } = parseRedirect(redirectInput, provider);
5556
if (!provider.consumeState(state)) {
5657
throw new OAuthFlowError(
5758
"state_mismatch",
@@ -101,14 +102,23 @@ export async function finishAuthorization(
101102
// RFC 9207: an authorization response that names another authorization server, or that omits `iss`
102103
// although this server promises to send it, may have been injected by a mix-up attack; never exchange its code.
103104
function assertAuthorizationResponseIssuer(provider: McpOAuthProvider, iss: string | undefined): void {
104-
const metadata = provider.discoveryState()?.authorizationServerMetadata;
105-
if (metadata === undefined) return;
105+
const discovery = provider.discoveryState();
106+
if (discovery === undefined) {
107+
throw new OAuthFlowError(
108+
"needs_auth",
109+
`MCP server ${provider.serverName} has no trusted authorization discovery; restart with /mcp auth-start ${provider.serverName}.`,
110+
{ serverName: provider.serverName },
111+
);
112+
}
113+
const metadata = discovery.authorizationServerMetadata;
114+
const issuer = validatedAuthorizationServerIssuer(discovery, provider.serverName);
106115
// The SDK keeps unknown metadata fields (loose schema) but does not type this RFC 9207 one.
107116
const promised =
108-
(metadata as { authorization_response_iss_parameter_supported?: unknown })
109-
.authorization_response_iss_parameter_supported === true;
117+
metadata !== undefined &&
118+
"authorization_response_iss_parameter_supported" in metadata &&
119+
metadata.authorization_response_iss_parameter_supported === true;
110120
if (iss === undefined && !promised) return;
111-
if (iss !== undefined && iss === metadata.issuer) return;
121+
if (iss !== undefined && iss === issuer) return;
112122
throw new OAuthFlowError(
113123
"needs_auth",
114124
iss === undefined
@@ -165,19 +175,21 @@ export async function clientCredentialsGrant(
165175
resource: new URL(provider.serverUrl),
166176
fetchFn: oauthFetch(options.fetchFn),
167177
});
168-
await provider.saveTokens({ ...tokens, issuer: String(info.authorizationServerUrl) });
178+
await provider.saveTokens({ ...tokens, issuer: validatedAuthorizationServerIssuer(info, provider.serverName) });
169179
}
170180

171181
export async function logout(provider: McpOAuthProvider): Promise<void> {
172182
await provider.store.clear();
173183
}
174184

175185
async function discover(provider: McpOAuthProvider, options: OAuthFlowOptions): Promise<OAuthServerInfo> {
176-
if (options.discover !== undefined) return options.discover(provider.serverUrl);
177186
const cached = provider.discoveryState();
178187
if (cached !== undefined) return cached;
179188
const { discoverOAuthServerInfo } = await loadMcpSdkAuth();
180-
const info = await discoverOAuthServerInfo(provider.serverUrl, { fetchFn: oauthFetch(options.fetchFn) });
189+
const info =
190+
options.discover !== undefined
191+
? await options.discover(provider.serverUrl)
192+
: await discoverOAuthServerInfo(provider.serverUrl, { fetchFn: oauthFetch(options.fetchFn) });
181193
await provider.saveDiscoveryState(toDiscoveryState(info));
182194
return info;
183195
}
@@ -192,8 +204,9 @@ function toDiscoveryState(info: OAuthServerInfo): OAuthDiscoveryState {
192204

193205
function parseRedirect(
194206
input: string,
195-
serverName: string,
207+
provider: McpOAuthProvider,
196208
): { code: string; state: string | undefined; iss: string | undefined } {
209+
const serverName = provider.serverName;
197210
let url: URL;
198211
try {
199212
url = new URL(input.trim());
@@ -204,6 +217,7 @@ function parseRedirect(
204217
{ serverName },
205218
);
206219
}
220+
assertAuthorizationResponseIssuer(provider, url.searchParams.get("iss") ?? undefined);
207221
const error = url.searchParams.get("error");
208222
if (error !== null) {
209223
throw new OAuthFlowError("needs_auth", `MCP server ${serverName} authorization failed: ${error}`, { serverName });

0 commit comments

Comments
 (0)