diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 3b0bbb5dd..6a0ce0a23 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -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>, + 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) { diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index de4a8f8de..5ae1dc11c 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -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) { diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 0d07ec38b..43c884d1e 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -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); diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index 05e7ec29c..942514fde 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -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; } diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 6172b7283..bdceadbf7 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -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) { diff --git a/lib/__tests__/auth-crypto.test.ts b/lib/__tests__/auth-crypto.test.ts index 2fa3c854d..e30d869b5 100644 --- a/lib/__tests__/auth-crypto.test.ts +++ b/lib/__tests__/auth-crypto.test.ts @@ -31,6 +31,7 @@ describe('encryptSession / decryptSession', () => { serverUrl: 'https://mail.example.com', username: 'alice', password: 's3cret', + signedInAt: expect.any(Number), }); }); diff --git a/lib/__tests__/auth-login-routes-budget.test.ts b/lib/__tests__/auth-login-routes-budget.test.ts index 793cc52f5..1a228c07a 100644 --- a/lib/__tests__/auth-login-routes-budget.test.ts +++ b/lib/__tests__/auth-login-routes-budget.test.ts @@ -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() })); diff --git a/lib/__tests__/auth-routes-credential-check.test.ts b/lib/__tests__/auth-routes-credential-check.test.ts index 3c49cbe32..f286fc472 100644 --- a/lib/__tests__/auth-routes-credential-check.test.ts +++ b/lib/__tests__/auth-routes-credential-check.test.ts @@ -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(); diff --git a/lib/__tests__/session-max-age-routes.test.ts b/lib/__tests__/session-max-age-routes.test.ts new file mode 100644 index 000000000..5b3dd23b8 --- /dev/null +++ b/lib/__tests__/session-max-age-routes.test.ts @@ -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(); +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; +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) => 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 { + 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' }); + }); +}); diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index 1dfc768ab..89b2545a9 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -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 p.t === 'wopi', 'impersonation-grant': () => false, 'plugin-secrets': () => false, + 'session-age': () => false, }; export function encryptPayload(payload: Record, purpose: PayloadPurpose): string { diff --git a/lib/auth/session-age.ts b/lib/auth/session-age.ts new file mode 100644 index 000000000..e5689e889 --- /dev/null +++ b/lib/auth/session-age.ts @@ -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>; + +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 { + 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 = { 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); +} diff --git a/lib/stalwart/auth-context.ts b/lib/stalwart/auth-context.ts index 7d22b3f63..3a34f1901 100644 --- a/lib/stalwart/auth-context.ts +++ b/lib/stalwart/auth-context.ts @@ -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>; @@ -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( diff --git a/stores/__tests__/auth-store-restore-session-too-old.test.ts b/stores/__tests__/auth-store-restore-session-too-old.test.ts new file mode 100644 index 000000000..333775cf5 --- /dev/null +++ b/stores/__tests__/auth-store-restore-session-too-old.test.ts @@ -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 }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 0a414af71..b8b084d5d 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -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 { + 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 { // 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 { 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);