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
7 changes: 7 additions & 0 deletions .changeset/bind-oauth-credentials-to-issuer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/sdk': patch
Comment thread
maxisbey marked this conversation as resolved.
---

Bind stored OAuth client credentials to the authorization server that issued them. `auth()` adds an `issuer` property to what it passes to `saveClientInformation()` / `saveTokens()` and does not reuse a stored value with a different authorization server: the client registers or
authorizes again, or `auth()` throws when the credentials cannot be re-created by registering. `OAuthTokensSchema` and `OAuthClientInformationSchema` accept the optional `issuer`. `ClientCredentialsProvider`, `PrivateKeyJwtProvider` and `StaticPrivateKeyJwtProvider` accept
`expectedIssuer`; omitting it is deprecated.
5 changes: 5 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ For OAuth-secured MCP servers, the client `auth` module exposes:
- `PrivateKeyJwtProvider`
- `StaticPrivateKeyJwtProvider`

Pass `expectedIssuer` (the issuer URL of the authorization server the credentials were registered with) to each of these. `auth()` then only presents the credentials to that authorization server and throws if the MCP server advertises a different one. Omitting it is deprecated.
Comment thread
maxisbey marked this conversation as resolved.

If you implement `OAuthClientProvider` yourself, store the objects passed to `saveClientInformation()` and `saveTokens()` unchanged: they carry an `issuer` property naming the authorization server they came from, and `auth()` does not reuse them with a different one. If
`clientInformation()` returns pre-registered credentials, include `issuer` yourself.

Examples:

- [`simpleOAuthClient.ts`](../src/examples/client/simpleOAuthClient.ts)
Expand Down
72 changes: 67 additions & 5 deletions src/client/auth-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ export function createPrivateKeyJwtAuth(options: {
};
}

/**
* The `issuer` stamp for constructor-supplied client information. Omitting `expectedIssuer` is
* deprecated: nothing else says which authorization server such credentials belong to.
*/
function checkedExpectedIssuer(expectedIssuer: string | undefined): string | undefined {
if (expectedIssuer === undefined) {
// eslint-disable-next-line no-console
console.warn(
'[mcp-sdk] Omitting `expectedIssuer` is deprecated. Without it, the MCP server decides which authorization ' +
"server receives this client's credentials; pass your authorization server's issuer URL as " +
'`expectedIssuer` so they are only sent there.'
);
} else if (typeof expectedIssuer !== 'string' || !expectedIssuer) {
throw new Error("expectedIssuer must be the authorization server's issuer URL");
Comment thread
maxisbey marked this conversation as resolved.
}
return expectedIssuer;
}

/**
* Options for creating a ClientCredentialsProvider.
*/
Expand All @@ -113,6 +131,16 @@ export interface ClientCredentialsProviderOptions {
* Space-separated scopes values requested by the client.
*/
scope?: string;

/**
* The issuer URL of the authorization server these credentials were registered with.
* When set, `auth()` only presents them to that authorization server and throws if the
* MCP server advertises a different URL (one trailing slash aside).
*
* Omitting it is deprecated: the credentials then go to whichever authorization server
* the MCP server advertises first.
*/
expectedIssuer?: string;
}

/**
Expand All @@ -124,7 +152,8 @@ export interface ClientCredentialsProviderOptions {
* @example
* const provider = new ClientCredentialsProvider({
* clientId: 'my-client',
* clientSecret: 'my-secret'
* clientSecret: 'my-secret',
* expectedIssuer: 'https://auth.example.com'
* });
*
* const transport = new StreamableHTTPClientTransport(serverUrl, {
Expand All @@ -136,10 +165,14 @@ export class ClientCredentialsProvider implements OAuthClientProvider {
private _clientInfo: OAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;

constructor(options: ClientCredentialsProviderOptions & { expectedIssuer: string });
/** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */
constructor(options: ClientCredentialsProviderOptions);
constructor(options: ClientCredentialsProviderOptions) {
this._clientInfo = {
client_id: options.clientId,
client_secret: options.clientSecret
client_secret: options.clientSecret,
issuer: checkedExpectedIssuer(options.expectedIssuer)
};
this._clientMetadata = {
client_name: options.clientName ?? 'client-credentials-client',
Expand Down Expand Up @@ -227,6 +260,16 @@ export interface PrivateKeyJwtProviderOptions {
* Space-separated scopes values requested by the client.
*/
scope?: string;

/**
* The issuer URL of the authorization server these credentials were registered with.
* When set, `auth()` only presents them to that authorization server and throws if the
* MCP server advertises a different URL (one trailing slash aside).
*
* Omitting it is deprecated: the credentials then go to whichever authorization server
* the MCP server advertises first.
*/
expectedIssuer?: string;
}

/**
Expand All @@ -239,7 +282,8 @@ export interface PrivateKeyJwtProviderOptions {
* const provider = new PrivateKeyJwtProvider({
* clientId: 'my-client',
* privateKey: pemEncodedPrivateKey,
* algorithm: 'RS256'
* algorithm: 'RS256',
* expectedIssuer: 'https://auth.example.com'
* });
*
* const transport = new StreamableHTTPClientTransport(serverUrl, {
Expand All @@ -252,9 +296,13 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider {
private _clientMetadata: OAuthClientMetadata;
addClientAuthentication: AddClientAuthentication;

constructor(options: PrivateKeyJwtProviderOptions & { expectedIssuer: string });
/** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */
constructor(options: PrivateKeyJwtProviderOptions);
constructor(options: PrivateKeyJwtProviderOptions) {
this._clientInfo = {
client_id: options.clientId
client_id: options.clientId,
issuer: checkedExpectedIssuer(options.expectedIssuer)
};
this._clientMetadata = {
client_name: options.clientName ?? 'private-key-jwt-client',
Expand Down Expand Up @@ -341,6 +389,16 @@ export interface StaticPrivateKeyJwtProviderOptions {
* Space-separated scopes values requested by the client.
*/
scope?: string;

/**
* The issuer URL of the authorization server these credentials were registered with.
* When set, `auth()` only presents them to that authorization server and throws if the
* MCP server advertises a different URL (one trailing slash aside).
*
* Omitting it is deprecated: the credentials then go to whichever authorization server
* the MCP server advertises first.
*/
expectedIssuer?: string;
}

/**
Expand All @@ -356,9 +414,13 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
private _clientMetadata: OAuthClientMetadata;
addClientAuthentication: AddClientAuthentication;

constructor(options: StaticPrivateKeyJwtProviderOptions & { expectedIssuer: string });
/** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */
constructor(options: StaticPrivateKeyJwtProviderOptions);
constructor(options: StaticPrivateKeyJwtProviderOptions) {
this._clientInfo = {
client_id: options.clientId
client_id: options.clientId,
issuer: checkedExpectedIssuer(options.expectedIssuer)
};
this._clientMetadata = {
client_name: options.clientName ?? 'static-private-key-jwt-client',
Expand Down
126 changes: 112 additions & 14 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export interface OAuthClientProvider {
* Loads information about this OAuth client, as registered already with the
* server, or returns `undefined` if the client is not registered with the
* server.
*
* A provider that returns pre-registered credentials should include `issuer`, the URL of
* the authorization server they were registered with; {@linkcode auth} then does not
* present them to any other.
*/
clientInformation(): OAuthClientInformationMixed | undefined | Promise<OAuthClientInformationMixed | undefined>;

Expand All @@ -84,6 +88,10 @@ export interface OAuthClientProvider {
*
* This method is not required to be implemented if client information is
* statically known (e.g., pre-registered).
*
* The object carries `issuer`, the authorization server it was obtained from. Store it
* unchanged so that {@linkcode auth} does not reuse the registration with a different one.
* Client information stored without `issuer` is passed here, with it, after its first successful use.
*/
saveClientInformation?(clientInformation: OAuthClientInformationMixed): void | Promise<void>;

Expand All @@ -96,6 +104,9 @@ export interface OAuthClientProvider {
/**
* Stores new OAuth tokens for the current session, after a successful
* authorization.
*
* The object carries `issuer`, the authorization server that issued the tokens. Store it
* unchanged so that {@linkcode auth} does not present the refresh token to a different one.
*/
saveTokens(tokens: OAuthTokens): void | Promise<void>;

Expand Down Expand Up @@ -392,6 +403,47 @@ export async function parseErrorResponse(input: Response | string): Promise<OAut
}
}

/**
* Compares two authorization server identifiers, tolerating a single trailing `/`
* difference (`String(new URL(...))` is slash-suffixed, advertised values often are not).
*/
function issuersMatch(a: string, b: string): boolean {
let [x, y] = [a, b];
try {
// Two URLs are compared as parsed, so the spelling of scheme, host or default port does not matter.
[x, y] = [new URL(a).href, new URL(b).href];
} catch {
// Not two URLs: compared as written.
}
return x === y || (x.endsWith('/') && x.slice(0, -1) === y) || (y.endsWith('/') && y.slice(0, -1) === x);
}
Comment thread
maxisbey marked this conversation as resolved.

/**
* {@linkcode auth} stamps everything it passes to `saveClientInformation` / `saveTokens` with
* `issuer`, the authorization server URL used for discovery. This returns `stored` unless its
* stamp names a different authorization server, in which case the caller behaves as if nothing
* were stored. A value without a stamp (saved by an earlier version) is returned as-is, and
* is stamped once this authorization server has accepted it.
*/
function discardIfIssuerMismatch<T extends { issuer?: string }>(stored: T | null | undefined, issuer: string): T | undefined {
// `null`: a `JSON.parse(storage.getItem(...))`-style getter with nothing stored.
if (!stored) return undefined;
// A stamp that is not a string (raw storage) counts as no stamp.
if (typeof stored.issuer !== 'string') return stored.issuer == null ? stored : { ...stored, issuer: undefined };
return issuersMatch(stored.issuer, issuer) ? stored : undefined;
}

// `issuer` is added by the client when it stores a value; authorization server responses are parsed without it.
const TokenResponseSchema = OAuthTokensSchema.omit({ issuer: true });
const RegistrationResponseSchema = OAuthClientInformationFullSchema.omit({ issuer: true });

function boundElsewhereError(stored: { issuer?: string }, issuer: string): Error {
return new Error(
`OAuth client information is bound to authorization server ${stored.issuer} and is not presented to ${issuer}. ` +
'Clear the stored client information, or correct `expectedIssuer`, if the authorization server has moved.'
);
}

/**
* Orchestrates the full auth flow with a server.
*
Expand Down Expand Up @@ -503,6 +555,12 @@ async function authInternal(
});
}

// Binding key for stored credentials: the authorization server URL that discovery used
// (advertised by the resource server, restored from discoveryState(), or the legacy
// resource-origin fallback). `metadata.issuer` is deliberately not used: this version does
// not check that the metadata document echoes the URL it was fetched from.
const issuer = String(authorizationServerUrl);

// Send the metadata's resource indicator verbatim: `selectResourceURL` returns a parsed
// `URL`, and `URL.href` appends "/" to a pathless indicator such as `https://example.com`,
// which exact-match authorization servers reject (#1968). A URL returned by the
Expand All @@ -518,8 +576,26 @@ async function authInternal(
// The resolved scope is used consistently for both DCR and the authorization request.
const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;

// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
// Handle client registration if needed. Client information stamped for a different
// authorization server reads back as `undefined`, so the flow re-registers exactly as if
// nothing were stored.
const storedClientInformation = await Promise.resolve(provider.clientInformation());
let clientInformation = discardIfIssuerMismatch(storedClientInformation, issuer);
const canRegisterAgain = provider.saveClientInformation !== undefined && !!provider.redirectUrl;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: pre-existing, security-relevant: interactive clients with a custom addClientAuthentication still hand their long-lived client credential to whatever authorization server the MCP server names. The relaxed canRegisterAgain at src/client/auth.ts:571 re-registers at the new server and then runs the provider's custom authentication against it, although 1.x never checks that server's metadata.issuer. A rogue server can echo the original issuer and receive an assertion audience-bound to it. Fix: before re-registering past a stored binding for a provider with addClientAuthentication, verify the new server's metadata.issuer matches the discovery URL, or keep throwing for that shape as the previous revision did; the SEP-991 path needs the same check.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A provider has redirectUrl, saveClientInformation and a custom addClientAuthentication built with createPrivateKeyJwtAuth (static iss/sub = the client_id registered at AS-A, key held by the app), and its stored client information is stamped AS-A. A malicious MCP server advertises its own AS-B. At src/client/auth.ts:570 the stored value reads back undefined; src/client/auth.ts:571 now makes canRegisterAgain true because the addClientAuthentication === undefined clause was dropped, so src/client/auth.ts:612-620 registers at AS-B and the flow redirects there. AS-B auto-approves and returns a code; fetchToken at src/client/auth.ts:1519-1523 calls the custom authentication with AS-B's metadata. createPrivateKeyJwtAuth at src/client/auth-extensions.ts:38 takes the audience from metadata?.issuer, and src/client/auth.ts:545-549 states this version never compares metadata.issuer with the URL it was fetched from, so AS-B sets issuer to AS-A's and receives a signed assertion valid at AS-A for 300 s (client_credentials or any grant AS-A allows that client). The base branch leaks the same way (it…

Verification: pre-existing (security-relevant). Triggering condition: an interactive provider (redirectUrl + saveClientInformation) with a custom addClientAuthentication (e.g. createPrivateKeyJwtAuth with static iss/sub, or a SEP-991 URL-based client whose key is in its metadata document) whose stored client information is stamped AS-A, when a malicious MCP server advertises its own AS-B. Mechanism…

if (storedClientInformation && !clientInformation && !canRegisterAgain) {
// Pre-registered credentials, or a non-interactive client running on configured
// credentials: neither can be re-created by registering with this authorization server.
throw boundElsewhereError(storedClientInformation, issuer);
}
// Saved by an earlier version: bound to the first authorization server that accepts it.
const unstampedClientInformation = clientInformation?.issuer == null ? clientInformation : undefined;
const bindClientInformation = async () => {
try {
if (unstampedClientInformation) await provider.saveClientInformation?.({ ...unstampedClientInformation, issuer });
} catch {
Comment thread
maxisbey marked this conversation as resolved.
// A provider that only expects this call after a registration keeps working, unbound.
}
};
if (!clientInformation) {
if (authorizationCode !== undefined) {
throw new Error('Existing OAuth client information is required when exchanging an authorization code');
Comment thread
maxisbey marked this conversation as resolved.
Expand All @@ -538,9 +614,7 @@ async function authInternal(

if (shouldUseUrlBasedClientId) {
// SEP-991: URL-based Client IDs
clientInformation = {
client_id: clientMetadataUrl
};
clientInformation = { client_id: clientMetadataUrl, issuer };
await provider.saveClientInformation?.(clientInformation);
} else {
// Fallback to dynamic registration
Expand All @@ -555,8 +629,8 @@ async function authInternal(
fetchFn
});

await provider.saveClientInformation(fullInformation);
clientInformation = fullInformation;
clientInformation = { ...fullInformation, issuer };
await provider.saveClientInformation(clientInformation);
}
}

Expand All @@ -572,11 +646,22 @@ async function authInternal(
fetchFn
});

await provider.saveTokens(tokens);
await bindClientInformation();
await provider.saveTokens({ ...tokens, issuer });
return 'AUTHORIZED';
}

const tokens = await provider.tokens();
// A refresh token stamped for a different authorization server reads back as `undefined`,
// so it is never posted to this one's token endpoint.
const tokens = discardIfIssuerMismatch(await provider.tokens(), issuer);
if (tokens?.refresh_token && tokens.issuer == null) {
// eslint-disable-next-line no-console
console.warn(
"[mcp-sdk] stored OAuth tokens have no 'issuer' property (saved by an earlier version, or by a provider that " +
'does not keep it) and are used as-is; make sure your OAuthClientProvider stores what saveTokens() and ' +
'saveClientInformation() receive unchanged.'
);
}
Comment thread
maxisbey marked this conversation as resolved.
Comment thread
maxisbey marked this conversation as resolved.

// Handle token refresh or new authorization
if (tokens?.refresh_token) {
Expand All @@ -591,7 +676,8 @@ async function authInternal(
fetchFn
});

await provider.saveTokens(newTokens);
await bindClientInformation();
await provider.saveTokens({ ...newTokens, issuer });
return 'AUTHORIZED';
} catch (error) {
// If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry.
Expand Down Expand Up @@ -1276,7 +1362,7 @@ async function executeTokenRequest(
throw await parseErrorResponse(response);
}

return OAuthTokensSchema.parse(await response.json());
return TokenResponseSchema.parse(await response.json());
}

/**
Expand Down Expand Up @@ -1415,6 +1501,17 @@ export async function fetchToken(
fetchFn?: FetchLike;
} = {}
): Promise<OAuthTokens> {
// Nothing is sent to an authorization server other than the one the client information is stamped for.
const readClientInformation = async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): maintainers get two new async closures in the OAuth client layer with no declared return type, against the CLAUDE.md rule of explicit return types. readClientInformation at src/client/auth.ts:1505 and bindClientInformation at src/client/auth.ts:592 are both async () => {...} with an inferred type. Fix: annotate them (Promise<OAuthClientInformationMixed | undefined> and Promise<void>), which covers the 2 sites listed. Same instruction at 2 sites (src/client/auth.ts:1505, src/client/auth.ts:592).

Why this was flagged

Nothing fails at runtime. The diff adds const readClientInformation = async () => { at src/client/auth.ts:1505 and const bindClientInformation = async () => { at src/client/auth.ts:592, neither with a return type annotation. CLAUDE.md lists "Strict type checking, ES modules, explicit return types" as the TypeScript convention. Without the annotation the return type of readClientInformation is inferred from discardIfIssuerMismatch, so a future change to that helper's return type silently changes what fetchToken passes to executeTokenRequest at src/client/auth.ts:1541 instead of failing typecheck at the closure. The base branch has neither closure.

Verification: nit. Triggering condition: none at runtime; this is a repository-instruction violation only. The instruction exists as quoted: /home/claude/typescript-sdk/CLAUDE.md line 20 reads "- TypeScript: Strict type checking, ES modules, explicit return types", with no qualifier limiting it to exported or top-level functions. Both cited sites are added by this diff (git diff 289ac2c..HEAD --… | nit.…

const storedClientInformation = await provider.clientInformation();
const checked = discardIfIssuerMismatch(storedClientInformation, String(authorizationServerUrl));
if (storedClientInformation && !checked) {
throw boundElsewhereError(storedClientInformation, String(authorizationServerUrl));
}
return checked;
};
let clientInformation = await readClientInformation();

const scope = provider.clientMetadata.scope;

// Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code
Expand All @@ -1435,12 +1532,13 @@ export async function fetchToken(
tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl);
}

const clientInformation = await provider.clientInformation();
// A provider may fill in its client information while the request is prepared.
clientInformation ??= await readClientInformation();

return executeTokenRequest(authorizationServerUrl, {
metadata,
tokenRequestParams,
clientInformation: clientInformation ?? undefined,
clientInformation,
addClientAuthentication: provider.addClientAuthentication,
resource,
fetchFn
Expand Down Expand Up @@ -1495,5 +1593,5 @@ export async function registerClient(
throw await parseErrorResponse(response);
}

return OAuthClientInformationFullSchema.parse(await response.json());
return RegistrationResponseSchema.parse(await response.json());
}
Loading
Loading