Repository: auth0/nextjs-auth0 (ts)

Issue:
getAccessToken clears custom session.user attributes set via onUserLoaded
### Description

As per the README, onUserLoaded can be used to set custom session.user attributes (eg `session.user.age`). However these attributes are removed when the accessToken is refreshed.

This can be observed here https://github.com/auth0/nextjs-auth0/blob/411896ba5044bbeda1b362f7f85b5ab8128d12cd/src/tokens/session-token-cache.ts#L83-L88 where it saves to the session store, overriding the `session.user` key.

This appears unintentional, as later on it attempts to preserve the `session.user` (however its already been overwritten via the `getAccessToken` function

https://github.com/auth0/nextjs-auth0/blob/411896ba5044bbeda1b362f7f85b5ab8128d12cd/src/handlers/profile.ts#L31-L50

### Reproduction

1. Create a callback onUserLoaded that sets a custom `session.user` attribute (such as the example in the README that sets `session.user.age`

```
await auth0.handleCallback(req, res, {
      onUserLoaded: async (req, res, session, state) => {
        return {
          ...session,
          user: {
            ...session.user,
            age: 20
          }
        };
      }
    });
```


2. Force a token refresh

```
  const tokenCache = auth0.tokenCache(request, response);
  await tokenCache.getAccessToken({
    refresh: true,
  });
```

3. Observe the users session

```
const session = await auth0.getSession(req);
console.log(session);
```

The custom attribute will have been removed

### Environment

Version `0.16.0`

Pull request description:
[SDK-2258] Prevent custom session props being removed when refreshing AT
### Description

When a new AT is fetched, the session is also updated with a new ID Token and corresponding claims. Custom claims (usually added in the `afterCallback` hook) should not be removed when these new claims are applied.

### References

fix #161 

### Testing

- [ ] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [ ] I have added documentation for new/changed functionality in this PR or in auth0.com/docs
- [ ] All active GitHub checks for tests, formatting, and security are passing
- [ ] The correct base branch is being used, if not `master`


Patch:
diff --git a/src/session/get-access-token.ts b/src/session/get-access-token.ts
index a5b5ed99..1bc5e667 100644
--- a/src/session/get-access-token.ts
+++ b/src/session/get-access-token.ts
@@ -2,7 +2,7 @@ import { IncomingMessage, ServerResponse } from 'http';
 import { ClientFactory, Config } from '../auth0-session';
 import { AccessTokenError } from '../utils/errors';
 import { intersect, match } from '../utils/array';
-import { SessionCache, fromTokenSet } from '../session';
+import { SessionCache, fromTokenSet, fromJson } from '../session';
 import { NextApiRequest, NextApiResponse } from 'next';
 
 /**
@@ -113,8 +113,16 @@ export default function accessTokenFactory(
 
       // Update the session.
       const newSession = fromTokenSet(tokenSet, config);
-      newSession.refreshToken = newSession.refreshToken || session.refreshToken;
-      sessionCache.set(req, res, newSession);
+      sessionCache.set(
+        req,
+        res,
+        fromJson({
+          ...session,
+          ...newSession,
+          refreshToken: newSession.refreshToken || session.refreshToken,
+          user: { ...session.user, ...newSession.user }
+        })
+      );
 
       // Return the new access token.
       return {


Test patch:
diff --git a/tests/session/get-access-token.test.ts b/tests/session/get-access-token.test.ts
index a1ed7dbb..18f0df16 100644
--- a/tests/session/get-access-token.test.ts
+++ b/tests/session/get-access-token.test.ts
@@ -125,6 +125,8 @@ describe('get access token', () => {
     const cookieJar = await login(baseUrl);
     const { accessToken } = await get(baseUrl, '/api/access-token', { cookieJar });
     expect(accessToken).toEqual('new-token');
+    const { refreshToken } = await get(baseUrl, '/api/session', { cookieJar });
+    expect(refreshToken).toEqual('GEbRxBN...edjnXbL');
   });
 
   test('should retrieve a new access token if force refresh is set', async () => {
@@ -142,6 +144,8 @@ describe('get access token', () => {
     const cookieJar = await login(baseUrl);
     const { accessToken } = await get(baseUrl, '/api/access-token', { cookieJar });
     expect(accessToken).toEqual('new-token');
+    const { refreshToken } = await get(baseUrl, '/api/session', { cookieJar });
+    expect(refreshToken).toEqual('GEbRxBN...edjnXbL');
   });
 
   test('should retrieve a new access token and rotate the refresh token', async () => {
@@ -170,4 +174,43 @@ describe('get access token', () => {
     const { accessToken } = await get(baseUrl, '/api/access-token', { cookieJar });
     expect(accessToken).toEqual('eyJz93a...k4laUWw');
   });
+
+  test('should not overwrite custom session properties when applying a new access token', async () => {
+    refreshTokenExchange(
+      withApi,
+      'GEbRxBN...edjnXbL',
+      {
+        email: 'john@test.com',
+        name: 'john doe',
+        sub: '123'
+      },
+      'new-token'
+    );
+    const baseUrl = await setup(withApi, {
+      getAccessTokenOptions: { refresh: true },
+      callbackOptions: {
+        afterCallback: (_req, _res, session): Session => {
+          session.foo = 'bar';
+          session.user.bar = 'baz';
+          return session;
+        }
+      }
+    });
+    const cookieJar = await login(baseUrl);
+    const { accessToken } = await get(baseUrl, '/api/access-token', { cookieJar });
+    expect(accessToken).toEqual('new-token');
+    const session = await get(baseUrl, '/api/session', { cookieJar });
+    expect(session).toMatchObject({
+      foo: 'bar',
+      accessToken: 'new-token',
+      refreshToken: 'GEbRxBN...edjnXbL',
+      user: {
+        nickname: '__test_nickname__',
+        email: 'john@test.com',
+        name: 'john doe',
+        sub: '123',
+        bar: 'baz'
+      }
+    });
+  });
 });