Merge pull request #1436 from shukiv/fix/impersonation-expired-app-passwords

fix: let a mailbox be opened through the handoff more than five times
This commit is contained in:
Linus Rath
2026-10-31 16:24:52 +01:00
3 changed files with 70 additions and 4 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ import {
readImpersonationConfig,
resolveImpersonationServerUrl,
} from '@/lib/impersonation/master-config';
import { mintImpersonationCredential } from '@/lib/impersonation/app-password';
import { IMPERSONATION_DESCRIPTION_PREFIX, mintImpersonationCredential } from '@/lib/impersonation/app-password';
import { IMPERSONATION_GRANT_COOKIE, sealImpersonationGrant } from '@/lib/impersonation/grant-cookie';
import { IMPERSONATION_MARKER_COOKIE, IMPERSONATION_MARKER_MAX_AGE_SECONDS } from '@/lib/impersonation/marker-cookie';
@@ -107,7 +107,7 @@ export async function GET(request: NextRequest) {
mailbox: claims.mailbox,
masterUser: config.masterUser,
masterPassword: config.masterPassword,
description: `Support session ${claims.jti}`.slice(0, 100),
description: `${IMPERSONATION_DESCRIPTION_PREFIX}${claims.jti}`.slice(0, 100),
});
} catch (err) {
logger.error('Impersonation credential could not be created', {
+37 -1
View File
@@ -51,6 +51,9 @@ const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors',
type Call = { url: string; auth: string | null; body: unknown };
let calls: Call[];
let createResult: Record<string, unknown>;
// The mailbox's app passwords, as x:AppPassword/get lists them; null makes the
// listing fail.
let existing: { id: string; description: string; expiresAt?: string }[] | null;
function stalwartFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = String(input);
@@ -64,6 +67,11 @@ function stalwartFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Re
}));
}
const method = (body as { methodCalls: [string, Record<string, unknown>, string][] }).methodCalls[0];
if (method[0] === 'x:AppPassword/get') {
return Promise.resolve(Response.json({
methodResponses: [existing ? ['x:AppPassword/get', { list: existing }, '0'] : ['error', { type: 'serverFail' }, '0']],
}));
}
if (method[1].create) {
return Promise.resolve(Response.json({ methodResponses: [['x:AppPassword/set', createResult, '0']] }));
}
@@ -96,6 +104,7 @@ describe('impersonation handoff', () => {
jar.clear();
calls = [];
createResult = { created: { imp: { id: 'cred-1', secret: APP_SECRET } } };
existing = [];
vi.stubGlobal('fetch', vi.fn(stalwartFetch));
});
@@ -121,7 +130,7 @@ describe('impersonation handoff', () => {
const masterAuth = `Basic ${Buffer.from(`alice@example.org%master@example.org:${MASTER_PASSWORD}`).toString('base64')}`;
expect(calls.every((c) => c.auth === masterAuth)).toBe(true);
const create = calls.find((c) => c.url.endsWith('/jmap/'))!.body as {
const create = calls.find((c) => c.url.endsWith('/jmap/') && JSON.stringify(c.body).includes('"create"'))!.body as {
methodCalls: [string, { accountId: string; create: { imp: { expiresAt: string } } }, string][];
};
const [method, args] = create.methodCalls[0];
@@ -136,6 +145,33 @@ describe('impersonation handoff', () => {
expect(expires).toBeLessThanOrEqual(after + 8 * 3600_000);
});
it("deletes the mailbox's expired handoff app passwords before making a new one", async () => {
const past = '2026-01-01T00:00:00Z';
const future = new Date(Date.now() + 3600_000).toISOString().replace(/\.\d{3}Z$/, 'Z');
existing = [
{ id: 'old-1', description: 'Support session aaa', expiresAt: past },
{ id: 'old-2', description: 'Support session bbb', expiresAt: past },
{ id: 'live', description: 'Support session ccc', expiresAt: future },
{ id: 'theirs', description: 'Thunderbird on the laptop', expiresAt: past },
{ id: 'forever', description: 'Support session ddd' },
];
const res = await impersonate();
expect(res.status).toBe(303);
const jmap = calls.filter((c) => c.url.endsWith('/jmap/')).map((c) =>
(c.body as { methodCalls: [string, Record<string, unknown>, string][] }).methodCalls[0]);
expect(jmap.map(([name]) => name)).toEqual(['x:AppPassword/get', 'x:AppPassword/set', 'x:AppPassword/set']);
expect(jmap[1][1].destroy).toEqual(['old-1', 'old-2']);
expect(jmap[2][1].create).toBeDefined();
});
it('still hands over a session when the expired ones cannot be listed', async () => {
existing = null;
const res = await impersonate();
expect(res.status).toBe(303);
expect(calls.some((c) => JSON.stringify(c.body ?? null).includes('"destroy"'))).toBe(false);
});
it('refuses the handoff when Stalwart does not create the app password', async () => {
createResult = { notCreated: { imp: { type: 'forbidden', description: 'not allowed' } } };
const res = await impersonate();
+31 -1
View File
@@ -9,6 +9,9 @@ const STALWART_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
*/
export const IMPERSONATION_SESSION_TTL_SEC = 8 * 60 * 60;
/** Every impersonation app password's description starts with this. */
export const IMPERSONATION_DESCRIPTION_PREFIX = 'Support session ';
export class ImpersonationCredentialError extends Error {
constructor(message: string) {
super(message);
@@ -139,15 +142,42 @@ export async function mintImpersonationCredential(opts: {
}): Promise<ImpersonationCredential> {
const now = opts.now ?? new Date();
const expiresAt = utcDate(new Date(now.getTime() + IMPERSONATION_SESSION_TTL_SEC * 1000));
const authHeader = masterAuthHeader(opts.mailbox, opts.masterUser, opts.masterPassword);
await pruneExpiredImpersonationCredentials(opts.serverUrl, authHeader, now);
const created = await createAppPassword({
serverUrl: opts.serverUrl,
authHeader: masterAuthHeader(opts.mailbox, opts.masterUser, opts.masterPassword),
authHeader,
description: opts.description,
expiresAt,
});
return { ...created, expiresAt };
}
/**
* Delete the mailbox's own expired impersonation app passwords. Stalwart keeps
* an app password after its `expiresAt` and counts it against the mailbox's
* limit (5 by default), so a mailbox opened that many times through the
* handoff could never be opened again. Only passwords this module made, by
* their description, and only expired ones go: the user's own app passwords
* and a session still running are never touched. Best effort: a failure here
* leaves the handoff to try as before.
*/
async function pruneExpiredImpersonationCredentials(serverUrl: string, authHeader: string, now: Date): Promise<void> {
try {
const result = await callStalwart(serverUrl, authHeader, ['x:AppPassword/get', { ids: null }, '0']);
const list = (result.list as { id?: string; description?: string; expiresAt?: string }[] | undefined) ?? [];
const expired = list
.filter((p) => p.id && p.description?.startsWith(IMPERSONATION_DESCRIPTION_PREFIX))
.filter((p) => p.expiresAt && Date.parse(p.expiresAt) <= now.getTime())
.map((p) => p.id as string);
if (expired.length > 0) {
await callStalwart(serverUrl, authHeader, ['x:AppPassword/set', { destroy: expired }, '0']);
}
} catch {
// The create below reports anything that matters.
}
}
/** Best-effort revocation of a credential minted by {@link mintImpersonationCredential}. */
export async function revokeImpersonationCredential(opts: {
serverUrl: string;