mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: show the real sender and subject for a push after the browser was closed
This commit is contained in:
@@ -10,7 +10,8 @@ import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
|
||||
import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readBodyCapped, readJsonCapped } from '@/lib/security/capped-body';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { DisallowedUrlError } from '@/lib/security/url-guard';
|
||||
import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import type { StalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { readPushAuthContext } from '@/lib/stalwart/push-auth-context';
|
||||
import { rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
|
||||
@@ -98,7 +99,7 @@ async function loginsNamed(login: string): Promise<StalwartAuthContext[]> {
|
||||
const cookieStore = await cookies();
|
||||
const found: StalwartAuthContext[] = [];
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
const ctx = await readPushAuthContext(cookieStore, slot);
|
||||
if (ctx && (await loginFingerprint(ctx.serverUrl, ctx.username)) === login) found.push(ctx);
|
||||
}
|
||||
return found;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { cleanPreview } from '@/lib/utils';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { readPushAuthContext } from '@/lib/stalwart/push-auth-context';
|
||||
import {
|
||||
getStalwartCredentials,
|
||||
type StalwartCredentials,
|
||||
@@ -54,7 +54,7 @@ async function resolveTargetForAccount(accountId: string, login: string | null,
|
||||
const cookieStore = await cookies();
|
||||
const probes: Promise<{ target: ResolvedTarget; own: boolean } | null>[] = [];
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
const ctx = await readPushAuthContext(cookieStore, slot);
|
||||
if (!ctx) continue;
|
||||
const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
|
||||
probes.push(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The context cookie lasts as long as the browser session. With every window
|
||||
// closed and the browser started again by a push, only the remembered session
|
||||
// is left, and the worker has to make do with that or show "New mail".
|
||||
vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
|
||||
const maxHours = vi.hoisted(() => ({ value: 0 }));
|
||||
vi.mock('@/lib/auth/session-age', () => ({
|
||||
isPastMaxAge: (signedInAt: number, hours: number) => hours > 0 && Date.now() - signedInAt > hours * 3600_000,
|
||||
limitsEnforcedSince: async () => Date.now(),
|
||||
maxSessionHoursFor: async () => maxHours.value,
|
||||
}));
|
||||
const impersonation = vi.hoisted(() => ({ master: null as string | null }));
|
||||
vi.mock('@/lib/impersonation/master-config', () => ({
|
||||
readImpersonationConfig: () => (impersonation.master ? { masterPassword: impersonation.master } : null),
|
||||
}));
|
||||
|
||||
import { encryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readPushAuthContext } from '@/lib/stalwart/push-auth-context';
|
||||
import { stalwartAuthContextCookieName } from '@/lib/stalwart/auth-context';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
|
||||
function store(cookies: Record<string, string>) {
|
||||
return { get: (name: string) => (name in cookies ? { value: cookies[name] } : undefined) } as never;
|
||||
}
|
||||
|
||||
const remembered = (signedInAt = Date.now(), password = 'pw') => encryptSession('https://mail.example.org', 'me@example.org', password, signedInAt);
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.SESSION_SECRET = 'a-test-secret-that-is-long-enough-for-this-0123456789';
|
||||
maxHours.value = 0;
|
||||
impersonation.master = null;
|
||||
});
|
||||
|
||||
describe('readPushAuthContext', () => {
|
||||
it('prefers the context cookie', async () => {
|
||||
const context = encryptPayload({ serverUrl: 'https://ctx.example.org', username: 'ctx@example.org', authHeader: 'Bearer t' }, 'session-context');
|
||||
const found = await readPushAuthContext(store({ [stalwartAuthContextCookieName(0)]: context, [sessionCookieName(0)]: remembered() }), 0);
|
||||
expect(found?.username).toBe('ctx@example.org');
|
||||
});
|
||||
|
||||
it('falls back to the remembered session', async () => {
|
||||
const found = await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 1);
|
||||
expect(found).toMatchObject({ serverUrl: 'https://mail.example.org', username: 'me@example.org' });
|
||||
expect(found?.authHeader).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it('finds nothing in another slot, or without either cookie', async () => {
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 0)).toBeNull();
|
||||
expect(await readPushAuthContext(store({}), 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a session past the organisation\'s maximum length', async () => {
|
||||
maxHours.value = 1;
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now() - 2 * 3600_000) }), 0)).toBeNull();
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered() }), 0)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('refuses garbage and a cookie holding the impersonation master password', async () => {
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: 'garbage' }), 0)).toBeNull();
|
||||
impersonation.master = 'master-secret';
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now(), 'master-secret') }), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -30,9 +30,14 @@ const LOGINS = [
|
||||
{ serverUrl: 'https://two.example', authHeader: 'Basic bob', username: 'bob@two.example' },
|
||||
{ serverUrl: 'https://one.example', authHeader: 'Basic carol', username: 'carol@one.example' },
|
||||
];
|
||||
const ORIGINAL_LOGINS = [...LOGINS];
|
||||
vi.mock('@/lib/stalwart/auth-context', () => ({
|
||||
readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null,
|
||||
}));
|
||||
const remembered = vi.hoisted(() => ({ value: null as null | { serverUrl: string; authHeader: string; username: string } }));
|
||||
vi.mock('@/lib/stalwart/push-auth-context', () => ({
|
||||
readPushAuthContext: async (_store: unknown, slot: number) => LOGINS[slot] ?? (slot === 0 ? remembered.value : null),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
getStalwartCredentials: vi.fn(),
|
||||
@@ -88,6 +93,22 @@ async function callRoute(accountId: string, login?: string) {
|
||||
|
||||
const mailCalls = () => fetchJmapServer.mock.calls.filter(([url]) => !url.endsWith('/.well-known/jmap'));
|
||||
|
||||
describe('push preview after the browser was closed', () => {
|
||||
it('reads the login from the remembered session when the context cookie is gone', async () => {
|
||||
remembered.value = { serverUrl: 'https://one.example', authHeader: 'Basic remembered', username: 'alice@one.example' };
|
||||
LOGINS.length = 0;
|
||||
try {
|
||||
SESSIONS = { 'Basic remembered': session('https://one.example', 'a') };
|
||||
const { status, body } = await callRoute('a');
|
||||
expect(status).toBe(200);
|
||||
expect((body.email as { subject: string }).subject).toBe('mail of Basic remembered');
|
||||
} finally {
|
||||
remembered.value = null;
|
||||
LOGINS.push(...ORIGINAL_LOGINS);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('push preview when the account id is in more than one login', () => {
|
||||
beforeEach(() => {
|
||||
fetchJmapServer.mockClear();
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { decryptSession, sessionAuthHeader } from '@/lib/auth/crypto';
|
||||
import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor } from '@/lib/auth/session-age';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readImpersonationConfig } from '@/lib/impersonation/master-config';
|
||||
import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
|
||||
type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
/**
|
||||
* The login for a request from the service worker, which has no page to refresh
|
||||
* anything. The context cookie goes with the browser session, so a browser that
|
||||
* was closed and woken again by a push only has the remembered one, which a page
|
||||
* would turn back into a context on load (GET /api/auth/session, with the same
|
||||
* checks). Not signed in with "remember me", or by OAuth with an expired token:
|
||||
* none, and the worker shows its generic notification.
|
||||
*/
|
||||
export async function readPushAuthContext(cookieStore: CookieStore, slot: number): Promise<StalwartAuthContext | null> {
|
||||
const context = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (context) return context;
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (!token) return null;
|
||||
const credentials = decryptSession(token);
|
||||
if (!credentials) return null;
|
||||
const impersonation = readImpersonationConfig();
|
||||
if (impersonation && credentials.password === impersonation.masterPassword) return null;
|
||||
const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince();
|
||||
const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]);
|
||||
if (isPastMaxAge(signedInAt, maxHours)) return null;
|
||||
return { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: sessionAuthHeader(credentials) };
|
||||
}
|
||||
Reference in New Issue
Block a user