From 5ab0576554b3d37b30abd0dff12ea4ecb1dae274 Mon Sep 17 00:00:00 2001 From: Conal Mullan Date: Wed, 12 Aug 2026 10:27:59 +0100 Subject: [PATCH] fix: 30 day sliding session TTL instead of a hard 24h expiry Sessions expired after 24h, so every hosted OAuth user had to complete a full browser sign-in daily. The DS access token behind the session is valid for 365 days, so our own TTL - not the DS token - was forcing it: DS access token life : 365 days our session TTL : 24 hours <- binding constraint We issue no refresh_token and advertise only the authorization_code grant, so a client has no way to renew without user interaction. Raise TTL.SESSION to 30 days and slide it forward on each authenticated request, so an actively used connection never expires; 30 days becomes an inactivity window rather than a hard cap. Kept well inside the DS token's life because a session ID is a bearer credential. The slide only writes when the expiry has moved by more than an hour, so a busy session touches the store about hourly rather than per request, and it is fire-and-forget: a failed extension never fails the user's request. Verified: TTL.SESSION = 2592000s (30d), which is also the expires_in we advertise. Sliding is covered in both directions - an active session survives 50 days of use, an unused one still expires, and touchSession will not resurrect an already-expired session. --- src/oauth.ts | 31 +++++++++++++++++ src/session-store.ts | 8 ++++- src/transports/http.ts | 8 +++++ tests/unit/oauth.test.ts | 59 ++++++++++++++++++++++++++++++++ tests/unit/session-store.test.ts | 17 ++++++++- 5 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/oauth.ts b/src/oauth.ts index 07994e5..220a4a0 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -10,6 +10,12 @@ import { randomBytes, createHash } from "node:crypto"; import logger from "./logger.js"; import { getStore, PREFIXES, TTL } from "./session-store.js"; +/** + * Only slide a session's expiry once it has drifted by this much, so a busy + * session writes to the store roughly once an hour rather than once a request. + */ +const SESSION_SLIDE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour + // OAuth Configuration - loaded from environment export interface OAuthConfig { clientId: string; @@ -462,6 +468,31 @@ export async function getSession( return session; } +/** + * Slide a session's expiry forward by a full TTL. + * + * Called on each authenticated request so an actively used session never + * expires out from under the user. Only writes when the expiry has moved by + * more than SESSION_SLIDE_THRESHOLD, so a busy session doesn't write to the + * store on every single request. + * + * @returns true if the session was extended + */ +export async function touchSession(sessionId: string): Promise { + const store = getStore(); + const key = PREFIXES.SESSION + sessionId; + const session = await store.get(key); + if (!session) return false; + + const newExpiresAt = Date.now() + TTL.SESSION * 1000; + if (newExpiresAt - session.expiresAt < SESSION_SLIDE_THRESHOLD_MS) { + return false; + } + + await store.set(key, { ...session, expiresAt: newExpiresAt }, TTL.SESSION); + return true; +} + /** * Get access token from session (used for /oauth-api/v1/* calls) */ diff --git a/src/session-store.ts b/src/session-store.ts index 425288f..6b55712 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -142,7 +142,13 @@ export const PREFIXES = { // TTLs in seconds export const TTL = { - SESSION: 24 * 60 * 60, // 24 hours + // 30 days, slid forward on each authenticated request (see touchSession in + // oauth.ts), so an active user never has to re-authorise. The DS access + // token behind the session is valid for 365 days, so our TTL - not the DS + // token - is what forces re-authentication; at 24h that was every hosted + // customer, every day. Kept well below the DS token's life because a session + // ID is a bearer credential. + SESSION: 30 * 24 * 60 * 60, // 30 days CLIENT: 30 * 24 * 60 * 60, // 30 days CODE_VERIFIER: 10 * 60, // 10 minutes PENDING_AUTH: 10 * 60, // 10 minutes diff --git a/src/transports/http.ts b/src/transports/http.ts index 61e8332..553ca71 100644 --- a/src/transports/http.ts +++ b/src/transports/http.ts @@ -44,6 +44,7 @@ import { exchangeCodeForTokens, getRegisteredClientCount, isOAuthSessionId, + touchSession, } from "../oauth.js"; export interface HttpTransportConfig { @@ -164,6 +165,13 @@ function authMiddleware(requireAuth: boolean) { return; } + // Slide the session's expiry forward so an actively used connection + // never has to re-authorise. Best-effort: a failed extension must not + // fail the request the user actually made. + void touchSession(sessionId).catch((err: any) => + logger.warn(`Could not extend session expiry: ${err?.message}`), + ); + // Use the OAuth access token directly with /oauth-api/v1/* endpoints (req as any).apiKey = accessToken; (req as any).isOAuthSession = true; // Flag to use OAuth API URL diff --git a/tests/unit/oauth.test.ts b/tests/unit/oauth.test.ts index e174ad4..9181dfd 100644 --- a/tests/unit/oauth.test.ts +++ b/tests/unit/oauth.test.ts @@ -723,6 +723,65 @@ describe("oauth", () => { ).resolves.toBeNull(); }); + describe("sliding expiry (touchSession)", () => { + // The DS access token behind a session is valid for a year, so our TTL + // is what forces re-authentication. Sliding it on use means an active + // user never gets logged out; only genuine inactivity expires a session. + + it("extends an active session beyond the original TTL", async () => { + jest.useFakeTimers(); + const sessionId = await newSession(); + + // Use the session every 10 days for 50 days - five times the old 24h + // TTL and well past a static 30 day one. + for (let day = 10; day <= 50; day += 10) { + jest.advanceTimersByTime(10 * 24 * 60 * 60 * 1000); + await oauth.touchSession(sessionId); + await expect( + oauth.getAccessTokenFromSession(sessionId), + ).resolves.toBe("ds-access-token"); + } + }); + + it("still expires a session that is never used", async () => { + jest.useFakeTimers(); + const sessionId = await newSession(); + + jest.advanceTimersByTime((sessionStore.TTL.SESSION + 1) * 1000); + + await expect(oauth.getSession(sessionId)).resolves.toBeNull(); + }); + + it("reports whether it extended the session", async () => { + jest.useFakeTimers(); + const sessionId = await newSession(); + + // Immediately after creation the expiry has barely moved, so the + // write is skipped rather than repeated on every request. + await expect(oauth.touchSession(sessionId)).resolves.toBe(false); + + // Past the slide threshold it does extend. + jest.advanceTimersByTime(2 * 60 * 60 * 1000); + await expect(oauth.touchSession(sessionId)).resolves.toBe(true); + }); + + it("does not resurrect an expired session", async () => { + jest.useFakeTimers(); + const sessionId = await newSession(); + + jest.advanceTimersByTime((sessionStore.TTL.SESSION + 1) * 1000); + + await expect(oauth.touchSession(sessionId)).resolves.toBe(false); + await expect(oauth.getSession(sessionId)).resolves.toBeNull(); + }); + + it("returns false for an unknown session", async () => { + await expect(oauth.touchSession("no-such-session")).resolves.toBe( + false, + ); + }); + }); + it("drops the session on logout", async () => { const sessionId = await newSession(); diff --git a/tests/unit/session-store.test.ts b/tests/unit/session-store.test.ts index 9571329..83fec51 100644 --- a/tests/unit/session-store.test.ts +++ b/tests/unit/session-store.test.ts @@ -123,12 +123,27 @@ describe("session-store", () => { it("exposes TTLs in seconds", () => { const { sessionStore } = freshModules(); - expect(sessionStore.TTL.SESSION).toBe(24 * 60 * 60); + expect(sessionStore.TTL.SESSION).toBe(30 * 24 * 60 * 60); expect(sessionStore.TTL.CLIENT).toBe(30 * 24 * 60 * 60); expect(sessionStore.TTL.CODE_VERIFIER).toBe(10 * 60); expect(sessionStore.TTL.PENDING_AUTH).toBe(10 * 60); expect(sessionStore.TTL.AUTH_CODE).toBe(10 * 60); }); + + it("keeps the session TTL long enough not to force daily re-auth", () => { + const { sessionStore } = freshModules(); + + // The DS access token behind a session is valid for a year, so this TTL + // - not the DS token - is what forces a user to re-authorise. At 24h + // that was every hosted customer, every day. It is slid forward on use + // (see touchSession in oauth.ts), so this is the *inactivity* window. + const ONE_DAY = 24 * 60 * 60; + expect(sessionStore.TTL.SESSION).toBeGreaterThan(7 * ONE_DAY); + + // ...but a session ID is a bearer credential, so it must still expire + // well inside the DS token's 365 day life. + expect(sessionStore.TTL.SESSION).toBeLessThan(365 * ONE_DAY); + }); }); describe("getStore selection", () => {