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

Issue:
session_state is lost on silent refresh token renewal
I am trying to use the silent refresh feature with session state management, but run into the following issue:

I have very short access token lifetime (5min). When oidc-client-ts (successfully, I might add) refreshes the access token using the refresh token, it seems to clear the `session_state` field of User. This leads to session management not working when I reload the page, as now `session_state` is null.

Steps to reproduce:
- Set up a client with `monitorSession: true`, short access token lifetimes and refresh tokens
- Log in without any previous state
- Observe that the session mgmt iframe is loaded (via the debug log for example)
- Wait for silent refresh token renewal
- Reload the page
- Observe that no login attempt is made (since there is still a valid access token in sessionStorage)
- Observe that the session mgmt iframe is *not* loaded (since session_state is now null)
- This can also be verified by checking the content of sessionStorage

Pull request description:
preserve session_state during refresh token
Closes/fixes #569

### Checklist

- [ ] 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 d27df6e0..a1ef8142 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -672,7 +672,7 @@ export class SigninResponse {
     // (undocumented)
     scope?: string;
     // (undocumented)
-    readonly session_state: string | null;
+    session_state: string | null;
     // (undocumented)
     readonly state: string | null;
     // (undocumented)
diff --git a/src/RefreshState.ts b/src/RefreshState.ts
index 9eadfc7b..944c0ff4 100644
--- a/src/RefreshState.ts
+++ b/src/RefreshState.ts
@@ -11,17 +11,20 @@ export class RefreshState {
     public readonly data: unknown | undefined;
 
     public readonly refresh_token: string;
-    public readonly id_token: string;
-    public readonly scope: string;
+    public readonly id_token?: string;
+    public readonly session_state: string | null;
+    public readonly scope?: string;
 
     constructor(args: {
         refresh_token: string;
-        id_token: string;
-        scope: string;
+        id_token?: string;
+        session_state: string | null;
+        scope?: string;
         state?: unknown;
     }) {
         this.refresh_token = args.refresh_token;
         this.id_token = args.id_token;
+        this.session_state = args.session_state;
         this.scope = args.scope;
         this.data = args.state;
     }
diff --git a/src/ResponseValidator.ts b/src/ResponseValidator.ts
index 70e21002..c40c7975 100644
--- a/src/ResponseValidator.ts
+++ b/src/ResponseValidator.ts
@@ -75,6 +75,8 @@ export class ResponseValidator {
         const logger = this._logger.create("validateRefreshResponse");
 
         response.userState = state.data;
+        // if there's no session_state on the response, copy over session_state from original request
+        response.session_state ??= state.session_state;
         // if there's no scope on the response, then assume all scopes granted (per-spec) and copy over scopes from original request
         response.scope ??= state.scope;
 
diff --git a/src/SigninResponse.ts b/src/SigninResponse.ts
index 4c771e41..d89967fc 100644
--- a/src/SigninResponse.ts
+++ b/src/SigninResponse.ts
@@ -13,7 +13,7 @@ export class SigninResponse {
     // props present in the initial callback response regardless of success
     public readonly state: string | null;
     /** @see {@link User.session_state} */
-    public readonly session_state: string | null;
+    public session_state: string | null;
 
     // error props
     /** @see {@link ErrorResponse.error} */


Test patch:
diff --git a/src/OidcClient.test.ts b/src/OidcClient.test.ts
index 07a42d93..6d597bd5 100644
--- a/src/OidcClient.test.ts
+++ b/src/OidcClient.test.ts
@@ -345,6 +345,7 @@ describe("OidcClient", () => {
             const state = new RefreshState({
                 refresh_token: "refresh_token",
                 id_token: "id_token",
+                session_state: "session_state",
                 scope: "openid",
             });
 
@@ -356,7 +357,7 @@ describe("OidcClient", () => {
             expect(response).toMatchObject(tokenResponse);
         });
 
-        it("should preserve the scope", async () => {
+        it("should preserve the session_state and scope", async () => {
             // arrange
             const tokenResponse = {
                 access_token: "new_access_token",
@@ -368,6 +369,7 @@ describe("OidcClient", () => {
             const state = new RefreshState({
                 refresh_token: "refresh_token",
                 id_token: "id_token",
+                session_state: "session_state",
                 scope: "openid",
             });
 
@@ -381,6 +383,7 @@ describe("OidcClient", () => {
             });
             expect(response).toBeInstanceOf(SigninResponse);
             expect(response).toMatchObject(tokenResponse);
+            expect(response).toHaveProperty("session_state", state.session_state);
             expect(response).toHaveProperty("scope", state.scope);
         });
 
@@ -403,6 +406,7 @@ describe("OidcClient", () => {
             const state = new RefreshState({
                 refresh_token: "refresh_token",
                 id_token: "id_token",
+                session_state: "session_state",
                 scope: "openid",
             });
 
diff --git a/src/UserManager.test.ts b/src/UserManager.test.ts
index c0b59f5d..532cc0bf 100644
--- a/src/UserManager.test.ts
+++ b/src/UserManager.test.ts
@@ -454,7 +454,7 @@ describe("UserManager", () => {
             expect(refreshedUser!.profile).toHaveProperty("nickname", "Nicholas");
             expect(useRefreshTokenSpy).toBeCalledWith(
                 expect.objectContaining({
-                    state: { refresh_token: user.refresh_token },
+                    state: { refresh_token: user.refresh_token, session_state: null },
                 }),
             );
         });