Skip to content

Commit c403fb1

Browse files
authored
fix(auth): add sliding session expiration (#131)
When less than half the session TTL remains, the expiry is extended and the cookie re-issued with a fresh maxAge.
1 parent f75e059 commit c403fb1

3 files changed

Lines changed: 80 additions & 2 deletions

File tree

‎server/src/auth.js‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import crypto from 'node:crypto'
22
import bcrypt from 'bcryptjs'
33
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
4-
import { createSession, getSession, deleteSession, createUser, getUserByUsername, getUserById, updateUser } from './db.js'
4+
import { createSession, getSession, deleteSession, renewSessionIfDue, createUser, getUserByUsername, getUserById, updateUser } from './db.js'
55
import { config } from './config.js'
66

77
const COOKIE_NAME = 'helios_session'
@@ -143,6 +143,18 @@ export function requireAuth(db) {
143143
return async (c, next) => {
144144
const session = sessionIdFromCookie(db, c)
145145
if (!session) return c.json({ error: 'no autorizado' }, 401)
146+
// Expiración deslizante (#130): si la sesión queda por debajo de la mitad
147+
// del TTL, extender su vida y re-emitir la cookie con maxAge fresco.
148+
if (renewSessionIfDue(db, session.id, config.sessionTtlMs)) {
149+
const isHttps = c.req.header('x-forwarded-proto') === 'https' || c.req.url.startsWith('https://')
150+
setCookie(c, COOKIE_NAME, `${session.id}.${sign(db, session.id)}`, {
151+
httpOnly: true,
152+
sameSite: 'Lax',
153+
secure: isHttps,
154+
maxAge: Math.floor(config.sessionTtlMs / 1000),
155+
path: '/',
156+
})
157+
}
146158
c.set('userId', session.userId)
147159
return next()
148160
}

‎server/src/db.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,19 @@ export function deleteSession(db, id) {
295295
db.prepare('DELETE FROM sessions WHERE id = ?').run(id)
296296
}
297297

298+
// Expiración deslizante: si a la sesión le queda menos de la mitad del TTL,
299+
// extiende expires_at un TTL completo desde ahora. Un usuario activo no pierde
300+
// la sesión en el límite fijo. Devuelve true si renovó (el caller re-emite la
301+
// cookie con maxAge fresco).
302+
export function renewSessionIfDue(db, id, ttlMs) {
303+
const row = db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(id)
304+
if (!row) return false
305+
const now = Date.now()
306+
if (row.expires_at - now >= ttlMs / 2) return false
307+
db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(now + ttlMs, id)
308+
return true
309+
}
310+
298311
export function cleanSessions(db) {
299312
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now())
300313
const oneHourAgo = Date.now() - 3600 * 1000

‎server/test/auth.test.js‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, beforeEach } from 'vitest'
22
import Database from 'better-sqlite3'
33
import { initSchema, getUserByUsername } from '../src/db.js'
4-
import { loginRateLimited, registerLoginFail, loginOk, registerUser, handleLogin, ensureBootstrapAdmin, changeOwnPassword } from '../src/auth.js'
4+
import { loginRateLimited, registerLoginFail, loginOk, registerUser, handleLogin, ensureBootstrapAdmin, changeOwnPassword, requireAuth } from '../src/auth.js'
55

66
function mockContext(ip, cookie) {
77
const headers = new Headers()
@@ -13,6 +13,7 @@ function mockContext(ip, cookie) {
1313
raw: { headers },
1414
},
1515
header: () => {},
16+
set: () => {},
1617
res: { headers: { append: () => {} } },
1718
env: {},
1819
}
@@ -112,3 +113,55 @@ describe('rate-limit de login (SQLite)', () => {
112113
expect(loginRateLimited(db, b)).toBe(false)
113114
})
114115
})
116+
117+
describe('expiración deslizante de sesiones (#130)', () => {
118+
const TTL_MS = 30 * 24 * 3600 * 1000
119+
120+
async function loginCapturingCookie(username, password) {
121+
const c = mockContext('1.1.1.1')
122+
const captured = []
123+
c.header = (k, v) => captured.push([k, v])
124+
const res = await handleLogin(db, c, { username, password })
125+
const setCookie = captured.find(([k]) => k === 'Set-Cookie')?.[1] || ''
126+
const cookie = setCookie.split(';')[0] // helios_session=<id>.<sig>
127+
return { res, cookie }
128+
}
129+
130+
it('sesión por debajo de la mitad del TTL: renueva expires_at y re-emite cookie', async () => {
131+
await registerUser(db, 'slide', 'secreto123')
132+
const { cookie } = await loginCapturingCookie('slide', 'secreto123')
133+
expect(cookie).toMatch(/^helios_session=.+\..+/)
134+
const sessionId = cookie.split('=')[1].split('.')[0]
135+
const soon = Date.now() + 10 * 24 * 3600 * 1000 // 10 días < 15 (mitad del TTL)
136+
db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(soon, sessionId)
137+
138+
const c2 = mockContext('1.1.1.1', cookie)
139+
const captured = []
140+
c2.header = (k, v) => captured.push([k, v])
141+
let nextCalled = false
142+
await requireAuth(db)(c2, async () => { nextCalled = true })
143+
expect(nextCalled).toBe(true)
144+
const renewed = captured.find(([k]) => k === 'Set-Cookie')?.[1] || ''
145+
expect(renewed).toMatch(/^helios_session=.+\..+/)
146+
expect(renewed).toMatch(/Max-Age=2592000/)
147+
const after = db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(sessionId).expires_at
148+
expect(after).toBeGreaterThan(Date.now() + 25 * 24 * 3600 * 1000)
149+
})
150+
151+
it('sesión por encima de la mitad del TTL: no renueva ni re-emite cookie', async () => {
152+
await registerUser(db, 'fresh', 'secreto123')
153+
const { cookie } = await loginCapturingCookie('fresh', 'secreto123')
154+
const sessionId = cookie.split('=')[1].split('.')[0]
155+
const fresh = Date.now() + 20 * 24 * 3600 * 1000 // 20 días > 15
156+
db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(fresh, sessionId)
157+
158+
const c2 = mockContext('1.1.1.1', cookie)
159+
const captured = []
160+
c2.header = (k, v) => captured.push([k, v])
161+
let nextCalled = false
162+
await requireAuth(db)(c2, async () => { nextCalled = true })
163+
expect(nextCalled).toBe(true)
164+
expect(captured.find(([k]) => k === 'Set-Cookie')).toBeUndefined()
165+
expect(db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(sessionId).expires_at).toBe(fresh)
166+
})
167+
})

0 commit comments

Comments
 (0)