Skip to content

Commit 480a683

Browse files
committed
feat(client/auth): validate RFC 9207 iss parameter to mitigate mix-up attacks
Adds an optional `iss` argument to `finishAuth()` and validates it against the cached authorization server metadata before exchanging the code, per the RFC 9207 §2.4 decision table keyed on `authorization_response_iss_parameter_supported`. Reference implementation for SEP-2468.
1 parent dfe91e1 commit 480a683

5 files changed

Lines changed: 139 additions & 5 deletions

File tree

src/client/auth.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,58 @@ export class UnauthorizedError extends Error {
239239
}
240240
}
241241

242+
/**
243+
* Thrown when RFC 9207 `iss` parameter validation fails. The authorization
244+
* code MUST NOT be sent to any token endpoint after this is thrown.
245+
*/
246+
export class IssuerMismatchError extends UnauthorizedError {
247+
constructor(message: string) {
248+
super(message);
249+
this.name = 'IssuerMismatchError';
250+
}
251+
}
252+
253+
/**
254+
* Validates the `iss` parameter from an authorization response against the
255+
* authorization server's metadata, per RFC 9207 §2.4. The four cases are
256+
* keyed on whether the AS advertises `authorization_response_iss_parameter_supported`:
257+
*
258+
* | advertised | iss present | outcome |
259+
* |---|---|---|
260+
* | true | yes, matches | accept |
261+
* | true | yes, mismatch | reject |
262+
* | true | no | reject (server promised it) |
263+
* | false/undefined | yes | reject (unexpected, possible injection) |
264+
* | false/undefined | no | accept (server doesn't support 9207) |
265+
*
266+
* Comparison is simple string comparison per RFC 3986 §6.2.1 — no
267+
* normalization of case, ports, or trailing slashes.
268+
*/
269+
export function validateAuthorizationResponseIssuer(
270+
receivedIss: string | undefined,
271+
metadata: AuthorizationServerMetadata | undefined
272+
): void {
273+
const supported = metadata?.authorization_response_iss_parameter_supported === true;
274+
const expectedIssuer = metadata?.issuer;
275+
276+
if (supported) {
277+
if (receivedIss === undefined) {
278+
throw new IssuerMismatchError(
279+
'Authorization server advertises authorization_response_iss_parameter_supported but no iss parameter was received'
280+
);
281+
}
282+
if (receivedIss !== expectedIssuer) {
283+
throw new IssuerMismatchError(
284+
`Authorization response iss "${receivedIss}" does not match expected issuer "${expectedIssuer}"`
285+
);
286+
}
287+
} else if (receivedIss !== undefined) {
288+
throw new IssuerMismatchError(
289+
'Authorization server does not advertise authorization_response_iss_parameter_supported but an iss parameter was received'
290+
);
291+
}
292+
}
293+
242294
type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';
243295

244296
function isClientAuthMethod(method: string): method is ClientAuthMethod {
@@ -403,6 +455,13 @@ export async function auth(
403455
options: {
404456
serverUrl: string | URL;
405457
authorizationCode?: string;
458+
/**
459+
* The `iss` parameter from the authorization response redirect URI
460+
* (RFC 9207). When provided alongside `authorizationCode`, it is
461+
* validated against the authorization server metadata before the
462+
* code is exchanged.
463+
*/
464+
authorizationResponseIssuer?: string;
406465
scope?: string;
407466
resourceMetadataUrl?: URL;
408467
fetchFn?: FetchLike;
@@ -430,12 +489,14 @@ async function authInternal(
430489
{
431490
serverUrl,
432491
authorizationCode,
492+
authorizationResponseIssuer,
433493
scope,
434494
resourceMetadataUrl,
435495
fetchFn
436496
}: {
437497
serverUrl: string | URL;
438498
authorizationCode?: string;
499+
authorizationResponseIssuer?: string;
439500
scope?: string;
440501
resourceMetadataUrl?: URL;
441502
fetchFn?: FetchLike;
@@ -559,6 +620,9 @@ async function authInternal(
559620

560621
// Exchange authorization code for tokens, or fetch tokens directly for non-interactive flows
561622
if (authorizationCode !== undefined || nonInteractiveFlow) {
623+
if (authorizationCode !== undefined) {
624+
validateAuthorizationResponseIssuer(authorizationResponseIssuer, metadata);
625+
}
562626
const tokens = await fetchToken(provider, authorizationServerUrl, {
563627
metadata,
564628
resource,

src/client/sse.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,15 +216,19 @@ export class SSEClientTransport implements Transport {
216216

217217
/**
218218
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
219+
*
220+
* @param authorizationCode - The `code` parameter from the redirect URI.
221+
* @param iss - The `iss` parameter from the redirect URI, if present (RFC 9207). When the authorization server advertises support, omitting this will cause auth to fail.
219222
*/
220-
async finishAuth(authorizationCode: string): Promise<void> {
223+
async finishAuth(authorizationCode: string, iss?: string): Promise<void> {
221224
if (!this._authProvider) {
222225
throw new UnauthorizedError('No auth provider');
223226
}
224227

225228
const result = await auth(this._authProvider, {
226229
serverUrl: this._url,
227230
authorizationCode,
231+
authorizationResponseIssuer: iss,
228232
resourceMetadataUrl: this._resourceMetadataUrl,
229233
scope: this._scope,
230234
fetchFn: this._fetchWithInit

src/client/streamableHttp.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,15 +421,19 @@ export class StreamableHTTPClientTransport implements Transport {
421421

422422
/**
423423
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
424+
*
425+
* @param authorizationCode - The `code` parameter from the redirect URI.
426+
* @param iss - The `iss` parameter from the redirect URI, if present (RFC 9207). When the authorization server advertises support, omitting this will cause auth to fail.
424427
*/
425-
async finishAuth(authorizationCode: string): Promise<void> {
428+
async finishAuth(authorizationCode: string, iss?: string): Promise<void> {
426429
if (!this._authProvider) {
427430
throw new UnauthorizedError('No auth provider');
428431
}
429432

430433
const result = await auth(this._authProvider, {
431434
serverUrl: this._url,
432435
authorizationCode,
436+
authorizationResponseIssuer: iss,
433437
resourceMetadataUrl: this._resourceMetadataUrl,
434438
scope: this._scope,
435439
fetchFn: this._fetchWithInit

src/shared/auth.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ export const OAuthMetadataSchema = z.looseObject({
6666
introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(),
6767
introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(),
6868
code_challenge_methods_supported: z.array(z.string()).optional(),
69-
client_id_metadata_document_supported: z.boolean().optional()
69+
client_id_metadata_document_supported: z.boolean().optional(),
70+
authorization_response_iss_parameter_supported: z.boolean().optional()
7071
});
7172

7273
/**
@@ -109,7 +110,8 @@ export const OpenIdProviderMetadataSchema = z.looseObject({
109110
require_request_uri_registration: z.boolean().optional(),
110111
op_policy_uri: SafeUrlSchema.optional(),
111112
op_tos_uri: SafeUrlSchema.optional(),
112-
client_id_metadata_document_supported: z.boolean().optional()
113+
client_id_metadata_document_supported: z.boolean().optional(),
114+
authorization_response_iss_parameter_supported: z.boolean().optional()
113115
});
114116

115117
/**

test/client/auth.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
auth,
1414
type OAuthClientProvider,
1515
selectClientAuthMethod,
16-
isHttpsUrl
16+
isHttpsUrl,
17+
validateAuthorizationResponseIssuer,
18+
IssuerMismatchError
1719
} from '../../src/client/auth.js';
1820
import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js';
1921
import { InvalidClientMetadataError, ServerError } from '../../src/server/auth/errors.js';
@@ -3682,4 +3684,62 @@ describe('OAuth Authorization', () => {
36823684
});
36833685
});
36843686
});
3687+
3688+
describe('validateAuthorizationResponseIssuer (RFC 9207)', () => {
3689+
const issuer = 'https://auth.example.com';
3690+
const baseMetadata: AuthorizationServerMetadata = {
3691+
issuer,
3692+
authorization_endpoint: `${issuer}/authorize`,
3693+
token_endpoint: `${issuer}/token`,
3694+
response_types_supported: ['code']
3695+
};
3696+
3697+
it('accepts matching iss when server advertises support', () => {
3698+
expect(() =>
3699+
validateAuthorizationResponseIssuer(issuer, {
3700+
...baseMetadata,
3701+
authorization_response_iss_parameter_supported: true
3702+
})
3703+
).not.toThrow();
3704+
});
3705+
3706+
it('rejects mismatched iss when server advertises support', () => {
3707+
expect(() =>
3708+
validateAuthorizationResponseIssuer('https://attacker.example.com', {
3709+
...baseMetadata,
3710+
authorization_response_iss_parameter_supported: true
3711+
})
3712+
).toThrow(IssuerMismatchError);
3713+
});
3714+
3715+
it('rejects absent iss when server advertises support', () => {
3716+
expect(() =>
3717+
validateAuthorizationResponseIssuer(undefined, {
3718+
...baseMetadata,
3719+
authorization_response_iss_parameter_supported: true
3720+
})
3721+
).toThrow(IssuerMismatchError);
3722+
});
3723+
3724+
it('rejects unexpected iss when server does not advertise support', () => {
3725+
expect(() => validateAuthorizationResponseIssuer(issuer, baseMetadata)).toThrow(IssuerMismatchError);
3726+
});
3727+
3728+
it('accepts absent iss when server does not advertise support', () => {
3729+
expect(() => validateAuthorizationResponseIssuer(undefined, baseMetadata)).not.toThrow();
3730+
});
3731+
3732+
it('accepts absent iss when metadata is undefined', () => {
3733+
expect(() => validateAuthorizationResponseIssuer(undefined, undefined)).not.toThrow();
3734+
});
3735+
3736+
it('uses simple string comparison without normalization', () => {
3737+
expect(() =>
3738+
validateAuthorizationResponseIssuer(`${issuer}/`, {
3739+
...baseMetadata,
3740+
authorization_response_iss_parameter_supported: true
3741+
})
3742+
).toThrow(IssuerMismatchError);
3743+
});
3744+
});
36853745
});

0 commit comments

Comments
 (0)