fix: bind the sign-in time to the refresh token and count unstamped sessions from a fixed time

This commit is contained in:
Linus Rath
2026-10-28 03:42:19 +01:00
parent 4821227415
commit defe4e93f3
11 changed files with 329 additions and 163 deletions
+15 -23
View File
@@ -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<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 };
}
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) {
+3 -1
View File
@@ -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
+10 -12
View File
@@ -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);
+27 -11
View File
@@ -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' });
+6 -3
View File
@@ -209,13 +209,15 @@ async function storeAndRespond(
rememberMe: boolean,
): Promise<NextResponse> {
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,
});
}
+2 -2
View File
@@ -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));
+1 -1
View File
@@ -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);
});
+124 -67
View File
@@ -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<string, string>) => 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' });
});
});
+132 -27
View File
@@ -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<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';
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<number> {
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<number> | 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<number | null> {
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<number> {
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<string, unknown> = 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<string, unknown> = { signedInAt: stamp.signedInAt };
const payload: Record<string, unknown> = { 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 };
}
+6
View File
@@ -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';
+3 -16
View File
@@ -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<ReturnType<typeof cookies>>;
@@ -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(