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
3 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;
}
}