@@ -180,6 +180,15 @@ const refreshItemIdFor = (accessId: string): string => `${accessId}:refresh`;
180180/** Order-preserving de-duplication of a scope list. */
181181const dedupeScopes = ( scopes : readonly string [ ] ) : readonly string [ ] => [ ...new Set ( scopes ) ] ;
182182
183+ const intersectScopes = (
184+ requested : readonly string [ ] ,
185+ supported : readonly string [ ] | undefined ,
186+ ) : readonly string [ ] => {
187+ if ( ! supported || supported . length === 0 ) return requested ;
188+ const supportedSet = new Set ( supported ) ;
189+ return requested . filter ( ( scope ) => supportedSet . has ( scope ) ) ;
190+ } ;
191+
183192const recordedOAuthScope = (
184193 token : OAuth2TokenResponse ,
185194 requestedScopes : readonly string [ ] ,
@@ -354,6 +363,28 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
354363 // EXPLICIT — no localhost default. `null` means this executor has no OAuth
355364 // callback; redirect-requiring flows fail loudly via `requireRedirectUri`.
356365 const redirectUri = deps . redirectUri ;
366+ const discoveryOptions = { endpointUrlPolicy : deps . endpointUrlPolicy } ;
367+
368+ const filterAuthorizationCodeScopes = (
369+ client : LoadedOAuthClient ,
370+ requestedScopes : readonly string [ ] ,
371+ ) : Effect . Effect < readonly string [ ] , never > =>
372+ Effect . gen ( function * ( ) {
373+ if ( requestedScopes . length === 0 ) return requestedScopes ;
374+ const resource = client . resource
375+ ? yield * discoverProtectedResourceMetadata ( client . resource , discoveryOptions ) . pipe (
376+ Effect . catch ( ( ) => Effect . succeed ( null ) ) ,
377+ Effect . provide ( httpClientLayer ) ,
378+ )
379+ : null ;
380+ const issuer =
381+ resource ?. metadata . authorization_servers ?. [ 0 ] ?? new URL ( client . authorizationUrl ) . origin ;
382+ const as = yield * discoverAuthorizationServerMetadata ( issuer , discoveryOptions ) . pipe (
383+ Effect . catch ( ( ) => Effect . succeed ( null ) ) ,
384+ Effect . provide ( httpClientLayer ) ,
385+ ) ;
386+ return intersectScopes ( requestedScopes , as ?. metadata . scopes_supported ) ;
387+ } ) . pipe ( Effect . catch ( ( ) => Effect . succeed ( requestedScopes ) ) ) ;
357388
358389 // Caps on server-controlled discovery input — a hostile or buggy server must
359390 // not be able to hang `oauth.start` or overflow the authorize URL.
@@ -818,6 +849,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
818849 message : REDIRECT_URI_REQUIRED_MESSAGE ,
819850 } ) ;
820851 }
852+ // Prune stale DECLARED scopes against the AS's advertised set, but leave
853+ // resource-discovered scopes untouched: an RFC 9728 `scopes_supported`
854+ // list is already authoritative (§7.2) and must not be re-narrowed by a
855+ // divergent authorization server.
856+ const authorizationRequestedScopes =
857+ scopePolicy . kind === "discover"
858+ ? requestedScopes
859+ : yield * filterAuthorizationCodeScopes ( client , requestedScopes ) ;
821860
822861 // authorization_code: persist a session + build the authorize URL.
823862 const verifier = createPkceCodeVerifier ( ) ;
@@ -843,11 +882,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
843882 redirect_url : flowRedirectUri ,
844883 pkce_verifier : verifier ,
845884 identity_label : input . identityLabel ?? null ,
846- // Persist the requested scope set (the integration's declared or
847- // discovered scopes) so `complete`'s recorded-scope fallback reflects
848- // exactly what was requested when the AS omits `scope`, without
849- // re-resolving it at completion.
850- payload : { owner : input . owner , clientOwner : input . clientOwner , requestedScopes } ,
885+ // Persist the requested scope set (declared ∪ client, filtered to the
886+ // authorization-code flow) so `complete`'s recorded-scope fallback
887+ // reflects exactly what was requested when the AS omits `scope`,
888+ // without re-resolving the integration's declared scopes at completion.
889+ payload : {
890+ owner : input . owner ,
891+ clientOwner : input . clientOwner ,
892+ requestedScopes : authorizationRequestedScopes ,
893+ } ,
851894 expires_at : expiresAt ,
852895 created_at : now ,
853896 } ) ,
@@ -859,7 +902,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
859902 authorizationUrl : client . authorizationUrl ,
860903 clientId : client . clientId ,
861904 redirectUrl : flowRedirectUri ,
862- scopes : requestedScopes ,
905+ scopes : authorizationRequestedScopes ,
863906 state : providerState ,
864907 codeChallenge : challenge ,
865908 resource : client . resource ?? undefined ,
@@ -1127,6 +1170,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
11271170 scopesSupported : resource ?. metadata . scopes_supported ?? as . metadata . scopes_supported ,
11281171 registrationEndpoint : as . metadata . registration_endpoint ?? null ,
11291172 tokenEndpointAuthMethodsSupported : as . metadata . token_endpoint_auth_methods_supported ,
1173+ clientIdMetadataDocumentSupported :
1174+ as . metadata . client_id_metadata_document_supported === true ,
11301175 } satisfies OAuthProbeResult ;
11311176 } ) . pipe ( Effect . provide ( httpClientLayer ) ) ;
11321177
0 commit comments