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

Issue:
Pass resource payload to refresh token call
Hello everyone !
Hope you're doing well !

I am desperately trying to pass a resource param to refresh_token request, but it seems not possible through `mgr.signinSilent `method as `OidcClient.useRefreshToken` method call `exchangeRefreshToken` under the hood with those params : refresh_token, scope and timeoutInSeconds.

So, do you know a way to pass this extra param (resource) to refresh token request ?

If we need to push a MR, should we pass a resource param (or an extraQueryParams containing resource) from `mgr.signinSilent` down to `tokenClient.exchangeRefreshToken` through `useRefreshToken` ?

Thanks a lot !

Pull request description:
Add resource param to signinSilent request
<!-- Please link relevant issue numbers or provide context for this change -->
Closes/fixes #920 and probably #655

### 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 ff727000..b3d2b7b4 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -91,7 +91,7 @@ export class ErrorTimeout extends Error {
 export type ExtraHeader = string | (() => string);
 
 // @public (undocumented)
-export type ExtraSigninRequestArgs = Pick<CreateSigninRequestArgs, "nonce" | "extraQueryParams" | "extraTokenParams" | "state" | "redirect_uri" | "prompt" | "acr_values" | "login_hint" | "scope" | "max_age" | "ui_locales">;
+export type ExtraSigninRequestArgs = Pick<CreateSigninRequestArgs, "nonce" | "extraQueryParams" | "extraTokenParams" | "state" | "redirect_uri" | "prompt" | "acr_values" | "login_hint" | "scope" | "max_age" | "ui_locales" | "resource">;
 
 // @public (undocumented)
 export type ExtraSignoutRequestArgs = Pick<CreateSignoutRequestArgs, "extraQueryParams" | "state" | "id_token_hint" | "post_logout_redirect_uri">;
diff --git a/src/OidcClient.ts b/src/OidcClient.ts
index 5185c03b..b4f34166 100644
--- a/src/OidcClient.ts
+++ b/src/OidcClient.ts
@@ -201,6 +201,7 @@ export class OidcClient {
 
         const result = await this._tokenClient.exchangeRefreshToken({
             refresh_token: state.refresh_token,
+            resource: state.resource,
             // provide the (possible filtered) scope list
             scope,
             timeoutInSeconds,
diff --git a/src/RefreshState.ts b/src/RefreshState.ts
index f02f2cbe..66f78293 100644
--- a/src/RefreshState.ts
+++ b/src/RefreshState.ts
@@ -17,6 +17,7 @@ export class RefreshState {
     public readonly session_state: string | null;
     public readonly scope?: string;
     public readonly profile: UserProfile;
+    public readonly resource?: string | string[];
 
     constructor(args: {
         refresh_token: string;
@@ -26,13 +27,15 @@ export class RefreshState {
         profile: UserProfile;
 
         state?: unknown;
-    }) {
+    }, resource?: string | string[]) {
         this.refresh_token = args.refresh_token;
         this.id_token = args.id_token;
         this.session_state = args.session_state;
         this.scope = args.scope;
         this.profile = args.profile;
+        this.resource = resource;
 
         this.data = args.state;
+
     }
 }
diff --git a/src/TokenClient.ts b/src/TokenClient.ts
index e39bab8b..6d6f5ac4 100644
--- a/src/TokenClient.ts
+++ b/src/TokenClient.ts
@@ -43,6 +43,7 @@ export interface ExchangeRefreshTokenArgs {
     grant_type?: string;
     refresh_token: string;
     scope?: string;
+    resource?: string | string[];
 
     timeoutInSeconds?: number;
 }
@@ -201,7 +202,10 @@ export class TokenClient {
 
         const params = new URLSearchParams({ grant_type });
         for (const [key, value] of Object.entries(args)) {
-            if (value != null) {
+            if (Array.isArray(value)) {
+                value.forEach(param => params.append(key, param));
+            }
+            else if (value != null) {
                 params.set(key, value);
             }
         }
diff --git a/src/UserManager.ts b/src/UserManager.ts
index 5fee81c8..810c6f8c 100644
--- a/src/UserManager.ts
+++ b/src/UserManager.ts
@@ -20,7 +20,7 @@ import type { SigninResponse } from "./SigninResponse";
 /**
  * @public
  */
-export type ExtraSigninRequestArgs = Pick<CreateSigninRequestArgs, "nonce" | "extraQueryParams" | "extraTokenParams" | "state" | "redirect_uri" | "prompt" | "acr_values" | "login_hint" | "scope" | "max_age" | "ui_locales" >;
+export type ExtraSigninRequestArgs = Pick<CreateSigninRequestArgs, "nonce" | "extraQueryParams" | "extraTokenParams" | "state" | "redirect_uri" | "prompt" | "acr_values" | "login_hint" | "scope" | "max_age" | "ui_locales" | "resource">;
 /**
  * @public
  */
@@ -256,13 +256,14 @@ export class UserManager {
         const logger = this._logger.create("signinSilent");
         const {
             silentRequestTimeoutInSeconds,
+            resource,
             ...requestArgs
         } = args;
         // first determine if we have a refresh token, or need to use iframe
         let user = await this._loadUser();
         if (user?.refresh_token) {
             logger.debug("using refresh token");
-            const state = new RefreshState(user as Required<User>);
+            const state = new RefreshState(user as Required<User>, resource);
             return await this._useRefreshToken(state);
         }
 


Test patch:
diff --git a/src/OidcClient.test.ts b/src/OidcClient.test.ts
index 04fed861..68b35497 100644
--- a/src/OidcClient.test.ts
+++ b/src/OidcClient.test.ts
@@ -422,7 +422,7 @@ describe("OidcClient", () => {
                 session_state: "session_state",
                 scope: "openid",
                 profile: {} as UserProfile,
-            });
+            }, "resource");
 
             // act
             const response = await subject.useRefreshToken({ state });
@@ -432,6 +432,7 @@ describe("OidcClient", () => {
                 refresh_token: "refresh_token",
                 scope: "openid",
                 timeoutInSeconds: undefined,
+                resource: "resource",
             });
             expect(response).toBeInstanceOf(SigninResponse);
             expect(response).toMatchObject(tokenResponse);
diff --git a/src/UserManager.test.ts b/src/UserManager.test.ts
index 022db77f..2416c655 100644
--- a/src/UserManager.test.ts
+++ b/src/UserManager.test.ts
@@ -505,6 +505,41 @@ describe("UserManager", () => {
                 }),
             );
         });
+
+        it("should use the resource from settings when a refresh token is present", async () => {
+            // arrange
+            const user = new User({
+                access_token: "access_token",
+                token_type: "token_type",
+                refresh_token: "refresh_token",
+                profile: {
+                    sub: "sub",
+                    nickname: "Nick",
+                } as UserProfile,
+            });
+
+            const useRefreshTokenSpy = jest.spyOn(subject["_client"], "useRefreshToken").mockResolvedValue({
+                access_token: "new_access_token",
+                profile: {
+                    sub: "sub",
+                    nickname: "Nicholas",
+                },
+            } as unknown as SigninResponse);
+            subject["_loadUser"] = jest.fn().mockResolvedValue(user);
+
+            // act
+            await subject.signinSilent({ resource: "resource" });
+            expect(useRefreshTokenSpy).toBeCalledWith(
+                expect.objectContaining({
+                    state: {
+                        resource: "resource",
+                        refresh_token: user.refresh_token,
+                        session_state: null,
+                        "profile": { "nickname": "Nick", "sub": "sub" },
+                    },
+                }),
+            );
+        });
     });
 
     describe("signinSilentCallback", () => {