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:
parent
91755d652c
commit
1ef3cd34bd
3 changed files with 387 additions and 2 deletions
35
examples/clients/typescript/auth-test-reuse-credentials.ts
Normal file
35
examples/clients/typescript/auth-test-reuse-credentials.ts
Normal 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>'
|
||||
);
|
||||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue