@@ -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