diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 6a0ce0a23..e68645a30 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -23,7 +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'; +import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age'; function sessionCookieOptions() { return { @@ -77,30 +77,25 @@ function getSlot(request: NextRequest): number { /** * 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. + * recorded counts as signed in when the server first enforced the limits, + * a fixed time, so a kept copy of it runs out like any other. Returns the + * sign-in time, or the limit 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 }; - } + const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince(); // 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); + if (isPastMaxAge(signedInAt, maxHours)) { + cookieStore.delete(sessionCookieName(slot)); clearStalwartAuthContextInStore(cookieStore, slot); return { maxHours }; } - return { signedInAt: credentials.signedInAt }; + return { signedInAt }; } function sessionTooOld(maxHours: number) { @@ -209,14 +204,14 @@ export async function POST(request: NextRequest) { username, authHeader, ...(accountName !== username ? { accountName } : {}), - signedInAt, }); void recordLogin(username, normalizedServerUrl); const warning = insecureCookieHint(request); if (warning) logger.warn(`session: ${warning}`); - return NextResponse.json(warning ? { ok: true, warning } : { ok: true }); + // The browser counts the maximum session length from the server's time. + return NextResponse.json(warning ? { ok: true, warning, signedInAt } : { ok: true, signedInAt }); } catch (error) { if (error instanceof JmapAuthVerificationError) { return NextResponse.json( @@ -242,7 +237,7 @@ export async function GET(request: NextRequest) { } const credentials = decryptSession(token); - if (!credentials) { + if (!credentials || holdsMasterPassword(credentials)) { cookieStore.delete(cookieName); clearStalwartAuthContextInStore(cookieStore, slot); return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); @@ -255,13 +250,12 @@ export async function GET(request: NextRequest) { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: sessionAuthHeader(credentials), - signedInAt: age.signedInAt, }); // Only return non-sensitive fields. Use PUT to retrieve full credentials. const { serverUrl, username } = credentials; return NextResponse.json( - { serverUrl, username }, + { serverUrl, username, signedInAt: age.signedInAt }, { headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } }, ); } catch (error) { @@ -309,12 +303,10 @@ export async function PUT(request: NextRequest) { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: sessionAuthHeader(credentials), - signedInAt: age.signedInAt, }); - // The credentials as they were stored, without the bookkeeping. - const { signedInAt: _signedInAt, ...restored } = credentials; - return NextResponse.json(restored, { + // The browser counts the maximum session length from the server's time. + return NextResponse.json({ ...credentials, signedInAt: age.signedInAt }, { 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 5ae1dc11c..f79fdb6b9 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -106,6 +106,7 @@ export async function POST(request: NextRequest) { // Exchange code for tokens const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId); + const signedInAt = Date.now(); // For the mobile handoff flow the tokens are handed back to the app // verbatim - we deliberately don't write any cookies on the webmail @@ -117,7 +118,7 @@ export async function POST(request: NextRequest) { 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() }); + writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }); } const serverCookieName = refreshTokenServerCookieName(slot); if (pendingServerId) { @@ -169,6 +170,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in, + signed_in_at: signedInAt, }); } catch (error) { // Clean up pending cookie on any error diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 43c884d1e..fe25532d3 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth'; import { cookies } from 'next/headers'; -import { readStalwartAuthContextFromStore, setStalwartAuthContext } from '@/lib/stalwart/auth-context'; +import { setStalwartAuthContext } from '@/lib/stalwart/auth-context'; import { readSignInStamp, writeSignInStamp } from '@/lib/auth/session-age'; +import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { configManager } from '@/lib/admin/config-manager'; import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { recordLogin } from '@/lib/telemetry/login-tracker'; @@ -103,24 +104,21 @@ 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. + // A token sign-in's stamp (lib/auth/session-age.ts) knows no account: + // the token routes never see one. The first verified account names it, + // so the maximum session length of its domain applies on renewal. 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 }); + const refreshToken = cookieStore.get(refreshTokenCookieName(slot))?.value; + const stamp = refreshToken ? readSignInStamp(cookieStore, slot, refreshToken) : null; + if (refreshToken && stamp && !stamp.account) { + writeSignInStamp(cookieStore, slot, { signedInAt: stamp.signedInAt, refreshToken, 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 942514fde..b2ea53c3e 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -34,8 +34,8 @@ import { clearSignInStamp, isPastMaxAge, maxSessionHoursFor, - readSignInStamp, SESSION_TOO_OLD, + tokenSignedInAt, writeSignInStamp, } from '@/lib/auth/session-age'; import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; @@ -151,18 +151,21 @@ export async function POST(request: NextRequest) { const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId); + // A sign-in: the maximum session length counts from here, on the server + // and, from this answer, in the browser. + const signedInAt = Date.now(); const response = NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in, + signed_in_at: signedInAt, }); const cookieStore = await cookies(); 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() }); + writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }); } 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]); @@ -201,20 +204,23 @@ export async function PUT(request: NextRequest) { } // 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 { + // A refresh token without a matching stamp (from before stamps, or one + // whose stamp was deleted) counts from the fixed time the server first + // enforced the limits, never from now. + const age = await tokenSignedInAt(cookieStore, slot, refreshToken); + if (age) { const context = readStalwartAuthContextFromStore(cookieStore, slot); - const maxHours = await maxSessionHoursFor(stamp.account ?? context?.accountName ?? context?.username ?? null); - if (isPastMaxAge(stamp.signedInAt, maxHours)) { + const account = age.stamp?.account ?? context?.accountName ?? context?.username ?? null; + const maxHours = await maxSessionHoursFor(account); + if (isPastMaxAge(age.signedInAt, maxHours)) { await signOutSlot(cookieStore, slot, request.nextUrl.basePath, false); clearStalwartAuthContextInStore(cookieStore, slot); return NextResponse.json({ error: SESSION_TOO_OLD, maxHours }, { status: 401 }); } + // Bind the time to this token from now on. + if (!age.stamp) writeSignInStamp(cookieStore, slot, { signedInAt: age.signedInAt, refreshToken, ...(account ? { account } : {}) }); } + const signedIn = age ? { signed_in_at: age.signedInAt } : {}; // A session restore calls this to get its token back, not because the // current one expired. Serving the cached token avoids spending a refresh @@ -231,6 +237,7 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ access_token: cached.accessToken, expires_in: cached.expiresIn, + ...signedIn, }); } } @@ -279,6 +286,14 @@ export async function PUT(request: NextRequest) { if (tokens.refresh_token) { cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); + // The stamp follows the rotated token, with the same sign-in time and lifetime. + if (age) { + writeSignInStamp(cookieStore, slot, { + signedInAt: age.signedInAt, + refreshToken: tokens.refresh_token, + ...(age.stamp?.account ? { account: age.stamp.account } : {}), + }); + } } const expiresIn = tokens.expires_in || 3600; @@ -292,6 +307,7 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ access_token: tokens.access_token, expires_in: expiresIn, + ...signedIn, }); } catch (error) { logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' }); diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index bdceadbf7..9b61b428f 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -209,13 +209,15 @@ async function storeAndRespond( rememberMe: boolean, ): Promise { const cookieStore = await cookies(); + // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts). + const signedInAt = Date.now(); if (tokens.refresh_token) { const cookieName = refreshTokenCookieName(slot); // 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 options = rememberMe ? getCookieOptions() : sessionOptions; + cookieStore.set(cookieName, tokens.refresh_token, options); + writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }, options); } const serverCookieName = refreshTokenServerCookieName(slot); if (serverId) { @@ -228,5 +230,6 @@ async function storeAndRespond( access_token: tokens.access_token, expires_in: tokens.expires_in || 3600, has_refresh_token: !!tokens.refresh_token, + signed_in_at: signedInAt, }); } diff --git a/lib/__tests__/auth-crypto.test.ts b/lib/__tests__/auth-crypto.test.ts index e30d869b5..5d296c0e1 100644 --- a/lib/__tests__/auth-crypto.test.ts +++ b/lib/__tests__/auth-crypto.test.ts @@ -36,8 +36,8 @@ describe('encryptSession / decryptSession', () => { }); it('produces a base64 token with a random IV (two encrypts differ, both decrypt equal)', () => { - const a = encryptSession('https://x', 'u', 'p'); - const b = encryptSession('https://x', 'u', 'p'); + const a = encryptSession('https://x', 'u', 'p', 1000); + const b = encryptSession('https://x', 'u', 'p', 1000); expect(a).not.toBe(b); expect(Buffer.from(a, 'base64').toString('base64')).toBe(a); // valid base64 expect(decryptSession(a)).toEqual(decryptSession(b)); diff --git a/lib/__tests__/impersonation-session.test.ts b/lib/__tests__/impersonation-session.test.ts index b70abd176..aa44a2171 100644 --- a/lib/__tests__/impersonation-session.test.ts +++ b/lib/__tests__/impersonation-session.test.ts @@ -110,7 +110,7 @@ describe('impersonation handoff', () => { const put = await readSession(); expect(put.status).toBe(200); const body = await put.json(); - expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET }); + expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET, signedInAt: expect.any(Number) }); expect(JSON.stringify(body)).not.toContain(MASTER_PASSWORD); expect([...jar.values()].join()).not.toContain(MASTER_PASSWORD); }); diff --git a/lib/__tests__/session-max-age-routes.test.ts b/lib/__tests__/session-max-age-routes.test.ts index 5b3dd23b8..8048e2a28 100644 --- a/lib/__tests__/session-max-age-routes.test.ts +++ b/lib/__tests__/session-max-age-routes.test.ts @@ -1,14 +1,19 @@ // @vitest-environment node import { createCipheriv, createHash, randomBytes } from 'node:crypto'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, 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. + * older than `maxHours` of the account's domain scopes. A cookie or token + * without a sign-in time counts from one fixed time, when the server first + * enforced the limits, so deleting the stamp or keeping an old cookie never + * makes a session younger. */ const SECRET = 's'.repeat(64); @@ -43,7 +48,6 @@ vi.mock('@/lib/auth/verify-jmap-auth', () => ({ 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), @@ -54,8 +58,12 @@ vi.mock('@/lib/oauth/token-exchange', () => ({ DEFAULT_CLIENT_ID: 'bulwark-webmail', })); -const { encryptSession, decryptSession, decryptPayload } = await import('@/lib/auth/crypto'); -const { readSignInStamp, writeSignInStamp } = await import('@/lib/auth/session-age'); +const stateDir = mkdtempSync(path.join(tmpdir(), 'bw-session-age-')); +process.env.ADMIN_STATE_DIR = stateDir; +afterAll(() => rmSync(stateDir, { recursive: true, force: true })); + +const { encryptSession, decryptSession } = await import('@/lib/auth/crypto'); +const { readSignInStamp, writeSignInStamp, limitsEnforcedSince, resetLimitsEnforcedSince } = 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'); @@ -82,19 +90,33 @@ function legacySessionCookie(username: string, password: string): string { return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64'); } +const store = async () => (await (await import('next/headers')).cookies()) as never; 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'); +const refresh = () => tokenRoute.PUT(new NextRequest('https://webmail.example/api/auth/token?slot=0&force=true', { method: 'PUT', headers: SAME_ORIGIN })); +const signInWithProvider = () => 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 }), +})); -beforeEach(() => { +let issued = 0; +const tokenFetch = vi.fn(); + +beforeEach(async () => { jar.clear(); + rmSync(path.join(stateDir, 'session-limits-since'), { force: true }); + resetLimitsEnforcedSince(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(NOW); + // The server enforces the limits from NOW on. + expect(await limitsEnforcedSince()).toBe(NOW); policy = policyWithDomainMax(8); + issued = 0; tokenFetch.mockReset(); tokenFetch.mockImplementation(async (url: string) => { - if (url.endsWith('/token')) return Response.json({ access_token: 'new-access', expires_in: 300 }); + // The provider rotates the refresh token on every renewal. + if (url.endsWith('/token')) return Response.json({ access_token: 'new-access', refresh_token: `rotated-${++issued}`, expires_in: 300 }); return new Response(null, { status: 200 }); }); vi.stubGlobal('fetch', tokenFetch); @@ -105,24 +127,31 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe('the time the limits were first enforced', () => { + it('is recorded once and kept across restarts', async () => { + vi.setSystemTime(NOW + 5 * HOUR); + resetLimitsEnforcedSince(); + expect(await limitsEnforcedSince()).toBe(NOW); + }); +}); + describe('remembered sessions (/api/auth/session)', () => { - it('records the sign-in time when the session is remembered', async () => { + it('records the sign-in time when the session is remembered, and answers with it', 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(await res.json()).toEqual({ ok: true, signedInAt: NOW }); 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 () => { + it('restores a session younger than the maximum and says when it signed in', 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); + expect(await res.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw', signedInAt: NOW - 7 * HOUR }); }); it('refuses a session older than its domain allows and clears its cookies', async () => { @@ -150,41 +179,46 @@ describe('remembered sessions (/api/auth/session)', () => { 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); + it('counts a cookie without a sign-in time from the fixed time, without rewriting it', async () => { + const legacy = legacySessionCookie('org-alice@example.org', 'pw'); + jar.set('jmap_session', legacy); vi.setSystemTime(NOW + 7 * HOUR); - expect((await restore()).status).toBe(200); + const first = await restore(); + expect(first.status).toBe(200); + expect((await first.json()).signedInAt).toBe(NOW); + expect(jar.get('jmap_session')).toBe(legacy); + vi.setSystemTime(NOW + 8 * HOUR); expect((await restore()).status).toBe(401); }); + + it('refuses a kept copy of an old cookie replayed later', async () => { + const legacy = legacySessionCookie('org-alice@example.org', 'pw'); + // First seen only now, nine hours after the server started enforcing. + vi.setSystemTime(NOW + 9 * HOUR); + jar.set('jmap_session', legacy); + expect((await restore()).status).toBe(401); + jar.set('jmap_session', legacy); + expect((await peek()).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 }), - })); + it('records the sign-in time with the refresh token when a provider sign-in stores it', async () => { + const res = await signInWithProvider(); expect(res.status).toBe(200); - expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW }); + expect((await res.json()).signed_in_at).toBe(NOW); + expect(readSignInStamp(await store(), 0, 'refresh-1')).toMatchObject({ signedInAt: NOW }); + // The stamp belongs to that token only. + expect(readSignInStamp(await store(), 0, 'another-token')).toBeNull(); }); 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' }); + writeSignInStamp(await store(), 0, { signedInAt: NOW + HOUR, refreshToken: 'refresh-1', account: 'org-alice@example.org' }); + vi.setSystemTime(NOW + 9 * HOUR); const res = await refresh(); expect(res.status).toBe(401); expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 }); @@ -194,47 +228,70 @@ describe('provider tokens (/api/auth/token)', () => { 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' }); + it('renews a younger one, and the stamp follows the rotated token with the same time', async () => { + await signInWithProvider(); + vi.setSystemTime(NOW + 3 * HOUR); const res = await refresh(); expect(res.status).toBe(200); - expect((await res.json()).access_token).toBe('new-access'); + expect(await res.json()).toMatchObject({ access_token: 'new-access', signed_in_at: NOW }); + expect(jar.get('jmap_rt')).toBe('rotated-1'); + expect(readSignInStamp(await store(), 0, 'rotated-1')).toMatchObject({ signedInAt: NOW }); + + vi.setSystemTime(NOW + 7 * HOUR); + expect((await refresh()).status).toBe(200); + vi.setSystemTime(NOW + 8 * HOUR); + expect((await refresh()).status).toBe(401); }); - it('counts a refresh token without a sign-in time as signed in when first seen', async () => { - jar.set('jmap_rt', 'refresh-1'); + it('counts a refresh token without a stamp from the fixed time and binds that time to it', async () => { + jar.set('jmap_rt', 'refresh-legacy'); + vi.setSystemTime(NOW + HOUR); + const res = await refresh(); + expect(res.status).toBe(200); + expect((await res.json()).signed_in_at).toBe(NOW); + expect(readSignInStamp(await store(), 0, 'rotated-1')).toMatchObject({ signedInAt: NOW }); + + vi.setSystemTime(NOW + 8 * HOUR); + expect((await refresh()).status).toBe(401); + }); + + it('does not make a session younger when its stamp is deleted', async () => { + vi.setSystemTime(NOW + 5 * HOUR); + await signInWithProvider(); + jar.delete('jmap_signed_in'); + + vi.setSystemTime(NOW + 8 * HOUR); + const res = await refresh(); + expect(res.status).toBe(401); + }); + + it('applies the domain of the account the stamp names', async () => { + jar.set('jmap_rt', 'refresh-1'); + writeSignInStamp(await store(), 0, { signedInAt: NOW, refreshToken: 'refresh-1', account: 'org-dave@org-two.test' }); + vi.setSystemTime(NOW + 100 * HOUR); expect((await refresh()).status).toBe(200); - const store = (await (await import('next/headers')).cookies()) as never; - expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW }); + }); + + it('does not take a stamp written for another refresh token', async () => { + jar.set('jmap_rt', 'refresh-2'); + writeSignInStamp(await store(), 0, { signedInAt: NOW + 7 * HOUR, refreshToken: 'refresh-1' }); + + vi.setSystemTime(NOW + 8 * HOUR); + expect((await refresh()).status).toBe(401); }); }); 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' }); + it("names the account on a token sign-in's stamp", async () => { + await signInWithProvider(); + const res = await 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 }), + })); + expect(res.status).toBe(200); + expect(readSignInStamp(await store(), 0, 'refresh-1')).toMatchObject({ signedInAt: NOW, account: 'org-alice@example.org' }); }); }); diff --git a/lib/auth/session-age.ts b/lib/auth/session-age.ts index e5689e889..ba7f97e8a 100644 --- a/lib/auth/session-age.ts +++ b/lib/auth/session-age.ts @@ -8,41 +8,58 @@ * 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 sign-in time travels in the remembered-session cookie (`signedInAt` in + * lib/auth/crypto.ts) and, for token sign-ins, in a stamp cookie of its own + * that is bound to the refresh token by a hash and rewritten with it on + * every rotation. A cookie or token without a sign-in time (one written + * before this existed, or a stamp that was deleted or doesn't match the + * token) counts as signed in at one fixed time: when the server first + * enforced the limits (limitsEnforcedSince). Deleting the stamp therefore + * never makes a session younger. * * 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 { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; import type { cookies } from 'next/headers'; import { configManager } from '@/lib/admin/config-manager'; +import { ensureStateDir, getStatePath } from '@/lib/admin/paths'; import { resolvePolicy } from '@/lib/admin/policy-scopes'; import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { logger } from '@/lib/logger'; +export { SESSION_TOO_OLD } from '@/lib/session-policy/constants'; + 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'; +const positiveHours = (value: unknown): number => (typeof value === 'number' && value > 0 ? value : 0); -/** The maximum session length (hours, 0 = none) for an account's address. */ +/** + * The maximum session length (hours, 0 = none) for an account's address. + * Without an address (a token sign-in whose stamp names no account, which + * includes one whose stamp was deleted) the strictest value of Everyone and + * every domain entry applies, so losing the account never loosens the limit. + */ export async function maxSessionHoursFor(account: string | null | undefined): Promise { try { await configManager.ensureLoaded?.(); + const raw = configManager.getPolicy(); 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; + if (email) return positiveHours(resolvePolicy(raw, { email, groups: [] }).policy.organisation?.session?.maxHours); + const values = [ + positiveHours(resolvePolicy(raw, null).policy.organisation?.session?.maxHours), + ...(raw.scopes ?? []) + .filter((scope) => (scope.match?.domains?.length ?? 0) > 0) + .map((scope) => positiveHours(scope.policy?.organisation?.session?.maxHours)), + ].filter((hours) => hours > 0); + return values.length > 0 ? Math.min(...values) : 0; } catch (error) { logger.warn('Could not read the maximum session length', { error: error instanceof Error ? error.message : 'Unknown error' }); return 0; @@ -50,47 +67,120 @@ export async function maxSessionHoursFor(account: string | null | undefined): Pr } /** 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; +export function isPastMaxAge(signedInAt: number, maxHours: number, now = Date.now()): boolean { + if (!maxHours) 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 fixed time for sessions without a sign-in time ───────────────────── + +const ENFORCED_SINCE_FILE = 'session-limits-since'; +const PROCESS_START = Date.now() - Math.round(process.uptime() * 1000); +let enforcedSince: Promise | null = null; + +function parseTime(text: string): number | null { + const value = Number(text.trim()); + return Number.isFinite(value) && value > 0 ? value : null; +} + +async function readRecorded(path: string): Promise { + try { + return parseTime(await readFile(path, 'utf8')); + } catch { + return null; + } } /** - * 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. + * When this server first enforced the session limits, recorded once in the + * admin state dir. Falls back to the process start when the file can't be + * written (a read-only state dir), which restarts the count on a restart. + */ +export function limitsEnforcedSince(): Promise { + enforcedSince ??= (async () => { + const path = getStatePath(ENFORCED_SINCE_FILE); + const recorded = await readRecorded(path); + if (recorded) return recorded; + const now = Date.now(); + try { + await ensureStateDir(); + // 'wx': a second process starting at the same moment keeps the first one's time. + await writeFile(path, String(now), { flag: 'wx' }); + return now; + } catch { + const other = await readRecorded(path); + if (other) return other; + logger.warn('Could not record when the session limits were first enforced; counting from the process start'); + return PROCESS_START; + } + })(); + return enforcedSince; +} + +/** Test helper. */ +export function resetLimitsEnforcedSince(): void { + enforcedSince = null; +} + +// ── The sign-in stamp of a token sign-in ─────────────────────────────────── + +/** + * The sign-in time of a token sign-in, bound to the refresh token it was + * written with. `account` is filled in once the auth context names it. */ export interface SignInStamp { signedInAt: number; + tokenHash: string; account?: string; } +export function refreshTokenHash(refreshToken: string): string { + return createHash('sha256').update(refreshToken).digest('base64url'); +} + 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 { +/** The slot's stamp, or null when it is missing, unreadable, or written for another refresh token. */ +export function readSignInStamp(cookieStore: CookieStore, slot: number, refreshToken: string): 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 }; + const signedInAt = payload?.signedInAt; + if (typeof signedInAt !== 'number' || !Number.isFinite(signedInAt)) return null; + if (payload?.tokenHash !== refreshTokenHash(refreshToken)) return null; + const account = typeof payload.account === 'string' ? payload.account : undefined; + return { signedInAt, tokenHash: payload.tokenHash, ...(account ? { account } : {}) }; } 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 { +let missingSecretLogged = false; + +/** + * Written whenever the refresh token cookie is, with that cookie's options. + * Without a session secret nothing can be sealed; the client's timer still applies. + */ +export function writeSignInStamp( + cookieStore: CookieStore, + slot: number, + stamp: { signedInAt: number; refreshToken: string; account?: string }, + options: Record = getCookieOptions(), +): void { + if (!hasSessionSecret()) { + if (!missingSecretLogged) { + missingSecretLogged = true; + logger.warn('No session secret: the maximum session length is enforced by the browser only'); + } + return; + } try { - const payload: Record = { signedInAt: stamp.signedInAt }; + const payload: Record = { signedInAt: stamp.signedInAt, tokenHash: refreshTokenHash(stamp.refreshToken) }; if (stamp.account) payload.account = stamp.account; - cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), getCookieOptions()); + cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), options); } catch (error) { logger.warn('Could not record the sign-in time', { error: error instanceof Error ? error.message : 'Unknown error' }); } @@ -100,3 +190,18 @@ export function clearSignInStamp(cookieStore: CookieStore, slot: number): void { const name = signInStampCookieName(slot); if (cookieStore.get(name)) cookieStore.delete(name); } + +/** + * When a token sign-in happened: its stamp's time, or the fixed time when + * there is no stamp for this refresh token. Null without a session secret, + * when the server can't keep sign-in times at all. + */ +export async function tokenSignedInAt( + cookieStore: CookieStore, + slot: number, + refreshToken: string, +): Promise<{ signedInAt: number; stamp: SignInStamp | null } | null> { + if (!hasSessionSecret()) return null; + const stamp = readSignInStamp(cookieStore, slot, refreshToken); + return { signedInAt: stamp?.signedInAt ?? await limitsEnforcedSince(), stamp }; +} diff --git a/lib/session-policy/constants.ts b/lib/session-policy/constants.ts new file mode 100644 index 000000000..dab0c03a7 --- /dev/null +++ b/lib/session-policy/constants.ts @@ -0,0 +1,6 @@ +/** + * What the sign-in routes answer (`error`) when they refuse a session that + * reached the organisation's maximum length. The server (lib/auth/session-age.ts) + * and the browser (stores/auth-store.ts) both read it from here. + */ +export const SESSION_TOO_OLD = 'session_expired'; diff --git a/lib/stalwart/auth-context.ts b/lib/stalwart/auth-context.ts index 3a34f1901..7d22b3f63 100644 --- a/lib/stalwart/auth-context.ts +++ b/lib/stalwart/auth-context.ts @@ -14,12 +14,6 @@ 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>; @@ -45,8 +39,7 @@ 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.signedInAt === undefined || typeof candidate.signedInAt === 'number'); + && (candidate.accountName === undefined || typeof candidate.accountName === 'string'); } function getSessionCookieOptions() { @@ -66,14 +59,8 @@ 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, signedInAt } = payload; - return { - serverUrl, - username, - authHeader, - ...(accountName ? { accountName } : {}), - ...(typeof signedInAt === 'number' ? { signedInAt } : {}), - }; + const { serverUrl, username, authHeader, accountName } = payload; + return accountName ? { serverUrl, username, authHeader, accountName } : { serverUrl, username, authHeader }; } export function setStalwartAuthContextInStore(