Repository: auth0/nextjs-auth0
Pull request #1116 (2023-03-16 18:37:54)

Allow getting a session without updating the session
<!--
❗ For general support or usage questions, use the Auth0 Community forums or raise a support ticket.

By submitting a pull request to this repository, you agree to the terms within the Auth0 Code of Conduct: https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md.
-->

- [x] All new/changed/fixed functionality is covered by tests (or N/A)
- [x] I have added documentation for all new/changed functionality (or N/A)

<!--
❗ All the above items are required. Pull requests with an incomplete or missing checklist will be closed.
-->

### 📋 Changes

The library does not currently allow getting a session without touching that session (assuming rolling is enabled). In order to properly implement a session expiry modal, there needs to be an ability to get the session expiry time without causing the expiry to be updated. This PR adds a config setting (`autoSave`) that allows disabling automatically updating the session when getting a session. To restore rolling capabilities, a `touchSession` function has been added that will allow control over when the session expiry is updated

<!--
Describe both what is changing and why this is important. Include:

- Types and methods added, deleted, deprecated, or changed
- A summary of usage if this is a new feature or a change to a public API
-->

### 📎 References

<!--
Add relevant links supporting this change, such as:

- GitHub issue/PR number addressed or fixed
- Auth0 Community post
- StackOverflow answer
- Related pull requests/issues from other repositories

If there are no references, simply delete this section.
-->

### 🎯 Testing

Unit tests should fully cover this change

<!--
Describe how this can be tested by reviewers. Be specific about anything not tested and why. Include any manual steps for testing end-to-end, or for testing functionality not covered by unit tests.
-->


vercel[bot]: @aovens-quantifi is attempting to deploy a commit to the **Auth0** Team on [Vercel](https://vercel.com).

A member of the Team first needs to [authorize it](https://vercel.com/git/authorize?team=Auth0&type=github&job=%7B%22headInfo%22%3A%7B%22sha%22%3A%22215130d1c736af79d4aadd6a21f5a557b9b77291%22%7D%2C%22id%22%3A%22QmdZ2a284ecAFNf9fWBavJGiqkBjQZn9g28uSa4RB8u2dJ%22%2C%22org%22%3A%22auth0%22%2C%22prId%22%3A1116%2C%22repo%22%3A%22nextjs-auth0%22%7D).


adamjmcgrath: Thanks for raising this @aovens-quantifi - it's a nice idea

Your option will not have any effect if `getSession(req, res, false)` is not the first time the session is accessed, which would make it confusing to use.

eg.

```js
withApiAuthRequired(function(req, res) { // cookie already updated
  const session = await getSession(req, res, false);
});
// or
function(req, res) {
  const at = await getAccessToken(req, res); // cookie already updated
  await getSession(req, res, false);
};
```

I think it would potentially be a good feature, so I'm happy to work on a solution with you. Just so I know the background, what problem are you trying to solve that you can't just use rolling=false? Presumably you want the session duration to be based on the user's last activity but don't want every request to update the cookie? What is your criteria for requests that should update the cookie and requests that should't?
aovens-quantifi: Hey @adamjmcgrath, thanks for the reply. Good catch on the request caching. You're right that every session access for a patlrticular route would need this flag set to false.

Ultimately I only need a single route to have this set to false. I need to be able to retreive the current session expiry without causing it to roll forward.

We're using a SPA written in next, and so any async chatter to the server or activity in another tab will not update my tab's session expiry value. Therefore, before I show the session expiry modal, I need to figure out if it is actually about to expire. To do this I need to get the current value without affecting the current value
aovens-quantifi: I think allowing this via a request header might makef the most sense. Another use case would be if a page has infinite polling. I would argue the polling should not count as "user activity" for the purposes of session timeout 
adamjmcgrath: Hi @aovens-quantifi - thanks for sharing that. Yep, definitely agree this would be a useful feature.

`express-session` and `next-session` have configuration options that disable/enable the auto saving session feature that we do when rolling=true (https://expressjs.com/en/resources/middleware/session.html#resave and [autoCommit](https://www.npmjs.com/package/next-session#user-content-sessiontouch)).

They then both expose a `touchSession` type method (https://expressjs.com/en/resources/middleware/session.html#sessiontouch and https://www.npmjs.com/package/next-session#user-content-sessiontouch) and if `resave`/`autoCommit` is false - it's up to the operator to call `touchSession` to bump the updated-at time of the session.

I would be happy to go for something like that. e.g.

```ts
// autoSave default is true (and should only be set to `false` when rolling=true)
const sessionConfig: SessionConfig = { rolling: true, autoSave: false };

// if autoSave is `false` you need to call `touchSession` to update the session
import { touchSession } from '@auth0/nextjs-auth0';

export default api = (req, res) => {
  await touchSession(req, res);
  return { ... };
}
```


aovens-quantifi: I like it! I'll implement :) 
adamjmcgrath: Great! Thanks @aovens-quantifi! Feel free to reach out on this thread if you have any questions

Patch:
diff --git a/src/auth0-session/config.ts b/src/auth0-session/config.ts
index e0dd4394..8bcbb160 100644
--- a/src/auth0-session/config.ts
+++ b/src/auth0-session/config.ts
@@ -235,6 +235,13 @@ export interface SessionConfig {
    */
   absoluteDuration: boolean | number;
 
+  /**
+   * Boolean value to enable automatic session saving when using rolling sessions.
+   * If this is `false`, you must call `touchSession(req, res)` to update the session.
+   * Defaults to `true`.
+   */
+  autoSave?: boolean;
+
   /**
    * Boolean value to store the ID token in the session. Storing it can make the session cookie too
    * large.
diff --git a/src/auth0-session/get-config.ts b/src/auth0-session/get-config.ts
index 3defc218..584fc33e 100644
--- a/src/auth0-session/get-config.ts
+++ b/src/auth0-session/get-config.ts
@@ -28,6 +28,7 @@ const paramsSchema = Joi.object({
     })
       .optional()
       .default(7 * 24 * 60 * 60), // 7 days,
+    autoSave: Joi.boolean().optional().default(true),
     name: Joi.string().token().optional().default('appSession'),
     store: Joi.object().optional(),
     genId: Joi.function().maxArity(1).when(Joi.ref('store'), { then: Joi.required() }),
diff --git a/src/config.ts b/src/config.ts
index 3c0bf790..3f2817e4 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -248,6 +248,14 @@ export interface SessionConfig {
    */
   absoluteDuration: boolean | number;
 
+  /**
+   * Boolean value to enable automatic session saving when using rolling sessions.
+   * If this is `false`, you must call `touchSession(req, res)` to update the session.
+   * Defaults to `true`.
+   * You can also use the `AUTH0_SESSION_AUTO_SAVE` environment variable.
+   */
+  autoSave?: boolean;
+
   /**
    * Boolean value to store the ID token in the session. Storing it can make the session cookie too
    * large.
@@ -395,6 +403,7 @@ export interface NextConfig extends Pick<BaseConfig, 'identityClaimFilter'> {
  * - `AUTH0_SESSION_ROLLING`: See {@link SessionConfig.rolling}.
  * - `AUTH0_SESSION_ROLLING_DURATION`: See {@link SessionConfig.rollingDuration}.
  * - `AUTH0_SESSION_ABSOLUTE_DURATION`: See {@link SessionConfig.absoluteDuration}.
+ * - `AUTH0_SESSION_AUTO_SAVE`: See {@link SessionConfig.autoSave}.
  * - `AUTH0_COOKIE_DOMAIN`: See {@link CookieConfig.domain}.
  * - `AUTH0_COOKIE_PATH`: See {@link CookieConfig.path}.
  * - `AUTH0_COOKIE_TRANSIENT`: See {@link CookieConfig.transient}.
@@ -495,6 +504,7 @@ export const getConfig = (params: ConfigParameters = {}): { baseConfig: BaseConf
   const AUTH0_SESSION_ROLLING = process.env.AUTH0_SESSION_ROLLING;
   const AUTH0_SESSION_ROLLING_DURATION = process.env.AUTH0_SESSION_ROLLING_DURATION;
   const AUTH0_SESSION_ABSOLUTE_DURATION = process.env.AUTH0_SESSION_ABSOLUTE_DURATION;
+  const AUTH0_SESSION_AUTO_SAVE = process.env.AUTH0_SESSION_AUTO_SAVE;
   const AUTH0_SESSION_STORE_ID_TOKEN = process.env.AUTH0_SESSION_STORE_ID_TOKEN;
   const AUTH0_COOKIE_DOMAIN = process.env.AUTH0_COOKIE_DOMAIN;
   const AUTH0_COOKIE_PATH = process.env.AUTH0_COOKIE_PATH;
@@ -542,6 +552,7 @@ export const getConfig = (params: ConfigParameters = {}): { baseConfig: BaseConf
         AUTH0_SESSION_ABSOLUTE_DURATION && isNaN(Number(AUTH0_SESSION_ABSOLUTE_DURATION))
           ? bool(AUTH0_SESSION_ABSOLUTE_DURATION)
           : num(AUTH0_SESSION_ABSOLUTE_DURATION),
+      autoSave: bool(AUTH0_SESSION_AUTO_SAVE, true),
       storeIDToken: bool(AUTH0_SESSION_STORE_ID_TOKEN),
       ...baseParams.session,
       cookie: {
diff --git a/src/index.ts b/src/index.ts
index 4e5c6e5b..3b5001c9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -42,6 +42,8 @@ import {
   AccessTokenRequest,
   GetAccessTokenResult,
   Claims,
+  touchSessionFactory,
+  TouchSession,
   updateSessionFactory,
   UpdateSession
 } from './session/';
@@ -75,6 +77,11 @@ export interface Auth0Server {
    */
   getSession: GetSession;
 
+  /**
+   * Update the expiry of a rolling session when autoSave is disabled.
+   */
+  touchSession: TouchSession;
+
   /**
    * Append properties to the user.
    */
@@ -174,6 +181,7 @@ export const _initAuth = (params?: ConfigParameters): Auth0Server & { sessionCac
 
   // Init Next layer (with next config)
   const getSession = sessionFactory(sessionCache);
+  const touchSession = touchSessionFactory(sessionCache);
   const updateSession = updateSessionFactory(sessionCache);
   const getAccessToken = accessTokenFactory(nextConfig, getClient, sessionCache);
   const withApiAuthRequired = withApiAuthRequiredFactory(sessionCache);
@@ -187,6 +195,7 @@ export const _initAuth = (params?: ConfigParameters): Auth0Server & { sessionCac
   return {
     sessionCache,
     getSession,
+    touchSession,
     updateSession,
     getAccessToken,
     withApiAuthRequired,
@@ -250,6 +259,7 @@ export {
   WithPageAuthRequired,
   SessionCache,
   GetSession,
+  TouchSession,
   UpdateSession,
   GetAccessToken,
   Session,
diff --git a/src/session/cache.ts b/src/session/cache.ts
index bfe4b03d..f7b9e01a 100644
--- a/src/session/cache.ts
+++ b/src/session/cache.ts
@@ -22,7 +22,7 @@ export default class SessionCache<
       const [json, iat] = await this.sessionStore.read(req);
       this.iatCache.set(req, iat);
       this.cache.set(req, fromJson(json));
-      if (this.config.session.rolling && autoSave) {
+      if (this.config.session.rolling && this.config.session.autoSave && autoSave) {
         await this.save(req, res);
       }
     }
diff --git a/src/session/index.ts b/src/session/index.ts
index 7c8b2dc1..85dc9031 100644
--- a/src/session/index.ts
+++ b/src/session/index.ts
@@ -7,4 +7,5 @@ export {
   GetAccessTokenResult
 } from './get-access-token';
 export { default as SessionCache } from './cache';
+export { default as touchSessionFactory, TouchSession } from './touch-session';
 export { default as updateSessionFactory, UpdateSession } from './update-session';
diff --git a/src/session/touch-session.ts b/src/session/touch-session.ts
new file mode 100644
index 00000000..42a90e64
--- /dev/null
+++ b/src/session/touch-session.ts
@@ -0,0 +1,40 @@
+import { IncomingMessage, ServerResponse } from 'http';
+import { NextApiRequest, NextApiResponse } from 'next';
+import { SessionCache } from '../session';
+import { assertReqRes } from '../utils/assert';
+
+/**
+ * Touch the session object. If rolling sessions are enabled and autoSave is disabled, you will need
+ * to call this method to update the session expiry.
+ *
+ * ```js
+ * // pages/api/graphql.js
+ * import { touchSession } from '@auth0/nextjs-auth0';
+ *
+ * export default async function graphql(req, res) {
+ *   await touchSession(req, res);
+ *
+ *  // ...
+ * };
+ * ```
+ *
+ * @category Server
+ */
+export type TouchSession = (
+  req: IncomingMessage | NextApiRequest,
+  res: ServerResponse | NextApiResponse
+) => Promise<void>;
+
+/**
+ * @ignore
+ */
+export default function touchSessionFactory(sessionCache: SessionCache): TouchSession {
+  return async (req, res) => {
+    assertReqRes(req, res);
+    const session = await sessionCache.get(req, res);
+    if (!session) {
+      return;
+    }
+    await sessionCache.save(req, res);
+  };
+}


Test patch:
diff --git a/tests/config.test.ts b/tests/config.test.ts
index bab1c4a4..a681ace6 100644
--- a/tests/config.test.ts
+++ b/tests/config.test.ts
@@ -50,6 +50,7 @@ describe('config params', () => {
         rolling: true,
         rollingDuration: 86400,
         absoluteDuration: 604800,
+        autoSave: true,
         storeIDToken: true,
         cookie: {
           domain: undefined,
diff --git a/tests/fixtures/global.d.ts b/tests/fixtures/global.d.ts
index 1776fdba..0af85293 100644
--- a/tests/fixtures/global.d.ts
+++ b/tests/fixtures/global.d.ts
@@ -1,5 +1,6 @@
 declare global {
   var getSession: Function | undefined;
+  var touchSession: Function | undefined;
   var updateSession: Function | undefined;
   var handleAuth: Function | undefined;
   var withApiAuthRequired: Function | undefined;
diff --git a/tests/fixtures/setup.ts b/tests/fixtures/setup.ts
index 8a8c99dc..7802eb0f 100644
--- a/tests/fixtures/setup.ts
+++ b/tests/fixtures/setup.ts
@@ -78,6 +78,7 @@ export const setup = async (
     handleLogout,
     handleProfile,
     getSession,
+    touchSession,
     updateSession,
     getAccessToken,
     withApiAuthRequired,
@@ -90,6 +91,7 @@ export const setup = async (
   const handlers: Handlers = { onError, callback, login, logout, profile };
   global.handleAuth = handleAuth.bind(null, handlers);
   global.getSession = getSession;
+  global.touchSession = touchSession;
   global.updateSession = updateSession;
   global.withApiAuthRequired = withApiAuthRequired;
   global.withPageAuthRequired = (): any => withPageAuthRequired(withPageAuthRequiredOptions);
@@ -105,6 +107,7 @@ export const teardown = async (): Promise<void> => {
   nock.cleanAll();
   await stop();
   delete global.getSession;
+  delete global.touchSession;
   delete global.updateSession;
   delete global.handleAuth;
   delete global.withApiAuthRequired;
diff --git a/tests/fixtures/test-app/pages/api/touch-session.ts b/tests/fixtures/test-app/pages/api/touch-session.ts
new file mode 100644
index 00000000..aa334578
--- /dev/null
+++ b/tests/fixtures/test-app/pages/api/touch-session.ts
@@ -0,0 +1,7 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+
+export default async function sessionHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
+  await global.touchSession?.(req, res);
+  const json = await global.getSession?.(req, res);
+  res.status(200).json(json);
+}
diff --git a/tests/fixtures/test-app/tsconfig.json b/tests/fixtures/test-app/tsconfig.json
index 35d51eac..5bee8c4d 100644
--- a/tests/fixtures/test-app/tsconfig.json
+++ b/tests/fixtures/test-app/tsconfig.json
@@ -16,7 +16,8 @@
     "moduleResolution": "node",
     "resolveJsonModule": true,
     "isolatedModules": true,
-    "jsx": "preserve"
+    "jsx": "preserve",
+    "incremental": true
   },
   "include": [
     "next-env.d.ts",
diff --git a/tests/session/touch-session.test.ts b/tests/session/touch-session.test.ts
new file mode 100644
index 00000000..733fb8da
--- /dev/null
+++ b/tests/session/touch-session.test.ts
@@ -0,0 +1,45 @@
+import { login, setup, teardown } from '../fixtures/setup';
+import { withoutApi } from '../fixtures/default-settings';
+import { get } from '../auth0-session/fixtures/helpers';
+
+describe('touch-session', () => {
+  afterEach(teardown);
+
+  test('should not update the session when getting the session', async () => {
+    const baseUrl = await setup({
+      ...withoutApi,
+      session: {
+        autoSave: false
+      }
+    });
+    const cookieJar = await login(baseUrl);
+    const [authCookie] = await cookieJar.getCookies(baseUrl);
+    await get(baseUrl, '/api/auth/me', { cookieJar });
+    const [updatedAuthCookie] = await cookieJar.getCookies(baseUrl);
+    expect(updatedAuthCookie).toEqual(authCookie);
+  });
+
+  test('should update the session when calling touchSession', async () => {
+    const baseUrl = await setup({
+      ...withoutApi,
+      session: {
+        autoSave: false
+      }
+    });
+    const cookieJar = await login(baseUrl);
+    const [authCookie] = await cookieJar.getCookies(baseUrl);
+    await get(baseUrl, '/api/touch-session', { cookieJar });
+    const [updatedAuthCookie] = await cookieJar.getCookies(baseUrl);
+    expect(updatedAuthCookie).not.toEqual(authCookie);
+  });
+
+  test('should not throw when there is no session', async () => {
+    const baseUrl = await setup({
+      ...withoutApi,
+      session: {
+        autoSave: false
+      }
+    });
+    await expect(get(baseUrl, '/api/touch-session')).resolves.not.toThrow();
+  });
+});