Skip to content

Commit e848b15

Browse files
committed
fix(client): invalidate credentials and re-register when the authorization server changes (SEP-2352)
1 parent 8d55531 commit e848b15

3 files changed

Lines changed: 273 additions & 0 deletions

File tree

.changeset/sep-2352-as-binding.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Implement SEP-2352 authorization server binding: when OAuth discovery shows the authorization server has changed since client credentials were recorded, `auth()` now invalidates the stale client registration and tokens (`invalidateCredentials('client')` / `('tokens')`) and re-registers with the new authorization server. CIMD (HTTPS URL) client IDs are exempt, as they are portable across authorization servers. Provider implementations should persist client credentials keyed by the authorization server's `issuer` identifier.

packages/client/src/client/auth.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ export interface OAuthClientProvider {
167167
* Loads information about this OAuth client, as registered already with the
168168
* server, or returns `undefined` if the client is not registered with the
169169
* server.
170+
*
171+
* Per SEP-2352 (authorization server binding), implementations that persist
172+
* client credentials SHOULD key them by the authorization server's `issuer`
173+
* identifier, and SHOULD NOT return credentials that were issued by a
174+
* different authorization server. CIMD (HTTPS URL) client IDs are exempt:
175+
* they are portable across authorization servers.
170176
*/
171177
clientInformation(): OAuthClientInformationMixed | undefined | Promise<OAuthClientInformationMixed | undefined>;
172178

@@ -177,6 +183,11 @@ export interface OAuthClientProvider {
177183
*
178184
* This method is not required to be implemented if client information is
179185
* statically known (e.g., pre-registered).
186+
*
187+
* Per SEP-2352 (authorization server binding), implementations SHOULD persist
188+
* client credentials keyed by the authorization server's `issuer` identifier,
189+
* so credentials registered with one authorization server are never reused
190+
* with another.
180191
*/
181192
saveClientInformation?(clientInformation: OAuthClientInformationMixed): void | Promise<void>;
182193

@@ -681,6 +692,40 @@ async function authInternal(
681692
});
682693
}
683694

695+
// SEP-2352: Authorization server binding. Client credentials are bound to the
696+
// authorization server that issued them; when discovery shows the authorization
697+
// server has changed (e.g., via updated protected resource metadata), stale client
698+
// credentials and tokens MUST NOT be reused and the client MUST re-register.
699+
//
700+
// Canonical comparison key: the validated authorization server metadata `issuer`
701+
// (the identifier SEP-2352 specifies), falling back to the authorization server URL
702+
// when metadata is unavailable. Under RFC 8414 the issuer and the URL used for
703+
// discovery coincide, so a match on either is treated as the same authorization
704+
// server to avoid false-positive invalidation.
705+
const previousAuthServerIdentities = [
706+
cachedState?.authorizationServerMetadata?.issuer,
707+
cachedState?.authorizationServerUrl,
708+
await provider.authorizationServerUrl?.()
709+
]
710+
.filter((value): value is string => typeof value === 'string' && value.length > 0)
711+
.map(value => normalizeAuthorizationServerIdentity(value));
712+
const currentAuthServerIdentities = [metadata?.issuer, String(authorizationServerUrl)]
713+
.filter((value): value is string => typeof value === 'string' && value.length > 0)
714+
.map(value => normalizeAuthorizationServerIdentity(value));
715+
const authorizationServerChanged =
716+
previousAuthServerIdentities.length > 0 &&
717+
!currentAuthServerIdentities.some(identity => previousAuthServerIdentities.includes(identity));
718+
719+
if (authorizationServerChanged) {
720+
const staleClientInformation = await Promise.resolve(provider.clientInformation());
721+
// CIMD (URL-based) client IDs are portable across authorization servers
722+
// (SEP-991/SEP-2352) — no invalidation or re-registration is needed.
723+
if (staleClientInformation && !isHttpsUrl(staleClientInformation.client_id)) {
724+
await provider.invalidateCredentials?.('client');
725+
await provider.invalidateCredentials?.('tokens');
726+
}
727+
}
728+
684729
// Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider)
685730
await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl));
686731

@@ -840,6 +885,20 @@ export function isHttpsUrl(value?: string): boolean {
840885
}
841886
}
842887

888+
/**
889+
* SEP-2352: Normalizes an authorization server identity (issuer identifier or
890+
* authorization server URL) for comparison, so that textual variations of the
891+
* same URL (e.g. a missing trailing slash on an origin-only issuer) do not
892+
* register as an authorization server change.
893+
*/
894+
function normalizeAuthorizationServerIdentity(value: string): string {
895+
try {
896+
return new URL(value).href;
897+
} catch {
898+
return value;
899+
}
900+
}
901+
843902
export async function selectResourceURL(
844903
serverUrl: string | URL,
845904
provider: OAuthClientProvider,

packages/client/test/client/auth.test.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4131,3 +4131,212 @@ describe('OAuth Authorization', () => {
41314131
});
41324132
});
41334133
});
4134+
4135+
describe('SEP-2352: authorization server binding', () => {
4136+
const oldAuthServerUrl = 'https://old-auth.example.com';
4137+
4138+
const newResourceMetadata = {
4139+
resource: 'https://resource.example.com',
4140+
authorization_servers: ['https://new-auth.example.com']
4141+
};
4142+
4143+
const newAuthMetadata = {
4144+
issuer: 'https://new-auth.example.com',
4145+
authorization_endpoint: 'https://new-auth.example.com/authorize',
4146+
token_endpoint: 'https://new-auth.example.com/token',
4147+
registration_endpoint: 'https://new-auth.example.com/register',
4148+
response_types_supported: ['code'],
4149+
code_challenge_methods_supported: ['S256']
4150+
};
4151+
4152+
const sameResourceMetadata = {
4153+
resource: 'https://resource.example.com',
4154+
authorization_servers: [oldAuthServerUrl]
4155+
};
4156+
4157+
const sameAuthMetadata = {
4158+
issuer: oldAuthServerUrl,
4159+
authorization_endpoint: `${oldAuthServerUrl}/authorize`,
4160+
token_endpoint: `${oldAuthServerUrl}/token`,
4161+
registration_endpoint: `${oldAuthServerUrl}/register`,
4162+
response_types_supported: ['code'],
4163+
code_challenge_methods_supported: ['S256']
4164+
};
4165+
4166+
/**
4167+
* Creates a provider that previously completed an OAuth flow against
4168+
* `oldAuthServerUrl` (recorded via `authorizationServerUrl()`), holds stored
4169+
* client credentials, and honors `invalidateCredentials` by dropping them.
4170+
*/
4171+
function createBoundProvider(initialClientInformation: { client_id: string; client_secret?: string }): {
4172+
provider: OAuthClientProvider;
4173+
invalidateCredentials: Mock;
4174+
saveClientInformation: Mock;
4175+
redirectToAuthorization: Mock;
4176+
} {
4177+
let clientInformation: { client_id: string; client_secret?: string } | undefined = initialClientInformation;
4178+
4179+
const invalidateCredentials = vi.fn(async (scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery') => {
4180+
if (scope === 'all' || scope === 'client') {
4181+
clientInformation = undefined;
4182+
}
4183+
});
4184+
const saveClientInformation = vi.fn(async (info: { client_id: string; client_secret?: string }) => {
4185+
clientInformation = info;
4186+
});
4187+
const redirectToAuthorization = vi.fn();
4188+
4189+
const provider: OAuthClientProvider = {
4190+
get redirectUrl() {
4191+
return 'http://localhost:3000/callback';
4192+
},
4193+
get clientMetadata() {
4194+
return {
4195+
redirect_uris: ['http://localhost:3000/callback'],
4196+
client_name: 'Test Client'
4197+
};
4198+
},
4199+
clientInformation: vi.fn(async () => clientInformation),
4200+
saveClientInformation,
4201+
tokens: vi.fn().mockResolvedValue(undefined),
4202+
saveTokens: vi.fn(),
4203+
redirectToAuthorization,
4204+
saveCodeVerifier: vi.fn(),
4205+
codeVerifier: vi.fn().mockResolvedValue('test_verifier'),
4206+
authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl),
4207+
invalidateCredentials
4208+
};
4209+
4210+
return { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization };
4211+
}
4212+
4213+
function mockDiscoveryAndRegistration(options: {
4214+
resourceMetadata: { resource: string; authorization_servers: string[] };
4215+
authMetadata: { issuer: string };
4216+
registeredClient?: { client_id: string; client_secret?: string };
4217+
}): void {
4218+
mockFetch.mockImplementation((url, init) => {
4219+
const urlString = url.toString();
4220+
4221+
if (urlString.includes('/.well-known/oauth-protected-resource')) {
4222+
return Promise.resolve({
4223+
ok: true,
4224+
status: 200,
4225+
json: async () => options.resourceMetadata
4226+
});
4227+
}
4228+
4229+
if (urlString.includes('/.well-known/oauth-authorization-server')) {
4230+
return Promise.resolve({
4231+
ok: true,
4232+
status: 200,
4233+
json: async () => options.authMetadata
4234+
});
4235+
}
4236+
4237+
if (urlString.includes('/register') && init?.method === 'POST') {
4238+
if (!options.registeredClient) {
4239+
return Promise.reject(new Error(`Unexpected registration request: ${urlString}`));
4240+
}
4241+
return Promise.resolve({
4242+
ok: true,
4243+
status: 201,
4244+
json: async () => ({
4245+
...JSON.parse(init.body as string),
4246+
...options.registeredClient
4247+
})
4248+
});
4249+
}
4250+
4251+
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
4252+
});
4253+
}
4254+
4255+
beforeEach(() => {
4256+
mockFetch.mockReset();
4257+
vi.clearAllMocks();
4258+
});
4259+
4260+
it('invalidates client credentials and tokens, then re-registers, when the authorization server changes', async () => {
4261+
const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({
4262+
client_id: 'old-client-id',
4263+
client_secret: 'old-client-secret'
4264+
});
4265+
4266+
mockDiscoveryAndRegistration({
4267+
resourceMetadata: newResourceMetadata,
4268+
authMetadata: newAuthMetadata,
4269+
registeredClient: { client_id: 'new-client-id', client_secret: 'new-client-secret' }
4270+
});
4271+
4272+
const result = await auth(provider, { serverUrl: 'https://resource.example.com' });
4273+
4274+
expect(result).toBe('REDIRECT');
4275+
4276+
// Stale credentials bound to the old authorization server are invalidated
4277+
expect(invalidateCredentials).toHaveBeenCalledWith('client');
4278+
expect(invalidateCredentials).toHaveBeenCalledWith('tokens');
4279+
4280+
// The client re-registers with the new authorization server
4281+
const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register'));
4282+
expect(registrationCalls).toHaveLength(1);
4283+
expect(registrationCalls[0]![0].toString()).toBe('https://new-auth.example.com/register');
4284+
expect(saveClientInformation).toHaveBeenCalledWith(expect.objectContaining({ client_id: 'new-client-id' }));
4285+
4286+
// The authorization redirect uses the newly registered client, not the stale one
4287+
const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0];
4288+
expect(redirectUrl.origin).toBe('https://new-auth.example.com');
4289+
expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id');
4290+
});
4291+
4292+
it('does not invalidate credentials when the authorization server is unchanged', async () => {
4293+
const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({
4294+
client_id: 'old-client-id',
4295+
client_secret: 'old-client-secret'
4296+
});
4297+
4298+
mockDiscoveryAndRegistration({
4299+
resourceMetadata: sameResourceMetadata,
4300+
authMetadata: sameAuthMetadata
4301+
});
4302+
4303+
const result = await auth(provider, { serverUrl: 'https://resource.example.com' });
4304+
4305+
expect(result).toBe('REDIRECT');
4306+
expect(invalidateCredentials).not.toHaveBeenCalled();
4307+
4308+
// No re-registration; the existing client credentials are reused
4309+
const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register'));
4310+
expect(registrationCalls).toHaveLength(0);
4311+
4312+
const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0];
4313+
expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id');
4314+
});
4315+
4316+
it('does not invalidate CIMD (HTTPS URL) client IDs when the authorization server changes', async () => {
4317+
const cimdClientId = 'https://client.example.com/oauth/client-metadata.json';
4318+
const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({
4319+
client_id: cimdClientId
4320+
});
4321+
4322+
mockDiscoveryAndRegistration({
4323+
resourceMetadata: newResourceMetadata,
4324+
authMetadata: newAuthMetadata
4325+
});
4326+
4327+
const result = await auth(provider, { serverUrl: 'https://resource.example.com' });
4328+
4329+
expect(result).toBe('REDIRECT');
4330+
4331+
// CIMD client IDs are portable across authorization servers — no invalidation
4332+
expect(invalidateCredentials).not.toHaveBeenCalled();
4333+
4334+
// No re-registration; the portable client ID is reused with the new server
4335+
const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register'));
4336+
expect(registrationCalls).toHaveLength(0);
4337+
4338+
const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0];
4339+
expect(redirectUrl.origin).toBe('https://new-auth.example.com');
4340+
expect(redirectUrl.searchParams.get('client_id')).toBe(cimdClientId);
4341+
});
4342+
});

0 commit comments

Comments
 (0)