SEP-2352: authorization-server migration scenario (#286)

* Add SEP-2352 authorization-server migration scenario

SEP-2352 requires that client credentials are bound to the issuing
authorization server: when PRM authorization_servers changes to a new
issuer, clients MUST re-register and MUST NOT reuse the previous AS's
client credentials.

- Traceability yaml: 3 checks, 3 excluded (internal state / UI)
- New auth/authorization-server-migration scenario (draft suite): two
  auth servers; PRM flips from AS1 to AS2 after the first authenticated
  request; AS2 asserts it received a fresh /register and never saw AS1's
  client_id at /authorize or /token
- ConformanceOAuthProvider gains invalidateCredentials and bindIssuer so
  the everything-client can key credentials by issuer (passing example)
- everything-client adds an issuer-aware handler for this scenario that
  re-reads PRM on each 401 and rebinds before re-authorizing
- auth-test-reuse-credentials negative client + vitest case

* Drop application_type from DCR metadata (not in SDK OAuthClientMetadata type)

---------

Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
Den Delimarsky 2026-05-19 14:59:31 -07:00 • committed by GitHub
commit 1ef3cd34bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 387 additions and 2 deletions

View file

@ -0,0 +1,35 @@
/**
* Negative client for SEP-2352: uses a single provider with no issuer keying,
* so when PRM authorization_servers changes it presents the previous AS's
* client_id at the new AS. Expected to trigger
* sep-2352-no-reuse-on-as-change / sep-2352-reregister-on-as-change FAILURE.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry';
import { runAsCli } from './helpers/cliRunner';
export async function runClient(serverUrl: string): Promise<void> {
const oauthFetch = withOAuthRetry(
'auth-test-reuse-credentials',
new URL(serverUrl),
handle401
)(fetch);
const client = new Client(
{ name: 'auth-test-reuse-credentials', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
await client.connect(transport);
await client.listTools();
await client.callTool({ name: 'test-tool', arguments: {} });
await transport.close();
}
runAsCli(
runClient,
import.meta.url,
'auth-test-reuse-credentials <server-url>'
);

View file

@ -21,6 +21,11 @@ import {
} from '@modelcontextprotocol/sdk/client/auth-extensions.js';
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
import {
auth,
extractWWWAuthenticateParams
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import {
withOAuthRetry,
withOAuthRetryWithProvider,
@ -270,6 +275,72 @@ registerScenarios(
runAuthClient
);
// SEP-2352: a well-behaved client keys credentials by issuer. Before each
// (re-)authorization, fetch PRM and rebind the provider; bindIssuer clears
// stale credentials when authorization_servers has changed so the SDK
// re-registers instead of presenting the previous AS's client_id.
async function runAuthMigrationClient(serverUrl: string): Promise<void> {
const provider = new ConformanceOAuthProvider(
'http://localhost:3000/callback',
{
client_name: 'auth-migration-client',
redirect_uris: ['http://localhost:3000/callback']
}
);
const issuerAware401: typeof handle401 = async (
response,
p,
next,
sUrl
): Promise<void> => {
const { resourceMetadataUrl, scope } =
extractWWWAuthenticateParams(response);
if (resourceMetadataUrl) {
const prm = await (await next(resourceMetadataUrl)).json();
const issuer = Array.isArray(prm?.authorization_servers)
? prm.authorization_servers[0]
: undefined;
if (issuer) p.bindIssuer(issuer);
}
let result = await auth(p, {
serverUrl: sUrl,
resourceMetadataUrl,
scope,
fetchFn: next as FetchLike
});
if (result === 'REDIRECT') {
const code = await p.getAuthCode();
result = await auth(p, {
serverUrl: sUrl,
resourceMetadataUrl,
scope,
authorizationCode: code,
fetchFn: next as FetchLike
});
}
};
const oauthFetch = withOAuthRetryWithProvider(
provider,
new URL(serverUrl),
issuerAware401
)(fetch);
const client = new Client(
{ name: 'auth-migration-client', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
await client.connect(transport);
await client.listTools(); // phase 1: AS₁
await client.callTool({ name: 'test-tool', arguments: {} }); // phase 2: AS₂
await transport.close();
}
registerScenario('auth/authorization-server-migration', runAuthMigrationClient);
// ============================================================================
// Elicitation defaults scenario
// ============================================================================

View file

@ -12,6 +12,8 @@ export class ConformanceOAuthProvider implements OAuthClientProvider {
private _codeVerifier?: string;
private _authCode?: string;
private _authCodePromise?: Promise<string>;
/** Issuer the current credentials were obtained from (SEP-2352 keying). */
private _boundIssuer?: string;
constructor(
private readonly _redirectUrl: string | URL,
@ -92,4 +94,30 @@ export class ConformanceOAuthProvider implements OAuthClientProvider {
}
return this._codeVerifier;
}
/** SDK calls this on auth errors; also used by bindIssuer below. */
invalidateCredentials(scope: 'all' | 'tokens'): void {
this._tokens = undefined;
this._authCode = undefined;
if (scope === 'all') {
this._clientInformation = undefined;
this._codeVerifier = undefined;
}
}
/**
* SEP-2352: associate stored credentials with the AS issuer that issued them.
* If the issuer changes (PRM migrated to a new authorization server), clear
* everything so the SDK re-registers instead of reusing stale credentials.
* Returns true when a change was detected and credentials were cleared.
*/
bindIssuer(issuer: string): boolean {
if (this._boundIssuer !== undefined && this._boundIssuer !== issuer) {
this.invalidateCredentials('all');
this._boundIssuer = issuer;
return true;
}
this._boundIssuer = issuer;
return false;
}
}

View file

@ -0,0 +1,212 @@
/**
* SEP-2352 — Authorization-server binding and migration.
*
* The MCP server's PRM initially lists AS₁. The client registers, authorizes,
* and calls tools/list. The harness then invalidates the token and flips PRM
* to AS₂. On the next 401 the client re-discovers PRM, sees a new issuer, and
* MUST re-register with AS₂ rather than reuse AS₁'s client credentials.
*/
import type { Request, Response, NextFunction } from 'express';
import type { Scenario, ConformanceCheck } from '../../../types';
import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types';
import { createAuthServer } from './helpers/createAuthServer';
import { createServer } from './helpers/createServer';
import { ServerLifecycle } from './helpers/serverLifecycle';
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
import { SpecReferences } from './spec-references';
export class AuthorizationServerMigrationScenario implements Scenario {
name = 'auth/authorization-server-migration';
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
description =
'Tests that a client, when the PRM authorization_servers changes to a new issuer, re-registers with the new authorization server and does not reuse credentials from the previous one (SEP-2352).';
private as1 = new ServerLifecycle();
private as2 = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
const tokenVerifier = new MockTokenVerifier(this.checks, ['mcp:basic']);
/** AS₁ issues a recognizable client_id so AS₂ can detect cross-AS reuse. */
const AS1_CLIENT_ID = 'as1-client-id-LEAKED-IF-SEEN-AT-AS2';
let as2SawRegister = false;
let as2SawAs1ClientId = false;
let as2SawAs1ClientIdAtToken = false;
// ── AS₁ ────────────────────────────────────────────────────────────────
const as1App = createAuthServer(this.checks, this.as1.getUrl, {
tokenVerifier,
onRegistrationRequest: () => ({
clientId: AS1_CLIENT_ID,
clientSecret: 'as1-client-secret'
})
});
await this.as1.start(as1App);
// ── AS₂ ────────────────────────────────────────────────────────────────
const as2App = createAuthServer(this.checks, this.as2.getUrl, {
tokenVerifier,
onRegistrationRequest: () => {
as2SawRegister = true;
return { clientId: 'as2-client-id', clientSecret: 'as2-client-secret' };
},
onAuthorizationRequest: (data) => {
if (data.clientId === AS1_CLIENT_ID) as2SawAs1ClientId = true;
},
onTokenRequest: (data) => {
const cid =
data.body.client_id ??
this.basicAuthClientId(data.authorizationHeader);
if (cid === AS1_CLIENT_ID) as2SawAs1ClientIdAtToken = true;
const scopes = data.scope ? data.scope.split(' ') : ['mcp:basic'];
const token = `test-token-as2-${Date.now()}`;
tokenVerifier.registerToken(token, scopes);
return { token, scopes };
}
});
await this.as2.start(as2App);
// ── MCP server with mutable PRM authorization_servers ──────────────────
let migrated = false;
const currentAuthServerUrl = () =>
migrated ? this.as2.getUrl() : this.as1.getUrl();
const resourceMetadataUrl = () =>
`${this.server.getUrl()}/.well-known/oauth-protected-resource/mcp`;
const middleware = async (
req: Request,
res: Response,
next: NextFunction
) => {
let body = req.body;
if (typeof body === 'string') body = JSON.parse(body);
const method = body?.method;
// initialize / notifications never require auth
if (method === 'initialize' || method?.startsWith('notifications/'))
return next();
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
return res
.status(401)
.set(
'WWW-Authenticate',
`Bearer scope="mcp:basic", resource_metadata="${resourceMetadataUrl()}"`
)
.json({ error: 'invalid_token' });
}
const token = auth.substring('Bearer '.length);
const info = await tokenVerifier.verifyAccessToken(token);
// Phase 1: accept any verified token, then flip PRM to AS₂ for the next
// call. Phase 2: reject the (now-stale) AS₁ token so the client
// re-discovers PRM and sees AS₂.
if (!migrated) {
migrated = true;
return next();
}
// After migration, only AS₂ tokens are valid.
if (!info.token.startsWith('test-token-as2-')) {
return res
.status(401)
.set(
'WWW-Authenticate',
`Bearer scope="mcp:basic", resource_metadata="${resourceMetadataUrl()}"`
)
.json({
error: 'invalid_token',
error_description: 'authorization server has changed'
});
}
return next();
};
const app = createServer(
this.checks,
this.server.getUrl,
currentAuthServerUrl,
{
prmPath: '/.well-known/oauth-protected-resource/mcp',
requiredScopes: ['mcp:basic'],
includeScopeInWwwAuth: true,
authMiddleware: middleware,
tokenVerifier
}
);
await this.server.start(app);
// Record evaluation closures so getChecks can see them after stop().
this._evaluate = () => {
const ts = new Date().toISOString();
const reusedAtAS2 = as2SawAs1ClientId || as2SawAs1ClientIdAtToken;
this.checks.push({
id: 'sep-2352-reregister-on-as-change',
name: 'Client re-registers with the new authorization server',
description: as2SawRegister
? 'Client performed Dynamic Client Registration with the new authorization server after PRM authorization_servers changed'
: 'Client MUST re-register with the new authorization server when PRM authorization_servers changes (SEP-2352); no registration request was observed at the new AS',
status: as2SawRegister ? 'SUCCESS' : 'FAILURE',
timestamp: ts,
specReferences: [SpecReferences.MCP_DCR]
});
this.checks.push({
id: 'sep-2352-no-reuse-on-as-change',
name: 'Client does not reuse the previous AS client credentials',
description: reusedAtAS2
? 'Client MUST NOT reuse client credentials from a different authorization server (SEP-2352); the previous AS client_id was observed at the new AS'
: 'Client did not present the previous AS client_id at the new authorization server',
status: reusedAtAS2 ? 'FAILURE' : 'SUCCESS',
timestamp: ts,
specReferences: [SpecReferences.MCP_DCR],
details: {
previousClientId: AS1_CLIENT_ID,
seenAtAuthorize: as2SawAs1ClientId,
seenAtToken: as2SawAs1ClientIdAtToken
}
});
// The "no cross-AS credential reuse" general MUST NOT is the same wire
// observation as no-reuse-on-as-change in this scenario; emit it as a
// distinct id so the yaml traceability is 1:1.
this.checks.push({
id: 'sep-2352-no-cross-as-credential-reuse',
name: 'Client does not assume credentials are portable across authorization servers',
description: reusedAtAS2
? 'Client MUST NOT assume that credentials valid for one authorization server will be accepted by another (SEP-2352)'
: 'Client treated credentials as bound to the issuing authorization server',
status: reusedAtAS2 ? 'FAILURE' : 'SUCCESS',
timestamp: ts,
specReferences: [SpecReferences.MCP_DCR]
});
};
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
private _evaluate: () => void = () => {};
private basicAuthClientId(h?: string): string | undefined {
if (!h?.startsWith('Basic ')) return undefined;
try {
const [id] = Buffer.from(h.slice(6), 'base64')
.toString('utf8')
.split(':');
return id;
} catch {
return undefined;
}
}
async stop() {
await this.server.stop();
await this.as1.stop();
await this.as2.stop();
}
getChecks(): ConformanceCheck[] {
this._evaluate();
return this.checks;
}
}

View file

@ -14,6 +14,7 @@ import { runClient as partialScopesClient } from '../../../../examples/clients/t
import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403';
import { runClient as noRetryLimitClient } from '../../../../examples/clients/typescript/auth-test-no-retry-limit';
import { runClient as noPkceClient } from '../../../../examples/clients/typescript/auth-test-no-pkce';
import { runClient as reuseCredsClient } from '../../../../examples/clients/typescript/auth-test-reuse-credentials';
import { getHandler } from '../../../../examples/clients/typescript/everything-client';
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';
@ -29,7 +30,10 @@ const allowClientErrorScenarios = new Set<string>([
// Client is expected to give up (error) after limited retries, but check should pass
'auth/scope-retry-limit',
// Client is expected to error when PRM resource doesn't match server URL
'auth/resource-mismatch'
'auth/resource-mismatch',
// The post-migration retry path may surface as a client error after
// re-registering; the SEP-2352 checks are evaluated in getChecks()
'auth/authorization-server-migration'
]);
describe('Client Auth Scenarios', () => {
@ -135,6 +139,22 @@ describe('Negative tests', () => {
});
});
test('client reuses credentials across authorization servers (SEP-2352)', async () => {
const runner = new InlineClientRunner(reuseCredsClient);
await runClientAgainstScenario(
runner,
'auth/authorization-server-migration',
{
allowClientError: true,
expectedFailureSlugs: [
'sep-2352-reregister-on-as-change',
'sep-2352-no-reuse-on-as-change',
'sep-2352-no-cross-as-credential-reuse'
]
}
);
});
test('client does not use PKCE', async () => {
const runner = new InlineClientRunner(noPkceClient);
await runClientAgainstScenario(runner, 'auth/metadata-default', {

View file

@ -28,6 +28,7 @@ import {
OfflineAccessScopeScenario,
OfflineAccessNotSupportedScenario
} from './offline-access';
import { AuthorizationServerMigrationScenario } from './authorization-server-migration';
// Auth scenarios (required for tier 1)
export const authScenariosList: Scenario[] = [
@ -61,5 +62,6 @@ export const extensionScenariosList: Scenario[] = [
export const draftScenariosList: Scenario[] = [
new ResourceMismatchScenario(),
new OfflineAccessScopeScenario(),
new OfflineAccessNotSupportedScenario()
new OfflineAccessNotSupportedScenario(),
new AuthorizationServerMigrationScenario()
];

17
src/seps/sep-2352.yaml Normal file
View file

@ -0,0 +1,17 @@
sep: 2352
spec_url: https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-binding
requirements:
- check: sep-2352-no-cross-as-credential-reuse
text: 'Clients MUST NOT assume that credentials valid for one authorization server will be accepted by another.'
url: https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-location
- check: sep-2352-no-reuse-on-as-change
text: 'When the authorization server changes (detected via updated protected resource metadata), clients MUST NOT reuse client credentials from a different authorization server.'
- check: sep-2352-reregister-on-as-change
text: 'When the authorization server changes (detected via updated protected resource metadata), clients MUST re-register with the new authorization server.'
- text: 'Clients MUST maintain separate registration state (client credentials, tokens) per authorization server.'
excluded: 'internal storage requirement; not directly observable on the wire'
- text: 'Clients that use pre-registered credentials, or persist client credentials obtained via Dynamic Client Registration, MUST associate those credentials with the specific authorization server that issued them, keyed by the authorization server issuer identifier.'
excluded: 'internal state-keying requirement; not protocol-observable'
- text: 'If the authorization server indicated by protected resource metadata no longer matches the one the credentials were registered with, clients SHOULD surface an error rather than silently attempting to use mismatched credentials.'
excluded: 'UI behavior; the negative half (do not send mismatched credentials) is covered by sep-2352-no-reuse-on-as-change'