diff --git a/backend/src/__tests__/missing-external-networks-route.test.ts b/backend/src/__tests__/missing-external-networks-route.test.ts index 4b2e7105..52759650 100644 --- a/backend/src/__tests__/missing-external-networks-route.test.ts +++ b/backend/src/__tests__/missing-external-networks-route.test.ts @@ -96,6 +96,7 @@ describe('deploy provenance trust boundary', () => { const res = { status: vi.fn().mockReturnThis(), json: vi.fn(() => resolve()), + cookie: vi.fn(), } as unknown as import('express').Response; void Promise.resolve(authMiddleware(req, res, () => { nextCalled = true; diff --git a/backend/src/__tests__/session-remember-refresh.test.ts b/backend/src/__tests__/session-remember-refresh.test.ts new file mode 100644 index 00000000..991c7f43 --- /dev/null +++ b/backend/src/__tests__/session-remember-refresh.test.ts @@ -0,0 +1,260 @@ +/** + * Tests for the two pieces added to fix sessions expiring out from under + * active users: sliding-refresh (authMiddleware silently reissues a + * near-expiry session cookie) and "stay signed in" (a longer-lived session + * chosen at login, carried through MFA and password-change reissues). + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { OTP } from 'otplib'; +import { + setupTestDb, + cleanupTestDb, + loginAsTestAdmin, + seedMfaUser, + TEST_USERNAME, + TEST_PASSWORD, + TEST_JWT_SECRET, +} from './helpers/setupTestDb'; + +// Match the server-side otplib configuration so test-generated OTPs are +// accepted by the verify path (see MfaService and __tests__/mfa.test.ts). +const authenticator = new OTP({ strategy: 'totp' }); +const TOTP_PARAMS = { algorithm: 'sha1' as const, digits: 6, period: 30 }; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ app } = await import('../index')); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +/** All Set-Cookie entries in a response for one cookie name. */ +function cookieEntries(setCookieHeader: string | string[] | undefined, name: string): string[] { + const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : setCookieHeader ? [setCookieHeader] : []; + return cookies.filter((c) => c.startsWith(`${name}=`)); +} + +/** All sencho_token Set-Cookie entries in a response (should never be more than one). */ +function sessionCookieEntries(setCookieHeader: string | string[] | undefined): string[] { + return cookieEntries(setCookieHeader, 'sencho_token'); +} + +/** Extract the sencho_token cookie's raw JWT value from a Set-Cookie header. */ +function extractSessionToken(setCookieHeader: string | string[] | undefined): string | undefined { + const match = sessionCookieEntries(setCookieHeader)[0]; + return match?.split(';')[0].split('=')[1]; +} + +describe('sliding session refresh', () => { + afterEach(() => { + DatabaseService.getInstance().updateGlobalSetting('session_sliding_refresh', '1'); + }); + + it('refreshes a session nearing expiry', async () => { + const nearExpiryToken = jwt.sign({ username: TEST_USERNAME, remember: false }, TEST_JWT_SECRET, { expiresIn: '30s' }); + const res = await request(app) + .get('/api/auth/check') + .set('Authorization', `Bearer ${nearExpiryToken}`); + + expect(res.status).toBe(200); + const refreshed = extractSessionToken(res.headers['set-cookie']); + expect(refreshed).toBeDefined(); + const decoded = jwt.verify(refreshed!, TEST_JWT_SECRET) as { exp: number; remember?: boolean }; + // Refreshed back to a full 24h session, not just extended by seconds. + expect(decoded.exp * 1000 - Date.now()).toBeGreaterThan(23 * 60 * 60 * 1000); + expect(decoded.remember).toBe(false); + }); + + it('does not refresh a session with plenty of life left', async () => { + const freshCookie = await loginAsTestAdmin(app); + const res = await request(app) + .get('/api/auth/check') + .set('Cookie', freshCookie); + + expect(res.status).toBe(200); + expect(res.headers['set-cookie']).toBeUndefined(); + }); + + it('reissues a "stay signed in" session back to a 30-day TTL, not 24h', async () => { + const nearExpiryRememberToken = jwt.sign({ username: TEST_USERNAME, remember: true }, TEST_JWT_SECRET, { expiresIn: '30s' }); + const res = await request(app) + .get('/api/auth/check') + .set('Authorization', `Bearer ${nearExpiryRememberToken}`); + + const refreshed = extractSessionToken(res.headers['set-cookie']); + expect(refreshed).toBeDefined(); + const decoded = jwt.verify(refreshed!, TEST_JWT_SECRET) as { exp: number; remember?: boolean }; + expect(decoded.remember).toBe(true); + expect(decoded.exp * 1000 - Date.now()).toBeGreaterThan(29 * 24 * 60 * 60 * 1000); + }); + + it('does not refresh when session_sliding_refresh is disabled', async () => { + DatabaseService.getInstance().updateGlobalSetting('session_sliding_refresh', '0'); + const nearExpiryToken = jwt.sign({ username: TEST_USERNAME, remember: false }, TEST_JWT_SECRET, { expiresIn: '30s' }); + const res = await request(app) + .get('/api/auth/check') + .set('Authorization', `Bearer ${nearExpiryToken}`); + + expect(res.status).toBe(200); + expect(res.headers['set-cookie']).toBeUndefined(); + }); + + // The refresh sits after the token-version check in authMiddleware, which is + // what makes it safe: a stale token cannot ride the refresh back to life. If + // that ordering ever moved, these would be the tests to catch it. + it('rejects (does not refresh) a near-expiry token with a stale token_version', async () => { + const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!; + const staleToken = jwt.sign( + { username: TEST_USERNAME, remember: false, tv: user.token_version - 1 }, + TEST_JWT_SECRET, + { expiresIn: '30s' }, + ); + const res = await request(app) + .get('/api/auth/check') + .set('Authorization', `Bearer ${staleToken}`); + + expect(res.status).toBe(401); + expect(res.headers['set-cookie']).toBeUndefined(); + }); + + it('refreshes a near-expiry token with a current token_version and carries it forward', async () => { + const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!; + const currentToken = jwt.sign( + { username: TEST_USERNAME, remember: false, tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '30s' }, + ); + const res = await request(app) + .get('/api/auth/check') + .set('Authorization', `Bearer ${currentToken}`); + + expect(res.status).toBe(200); + const refreshed = extractSessionToken(res.headers['set-cookie']); + expect(refreshed).toBeDefined(); + const decoded = jwt.verify(refreshed!, TEST_JWT_SECRET) as { exp: number; tv?: number }; + expect(decoded.tv).toBe(user.token_version); + expect(decoded.exp * 1000 - Date.now()).toBeGreaterThan(23 * 60 * 60 * 1000); + }); +}); + +describe('"stay signed in" at login', () => { + it('issues a 30-day session when remember is true', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: TEST_USERNAME, password: TEST_PASSWORD, remember: true }); + + expect(res.status).toBe(200); + const cookieEntry = sessionCookieEntries(res.headers['set-cookie'])[0]; + expect(cookieEntry).toBeDefined(); + // The cookie's own Max-Age must match the JWT's exp, or a browser would + // drop the cookie before the token expires, defeating "stay signed in" + // even though the token itself looks correct. + expect(cookieEntry).toMatch(/Max-Age=2592000/); + const token = extractSessionToken(res.headers['set-cookie']); + expect(token).toBeDefined(); + const decoded = jwt.verify(token!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean }; + expect(decoded.remember).toBe(true); + expect(decoded.exp - decoded.iat).toBeCloseTo(30 * 24 * 60 * 60, -2); + }); + + it('issues the standard 24h session when remember is omitted', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: TEST_USERNAME, password: TEST_PASSWORD }); + + const token = extractSessionToken(res.headers['set-cookie']); + const decoded = jwt.verify(token!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean }; + expect(decoded.remember).toBe(false); + expect(decoded.exp - decoded.iat).toBeCloseTo(24 * 60 * 60, -2); + }); + + it('carries remember through an MFA challenge to the final session', async () => { + const { secret } = await seedMfaUser('mfa-remember-user', 'mfa-remember-pass'); + + const loginRes = await request(app) + .post('/api/auth/login') + .send({ username: 'mfa-remember-user', password: 'mfa-remember-pass', remember: true }); + expect(loginRes.body.mfaRequired).toBe(true); + const pendingCookieHeader = cookieEntries(loginRes.headers['set-cookie'], 'sencho_mfa_pending')[0]; + expect(pendingCookieHeader).toBeDefined(); + + const code = authenticator.generateSync({ secret, ...TOTP_PARAMS }); + const mfaRes = await request(app) + .post('/api/auth/login/mfa') + .set('Cookie', pendingCookieHeader!) + .send({ code }); + + expect(mfaRes.status).toBe(200); + const finalToken = extractSessionToken(mfaRes.headers['set-cookie']); + expect(finalToken).toBeDefined(); + const decoded = jwt.verify(finalToken!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean }; + expect(decoded.remember).toBe(true); + expect(decoded.exp - decoded.iat).toBeCloseTo(30 * 24 * 60 * 60, -2); + }); +}); + +describe('reissueSessionAfterTokenBump preserves "stay signed in"', () => { + it('keeps a 30-day session after a password change', async () => { + const bcrypt = (await import('bcrypt')).default; + const passwordHash = await bcrypt.hash('bump-test-pass', 1); + DatabaseService.getInstance().addUser({ username: 'bump-test-user', password_hash: passwordHash, role: 'admin' }); + + const loginRes = await request(app) + .post('/api/auth/login') + .send({ username: 'bump-test-user', password: 'bump-test-pass', remember: true }); + const rememberedCookie = extractSessionToken(loginRes.headers['set-cookie']); + const cookieHeader = sessionCookieEntries(loginRes.headers['set-cookie'])[0]; + + const changeRes = await request(app) + .put('/api/auth/password') + .set('Cookie', cookieHeader!) + .send({ oldPassword: 'bump-test-pass', newPassword: 'bump-test-pass-2' }); + + expect(changeRes.status).toBe(200); + // Exactly one sencho_token Set-Cookie, not one from the sliding refresh + // (pre-bump token_version) followed by a second from the post-bump + // reissue: a second, stale entry would leave any client that reads the + // first Set-Cookie signed out on its very next request. + expect(sessionCookieEntries(changeRes.headers['set-cookie'])).toHaveLength(1); + const reissued = extractSessionToken(changeRes.headers['set-cookie']); + expect(reissued).toBeDefined(); + expect(reissued).not.toBe(rememberedCookie); + const decoded = jwt.verify(reissued!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean }; + expect(decoded.remember).toBe(true); + expect(decoded.exp - decoded.iat).toBeCloseTo(30 * 24 * 60 * 60, -2); + }); + + it('does not duplicate the session cookie when the password change lands inside the sliding-refresh window', async () => { + const bcrypt = (await import('bcrypt')).default; + const passwordHash = await bcrypt.hash('bump-window-pass', 1); + const db = DatabaseService.getInstance(); + db.addUser({ username: 'bump-window-user', password_hash: passwordHash, role: 'admin' }); + const user = db.getUserByUsername('bump-window-user')!; + + // Hand-sign a near-expiry token so authMiddleware's sliding refresh fires + // on this very request, immediately before the route bumps token_version. + const nearExpiryToken = jwt.sign( + { username: 'bump-window-user', remember: true, tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '30s' }, + ); + + const changeRes = await request(app) + .put('/api/auth/password') + .set('Authorization', `Bearer ${nearExpiryToken}`) + .send({ oldPassword: 'bump-window-pass', newPassword: 'bump-window-pass-2' }); + + expect(changeRes.status).toBe(200); + const entries = sessionCookieEntries(changeRes.headers['set-cookie']); + expect(entries).toHaveLength(1); + const decoded = jwt.verify(extractSessionToken(changeRes.headers['set-cookie'])!, TEST_JWT_SECRET) as { tv?: number }; + expect(decoded.tv).toBe(db.getUserByUsername('bump-window-user')!.token_version); + }); +}); diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index ba6a3eb4..cc3fb9bb 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -241,6 +241,47 @@ describe('prune_on_update (auto-prune after updates)', () => { }); }); +describe('session_sliding_refresh (keep active sessions alive)', () => { + it('defaults to ON in a freshly seeded database', () => { + expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).toBe('1'); + }); + + it('is exposed through the settings GET projection', async () => { + const res = await request(app).get('/api/settings').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.session_sliding_refresh).toBeDefined(); + }); + + it('accepts a well-formed write and persists it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'session_sliding_refresh', value: '0' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).toBe('0'); + // Restore the seeded default so later suites observe the shipped behavior. + DatabaseService.getInstance().updateGlobalSetting('session_sliding_refresh', '1'); + }); + + it('rejects a non-enum value (400) and does not write it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'session_sliding_refresh', value: 'banana' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).not.toBe('banana'); + }); + + it('rejects a non-admin write with 403', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', viewerCookie) + .send({ key: 'session_sliding_refresh', value: '0' }); + expect(res.status).toBe(403); + }); +}); + describe('prune_orphaned_scans (purge scans for deleted images/stacks)', () => { it('defaults to ON in a freshly seeded database', () => { expect(DatabaseService.getInstance().getGlobalSettings().prune_orphaned_scans).toBe('1'); diff --git a/backend/src/helpers/constants.ts b/backend/src/helpers/constants.ts index 51de5540..5e30597f 100644 --- a/backend/src/helpers/constants.ts +++ b/backend/src/helpers/constants.ts @@ -23,6 +23,11 @@ export const MAX_ASSIGNMENTS = 1000; // Session cookies export const COOKIE_NAME = 'sencho_token'; export const SESSION_COOKIE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours +export const REMEMBER_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, "stay signed in" +// Sliding-refresh window: a user-session token with less than this much life +// left gets silently reissued with a fresh full TTL, so continued activity +// never runs into the hard expiry. See middleware/auth.ts::authMiddleware. +export const SESSION_REFRESH_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour export const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending'; export const MFA_PENDING_SCOPE = 'mfa_pending'; export const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge diff --git a/backend/src/helpers/cookies.ts b/backend/src/helpers/cookies.ts index b040ac5f..066f2689 100644 --- a/backend/src/helpers/cookies.ts +++ b/backend/src/helpers/cookies.ts @@ -1,15 +1,19 @@ import type { Request } from 'express'; -import { SESSION_COOKIE_MAX_AGE_MS } from './constants'; /** True when the request arrived over HTTPS, either directly or via a trusted TLS-terminating proxy. */ export const isSecureRequest = (req: Request): boolean => { return req.secure || req.headers['x-forwarded-proto'] === 'https'; }; -/** Cookie options derived from the current request (secure flag follows the connection). */ +/** + * Cookie options derived from the current request (secure flag follows the + * connection). Lifetime is deliberately not included: each caller sets its own + * `maxAge` (session cookies vary between the default and "stay signed in", the + * MFA-pending cookie is minutes long), so a shared default here would only ever + * be overridden or misread. + */ export const getCookieOptions = (req: Request) => ({ httpOnly: true, secure: isSecureRequest(req), sameSite: 'strict' as const, - maxAge: SESSION_COOKIE_MAX_AGE_MS, }); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index d704d9cc..86434dc2 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -17,6 +17,9 @@ import { MFA_PENDING_COOKIE_NAME, MFA_PENDING_SCOPE, MFA_PENDING_TTL_MS, + SESSION_COOKIE_MAX_AGE_MS, + REMEMBER_SESSION_MAX_AGE_MS, + SESSION_REFRESH_THRESHOLD_MS, } from '../helpers/constants'; import { getCookieOptions } from '../helpers/cookies'; import { looksLikeApiToken } from '../utils/apiTokenFormat'; @@ -79,7 +82,7 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('No JWT secret'); - const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean }; + const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean; remember?: boolean; exp?: number }; if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session'); @@ -161,8 +164,11 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response } // Token version check: rejects sessions after password change, role change, or admin reset. - // Pre-migration tokens (no tv claim) are accepted for backward compat and expire within 24h. - if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) { + // A token without a tv claim is a pre-migration legacy token minted when + // token_version was 1. Default to 1 on decode so the token is rejected only + // when a security event (password change, MFA reset, role change, admin + // invalidation) has actually bumped the version since it was issued. + if (dbUser.token_version !== (decoded.tv ?? 1)) { if (isDebugEnabled()) console.log('[Auth:diag] Token version mismatch for:', decoded.username, 'jwt:', decoded.tv, 'db:', dbUser.token_version); console.log('[Auth] Session rejected: token version mismatch for:', decoded.username); res.status(401).json({ error: 'Session invalidated. Please log in again.' }); @@ -173,6 +179,28 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response // Use the DB role (not the JWT role) so role changes take effect immediately req.user = { username: dbUser.username, role: dbUser.role as UserRole, userId: dbUser.id }; + const remember = decoded.remember === true; + req.sessionRemember = remember; + + // Sliding refresh: a session nearing its expiry gets silently reissued with + // a fresh full TTL (matching whichever TTL, 24h or "stay signed in" 30d, the + // original login chose), so an actively-used tab never runs into the hard + // cutoff. Disabled via the session_sliding_refresh setting (default on) for + // admins who want a strict absolute session ceiling. This is a best-effort + // optimization on an already-authenticated request, so its own try/catch + // keeps a reissue failure from being reported as an invalid token. + if (settings.session_sliding_refresh !== '0' && typeof decoded.exp === 'number') { + const remainingMs = decoded.exp * 1000 - Date.now(); + const shouldRefresh = remainingMs < SESSION_REFRESH_THRESHOLD_MS; + if (isDebugEnabled()) console.log('[Auth:diag] Sliding refresh check:', decoded.username, 'remainingMs:', remainingMs, 'refreshed:', shouldRefresh); + if (shouldRefresh) { + try { + issueSessionCookie(res, req, dbUser, jwtSecret, remember); + } catch (refreshErr) { + console.error('[Auth] Sliding session refresh failed for', dbUser.username, getErrorMessage(refreshErr, 'unknown')); + } + } + } next(); } catch (err) { @@ -182,19 +210,48 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response } }; -/** Sign a session JWT and set it as an httpOnly cookie. */ +/** + * Drop any already-queued `Set-Cookie` entry for `name` on this response + * before a caller appends a new one. `res.cookie()` appends rather than + * replaces, so a request path that issues the same cookie twice (the + * sliding refresh in `authMiddleware` followed by a token-bump reissue in + * the same response, e.g. a password change inside the refresh window) + * would otherwise send two `Set-Cookie` headers for one name: the first + * carrying an already-superseded `token_version`. Browsers apply the last + * one, but any other client taking the first would treat itself as + * signed out on its very next request. Deduping keeps exactly one, correct + * cookie in the response regardless of call order. + */ +function dropQueuedCookie(res: Response, name: string): void { + const existing = res.getHeader('Set-Cookie'); + if (!existing) return; + const entries = Array.isArray(existing) ? existing : [String(existing)]; + const filtered = entries.filter((entry) => !entry.startsWith(`${name}=`)); + if (filtered.length !== entries.length) res.setHeader('Set-Cookie', filtered); +} + +/** + * Sign a session JWT and set it as an httpOnly cookie. `remember` extends the + * session to `REMEMBER_SESSION_MAX_AGE_MS` (30 days, "stay signed in") instead + * of the default `SESSION_COOKIE_MAX_AGE_MS` (24h); the choice is carried in + * the token's `remember` claim so a later sliding refresh (authMiddleware) or + * post-token-bump reissue (reissueSessionAfterTokenBump) reapplies the same TTL. + */ export function issueSessionCookie( res: Response, req: Request, user: { username: string; role: string; token_version: number }, jwtSecret: string, + remember = false, ): void { + const maxAgeMs = remember ? REMEMBER_SESSION_MAX_AGE_MS : SESSION_COOKIE_MAX_AGE_MS; const token = jwt.sign( - { username: user.username, role: user.role, tv: user.token_version }, + { username: user.username, role: user.role, tv: user.token_version, remember }, jwtSecret, - { expiresIn: '24h' }, + { expiresIn: Math.floor(maxAgeMs / 1000) }, ); - res.cookie(COOKIE_NAME, token, getCookieOptions(req)); + dropQueuedCookie(res, COOKIE_NAME); + res.cookie(COOKIE_NAME, token, { ...getCookieOptions(req), maxAge: maxAgeMs }); } /** @@ -209,10 +266,10 @@ export function issueMfaPendingCookie( req: Request, user: { id: number; username: string }, jwtSecret: string, - opts: { sso?: boolean } = {}, + opts: { sso?: boolean; remember?: boolean } = {}, ): void { const token = jwt.sign( - { scope: MFA_PENDING_SCOPE, user_id: user.id, username: user.username, sso: opts.sso === true }, + { scope: MFA_PENDING_SCOPE, user_id: user.id, username: user.username, sso: opts.sso === true, remember: opts.remember === true }, jwtSecret, { expiresIn: Math.floor(MFA_PENDING_TTL_MS / 1000) }, ); @@ -239,6 +296,6 @@ export function reissueSessionAfterTokenBump(req: Request, res: Response, userId const refreshed = db.getUserById(userId); const settings = db.getGlobalSettings(); if (refreshed && settings.auth_jwt_secret) { - issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret); + issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret, req.sessionRemember === true); } } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 817aa2a1..e8e10f43 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -106,6 +106,7 @@ authRouter.post('/setup', authRateLimiter, async (req: Request, res: Response): // Login endpoint authRouter.post('/login', authRateLimiter, async (req: Request, res: Response): Promise => { const { username, password } = req.body; + const remember = req.body.remember === true; if (!username || !password) { res.status(400).json({ error: 'Username and password are required' }); @@ -131,13 +132,13 @@ authRouter.post('/login', authRateLimiter, async (req: Request, res: Response): console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null); } if (mfa?.enabled) { - issueMfaPendingCookie(res, req, user, jwtSecret); + issueMfaPendingCookie(res, req, user, jwtSecret, { remember }); console.log('[Auth] Login password OK, MFA challenge pending:', user.username); res.json({ success: true, mfaRequired: true }); return; } - issueSessionCookie(res, req, user, jwtSecret); + issueSessionCookie(res, req, user, jwtSecret, remember); console.log('[Auth] Login successful:', user.username); res.json({ success: true, message: 'Login successful' }); return; diff --git a/backend/src/routes/mfa.ts b/backend/src/routes/mfa.ts index 4f8c3050..b933a0d6 100644 --- a/backend/src/routes/mfa.ts +++ b/backend/src/routes/mfa.ts @@ -72,7 +72,7 @@ mfaRouter.post('/login/mfa', authRateLimiter, async (req: Request, res: Response return; } - let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean }; + let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean; remember?: boolean }; try { decoded = jwt.verify(pendingCookie, jwtSecret) as typeof decoded; } catch { @@ -173,7 +173,7 @@ mfaRouter.post('/login/mfa', authRateLimiter, async (req: Request, res: Response db.clearMfaFailures(decoded.user_id); clearMfaPendingCookie(res, req); - issueSessionCookie(res, req, user, jwtSecret); + issueSessionCookie(res, req, user, jwtSecret, decoded.remember === true); console.log('[Auth] MFA challenge cleared:', user.username); if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: success user=', user.username, 'durationMs=', Date.now() - startedAt); res.json({ success: true }); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 782b13c2..1370c8e7 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -36,6 +36,7 @@ const ALLOWED_SETTING_KEYS = new Set([ 'auto_create_missing_external_networks', 'image_update_sidebar_indicators', 'notification_dispatch_retries', + 'session_sliding_refresh', ]); // Keys whose write requires a paid license, not just an admin role. @@ -77,6 +78,7 @@ const SettingsPatchSchema = z.object({ }); } }).transform((v) => String(parseNotificationDispatchRetries(v)!)), + session_sliding_refresh: z.enum(['0', '1']), }).partial(); export const settingsRouter = Router(); diff --git a/backend/src/routes/sso.ts b/backend/src/routes/sso.ts index 1839cf27..274216e9 100644 --- a/backend/src/routes/sso.ts +++ b/backend/src/routes/sso.ts @@ -43,6 +43,7 @@ ssoRouter.get('/providers', (_req: Request, res: Response): void => { ssoRouter.post('/ldap', authRateLimiter, async (req: Request, res: Response): Promise => { try { const { username, password } = req.body; + const remember = req.body.remember === true; if (!username || !password) { res.status(400).json({ error: 'Username and password are required' }); return; @@ -71,13 +72,13 @@ ssoRouter.post('/ldap', authRateLimiter, async (req: Request, res: Response): Pr console.log('[MFA:diag] login: path=ldap user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1); } if (mfa?.enabled && mfa.sso_enforce_mfa) { - issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true }); + issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true, remember }); console.log(`[SSO] LDAP login password OK, MFA challenge pending: ${user.username}`); res.json({ success: true, mfaRequired: true }); return; } - issueSessionCookie(res, req, user, settings.auth_jwt_secret); + issueSessionCookie(res, req, user, settings.auth_jwt_secret, remember); console.log(`[SSO] LDAP login successful: ${user.username}`); res.json({ success: true, message: 'Login successful' }); } catch (error) { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index a1032014..0fe44863 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -2022,6 +2022,11 @@ export class DatabaseService { stmt.run('notification_dispatch_retries', '0'); stmt.run('env_block_deploy_on_missing_required', '0'); stmt.run('auto_create_missing_external_networks', '0'); + // Silently extend an actively-used session's cookie instead of hard + // expiring it. On by default (matches how most session-based web apps + // behave); admins who want a strict absolute session ceiling can turn + // it off in Settings > Users. + stmt.run('session_sliding_refresh', '1'); // Seed the default local node if none exists const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0; diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts index abd63dbc..6007ef5d 100644 --- a/backend/src/types/express.ts +++ b/backend/src/types/express.ts @@ -19,6 +19,8 @@ declare global { mfaPendingUserId?: number; /** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */ mfaPendingSso?: boolean; + /** True when the caller's current user-session cookie was issued with "stay signed in". Read by reissueSessionAfterTokenBump so a password/MFA change doesn't silently shorten a remembered session. */ + sessionRemember?: boolean; /** Cached remote-proxy target resolved by `remoteNodeProxy`'s outer gate so the http-proxy router/proxyReq callbacks do not re-resolve. */ proxyTarget?: { apiUrl: string; apiToken: string }; /** Trusted deploy provenance from machine auth or gateway overwrite. */ diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index 6e93aa86..ae047376 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -184,6 +184,12 @@ Sencho enforces user changes immediately by versioning JWT tokens at the user re Cookies and Bearer tokens go through the same auth middleware, so the same rules apply to API-token-based sessions where a token is bound to a user. +### Session lifetime + +A signed-in session lasts 24 hours by default, or 30 days if **Stay signed in** was checked at sign-in. Either way, an actively-used session renews itself: any request made within an hour of expiry silently reissues the cookie for a fresh full lifetime, so you are never signed out mid-session just for staying active. Only real inactivity, or one of the events in the table above, ends a session. + +Admins can turn this renewal off from **Settings > Users > Session policy** (**Keep active sessions alive**) to enforce a strict, fixed ceiling on every session regardless of activity. It is on by default. + ## SSO auto-provisioning With SSO configured on Admiral, users authenticate through an identity provider (LDAP, Custom OIDC, Google, GitHub, Okta). On their first successful sign-in, Sencho auto-creates a user record. SSO accounts appear in the Users list alongside local accounts and can be edited the same way; only the password and (optionally) the role differ. @@ -227,6 +233,9 @@ Entries include the acting user, IP address, HTTP method and path, response stat Token-version bumps invalidate sessions. Two events do this: an admin changed the user's password, or an admin reset their 2FA. Both rotate the user's token version, so every JWT issued before the rotation is rejected on the next request. The user can sign in again with their (possibly new) password. Role changes do **not** sign the user out; they take effect on the next request without rotating the token version. + + Check whether **Session policy > Keep active sessions alive** was turned off in **Settings > Users**. With it off, every session hits a strict, fixed 24-hour (or 30-day, with **Stay signed in**) ceiling regardless of activity. Turn it back on so an active session renews itself instead of hard-expiring, or have the user check **Stay signed in** at their next sign-in for a longer session between visits. + Two causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or name on the assignment does not match the request's resource. Re-open the user in the edit form and check the existing-scope row matches the stack name (case-sensitive) exactly. diff --git a/frontend/src/components/Login.test.tsx b/frontend/src/components/Login.test.tsx new file mode 100644 index 00000000..be24d4c6 --- /dev/null +++ b/frontend/src/components/Login.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Login } from './Login'; + +const loginMock = vi.fn().mockResolvedValue({ success: true }); +const ssoLdapLoginMock = vi.fn().mockResolvedValue({ success: true }); + +vi.mock('@/context/AuthContext', () => ({ + useAuth: () => ({ login: loginMock, ssoLdapLogin: ssoLdapLoginMock }), +})); + +beforeEach(() => { + loginMock.mockClear(); + ssoLdapLoginMock.mockClear(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] })); +}); + +async function fillCredentials() { + await userEvent.type(screen.getByLabelText('Username'), 'admin'); + await userEvent.type(screen.getByLabelText('Password'), 'password123'); +} + +describe('Login "Stay signed in"', () => { + it('submits remember=false by default', async () => { + render(); + await fillCredentials(); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => expect(loginMock).toHaveBeenCalledWith('admin', 'password123', false)); + }); + + it('submits remember=true when the checkbox is checked', async () => { + render(); + await fillCredentials(); + await userEvent.click(screen.getByLabelText('Stay signed in')); + await userEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => expect(loginMock).toHaveBeenCalledWith('admin', 'password123', true)); + }); + + it('threads remember=true through the LDAP form too', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ provider: 'ldap', displayName: 'Directory', type: 'ldap' }], + })); + render(); + await waitFor(() => expect(screen.getByText('LDAP')).toBeInTheDocument()); + await userEvent.click(screen.getByText('LDAP')); + await fillCredentials(); + await userEvent.click(screen.getByLabelText('Stay signed in')); + await userEvent.click(screen.getByRole('button', { name: /sign in with ldap/i })); + + await waitFor(() => expect(ssoLdapLoginMock).toHaveBeenCalledWith('admin', 'password123', true)); + }); +}); diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 2e9ddc1f..f47550df 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -3,6 +3,7 @@ import { useAuth } from '@/context/AuthContext'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; import { ArrowRight, KeyRound, Loader2 } from 'lucide-react'; import { AuthCanvas } from '@/components/auth/AuthCanvas'; import { AuthStepHeader } from '@/components/auth/AuthStepHeader'; @@ -63,6 +64,7 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d const [loginMode, setLoginMode] = useState<'local' | 'ldap'>('local'); const [ssoProviders, setSsoProviders] = useState([]); const [capsLock, setCapsLock] = useState(false); + const [rememberMe, setRememberMe] = useState(false); useEffect(() => { fetch('/api/auth/sso/providers', { credentials: 'include' }) @@ -82,8 +84,8 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d setIsLoading(true); const result = loginMode === 'ldap' && ssoLdapLogin - ? await ssoLdapLogin(username, password) - : await login(username, password); + ? await ssoLdapLogin(username, password, rememberMe) + : await login(username, password, rememberMe); if (!result.success) setError(result.error || 'Login failed'); setIsLoading(false); }; @@ -178,6 +180,20 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d /> +
+ setRememberMe(c === true)} + /> + +
+ {error && {error}}