feat: refuse to restore or renew sessions older than the admin-set maximum length

This commit is contained in:
Linus Rath
2026-10-28 02:30:32 +01:00
parent 45cc6988e0
commit 6e5d8faec6
14 changed files with 571 additions and 15 deletions
+52 -2
View File
@@ -23,6 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation';
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
import { isPastMaxAge, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
function sessionCookieOptions() {
return {
@@ -73,6 +74,42 @@ function getSlot(request: NextRequest): number {
return slot;
}
/**
* The organisation's maximum session length for a remembered session
* (lib/auth/session-age.ts). A cookie from before sign-in times were
* recorded counts as signed in now and gets the time written into it.
* Returns the sign-in time, or null when the session is too old: its
* cookies are cleared then.
*/
async function checkSessionAge(
cookieStore: Awaited<ReturnType<typeof cookies>>,
slot: number,
credentials: SessionCredentials,
): Promise<{ signedInAt: number } | { maxHours: number }> {
const cookieName = sessionCookieName(slot);
if (credentials.signedInAt === undefined) {
const signedInAt = Date.now();
const secret = credentials.token !== undefined ? { token: credentials.token } : credentials.password;
cookieStore.set(cookieName, encryptSession(credentials.serverUrl, credentials.username, secret, signedInAt), sessionCookieOptions());
return { signedInAt };
}
// An impersonation login is `target%master`; the policy is the target's.
const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]);
if (isPastMaxAge(credentials.signedInAt, maxHours)) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return { maxHours };
}
return { signedInAt: credentials.signedInAt };
}
function sessionTooOld(maxHours: number) {
return NextResponse.json(
{ error: SESSION_TOO_OLD, maxHours },
{ status: 401, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
);
}
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
// so a cross-site top-level POST would otherwise reach this handler.
@@ -162,7 +199,9 @@ export async function POST(request: NextRequest) {
throw error;
}
refund();
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password);
// A remembered sign-in starts the maximum session length.
const signedInAt = Date.now();
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password, signedInAt);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, sessionCookieOptions());
setStalwartAuthContextInStore(cookieStore, slot, {
@@ -170,6 +209,7 @@ export async function POST(request: NextRequest) {
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
signedInAt,
});
void recordLogin(username, normalizedServerUrl);
@@ -208,10 +248,14 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
const age = await checkSessionAge(cookieStore, slot, credentials);
if ('maxHours' in age) return sessionTooOld(age.maxHours);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: sessionAuthHeader(credentials),
signedInAt: age.signedInAt,
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
@@ -258,13 +302,19 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
const age = await checkSessionAge(cookieStore, slot, credentials);
if ('maxHours' in age) return sessionTooOld(age.maxHours);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: sessionAuthHeader(credentials),
signedInAt: age.signedInAt,
});
return NextResponse.json(credentials, {
// The credentials as they were stored, without the bookkeeping.
const { signedInAt: _signedInAt, ...restored } = credentials;
return NextResponse.json(restored, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
} catch (error) {
+3
View File
@@ -14,6 +14,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { storeIdToken } from '@/lib/oauth/end-session';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { writeSignInStamp } from '@/lib/auth/session-age';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
@@ -115,6 +116,8 @@ export async function POST(request: NextRequest) {
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (pendingServerId) {
+16 -1
View File
@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { cookies } from 'next/headers';
import { readStalwartAuthContextFromStore, setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { readSignInStamp, writeSignInStamp } from '@/lib/auth/session-age';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
@@ -101,11 +103,24 @@ export async function POST(request: NextRequest) {
// Just signed in: an older refusal of this credential no longer applies.
recordStoredCredentialStatus(authHeader, 200);
// The context is written again on every renewed token and restore, so
// the sign-in time is carried over: from this slot's context for the
// same account, else from the token sign-in's stamp, else it is now.
const cookieStore = await cookies();
const previous = readStalwartAuthContextFromStore(cookieStore, slot);
const stamp = readSignInStamp(cookieStore, slot);
const ownStamp = stamp && (!stamp.account || stamp.account === accountName) ? stamp : null;
const samePrevious = previous && previous.serverUrl === normalizedServerUrl && previous.username === username ? previous : null;
const signedInAt = samePrevious?.signedInAt ?? ownStamp?.signedInAt ?? Date.now();
// The token routes know no account; the first verified one is the stamp's.
if (ownStamp && !ownStamp.account) writeSignInStamp(cookieStore, slot, { ...ownStamp, account: accountName });
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
signedInAt,
});
void recordLogin(username, normalizedServerUrl);
+28
View File
@@ -30,6 +30,15 @@ import {
} from '@/lib/oauth/end-session';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import {
clearSignInStamp,
isPastMaxAge,
maxSessionHoursFor,
readSignInStamp,
SESSION_TOO_OLD,
writeSignInStamp,
} from '@/lib/auth/session-age';
import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
@@ -152,6 +161,8 @@ export async function POST(request: NextRequest) {
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
// A sign-in: the maximum session length counts from here.
writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
}
const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600, [tokens.refresh_token, serverId]);
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken, serverId]);
@@ -189,6 +200,22 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
}
// The organisation's maximum session length (lib/auth/session-age.ts).
// A refresh token from before sign-in times were recorded counts as
// signed in now.
const stamp = readSignInStamp(cookieStore, slot);
if (!stamp) {
writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
} else {
const context = readStalwartAuthContextFromStore(cookieStore, slot);
const maxHours = await maxSessionHoursFor(stamp.account ?? context?.accountName ?? context?.username ?? null);
if (isPastMaxAge(stamp.signedInAt, maxHours)) {
await signOutSlot(cookieStore, slot, request.nextUrl.basePath, false);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: SESSION_TOO_OLD, maxHours }, { status: 401 });
}
}
// A session restore calls this to get its token back, not because the
// current one expired. Serving the cached token avoids spending a refresh
// the IdP may legitimately reject: Rauthy stamps refresh tokens with
@@ -307,6 +334,7 @@ async function signOutSlot(
for (const name of [cookieName, refreshTokenServerCookieName(slot), accessTokenCookieName(slot)]) {
if (cookieStore.get(name)) cookieStore.delete(name);
}
clearSignInStamp(cookieStore, slot);
if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath);
return endSessionUrl;
}
@@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
import { writeSignInStamp } from '@/lib/auth/session-age';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens.
@@ -213,6 +214,8 @@ async function storeAndRespond(
// Without "Remember me" the sign-in ends with the browser session.
const { maxAge: _maxAge, ...sessionOptions } = getCookieOptions();
cookieStore.set(cookieName, tokens.refresh_token, rememberMe ? getCookieOptions() : sessionOptions);
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (serverId) {
+1
View File
@@ -31,6 +31,7 @@ describe('encryptSession / decryptSession', () => {
serverUrl: 'https://mail.example.com',
username: 'alice',
password: 's3cret',
signedInAt: expect.any(Number),
});
});
@@ -48,6 +48,7 @@ vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: vi.fn(),
setStalwartAuthContextInStore: vi.fn(),
clearStalwartAuthContextInStore: vi.fn(),
readStalwartAuthContextFromStore: () => null,
}));
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() }));
@@ -56,6 +56,7 @@ vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: (...args: unknown[]) => setStalwartAuthContext(...args),
setStalwartAuthContextInStore: (...args: unknown[]) => setStalwartAuthContextInStore(...args),
clearStalwartAuthContextInStore: vi.fn(),
readStalwartAuthContextFromStore: () => null,
}));
const recordLogin = vi.fn();
@@ -0,0 +1,240 @@
// @vitest-environment node
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import type { SettingsPolicy } from '@/lib/admin/types';
/**
* The organisation's maximum session length on the server: the routes that
* restore a remembered sign-in or renew a provider token refuse a session
* older than `maxHours` of the account's domain scopes, and a cookie from
* before sign-in times were recorded counts as signed in when first seen.
*/
const SECRET = 's'.repeat(64);
vi.mock('@/lib/auth/session-secret', () => ({ getSessionSecret: () => SECRET, hasSessionSecret: () => true }));
vi.mock('@/lib/logger', () => ({ logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} } }));
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: () => {} }));
vi.mock('@/lib/oauth/cookie-config', () => ({
getCookieOptions: () => ({ httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 2592000 }),
}));
const jar = new Map<string, string>();
vi.mock('next/headers', () => ({
cookies: async () => ({
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
set: (name: string, value: string) => { jar.set(name, value); },
delete: (arg: string | { name: string }) => { jar.delete(typeof arg === 'string' ? arg : arg.name); },
getAll: () => [...jar].map(([name, value]) => ({ name, value })),
}),
}));
let policy: Partial<SettingsPolicy>;
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key === 'jmapServerUrl' ? 'https://mail.example.org' : fallback),
getPolicy: () => policy,
},
}));
vi.mock('@/lib/auth/verify-jmap-auth', () => ({
JmapAuthVerificationError: class extends Error { status = 401; },
resolveJmapIdentity: async (serverUrl: string, _auth: string, username: string) => ({ serverUrl, accountName: username }),
}));
const tokenFetch = vi.fn();
vi.mock('@/lib/oauth/token-exchange', () => ({
exchangeCodeForTokens: async () => ({ access_token: 'first-access', refresh_token: 'refresh-1', expires_in: 300 }),
buildOAuthParams: (params: Record<string, string>) => new URLSearchParams(params),
getMetadata: async () => ({ revocation_endpoint: 'https://id.example.org/revoke' }),
getRequiredConfig: () => ({ clientId: 'webmail' }),
getTokenEndpoint: async () => 'https://id.example.org/token',
defaultClientOptions: async () => ({ fallbackClientId: 'bulwark-webmail' }),
DEFAULT_CLIENT_ID: 'bulwark-webmail',
}));
const { encryptSession, decryptSession, decryptPayload } = await import('@/lib/auth/crypto');
const { readSignInStamp, writeSignInStamp } = await import('@/lib/auth/session-age');
const sessionRoute = await import('@/app/api/auth/session/route');
const tokenRoute = await import('@/app/api/auth/token/route');
const contextRoute = await import('@/app/api/auth/stalwart-context/route');
const HOUR = 60 * 60 * 1000;
const NOW = new Date('2026-10-28T12:00:00Z').getTime();
const SERVER = 'https://mail.example.org';
const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' };
/** Eight hours for example.org, nothing for everyone else. */
function policyWithDomainMax(hours: number): Partial<SettingsPolicy> {
return {
features: {} as never, restrictions: {}, defaults: {},
scopes: [{ id: 'example', label: 'example.org', match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: hours } } } }],
};
}
/** A remembered-session cookie as Bulwark wrote it before sign-in times were recorded. */
function legacySessionCookie(username: string, password: string): string {
const key = createHash('sha256').update(SECRET).digest();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(JSON.stringify({ v: 1, serverUrl: SERVER, username, password }), 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64');
}
const restore = () => sessionRoute.PUT(new NextRequest('https://webmail.example/api/auth/session', { method: 'PUT', headers: SAME_ORIGIN }));
const peek = () => sessionRoute.GET(new NextRequest('https://webmail.example/api/auth/session'));
const refresh = () => tokenRoute.PUT(new NextRequest('https://webmail.example/api/auth/token?slot=0', { method: 'PUT', headers: SAME_ORIGIN }));
const context = () => decryptPayload(jar.get('jmap_stalwart_ctx')!, 'session-context');
beforeEach(() => {
jar.clear();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(NOW);
policy = policyWithDomainMax(8);
tokenFetch.mockReset();
tokenFetch.mockImplementation(async (url: string) => {
if (url.endsWith('/token')) return Response.json({ access_token: 'new-access', expires_in: 300 });
return new Response(null, { status: 200 });
});
vi.stubGlobal('fetch', tokenFetch);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('remembered sessions (/api/auth/session)', () => {
it('records the sign-in time when the session is remembered', async () => {
const res = await sessionRoute.POST(new NextRequest('https://webmail.example/api/auth/session?slot=0', {
method: 'POST',
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' }),
}));
expect(res.status).toBe(200);
expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
expect(context()?.signedInAt).toBe(NOW);
});
it('restores a session younger than the maximum, without the sign-in time in the answer', async () => {
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 7 * HOUR));
const res = await restore();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' });
expect(context()?.signedInAt).toBe(NOW - 7 * HOUR);
});
it('refuses a session older than its domain allows and clears its cookies', async () => {
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 8 * HOUR));
jar.set('jmap_stalwart_ctx', 'stale');
const res = await restore();
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 });
expect(jar.has('jmap_session')).toBe(false);
expect(jar.has('jmap_stalwart_ctx')).toBe(false);
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 9 * HOUR));
expect((await peek()).status).toBe(401);
});
it('leaves an account of another domain alone', async () => {
jar.set('jmap_session', encryptSession(SERVER, 'org-dave@org-two.test', 'pw', NOW - 100 * HOUR));
expect((await restore()).status).toBe(200);
});
it('applies the maximum of Everyone to every domain', async () => {
policy = { ...policyWithDomainMax(8), organisation: { session: { idleMinutes: 0, maxHours: 24, warnSeconds: 60 } } as never };
jar.set('jmap_session', encryptSession(SERVER, 'org-dave@org-two.test', 'pw', NOW - 25 * HOUR));
expect((await restore()).status).toBe(401);
});
it('counts a cookie without a sign-in time as signed in when first seen', async () => {
jar.set('jmap_session', legacySessionCookie('org-alice@example.org', 'pw'));
const first = await restore();
expect(first.status).toBe(200);
expect(await first.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' });
expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
vi.setSystemTime(NOW + 7 * HOUR);
expect((await restore()).status).toBe(200);
vi.setSystemTime(NOW + 8 * HOUR);
expect((await restore()).status).toBe(401);
});
});
describe('provider tokens (/api/auth/token)', () => {
it('records the sign-in time when a provider sign-in stores its refresh token', async () => {
// A leftover stamp from an earlier sign-in in this slot.
const store = (await (await import('next/headers')).cookies()) as never;
writeSignInStamp(store, 0, { signedInAt: NOW - 20 * HOUR, account: 'org-bob@example.org' });
const res = await tokenRoute.POST(new NextRequest('https://webmail.example/api/auth/token?slot=0', {
method: 'POST',
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
body: JSON.stringify({ code: 'c', code_verifier: 'v', redirect_uri: 'https://webmail.example/auth/callback', slot: 0 }),
}));
expect(res.status).toBe(200);
expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW });
});
it('refuses to renew a token sign-in older than the maximum and revokes it', async () => {
jar.set('jmap_rt', 'refresh-1');
const store = (await (await import('next/headers')).cookies()) as never;
writeSignInStamp(store, 0, { signedInAt: NOW - 8 * HOUR, account: 'org-alice@example.org' });
const res = await refresh();
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 });
expect(jar.has('jmap_rt')).toBe(false);
expect(jar.has('jmap_signed_in')).toBe(false);
expect(tokenFetch).toHaveBeenCalledWith('https://id.example.org/revoke', expect.anything());
expect(tokenFetch).not.toHaveBeenCalledWith('https://id.example.org/token', expect.anything());
});
it('renews a token sign-in younger than the maximum', async () => {
jar.set('jmap_rt', 'refresh-1');
const store = (await (await import('next/headers')).cookies()) as never;
writeSignInStamp(store, 0, { signedInAt: NOW - 7 * HOUR, account: 'org-alice@example.org' });
const res = await refresh();
expect(res.status).toBe(200);
expect((await res.json()).access_token).toBe('new-access');
});
it('counts a refresh token without a sign-in time as signed in when first seen', async () => {
jar.set('jmap_rt', 'refresh-1');
expect((await refresh()).status).toBe(200);
const store = (await (await import('next/headers')).cookies()) as never;
expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW });
});
});
describe('the auth context (/api/auth/stalwart-context)', () => {
const post = () => contextRoute.POST(new NextRequest('https://webmail.example/api/auth/stalwart-context', {
method: 'POST',
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', authHeader: 'Bearer new', slot: 0 }),
}));
it('keeps the sign-in time when a renewed token is written again', async () => {
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 3 * HOUR));
await restore();
vi.setSystemTime(NOW + HOUR);
expect((await post()).status).toBe(200);
expect(context()?.signedInAt).toBe(NOW - 3 * HOUR);
});
it("takes a token sign-in's time from its stamp and names the account on it", async () => {
const store = (await (await import('next/headers')).cookies()) as never;
writeSignInStamp(store, 0, { signedInAt: NOW - 2 * HOUR });
expect((await post()).status).toBe(200);
expect(context()?.signedInAt).toBe(NOW - 2 * HOUR);
expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW - 2 * HOUR, account: 'org-alice@example.org' });
});
});
+21 -7
View File
@@ -23,10 +23,13 @@ function getKey(): Buffer {
/**
* A remembered sign-in: the account's password (Basic auth), or the access
* token the user signed in with (Bearer auth, e.g. a Fastmail API token).
* `signedInAt`: when the user signed in (ms), for the organisation's maximum
* session length (lib/auth/session-age.ts); absent in cookies written
* before it was recorded.
*/
export type SessionCredentials =
| { serverUrl: string; username: string; password: string; token?: undefined }
| { serverUrl: string; username: string; token: string; password?: undefined };
| { serverUrl: string; username: string; password: string; token?: undefined; signedInAt?: number }
| { serverUrl: string; username: string; token: string; password?: undefined; signedInAt?: number };
/** The Authorization header remembered credentials sign in with. */
export function sessionAuthHeader(credentials: SessionCredentials): string {
@@ -34,13 +37,18 @@ export function sessionAuthHeader(credentials: SessionCredentials): string {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
export function encryptSession(serverUrl: string, username: string, secret: string | { token: string }): string {
export function encryptSession(
serverUrl: string,
username: string,
secret: string | { token: string },
signedInAt: number = Date.now(),
): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const credential = typeof secret === 'string' ? { password: secret } : { token: secret.token };
const payload = JSON.stringify({ v: 1, serverUrl, username, ...credential });
const payload = JSON.stringify({ v: 1, serverUrl, username, ...credential, signedInAt });
const encrypted = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
@@ -64,11 +72,14 @@ export function decryptSession(token: string): SessionCredentials | null {
const parsed = JSON.parse(decrypted.toString('utf8'));
if (parsed.v !== 1 || !parsed.serverUrl || !parsed.username) return null;
const signedInAt = typeof parsed.signedInAt === 'number' && Number.isFinite(parsed.signedInAt) && parsed.signedInAt > 0
? { signedInAt: parsed.signedInAt as number }
: {};
if (typeof parsed.password === 'string' && parsed.password) {
return { serverUrl: parsed.serverUrl, username: parsed.username, password: parsed.password };
return { serverUrl: parsed.serverUrl, username: parsed.username, password: parsed.password, ...signedInAt };
}
if (typeof parsed.token === 'string' && parsed.token) {
return { serverUrl: parsed.serverUrl, username: parsed.username, token: parsed.token };
return { serverUrl: parsed.serverUrl, username: parsed.username, token: parsed.token, ...signedInAt };
}
return null;
} catch (error) {
@@ -104,7 +115,9 @@ export type PayloadPurpose =
/** The app password an impersonation handoff minted, kept for revocation. */
| 'impersonation-grant'
/** A user's plugin credentials (lib/plugin-secrets/vault.ts). */
| 'plugin-secrets';
| 'plugin-secrets'
/** When a token sign-in happened, for the maximum session length (lib/auth/session-age.ts). */
| 'session-age';
/**
* Whether a blob minted before purposes were bound may be re-admitted for
@@ -125,6 +138,7 @@ const LEGACY_PAYLOAD_MARKERS: Record<PayloadPurpose, (payload: Record<string, un
'wopi-token': (p) => p.t === 'wopi',
'impersonation-grant': () => false,
'plugin-secrets': () => false,
'session-age': () => false,
};
export function encryptPayload(payload: Record<string, unknown>, purpose: PayloadPurpose): string {
+102
View File
@@ -0,0 +1,102 @@
/**
* The organisation's maximum session length (policy.json
* `organisation.session.maxHours`) on the server side.
*
* JMAP goes from the browser straight to Stalwart, so the client's timer
* signs users out on time (components/session/session-timeout-guard.tsx).
* The server's part is to stop a session older than the maximum from coming
* back: the routes that restore a remembered sign-in (/api/auth/session) or
* renew a provider token (/api/auth/token) refuse it and clear its cookies.
*
* The sign-in time travels in the remembered-session cookie (`signedInAt`
* in lib/auth/crypto.ts), in the auth-context cookie, and for token
* sign-ins in a small cookie of its own written when the refresh token is
* (the auth-context cookie ends with the browser, the refresh token lasts
* 30 days). A cookie written before this existed has no sign-in time: it
* counts as signed in when the server first sees it after the upgrade,
* which then writes the time into it.
*
* The server knows an account's address, not its groups, so only Everyone
* and the domain scopes count here; a group scope's value is enforced by the
* client alone.
*/
import type { cookies } from 'next/headers';
import { configManager } from '@/lib/admin/config-manager';
import { resolvePolicy } from '@/lib/admin/policy-scopes';
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { logger } from '@/lib/logger';
type CookieStore = Awaited<ReturnType<typeof cookies>>;
const HOUR_MS = 60 * 60 * 1000;
/** What a route answers when it refuses a session that is too old. */
export const SESSION_TOO_OLD = 'session_expired';
/** The maximum session length (hours, 0 = none) for an account's address. */
export async function maxSessionHoursFor(account: string | null | undefined): Promise<number> {
try {
await configManager.ensureLoaded?.();
const email = account?.trim().toLowerCase() ?? '';
const policy = resolvePolicy(configManager.getPolicy(), email ? { email, groups: [] } : null).policy;
const hours = policy.organisation?.session?.maxHours;
return typeof hours === 'number' && hours > 0 ? hours : 0;
} catch (error) {
logger.warn('Could not read the maximum session length', { error: error instanceof Error ? error.message : 'Unknown error' });
return 0;
}
}
/** Whether a session signed in at `signedInAt` has outlived `maxHours`. */
export function isPastMaxAge(signedInAt: number | undefined, maxHours: number, now = Date.now()): boolean {
if (!maxHours || typeof signedInAt !== 'number') return false;
return now - signedInAt >= maxHours * HOUR_MS;
}
export function validSignedInAt(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
}
/**
* The sign-in time of a token sign-in, kept as long as its refresh token.
* `account` is filled in once the auth context names the account.
*/
export interface SignInStamp {
signedInAt: number;
account?: string;
}
export function signInStampCookieName(slot: number): string {
return slot === 0 ? 'jmap_signed_in' : `jmap_signed_in_${slot}`;
}
export function readSignInStamp(cookieStore: CookieStore, slot: number): SignInStamp | null {
const value = cookieStore.get(signInStampCookieName(slot))?.value;
if (!value) return null;
try {
const payload = decryptPayload(value, 'session-age');
const signedInAt = validSignedInAt(payload?.signedInAt);
if (!signedInAt) return null;
return typeof payload?.account === 'string' ? { signedInAt, account: payload.account } : { signedInAt };
} catch {
return null;
}
}
/** Without a session secret nothing can be sealed; the client's timer still applies. */
export function writeSignInStamp(cookieStore: CookieStore, slot: number, stamp: SignInStamp): void {
try {
const payload: Record<string, unknown> = { signedInAt: stamp.signedInAt };
if (stamp.account) payload.account = stamp.account;
cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), getCookieOptions());
} catch (error) {
logger.warn('Could not record the sign-in time', { error: error instanceof Error ? error.message : 'Unknown error' });
}
}
export function clearSignInStamp(cookieStore: CookieStore, slot: number): void {
const name = signInStampCookieName(slot);
if (cookieStore.get(name)) cookieStore.delete(name);
}
+16 -3
View File
@@ -14,6 +14,12 @@ export interface StalwartAuthContext {
* data such as synced settings is keyed on this.
*/
accountName?: string;
/**
* When the user signed in (ms), for the organisation's maximum session
* length (lib/auth/session-age.ts). Carried over when the context is
* written again for the same account (a renewed token, a restore).
*/
signedInAt?: number;
}
type CookieStore = Awaited<ReturnType<typeof cookies>>;
@@ -39,7 +45,8 @@ function isValidContext(payload: unknown): payload is StalwartAuthContext {
return typeof candidate.serverUrl === 'string'
&& typeof candidate.username === 'string'
&& typeof candidate.authHeader === 'string'
&& (candidate.accountName === undefined || typeof candidate.accountName === 'string');
&& (candidate.accountName === undefined || typeof candidate.accountName === 'string')
&& (candidate.signedInAt === undefined || typeof candidate.signedInAt === 'number');
}
function getSessionCookieOptions() {
@@ -59,8 +66,14 @@ export function readStalwartAuthContextFromStore(
// Hand back exactly the declared shape: the envelope's own bookkeeping
// stays in the envelope rather than riding along into the credentials.
const { serverUrl, username, authHeader, accountName } = payload;
return accountName ? { serverUrl, username, authHeader, accountName } : { serverUrl, username, authHeader };
const { serverUrl, username, authHeader, accountName, signedInAt } = payload;
return {
serverUrl,
username,
authHeader,
...(accountName ? { accountName } : {}),
...(typeof signedInAt === 'number' ? { signedInAt } : {}),
};
}
export function setStalwartAuthContextInStore(
@@ -0,0 +1,64 @@
import { beforeEach, expect, it, vi } from 'vitest';
import { createConfig } from '@/lib/__tests__/fixtures/config';
// The server refuses to restore a session older than the organisation's
// maximum session length (lib/auth/session-age.ts). The login page then says
// why the user has to sign in again.
vi.mock('@/lib/jmap/client', () => {
class JMAPClient {
constructor(public serverUrl: string, public username: string, public password: string) {}
static withBearer(serverUrl: string, _token: string, username: string) { return new JMAPClient(serverUrl, username, ''); }
async connect() {}
getAuthHeader() { return 'Basic x'; }
onConnectionChange() {}
onRateLimit() {}
getRateLimitRemainingMs() { return 0; }
disconnect() {}
}
class RateLimitError extends Error {}
class RequestTimeoutError extends Error {}
return { JMAPClient, RateLimitError, RequestTimeoutError };
});
import * as nav from '@/lib/browser-navigation';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { consumeSessionEnd } from '@/lib/session-policy/sign-out-reason';
const account = (authMode: 'basic' | 'oauth') => ({
id: 'alice@example.org@mail.example.org', label: 'Alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org', authMode,
rememberMe: true, providerSession: authMode === 'oauth', cookieSlot: 0, displayName: 'Alice', email: 'alice@example.org', isConnected: false,
hasError: false, lastLoginAt: 1, avatarColor: '#000000', isDefault: true,
});
const tooOld = () => new Response(JSON.stringify({ error: 'session_expired', maxHours: 8 }), { status: 401 });
beforeEach(() => {
sessionStorage.clear();
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
const url = String(input);
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
if (url.includes('/api/auth/session') && init?.method === 'PUT') return tooOld();
if (url.includes('/api/auth/token') && init?.method === 'PUT') return tooOld();
return new Response('{}', { status: 200 });
}));
vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(() => {});
});
it('says on the login page that a remembered session reached its maximum length', async () => {
useAccountStore.setState({ accounts: [account('basic')] as never, activeAccountId: account('basic').id, defaultAccountId: account('basic').id });
await useAuthStore.getState().checkAuth();
await vi.waitFor(() => expect(useAccountStore.getState().accounts).toHaveLength(0));
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 8 });
});
it('says so for a provider sign-in whose renewal the server refused', async () => {
useAccountStore.setState({ accounts: [account('oauth')] as never, activeAccountId: account('oauth').id, defaultAccountId: account('oauth').id });
await useAuthStore.getState().checkAuth();
await vi.waitFor(() => expect(useAccountStore.getState().accounts).toHaveLength(0));
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 8 });
});
+23 -2
View File
@@ -24,6 +24,7 @@ import { useSearchHistoryStore } from './search-history-store';
import { useCalendarNotificationStore } from './calendar-notification-store';
import { usePolicyStore } from './policy-store';
import { policySubjectForClient } from '@/lib/admin/policy-subject';
import { rememberSessionEnd } from '@/lib/session-policy/sign-out-reason';
import type { Identity } from '@/lib/jmap/types';
import { authHooks } from '@/lib/plugin-hooks';
import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
@@ -536,12 +537,31 @@ async function persistSession(slot: number, session: RememberedSession): Promise
}
}
/**
* A restore or renewal the server refused because the session reached the
* organisation's maximum length (lib/auth/session-age.ts): the login page
* says so.
*/
async function noteSessionTooOld(res: Response): Promise<Response> {
if (res.status !== 401 || typeof res.clone !== 'function') return res;
try {
const data = await res.clone().json() as { error?: unknown; maxHours?: unknown };
if (data?.error === 'session_expired' && typeof data.maxHours === 'number') {
rememberSessionEnd({ kind: 'max', hours: data.maxHours });
}
} catch {
/* not JSON */
}
return res;
}
/** PUT /api/auth/token?slot=N - renew (or, unless `force`, reuse) the slot's access token. */
async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}): Promise<Response> {
// Renewing would write the tokens of a slot being signed out back.
if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401);
if (!IS_LITE) {
return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT', signal }));
return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT', signal }))
.then(noteSessionTooOld);
}
// Like the route's cookie cache: a restore resumes with the token it had,
// since a renewal before the refresh token's `nbf` is refused (#552).
@@ -563,7 +583,8 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}
/** PUT /api/auth/session[?slot=N] - the slot's remembered password or access token. */
async function fetchSlotSession(slot?: number): Promise<Response> {
if (!IS_LITE) {
return apiFetch(slot === undefined ? '/api/auth/session' : `/api/auth/session?slot=${slot}`, { method: 'PUT' });
return apiFetch(slot === undefined ? '/api/auth/session' : `/api/auth/session?slot=${slot}`, { method: 'PUT' })
.then(noteSessionTooOld);
}
const stored = readLiteBasicSession(slot ?? 0);
return stored ? jsonResponse(stored) : jsonResponse({ error: 'no_session' }, 401);