Skip to content

Commit b59e907

Browse files
authored
Reapply feat: add optional update() to SessionDataStore (#2590) (#2616)
2 parents 98c36dc + 911a77e commit b59e907

4 files changed

Lines changed: 186 additions & 4 deletions

File tree

EXAMPLES.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2651,6 +2651,28 @@ export const auth0 = new Auth0Client({
26512651
async delete(id) {
26522652
// delete the session using its ID
26532653
},
2654+
2655+
async update(id, sessionData) {
2656+
// Optional: update the session by its ID only if it already exists; return true if updated, false if not found.
2657+
// Prevents a session deleted by logout from being re-created by a concurrent in-flight rolling write.
2658+
//
2659+
// MUST be a single atomic operation — do not use get() + set() here,
2660+
// as that would recreate the TOCTOU race condition this method is designed to prevent.
2661+
//
2662+
// Example (PostgreSQL):
2663+
// const r = await db.query(
2664+
// "UPDATE sessions SET data=$2, expires_at=NOW()+interval'1 day' WHERE id=$1",
2665+
// [id, sessionData]
2666+
// );
2667+
// return r.rowCount > 0;
2668+
//
2669+
// Example (Redis — Lua script for atomicity):
2670+
// const lua = `if redis.call('exists',KEYS[1])==1 then
2671+
// redis.call('set',KEYS[1],ARGV[1],'EX',ARGV[2]) return 1
2672+
// else return 0 end`;
2673+
// return await redis.eval(lua, 1, id, JSON.stringify(sessionData), ttl) === 1;
2674+
},
2675+
26542676
async deleteByLogoutToken({ sid, sub }: { sid?: string; sub?: string }) {
26552677
// optional method to be implemented when using Back-Channel Logout
26562678
}

src/server/session/stateful-session-store.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,101 @@ describe("Stateful Session Store", async () => {
463463
});
464464

465465
describe("rolling session race condition", async () => {
466+
it("should use store.update() atomically when implemented and bail if it returns false", async () => {
467+
// store.update() returns false → session deleted by concurrent logout.
468+
// The SDK must not fall through to store.set() — zero TOCTOU gap.
469+
const sessionId = "ses_atomic";
470+
const secret = await generateSecret(32);
471+
const session: SessionData = {
472+
user: { sub: "user_123" },
473+
tokenSet: {
474+
accessToken: "at_123",
475+
refreshToken: "rt_123",
476+
expiresAt: 123456
477+
},
478+
internal: {
479+
sid: "auth0-sid",
480+
createdAt: Math.floor(Date.now() / 1000)
481+
}
482+
};
483+
const store = {
484+
get: vi.fn(),
485+
set: vi.fn(),
486+
delete: vi.fn(),
487+
update: vi.fn().mockResolvedValue(false) // session already gone
488+
};
489+
const maxAge = 60 * 60;
490+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
491+
const encryptedCookieValue = await encrypt(
492+
{ id: sessionId },
493+
secret,
494+
expiration
495+
);
496+
const headers = new Headers();
497+
headers.append("cookie", `__session=${encryptedCookieValue}`);
498+
const requestCookies = new RequestCookies(headers);
499+
const responseCookies = new ResponseCookies(new Headers());
500+
501+
const sessionStore = new StatefulSessionStore({
502+
secret,
503+
store,
504+
rolling: true
505+
});
506+
await sessionStore.set(requestCookies, responseCookies, session);
507+
508+
expect(store.update).toHaveBeenCalledWith(sessionId, session);
509+
expect(store.set).not.toHaveBeenCalled();
510+
expect(store.get).not.toHaveBeenCalled(); // no fallback read
511+
expect(responseCookies.get("__session")).toBeUndefined();
512+
});
513+
514+
it("should use store.update() atomically and refresh the cookie when it returns true", async () => {
515+
// store.update() returns true → session found and updated in one DB op.
516+
const sessionId = "ses_atomic_alive";
517+
const secret = await generateSecret(32);
518+
const session: SessionData = {
519+
user: { sub: "user_123" },
520+
tokenSet: {
521+
accessToken: "at_123",
522+
refreshToken: "rt_123",
523+
expiresAt: 123456
524+
},
525+
internal: {
526+
sid: "auth0-sid",
527+
createdAt: Math.floor(Date.now() / 1000)
528+
}
529+
};
530+
const store = {
531+
get: vi.fn(),
532+
set: vi.fn(),
533+
delete: vi.fn(),
534+
update: vi.fn().mockResolvedValue(true)
535+
};
536+
const maxAge = 60 * 60;
537+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
538+
const encryptedCookieValue = await encrypt(
539+
{ id: sessionId },
540+
secret,
541+
expiration
542+
);
543+
const headers = new Headers();
544+
headers.append("cookie", `__session=${encryptedCookieValue}`);
545+
const requestCookies = new RequestCookies(headers);
546+
const responseCookies = new ResponseCookies(new Headers());
547+
548+
const sessionStore = new StatefulSessionStore({
549+
secret,
550+
store,
551+
rolling: true
552+
});
553+
await sessionStore.set(requestCookies, responseCookies, session);
554+
555+
expect(store.update).toHaveBeenCalledWith(sessionId, session);
556+
expect(store.set).not.toHaveBeenCalled();
557+
expect(store.get).not.toHaveBeenCalled();
558+
expect(responseCookies.get("__session")).toBeDefined();
559+
});
560+
466561
it("should not re-create a session that was deleted by a concurrent logout", async () => {
467562
const sessionId = "ses_123";
468563
const secret = await generateSecret(32);
@@ -609,6 +704,45 @@ describe("Stateful Session Store", async () => {
609704
expect(store.set).toHaveBeenCalledOnce();
610705
expect(responseCookies.get("__session")).toBeDefined();
611706
});
707+
708+
it("should call store.set() and not store.update() for a brand-new session even when update() is implemented", async () => {
709+
// When there is no existing session cookie, existingSessionId is null and the guard
710+
// is bypassed entirely. store.set() must be called to create the new session;
711+
// store.update() must NOT be called — it would immediately return false (nothing
712+
// exists yet) and silently swallow the new session creation.
713+
const secret = await generateSecret(32);
714+
const session: SessionData = {
715+
user: { sub: "user_123" },
716+
tokenSet: {
717+
accessToken: "at",
718+
refreshToken: "rt",
719+
expiresAt: 123456
720+
},
721+
internal: {
722+
sid: "sid",
723+
createdAt: Math.floor(Date.now() / 1000)
724+
}
725+
};
726+
const store = {
727+
get: vi.fn(),
728+
set: vi.fn(),
729+
delete: vi.fn(),
730+
update: vi.fn() // must NOT be called for new sessions
731+
};
732+
const requestCookies = new RequestCookies(new Headers()); // no existing cookie
733+
const responseCookies = new ResponseCookies(new Headers());
734+
735+
const sessionStore = new StatefulSessionStore({
736+
secret,
737+
store,
738+
rolling: true
739+
});
740+
await sessionStore.set(requestCookies, responseCookies, session);
741+
742+
expect(store.update).not.toHaveBeenCalled();
743+
expect(store.set).toHaveBeenCalledOnce();
744+
expect(responseCookies.get("__session")).toBeDefined();
745+
});
612746
});
613747

614748
describe("session fixation", async () => {

src/server/session/stateful-session-store.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,25 @@ export class StatefulSessionStore extends AbstractSessionStore {
157157
// The guard only applies when we found an existing session ID in the request cookie
158158
// (existingSessionId !== null). Brand-new sessions (no cookie, or isNew login) bypass
159159
// the check because there is nothing in the store to verify against.
160+
//
161+
// If the store implements the optional update() method we use it as an atomic
162+
// check-and-write (UPDATE WHERE id = $1). Otherwise we fall back to a non-atomic
163+
// get() + set() pair.
160164
if (existingSessionId !== null) {
161-
const existingSession = await this.store.get(existingSessionId);
162-
if (!existingSession) {
163-
return;
165+
if (typeof this.store.update === "function") {
166+
const updated = await this.store.update(existingSessionId, session);
167+
if (!updated) {
168+
return;
169+
}
170+
} else {
171+
const existingSession = await this.store.get(existingSessionId);
172+
if (!existingSession) {
173+
return;
174+
}
175+
await this.store.set(existingSessionId, session);
164176
}
177+
} else {
178+
await this.store.set(sessionId, session);
165179
}
166180

167181
const maxAge = this.calculateMaxAge(session.internal.createdAt);
@@ -180,7 +194,6 @@ export class StatefulSessionStore extends AbstractSessionStore {
180194
...this.cookieConfig,
181195
maxAge
182196
});
183-
await this.store.set(sessionId, session);
184197

185198
// to enable read-after-write in the same request for middleware
186199
reqCookies.set(this.sessionCookieName, jwe.toString());

src/types/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export interface SessionData {
3737
[key: string]: unknown;
3838
}
3939

40+
/**
41+
* Interface for a custom session data store.
42+
*
43+
* **TTL contract:** every successful write method (`set`, `update`) must reset the session
44+
* TTL/expiry so that active sessions are not silently expired between requests.
45+
*/
4046
export interface SessionDataStore {
4147
/**
4248
* Gets the session from the store given a session ID.
@@ -48,6 +54,13 @@ export interface SessionDataStore {
4854
*/
4955
set(id: string, session: SessionData): Promise<void>;
5056

57+
/**
58+
* Optional: update the session by its ID only if it already exists.
59+
* Return `true` if updated, `false` if not found.
60+
*
61+
*/
62+
update?(id: string, session: SessionData): Promise<boolean>;
63+
5164
/**
5265
* Destroys the session with the given session ID.
5366
*/

0 commit comments

Comments
 (0)