mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: keep the Stalwart master password out of impersonated sessions
An impersonation handoff sealed `<target>%<master>` and the master password into the session cookie, and `PUT /api/auth/session` hands that cookie's contents to the browser on every restore. Anyone who received a handoff could read a credential that signs in as every mailbox and into Stalwart administration, with no expiry and no audit trail. The impersonate route now uses the master credential server-side, once, to create an app password on the target mailbox that expires after 8 hours. Only that app password reaches the cookies. Signing out revokes it through a sealed grant cookie, and PUT refuses any session that still holds the configured master password, so cookies minted before this change are dropped instead of returned.
This commit is contained in:
@@ -450,6 +450,11 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# them unset keeps the feature fully off. Treat the secret and the master
|
||||
# password as you would a root credential.
|
||||
#
|
||||
# The master password never leaves the server: each handoff uses it once to
|
||||
# create an app password on the target mailbox, valid for 8 hours and
|
||||
# revoked on sign-out. The target account's role therefore needs the
|
||||
# app-password permissions (Stalwart's default user role has them).
|
||||
#
|
||||
# BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters
|
||||
# BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com
|
||||
# BULWARK_STALWART_MASTER_PASSWORD= # required
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
readImpersonationConfig,
|
||||
resolveImpersonationServerUrl,
|
||||
} from '@/lib/impersonation/master-config';
|
||||
import { mintImpersonationCredential } from '@/lib/impersonation/app-password';
|
||||
import { IMPERSONATION_GRANT_COOKIE, sealImpersonationGrant } from '@/lib/impersonation/grant-cookie';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -40,8 +42,13 @@ function impersonationCookieOptions() {
|
||||
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
||||
* master credentials from env, then mints the same session cookies the
|
||||
* password-login path produces. The browser is redirected to "/?impersonated=1" (see
|
||||
* ImpersonationReconciler, GH #646) and the
|
||||
* SPA hydrates as if the user had just logged in with master@target%master.
|
||||
* ImpersonationReconciler, GH #646) and the SPA hydrates as if the user had
|
||||
* just logged in to the target mailbox.
|
||||
*
|
||||
* The master credential is used here, server-side, only to create an
|
||||
* expiring app password on the target mailbox. The session cookies carry
|
||||
* that app password: the browser reads its session back through
|
||||
* PUT /api/auth/session, and the master password would open every mailbox.
|
||||
*
|
||||
* Returns 404 when the feature is not configured so an unconfigured
|
||||
* deployment does not advertise the endpoint.
|
||||
@@ -92,26 +99,44 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Stalwart master-user impersonation: username = "<target>%<master>",
|
||||
// password = <master_password>. Per Stalwart docs:
|
||||
// Stalwart master-user login ("<target>%<master>", master password) opens
|
||||
// the target mailbox; use it once to mint the target's own app password.
|
||||
// https://stalw.art/docs/auth/authorization/administrator/
|
||||
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
|
||||
const authHeader = `Basic ${Buffer.from(
|
||||
`${impersonatedUsername}:${config.masterPassword}`,
|
||||
).toString('base64')}`;
|
||||
let credential;
|
||||
try {
|
||||
credential = await mintImpersonationCredential({
|
||||
serverUrl: normalizedServerUrl,
|
||||
mailbox: claims.mailbox,
|
||||
masterUser: config.masterUser,
|
||||
masterPassword: config.masterPassword,
|
||||
description: `Support session ${claims.jti}`.slice(0, 100),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Impersonation credential could not be created', {
|
||||
jti: claims.jti,
|
||||
mailbox: claims.mailbox,
|
||||
error: err instanceof Error ? err.message : 'Unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Could not open the mailbox' }, { status: 502 });
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = encryptSession(
|
||||
normalizedServerUrl,
|
||||
impersonatedUsername,
|
||||
config.masterPassword,
|
||||
);
|
||||
const sessionToken = encryptSession(normalizedServerUrl, claims.mailbox, credential.secret);
|
||||
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username: impersonatedUsername,
|
||||
authHeader,
|
||||
username: claims.mailbox,
|
||||
authHeader: `Basic ${Buffer.from(`${claims.mailbox}:${credential.secret}`).toString('base64')}`,
|
||||
});
|
||||
cookieStore.set(
|
||||
IMPERSONATION_GRANT_COOKIE,
|
||||
sealImpersonationGrant({
|
||||
serverUrl: normalizedServerUrl,
|
||||
mailbox: claims.mailbox,
|
||||
credentialId: credential.id,
|
||||
}),
|
||||
impersonationCookieOptions(),
|
||||
);
|
||||
|
||||
// Structured audit log - operators rely on this for security review.
|
||||
logger.info('Impersonation session granted', {
|
||||
@@ -121,6 +146,7 @@ export async function GET(request: NextRequest) {
|
||||
tenant_id: claims.tenant_id,
|
||||
actor_user_id: claims.actor_user_id,
|
||||
iss: claims.iss,
|
||||
expires_at: credential.expiresAt,
|
||||
ip:
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
@@ -129,7 +155,7 @@ export async function GET(request: NextRequest) {
|
||||
user_agent: request.headers.get('user-agent'),
|
||||
});
|
||||
|
||||
void recordLogin(impersonatedUsername, normalizedServerUrl);
|
||||
void recordLogin(claims.mailbox, normalizedServerUrl);
|
||||
|
||||
// Use a relative Location header so the browser resolves it against the
|
||||
// public request URL. NextResponse.redirect(new URL('/', request.url))
|
||||
|
||||
@@ -16,6 +16,9 @@ import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-server
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { insecureCookieHint, verificationFailureBody } from '@/lib/auth/verification-failure';
|
||||
import { readImpersonationConfig } from '@/lib/impersonation/master-config';
|
||||
import { revokeImpersonationCredential } from '@/lib/impersonation/app-password';
|
||||
import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie';
|
||||
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
@@ -24,6 +27,40 @@ function sessionCookieOptions() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A session sealed around the impersonation master password. Handoffs made
|
||||
* before impersonation switched to per-mailbox app passwords stored it in
|
||||
* the session cookie; it opens every mailbox, so it is never handed back to
|
||||
* a browser, whichever path stored it.
|
||||
*/
|
||||
function holdsMasterPassword(credentials: { password: string }): boolean {
|
||||
const config = readImpersonationConfig();
|
||||
return !!config && credentials.password === config.masterPassword;
|
||||
}
|
||||
|
||||
async function revokeImpersonationGrant(
|
||||
cookieStore: Awaited<ReturnType<typeof cookies>>,
|
||||
): Promise<void> {
|
||||
const grant = openImpersonationGrant(cookieStore.get(IMPERSONATION_GRANT_COOKIE)?.value);
|
||||
cookieStore.delete(IMPERSONATION_GRANT_COOKIE);
|
||||
const config = readImpersonationConfig();
|
||||
if (!grant || !config) return;
|
||||
try {
|
||||
await revokeImpersonationCredential({
|
||||
serverUrl: grant.serverUrl,
|
||||
mailbox: grant.mailbox,
|
||||
masterUser: config.masterUser,
|
||||
masterPassword: config.masterPassword,
|
||||
id: grant.credentialId,
|
||||
});
|
||||
} catch (error) {
|
||||
// The app password still expires on its own; sign-out must not fail.
|
||||
logger.warn('Impersonation credential revocation failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
@@ -181,7 +218,7 @@ export async function PUT(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 });
|
||||
@@ -217,10 +254,12 @@ export async function DELETE(request: NextRequest) {
|
||||
cookieStore.delete(sessionCookieName(i));
|
||||
clearStalwartAuthContextInStore(cookieStore, i);
|
||||
}
|
||||
await revokeImpersonationGrant(cookieStore);
|
||||
} else {
|
||||
const slot = getSlot(request);
|
||||
cookieStore.delete(sessionCookieName(slot));
|
||||
clearStalwartAuthContextInStore(cookieStore, slot);
|
||||
if (slot === 0) await revokeImpersonationGrant(cookieStore);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// @vitest-environment node
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
vi.mock('@/lib/auth/session-secret', () => ({
|
||||
getSessionSecret: () => 's'.repeat(64),
|
||||
hasSessionSecret: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
|
||||
}));
|
||||
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: () => {} }));
|
||||
|
||||
const jar = new Map<string, string>();
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({
|
||||
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
|
||||
set: (name: string, value: string) => { jar.set(name, value); },
|
||||
delete: (name: string) => { jar.delete(name); },
|
||||
getAll: () => [...jar].map(([name, value]) => ({ name, value })),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) =>
|
||||
key === 'jmapServerUrl' ? 'https://mail.example.org' : fallback,
|
||||
},
|
||||
}));
|
||||
|
||||
const SECRET = 'j'.repeat(40);
|
||||
const MASTER_PASSWORD = 'M4ster-P4ss-for-every-mailbox';
|
||||
const APP_SECRET = 'app-password-secret-for-alice';
|
||||
|
||||
function mintJwt(mailbox: string) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const b64url = (s: string) => Buffer.from(s).toString('base64url');
|
||||
const h = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const p = b64url(JSON.stringify({ iss: 'platform-api/webmail', iat: now, exp: now + 60, jti: randomUUID(), mailbox }));
|
||||
const sig = createHmac('sha256', SECRET).update(`${h}.${p}`).digest('base64url');
|
||||
return `${h}.${p}.${sig}`;
|
||||
}
|
||||
|
||||
const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' };
|
||||
|
||||
type Call = { url: string; auth: string | null; body: unknown };
|
||||
let calls: Call[];
|
||||
let createResult: Record<string, unknown>;
|
||||
|
||||
function stalwartFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = String(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined;
|
||||
calls.push({ url, auth: headers.get('authorization'), body });
|
||||
if (url.endsWith('/jmap/session')) {
|
||||
return Promise.resolve(Response.json({
|
||||
apiUrl: 'https://mail.example.org/jmap/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-alice' },
|
||||
}));
|
||||
}
|
||||
const method = (body as { methodCalls: [string, Record<string, unknown>, string][] }).methodCalls[0];
|
||||
if (method[1].create) {
|
||||
return Promise.resolve(Response.json({ methodResponses: [['x:AppPassword/set', createResult, '0']] }));
|
||||
}
|
||||
return Promise.resolve(Response.json({ methodResponses: [['x:AppPassword/set', { destroyed: method[1].destroy }, '0']] }));
|
||||
}
|
||||
|
||||
async function impersonate(mailbox = 'alice@example.org') {
|
||||
const { GET } = await import('@/app/api/auth/impersonate/route');
|
||||
return GET(new NextRequest(`https://webmail.example/api/auth/impersonate?token=${mintJwt(mailbox)}`));
|
||||
}
|
||||
|
||||
async function readSession() {
|
||||
const { PUT } = await import('@/app/api/auth/session/route');
|
||||
return PUT(new NextRequest('https://webmail.example/api/auth/session', { method: 'PUT', headers: SAME_ORIGIN }));
|
||||
}
|
||||
|
||||
describe('impersonation handoff', () => {
|
||||
beforeAll(() => {
|
||||
process.env.BULWARK_JWT_AUTH_SECRET = SECRET;
|
||||
process.env.BULWARK_STALWART_MASTER_USER = 'master@example.org';
|
||||
process.env.BULWARK_STALWART_MASTER_PASSWORD = MASTER_PASSWORD;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jar.clear();
|
||||
calls = [];
|
||||
createResult = { created: { imp: { id: 'cred-1', secret: APP_SECRET } } };
|
||||
vi.stubGlobal('fetch', vi.fn(stalwartFetch));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('hands the browser an app password for the target mailbox, never the master password', async () => {
|
||||
const res = await impersonate();
|
||||
expect(res.status).toBe(303);
|
||||
|
||||
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(JSON.stringify(body)).not.toContain(MASTER_PASSWORD);
|
||||
expect([...jar.values()].join()).not.toContain(MASTER_PASSWORD);
|
||||
});
|
||||
|
||||
it('uses the master credential only towards Stalwart, to create an expiring app password', async () => {
|
||||
const before = Date.now();
|
||||
await impersonate();
|
||||
|
||||
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 {
|
||||
methodCalls: [string, { accountId: string; create: { imp: { expiresAt: string } } }, string][];
|
||||
};
|
||||
const [method, args] = create.methodCalls[0];
|
||||
expect(method).toBe('x:AppPassword/set');
|
||||
expect(args.accountId).toBe('acc-alice');
|
||||
expect(args.create.imp.expiresAt).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/);
|
||||
const ttl = Date.parse(args.create.imp.expiresAt) - before;
|
||||
expect(ttl).toBeGreaterThan(7 * 3600_000);
|
||||
expect(ttl).toBeLessThanOrEqual(8 * 3600_000);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(res.status).toBe(502);
|
||||
expect(jar.size).toBe(0);
|
||||
});
|
||||
|
||||
it('revokes the app password on sign-out', async () => {
|
||||
await impersonate();
|
||||
calls = [];
|
||||
const { DELETE } = await import('@/app/api/auth/session/route');
|
||||
const res = await DELETE(new NextRequest('https://webmail.example/api/auth/session?all=true', {
|
||||
method: 'DELETE',
|
||||
headers: { 'sec-fetch-site': 'same-origin', origin: 'https://webmail.example' },
|
||||
}));
|
||||
expect(res.status).toBe(200);
|
||||
const destroy = calls.find((c) => c.url.endsWith('/jmap/'))!.body as {
|
||||
methodCalls: [string, { destroy: string[] }, string][];
|
||||
};
|
||||
expect(destroy.methodCalls[0][1].destroy).toEqual(['cred-1']);
|
||||
expect(jar.size).toBe(0);
|
||||
});
|
||||
|
||||
it('will not return a session that still holds the master password', async () => {
|
||||
const { encryptSession } = await import('@/lib/auth/crypto');
|
||||
jar.set('jmap_session', encryptSession('https://mail.example.org', 'alice@example.org%master@example.org', MASTER_PASSWORD));
|
||||
|
||||
const put = await readSession();
|
||||
expect(put.status).toBe(401);
|
||||
expect(JSON.stringify(await put.json())).not.toContain(MASTER_PASSWORD);
|
||||
expect(jar.has('jmap_session')).toBe(false);
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -77,7 +77,9 @@ export type PayloadPurpose =
|
||||
/** Fresh-IdP-login proof required before a device pairing code is issued. */
|
||||
| 'pair-reauth'
|
||||
/** WOPI access token, scoped to one file node and a six-hour expiry. */
|
||||
| 'wopi-token';
|
||||
| 'wopi-token'
|
||||
/** The app password an impersonation handoff minted, kept for revocation. */
|
||||
| 'impersonation-grant';
|
||||
|
||||
/**
|
||||
* Whether a blob minted before purposes were bound may be re-admitted for
|
||||
@@ -95,6 +97,7 @@ const LEGACY_PAYLOAD_MARKERS: Record<PayloadPurpose, (payload: Record<string, un
|
||||
'sso-pending': (p) => p.t === undefined && (p.purpose === undefined || p.purpose === 'reauth'),
|
||||
'pair-reauth': (p) => p.purpose === 'pair',
|
||||
'wopi-token': (p) => p.t === 'wopi',
|
||||
'impersonation-grant': () => false,
|
||||
};
|
||||
|
||||
export function encryptPayload(payload: Record<string, unknown>, purpose: PayloadPurpose): string {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
|
||||
const STALWART_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
|
||||
|
||||
/**
|
||||
* How long an impersonation handoff stays valid. Stalwart enforces it through
|
||||
* the app password's `expiresAt`, so the session ends even if the browser
|
||||
* keeps the cookie alive.
|
||||
*/
|
||||
export const IMPERSONATION_SESSION_TTL_SEC = 8 * 60 * 60;
|
||||
|
||||
export class ImpersonationCredentialError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ImpersonationCredentialError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImpersonationCredential {
|
||||
id: string;
|
||||
secret: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/** Basic auth header for Stalwart master-user login as `mailbox`. */
|
||||
export function masterAuthHeader(mailbox: string, masterUser: string, masterPassword: string): string {
|
||||
return `Basic ${Buffer.from(`${mailbox}%${masterUser}:${masterPassword}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
/** JMAP UTCDate: no fractional seconds (RFC 8620 section 1.4). */
|
||||
function utcDate(date: Date): string {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
function pickAccountId(primaryAccounts: Record<string, string> | undefined): string | null {
|
||||
if (!primaryAccounts) return null;
|
||||
return (
|
||||
primaryAccounts['urn:ietf:params:jmap:mail'] ??
|
||||
primaryAccounts['urn:stalwart:jmap'] ??
|
||||
Object.values(primaryAccounts)[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function callStalwart(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
methodCall: [string, Record<string, unknown>, string],
|
||||
): Promise<Record<string, unknown>> {
|
||||
const session = await fetchJmapSession(serverUrl, authHeader, { trusted: true });
|
||||
const accountId = pickAccountId(session?.primaryAccounts);
|
||||
if (!session || !accountId) {
|
||||
throw new ImpersonationCredentialError('Could not open the target mailbox with the master credential');
|
||||
}
|
||||
const apiUrl = rebaseApiUrl(session, serverUrl) ?? `${serverUrl}/jmap/`;
|
||||
const [method, args, callId] = methodCall;
|
||||
const response = await postJmap(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
JSON.stringify({ using: STALWART_USING, methodCalls: [[method, { accountId, ...args }, callId]] }),
|
||||
{ trusted: true },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new ImpersonationCredentialError(`Stalwart answered HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
|
||||
const result = data.methodResponses?.[0];
|
||||
if (!result || result[0] === 'error') {
|
||||
const detail = result?.[1] as { type?: string; description?: string } | undefined;
|
||||
throw new ImpersonationCredentialError(detail?.description || detail?.type || 'Unexpected JMAP response');
|
||||
}
|
||||
return result[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the master credential server-side, once, to create an expiring app
|
||||
* password on the target mailbox. The browser only ever receives this app
|
||||
* password: it opens that one mailbox and stops working at `expiresAt`,
|
||||
* while the master password signs in as every mailbox and never expires.
|
||||
*/
|
||||
export async function mintImpersonationCredential(opts: {
|
||||
serverUrl: string;
|
||||
mailbox: string;
|
||||
masterUser: string;
|
||||
masterPassword: string;
|
||||
description: string;
|
||||
now?: Date;
|
||||
}): Promise<ImpersonationCredential> {
|
||||
const now = opts.now ?? new Date();
|
||||
const expiresAt = utcDate(new Date(now.getTime() + IMPERSONATION_SESSION_TTL_SEC * 1000));
|
||||
const result = await callStalwart(
|
||||
opts.serverUrl,
|
||||
masterAuthHeader(opts.mailbox, opts.masterUser, opts.masterPassword),
|
||||
['x:AppPassword/set', { create: { imp: { description: opts.description, expiresAt } } }, '0'],
|
||||
);
|
||||
const created = (result.created as Record<string, { id?: string; secret?: string }> | undefined)?.imp;
|
||||
if (!created?.id || !created.secret) {
|
||||
const failed = (result.notCreated as Record<string, { type?: string; description?: string }> | undefined)?.imp;
|
||||
throw new ImpersonationCredentialError(failed?.description || failed?.type || 'App password was not created');
|
||||
}
|
||||
return { id: created.id, secret: created.secret, expiresAt };
|
||||
}
|
||||
|
||||
/** Best-effort revocation of a credential minted by {@link mintImpersonationCredential}. */
|
||||
export async function revokeImpersonationCredential(opts: {
|
||||
serverUrl: string;
|
||||
mailbox: string;
|
||||
masterUser: string;
|
||||
masterPassword: string;
|
||||
id: string;
|
||||
}): Promise<void> {
|
||||
await callStalwart(
|
||||
opts.serverUrl,
|
||||
masterAuthHeader(opts.mailbox, opts.masterUser, opts.masterPassword),
|
||||
['x:AppPassword/set', { destroy: [opts.id] }, '0'],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
|
||||
|
||||
/**
|
||||
* Remembers which app password an impersonation handoff minted, so signing
|
||||
* out can revoke it instead of leaving it valid until it expires.
|
||||
* Impersonation always lands in slot 0, so one cookie is enough.
|
||||
*/
|
||||
export const IMPERSONATION_GRANT_COOKIE = 'jmap_impersonation';
|
||||
|
||||
export interface ImpersonationGrant {
|
||||
serverUrl: string;
|
||||
mailbox: string;
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
export function sealImpersonationGrant(grant: ImpersonationGrant): string {
|
||||
return encryptPayload({ ...grant }, 'impersonation-grant');
|
||||
}
|
||||
|
||||
export function openImpersonationGrant(token: string | undefined): ImpersonationGrant | null {
|
||||
if (!token) return null;
|
||||
const payload = decryptPayload(token, 'impersonation-grant');
|
||||
if (!payload) return null;
|
||||
const { serverUrl, mailbox, credentialId } = payload;
|
||||
if (typeof serverUrl !== 'string' || typeof mailbox !== 'string' || typeof credentialId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return { serverUrl, mailbox, credentialId };
|
||||
}
|
||||
Reference in New Issue
Block a user