fix(auth): keep active sessions alive and add stay-signed-in (#1711)

This commit is contained in:
Anso
2026-07-28 07:36:50 -04:00
committed by GitHub
parent 2d88d9f8a8
commit 681ecc7047
20 changed files with 775 additions and 95 deletions
@@ -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;
@@ -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);
});
});
@@ -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');
+5
View File
@@ -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
+7 -3
View File
@@ -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,
});
+67 -10
View File
@@ -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);
}
}
+3 -2
View File
@@ -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<void> => {
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;
+2 -2
View File
@@ -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 });
+2
View File
@@ -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();
+3 -2
View File
@@ -43,6 +43,7 @@ ssoRouter.get('/providers', (_req: Request, res: Response): void => {
ssoRouter.post('/ldap', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
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) {
+5
View File
@@ -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;
+2
View File
@@ -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. */
+9
View File
@@ -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
<Accordion title="A user complains they were signed out unexpectedly">
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.
</Accordion>
<Accordion title="A user keeps getting signed out even while actively using Sencho">
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.
</Accordion>
<Accordion title="A scoped Deployer cannot deploy a stack they were granted">
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.
</Accordion>
+56
View File
@@ -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(<Login />);
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(<Login />);
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(<Login />);
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));
});
});
+18 -2
View File
@@ -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<SSOProvider[]>([]);
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
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="remember-me"
checked={rememberMe}
onCheckedChange={(c) => setRememberMe(c === true)}
/>
<label
htmlFor="remember-me"
className="text-sm text-stat-subtitle cursor-pointer select-none"
>
Stay signed in
</label>
</div>
{error && <ErrorRail>{error}</ErrorRail>}
<Button
+202 -67
View File
@@ -8,14 +8,18 @@ import { Combobox } from '@/components/ui/combobox';
import { ConfirmModal } from '@/components/ui/modal';
import { toast } from '@/components/ui/toast-store';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { TogglePill } from '@/components/ui/toggle-pill';
import { apiFetch } from '@/lib/api';
import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { CapabilityGate } from '@/components/CapabilityGate';
import { RefreshCw, Trash2, Plus, Pencil, ShieldOff } from 'lucide-react';
import { RefreshCw, Trash2, Plus, Pencil, ShieldOff, AlertTriangle } from 'lucide-react';
import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { DEFAULT_SETTINGS } from './types';
interface UserItem {
id: number;
@@ -35,6 +39,133 @@ interface RoleAssignmentItem {
created_at: number;
}
type SlidingRefresh = '0' | '1';
const DEFAULT_SLIDING_REFRESH: SlidingRefresh = DEFAULT_SETTINGS.session_sliding_refresh ?? '1';
function SessionPolicySkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
</div>
);
}
/**
* Instance-wide session behavior: whether an actively-used session silently
* renews itself instead of hard-expiring. Pinned to the local instance via
* `localOnly: true` on every fetch, like the rest of this page (a frontend
* convention, not a backend hub-only guard such as registries/secrets have),
* since it governs sign-in to this instance's own user table, not a remote
* node's.
*/
function SessionPolicySection() {
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading');
const [value, setValue] = useState<SlidingRefresh>(DEFAULT_SLIDING_REFRESH);
const [saved, setSaved] = useState<SlidingRefresh>(DEFAULT_SLIDING_REFRESH);
const [isSaving, setIsSaving] = useState(false);
const hasChanges = value !== saved;
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await apiFetch('/settings', { localOnly: true });
if (cancelled) return;
if (!res.ok) {
setPhase('error');
toast.error('Failed to load session policy.');
return;
}
const raw = (await res.json())?.session_sliding_refresh;
if (cancelled) return;
const loaded: SlidingRefresh = raw === '0' || raw === '1' ? raw : DEFAULT_SLIDING_REFRESH;
setValue(loaded);
setSaved(loaded);
setPhase('ready');
} catch {
if (!cancelled) {
setPhase('error');
toast.error('Failed to load session policy.');
}
}
})();
return () => { cancelled = true; };
}, []);
const saveSettings = async () => {
// Snapshot the submitted value: the toggle stays live while the PATCH is
// in flight, so adopting `value` after the await could mark an edit made
// meanwhile as already saved.
const submitted = value;
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
localOnly: true,
body: JSON.stringify({ session_sliding_refresh: submitted }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
setSaved(submitted);
toast.success('Session policy saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
if (phase === 'loading') return <SessionPolicySkeleton />;
if (phase === 'error') {
return (
<SettingsCallout
tone="error"
icon={<AlertTriangle className="h-4 w-4" />}
title="Could not load session policy"
subtitle="The current value could not be confirmed, so editing is unavailable. Reload the page to try again."
/>
);
}
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-6 border-0 p-0">
<SettingsSection title="Session policy">
<SettingsField
label="Keep active sessions alive"
helper="Silently renew a signed-in session while it stays active, instead of hard-expiring it on a fixed schedule. On by default; turn off to enforce a strict session ceiling regardless of activity."
>
<TogglePill
checked={value === '1'}
onChange={(next) => setValue(next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? '1 unsaved' : undefined)}>
{!readOnly && (
<SettingsPrimaryButton size="sm" onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" strokeWidth={1.5} />
Saving
</>
) : (
'Save session policy'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
export function UsersSection() {
const { user: currentUser } = useAuth();
const { isPaid } = useLicense();
@@ -261,7 +392,7 @@ export function UsersSection() {
return (
<CapabilityGate capability="users" featureName="User Management">
<div className="space-y-6">
<div className="flex flex-col gap-10">
{!showForm && (
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
@@ -422,72 +553,76 @@ export function UsersSection() {
subtitle="Add an operator to give someone else access to this control plane."
/>
) : (
<div className="border border-glass-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/30 border-b border-glass-border">
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Username</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Role</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Created</th>
<th className="text-right px-4 py-2.5 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isSelf = u.username === currentUser?.username;
return (
<tr key={u.id} className="border-b border-glass-border last:border-0 hover:bg-muted/10">
<td className="px-4 py-2.5 font-medium">
{u.username}
{isSelf && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
</td>
<td className="px-4 py-2.5">
<Badge variant={u.role === 'admin' ? 'default' : u.role === 'viewer' ? 'secondary' : 'outline'} className="text-xs capitalize">
{u.role}
</Badge>
</td>
<td className="px-4 py-2.5 text-muted-foreground">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
{u.mfaEnabled && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setResetMfaTarget(u)}
>
<ShieldOff className="w-3.5 h-3.5 text-warning" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Reset 2FA</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
variant="ghost"
size="sm"
disabled={isSelf}
onClick={() => setDeleteTarget(u)}
>
<Trash2 className="w-3.5 h-3.5 text-destructive" strokeWidth={1.5} />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<SettingsSection title="Users" kicker={`${users.length} total`}>
<div className="mt-3 border border-glass-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/30 border-b border-glass-border">
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Username</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Role</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Created</th>
<th className="text-right px-4 py-2.5 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isSelf = u.username === currentUser?.username;
return (
<tr key={u.id} className="border-b border-glass-border last:border-0 hover:bg-muted/10">
<td className="px-4 py-2.5 font-medium">
{u.username}
{isSelf && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
</td>
<td className="px-4 py-2.5">
<Badge variant={u.role === 'admin' ? 'default' : u.role === 'viewer' ? 'secondary' : 'outline'} className="text-xs capitalize">
{u.role}
</Badge>
</td>
<td className="px-4 py-2.5 text-muted-foreground">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
{u.mfaEnabled && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setResetMfaTarget(u)}
>
<ShieldOff className="w-3.5 h-3.5 text-warning" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Reset 2FA</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
variant="ghost"
size="sm"
disabled={isSelf}
onClick={() => setDeleteTarget(u)}
>
<Trash2 className="w-3.5 h-3.5 text-destructive" strokeWidth={1.5} />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</SettingsSection>
)}
<SessionPolicySection />
<ConfirmModal
open={resetMfaTarget !== null}
onOpenChange={(open) => { if (!open) setResetMfaTarget(null); }}
@@ -0,0 +1,83 @@
/**
* Focused coverage for the "Keep active sessions alive" (session_sliding_refresh)
* toggle added to UsersSection. Does not re-test the pre-existing user CRUD
* table, which has no prior test file and is out of this change's scope.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true, user: { username: 'admin' } }) }));
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => ({ isPaid: true }) }));
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
vi.mock('@/components/CapabilityGate', () => ({ CapabilityGate: ({ children }: { children: React.ReactNode }) => children }));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { UsersSection } from '../UsersSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedToastError = toast.error as unknown as ReturnType<typeof vi.fn>;
function mockApi(settingsOverrides: Record<string, string> = {}) {
mockedFetch.mockImplementation(async (path: string, opts?: { method?: string }) => {
if (path === '/users') return { ok: true, json: async () => [] };
if (path === '/settings' && (!opts?.method || opts.method === 'GET')) {
return { ok: true, json: async () => ({ session_sliding_refresh: '1', ...settingsOverrides }) };
}
if (path === '/settings' && opts?.method === 'PATCH') {
return { ok: true, json: async () => ({ success: true }) };
}
return { ok: true, json: async () => ({}) };
});
}
beforeEach(() => {
mockedFetch.mockReset();
mockApi();
});
describe('UsersSection > session policy', () => {
it('renders the toggle in the ON state from a fresh-install default payload', async () => {
render(<UsersSection />);
const toggle = await screen.findByRole('switch');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true'));
});
it('shows an error state and does not present the default value as real when the load fails', async () => {
mockedFetch.mockImplementation(async (path: string, opts?: { method?: string }) => {
if (path === '/users') return { ok: true, json: async () => [] };
if (path === '/settings' && (!opts?.method || opts.method === 'GET')) {
return { ok: false, status: 500, json: async () => ({ error: 'boom' }) };
}
return { ok: true, json: async () => ({}) };
});
render(<UsersSection />);
await screen.findByText(/could not load session policy/i);
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /save session policy/i })).not.toBeInTheDocument();
expect(mockedToastError).toHaveBeenCalled();
});
it('renders OFF when the settings payload has it disabled, and PATCHes only that key on save', async () => {
mockApi({ session_sliding_refresh: '0' });
render(<UsersSection />);
const toggle = await screen.findByRole('switch');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'));
await userEvent.click(toggle);
const save = await screen.findByRole('button', { name: /save session policy/i });
await userEvent.click(save);
await waitFor(() => {
const patchCall = [...mockedFetch.mock.calls].reverse().find((c) => c[1]?.method === 'PATCH');
expect(patchCall).toBeDefined();
expect(JSON.parse(patchCall![1].body as string)).toEqual({ session_sliding_refresh: '1' });
});
});
});
+1 -1
View File
@@ -82,7 +82,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
group: 'access',
label: 'Users',
description: 'Operators, role assignments, and access scopes.',
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions'],
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions', 'session', 'sliding refresh', 'stay signed in', 'sign out', 'logout'],
tier: null,
scope: 'global',
adminOnly: true,
@@ -23,6 +23,7 @@ export interface PatchableSettings {
auto_create_missing_external_networks?: '0' | '1';
image_update_sidebar_indicators?: '0' | '1';
notification_dispatch_retries?: string;
session_sliding_refresh?: '0' | '1';
}
export const DEFAULT_SETTINGS: PatchableSettings = {
@@ -50,6 +51,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
auto_create_missing_external_networks: '0',
image_update_sidebar_indicators: '1',
notification_dispatch_retries: '0',
session_sliding_refresh: '1',
};
export type SectionId =
+6 -6
View File
@@ -34,8 +34,8 @@ interface AuthContextType {
permissionsStatus: PermissionsStatus;
permissionsReady: boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
ssoLdapLogin: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
ssoLdapLogin: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>;
cancelMfa: () => Promise<void>;
logout: () => Promise<void>;
@@ -143,7 +143,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return false;
}, [permissions]);
const login = async (username: string, password: string): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
const login = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
@@ -151,7 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password, remember }),
});
const data = await response.json();
@@ -172,13 +172,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
const ssoLdapLogin = async (username: string, password: string): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
const ssoLdapLogin = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
try {
const response = await fetch('/api/auth/sso/ldap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password, remember }),
});
const data = await response.json();