Separate rotation and expiry of sessions

If a session is rotate in the middle of a server side rendering then
some random portions of requests made on the server side will fail with
a session taken error as the server is not going to update the cookies
of the client during these requests.

To avoid this pitfall extend the expiry time of sessions to be 10
seconds after the session has been rotated.  This is accomplished by
introducing a new timestamp on sessions called the rotateAt at time
alongside the expiresAt time.  Sessions used after rotateAt that haven't
been rotated get rotated into a new session and the existing session
gets the expiresAt time set to 10 seconds in the future.  Sessions that
are past the expiredAt time have no access.

This makes the logic around session expiry simpler, and also makes it
possible to audit when a session got rotated, and to mark sessions as
expired without a chance to rotate to a new session without having to
resort to a finished flag.
This commit is contained in:
Hornwitser 2025-07-09 14:54:54 +02:00
parent 352362b9c3
commit 3f492edea2
10 changed files with 37 additions and 38 deletions

View file

@ -6,12 +6,12 @@
## Environment Variables
### NUXT_SESSION_EXPIRES_TIMEOUT
### NUXT_SESSION_ROTATES_TIMEOUT
Time in seconds before a session is considered expired and need to be rotated over into a new session. When an endpoint using a session is hit after the session expires but before the session is discarded a new session is created as the successor with a new expiry and discard timeout. The old session then considered to have been superceeded and any requests using the old session will result in a 403 Forbidden with the message the session has been taken.
Time in seconds before a session need to be rotated over into a new session. When an endpoint using a session is hit after the session rotates timeout but before the session is discarded a new session is created as the successor with a new rotates and discard timeout. The old session then marked to expire in 10 seconds any requests using the old session will result in a 403 Forbidden with the message the session has been taken after the expiry.
### NUXT_SESSION_DISCARD_TIMEOUT
Time in seconds before a session is deleted from the client and server, resulting in the user having to authenticate again if the session wasn't rotated over into a new session before this timeout.
This should be several times greater that `NUXT_SESSION_EXPIRES_TIMEOUT`.
This should be several times greater that `NUXT_SESSION_ROTATES_TIMEOUT`.

View file

@ -4,7 +4,7 @@
-->
# Server-sent events
To update in real time this application sends a `text/event-source` stream using [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). These streams use the current session if any to filter restricted resources and ends when the session expires, necessitating a reconnect by the user agent. (If there are no session associated with the connection it ends after the session expiry timeout).
To update in real time this application sends a `text/event-source` stream using [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). These streams use the current session if any to filter restricted resources and ends when the session rotates timeout is hit, necessitating a reconnect by the user agent. (If there are no session associated with the connection it ends after the session rotates timeout).
## Events

View file

@ -6,8 +6,8 @@
When a user creates a new account or logs in to an existing account a session is created on the server and linked to the user's browser via a session cookie. This cookie contains a unique identifier along with a HMAC signature created using the server's cookie secret key. Since this unique identifier stored on the user's device is a technical requirement to securely do what the user is requesting the user's consent to its storage can be assumed.
Sessions have two future times associated with them: The expiration time is the point in time after the session will be recreated and the cookie reassigned, and the discard time is when the session is deleted from both the client and the server. The expiriation time is short, by default 1 hour, while the discard time is long, by default 2 weeks.
Sessions have three future times associated with them: The rotates time is the point in time after the session will be recreated and the cookie reassigned, the expiry time is the point in time after which use of the session will be rejected, and the discard time is when the session is deleted from both the client and the server. The rotation time is short, by default 1 hour, while the discard time is long, by default 2 weeks.
When a request is made to a session that's past the expiration time a new session is created to replace the existing one, and the session cookie is updated with the new session. The purpose of this is to reduce the time window a stolen session can be used in without being detected. If a request is made using a session that has already been replaced the server responds with a message saying the "session has been taken".
When a request is made to a session that's past the rotates time a new session is created to replace the existing one, the expiry time is set on the existing session to 10 seconds later, and the session cookie is updated with the new session. The purpose of this is to reduce the time window a stolen session can be used in without being detected. If a request is made using a session that has expired the server responds with a message saying the "session has been taken". The reason for having the session expire 10 seconds after the rotation is to prevent race conditions from triggering the session taken error.
Sessions are created for a limited timespan, purpose and access level, and expires after the timespan is over, the purpose is fulfilled or the access level changes. For example if the user's account is promoted from regular to crew the session will no longer be valid and will be recreated as a new session with the new access level on the next request.

View file

@ -23,7 +23,7 @@ export default defineNuxtConfig({
},
runtimeConfig: {
cookieSecretKeyFile: "",
sessionExpiresTimeout: 1 * oneHourSeconds,
sessionRotatesTimeout: 1 * oneHourSeconds,
sessionDiscardTimeout: 14 * oneDaySeconds,
vapidSubject: "",
vapidPrivateKeyFile: "",

View file

@ -58,12 +58,13 @@ export default defineEventHandler(async (event) => {
data: serverUserToApi(user),
});
// Expire sessions with the user in it if the access changed
// Rotate sessions with the user in it if the access changed
if (accessChanged) {
const sessions = await readSessions();
const nowMs = Date.now();
for (const session of sessions) {
if (session.accountId === user.id) {
session.expiresAtMs = 0;
session.rotatesAtMs = nowMs;
broadcastEvent({
type: "session-expired",
sessionId: session.id,

View file

@ -18,9 +18,8 @@ export default defineEventHandler(async (event) => {
const nowMs = Date.now();
for (const session of sessions) {
if (
!session.finished
&& session.successor !== undefined
&& session.expiresAtMs < nowMs
session.successor !== undefined
&& (session.expiresAtMs === undefined || session.expiresAtMs < nowMs)
&& session.accountId === serverSession.accountId
) {
session.expiresAtMs = nowMs;

View file

@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
if (session) {
const users = await readUsers();
const account = users.find(user => user.id === session.accountId);
if (account?.type === "anonymous" && session.successor === undefined) {
if (account?.type === "anonymous") {
throw createError({
status: 409,
message: "Cannot log out of an anonymous account",

View file

@ -10,10 +10,10 @@ export interface ServerSession {
id: Id,
access: ApiUserType,
accountId?: number,
expiresAtMs: number,
rotatesAtMs: number,
expiresAtMs?: number,
discardAtMs: number,
successor?: Id,
finished: boolean,
};
export interface ServerUser {

View file

@ -33,7 +33,7 @@ function sendMessageAndClose(
;
}
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number, expiresAtMs: number }>();
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number, rotatesAtMs: number }>();
let keepaliveInterval: ReturnType<typeof setInterval> | null = null
export async function addStream(
@ -49,7 +49,7 @@ export async function addStream(
streams.set(stream, {
sessionId: session?.id,
accountId: session?.accountId,
expiresAtMs: session?.expiresAtMs ?? Date.now() + runtimeConfig.sessionExpiresTimeout * 1000,
rotatesAtMs: session?.rotatesAtMs ?? Date.now() + runtimeConfig.sessionRotatesTimeout * 1000,
});
// Produce a response immediately to avoid the reply waiting for content.
const update: ApiEvent = {
@ -162,7 +162,7 @@ export async function broadcastEvent(event: ApiEvent) {
function sendKeepalive() {
const now = Date.now();
for (const [stream, streamData] of streams) {
if (streamData.expiresAtMs > now) {
if (streamData.rotatesAtMs > now) {
sendMessage(stream, ": keepalive\n");
} else {
sendMessageAndClose(stream, `data: cancelled\n\n`);

View file

@ -31,7 +31,7 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio
const sessionId = parseInt(existingSessionCookie, 10);
const session = sessions.find(session => session.id === sessionId);
if (session) {
session.finished = true;
session.expiresAtMs = Date.now();
broadcastEvent({
type: "session-expired",
sessionId,
@ -58,11 +58,10 @@ export async function setServerSession(event: H3Event, account: ServerUser) {
const now = Date.now();
const newSession: ServerSession = {
accountId: account.id,
access: account.type,
expiresAtMs: now + runtimeConfig.sessionExpiresTimeout * 1000,
access: account?.type ?? "anonymous",
accountId: account?.id,
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
finished: false,
id: await nextSessionId(),
};
@ -79,41 +78,41 @@ async function rotateSession(event: H3Event, sessions: ServerSession[], session:
const newSession: ServerSession = {
accountId: account?.id,
access: account?.type ?? "anonymous",
expiresAtMs: now + runtimeConfig.sessionExpiresTimeout * 1000,
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
finished: false,
id: await nextSessionId(),
};
session.successor = newSession.id;
session.expiresAtMs = Date.now() + 10 * 1000;
sessions.push(newSession);
await writeSessions(sessions);
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
return newSession;
}
export async function getServerSession(event: H3Event, ignoreExpired: boolean) {
export async function getServerSession(event: H3Event, ignoreTaken: boolean) {
const sessionCookie = await getSignedCookie(event, "session");
if (sessionCookie) {
const sessionId = parseInt(sessionCookie, 10);
const sessions = await readSessions();
const session = sessions.find(session => session.id === sessionId);
if (session) {
if (session.finished) {
const nowMs = Date.now();
if (nowMs >= session.discardAtMs) {
return undefined;
}
if (!ignoreExpired && session.successor !== undefined) {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
message: "Session has been taken by another agent.",
data: { code: "SESSION_TAKEN" },
});
}
const now = Date.now();
if (now >= session?.discardAtMs) {
if (session.expiresAtMs !== undefined && nowMs >= session.expiresAtMs) {
if (!ignoreTaken && session.successor !== undefined) {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
message: "Session has been taken by another agent.",
data: { code: "SESSION_TAKEN" },
});
}
return undefined;
}
if (!ignoreExpired && now >= session?.expiresAtMs) {
if (nowMs >= session.rotatesAtMs && session.successor === undefined) {
return await rotateSession(event, sessions, session);
}
}