Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean> {
const store = getStore();
const key = PREFIXES.SESSION + sessionId;
const session = await store.get<OAuthSession>(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)
*/
Expand Down
8 changes: 7 additions & 1 deletion src/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/transports/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
exchangeCodeForTokens,
getRegisteredClientCount,
isOAuthSessionId,
touchSession,
} from "../oauth.js";

export interface HttpTransportConfig {
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
17 changes: 16 additions & 1 deletion tests/unit/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading