Repository: authts/oidc-client-ts (ts)

Issue:
AzureAD requesting token refresh with all received scopes from signin can be problematic
I'm using oidc-client-ts for an application that logs users in using AzureAD. My signin request to Azure uses their special '.default' [scope](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#request-the-permissions-in-the-app-registration-portal) which provides all of the resource-specific scopes for the resource I'm requesting a token for.

One quirk related to using this scope is that when you make a request that uses it, it must be the _only_ scope in the token request, however the token response will list the `.deafult` scope _along with_ all scopes that it inherently provided.

I.E: 

Request:
```json
{
   ...
   scope: '<client_id>/.default'
}
````

Response:
```json
{
   ...
   scope: '<client_id>/.default <client_id>/User.Read <cliet_id>/User.Calendar'
}
````

This behavior itself is not an issue, however when trying to request a new token using the refresh token via oidc-client-ts the scope from the response is included in the refresh token request and this causees the refresh token request to fail from Azure because the request includes '.default' along with the other scopes.

My current workaround is to add an event listener for user load and overwrite the user's scopes with the scopes used in my initial request. I.E:

```javascript
userManager.events.addUserLoaded((user) => {
    user.scope = '<client_id>/.default'
    userManager.storeUser(currentUser)
});
```
This works but feels a bit clunky and feels like something that may be able to be handled better by the library.

I believe this behavior was added to oidc-client-ts [here](https://github.com/authts/oidc-client-ts/pull/365).

Some potential solutions I've pondered:

1. Providing some way to modify that behavior to use scopes from the initial signin request rather than scopes from the token response
2. Providing some way to explicitly define the scopes used for refresh requests

Pull request description:
Fix #695: Add filter list to scopes used with refresh tokens
Closes/fixes #695. See discussion in this issue.

### Checklist

- [x] This PR makes changes to the public API <!-- was the API report (docs/oidc-client-ts.api.md) updated by this PR? -->
- [x] I have included links for closing relevant issue numbers


Patch:
diff --git a/docs/oidc-client-ts.api.md b/docs/oidc-client-ts.api.md
index 989c1c8e..8235fbec 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -355,6 +355,7 @@ export interface OidcClientSettings {
     post_logout_redirect_uri?: string;
     prompt?: string;
     redirect_uri: string;
+    refreshTokenAllowedScope?: string | undefined;
     // @deprecated (undocumented)
     refreshTokenCredentials?: "same-origin" | "include" | "omit";
     resource?: string | string[];
@@ -372,7 +373,7 @@ export interface OidcClientSettings {
 
 // @public
 export class OidcClientSettingsStore {
-    constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, staleStateAgeInSeconds, clockSkewInSeconds, userInfoJwtIssuer, mergeClaims, stateStore, refreshTokenCredentials, revokeTokenAdditionalContentTypes, fetchRequestCredentials, extraQueryParams, extraTokenParams, }: OidcClientSettings);
+    constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, staleStateAgeInSeconds, clockSkewInSeconds, userInfoJwtIssuer, mergeClaims, stateStore, refreshTokenCredentials, revokeTokenAdditionalContentTypes, fetchRequestCredentials, refreshTokenAllowedScope, extraQueryParams, extraTokenParams, }: OidcClientSettings);
     // (undocumented)
     readonly acr_values: string | undefined;
     // (undocumented)
@@ -414,6 +415,8 @@ export class OidcClientSettingsStore {
     // (undocumented)
     readonly redirect_uri: string;
     // (undocumented)
+    readonly refreshTokenAllowedScope: string | undefined;
+    // (undocumented)
     readonly resource: string | string[] | undefined;
     // (undocumented)
     readonly response_mode: "query" | "fragment";
diff --git a/src/OidcClient.ts b/src/OidcClient.ts
index a3698030..30508bd6 100644
--- a/src/OidcClient.ts
+++ b/src/OidcClient.ts
@@ -194,15 +194,34 @@ export class OidcClient {
     }: UseRefreshTokenArgs): Promise<SigninResponse> {
         const logger = this._logger.create("useRefreshToken");
 
+        // https://github.com/authts/oidc-client-ts/issues/695
+        // In some cases (e.g. AzureAD), not all granted scopes are allowed on token refresh requests.
+        // Therefore, we filter all granted scopes by a list of allowable scopes.
+        let scope;
+        if (this.settings.refreshTokenAllowedScope === undefined) {
+            scope = state.scope;
+        } else {
+            const allowableScopes = this.settings.refreshTokenAllowedScope.split(" ");
+            const providedScopes = state.scope?.split(" ") || [];
+
+            scope = providedScopes.filter(s => allowableScopes.includes(s)).join(" ");
+        }
+
         const result = await this._tokenClient.exchangeRefreshToken({
             refresh_token: state.refresh_token,
-            scope: state.scope,
+            // provide the (possible filtered) scope list
+            scope,
             timeoutInSeconds,
         });
         const response = new SigninResponse(new URLSearchParams());
         Object.assign(response, result);
         logger.debug("validating response", response);
-        await this._validator.validateRefreshResponse(response, state);
+        await this._validator.validateRefreshResponse(response, {
+            ...state,
+            // overide the scope in the state handed over to the validator
+            // so it can set the granted scope to the requested scope in case none is included in the response
+            scope,
+        });
         return response;
     }
 
diff --git a/src/OidcClientSettings.ts b/src/OidcClientSettings.ts
index 2c569b8c..53ea1aba 100644
--- a/src/OidcClientSettings.ts
+++ b/src/OidcClientSettings.ts
@@ -122,6 +122,11 @@ export interface OidcClientSettings {
      * Use this if you need to send cookies to the OIDC/OAuth2 provider or if you are using a proxy that requires cookies
      */
     fetchRequestCredentials?: RequestCredentials;
+
+    /**
+     * Only scopes in this list will be passed in the token refresh request.
+     */
+    refreshTokenAllowedScope?: string | undefined;
 }
 
 /**
@@ -172,6 +177,7 @@ export class OidcClientSettingsStore {
 
     public readonly revokeTokenAdditionalContentTypes?: string[];
     public readonly fetchRequestCredentials: RequestCredentials;
+    public readonly refreshTokenAllowedScope: string | undefined;
 
     public constructor({
         // metadata related
@@ -194,6 +200,7 @@ export class OidcClientSettingsStore {
         refreshTokenCredentials,
         revokeTokenAdditionalContentTypes,
         fetchRequestCredentials,
+        refreshTokenAllowedScope,
         // extra query params
         extraQueryParams = {},
         extraTokenParams = {},
@@ -256,6 +263,8 @@ export class OidcClientSettingsStore {
             this.stateStore = new WebStorageStateStore({ store });
         }
 
+        this.refreshTokenAllowedScope = refreshTokenAllowedScope;
+
         this.extraQueryParams = extraQueryParams;
         this.extraTokenParams = extraTokenParams;
     }


Test patch:
diff --git a/src/OidcClient.test.ts b/src/OidcClient.test.ts
index 35ca2a0b..dacbe39f 100644
--- a/src/OidcClient.test.ts
+++ b/src/OidcClient.test.ts
@@ -5,7 +5,7 @@ import { JwtUtils } from "./utils";
 import type { ErrorResponse } from "./errors";
 import type { JwtClaims } from "./Claims";
 import { OidcClient } from "./OidcClient";
-import { OidcClientSettingsStore } from "./OidcClientSettings";
+import { OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings";
 import { SigninState } from "./SigninState";
 import { State } from "./State";
 import { SigninRequest } from "./SigninRequest";
@@ -439,6 +439,47 @@ describe("OidcClient", () => {
             expect(response).toHaveProperty("scope", state.scope);
         });
 
+        it("should filter allowable scopes", async () => {
+            // arrange
+            const settings: OidcClientSettings = {
+                authority: "authority",
+                client_id: "client",
+                redirect_uri: "redirect",
+                post_logout_redirect_uri: "http://app",
+                refreshTokenAllowedScope: "allowed_scope",
+            };
+            subject = new OidcClient(settings);
+
+            const tokenResponse = {
+                access_token: "new_access_token",
+            };
+            const exchangeRefreshTokenMock =
+                jest.spyOn(subject["_tokenClient"], "exchangeRefreshToken")
+                    .mockResolvedValue(tokenResponse);
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "sub" });
+            const state = new RefreshState({
+                refresh_token: "refresh_token",
+                id_token: "id_token",
+                session_state: "session_state",
+                scope: "unallowed_scope allowed_scope unallowed_scope_2",
+                profile: {} as UserProfile,
+            });
+
+            // act
+            const response = await subject.useRefreshToken({ state });
+
+            // assert
+            expect(exchangeRefreshTokenMock).toHaveBeenCalledWith( {
+                refresh_token: "refresh_token",
+                scope: "allowed_scope",
+                timeoutInSeconds: undefined,
+            });
+            expect(response).toBeInstanceOf(SigninResponse);
+            expect(response).toMatchObject(tokenResponse);
+            expect(response).toHaveProperty("session_state", state.session_state);
+            expect(response).toHaveProperty("scope", "allowed_scope");
+        });
+
         it("should enforce a matching sub claim", async () => {
             // arrange
             const profiles: Record<string, JwtClaims> = {