feat: master-user impersonation route + app-top-banner plugin slot

This commit is contained in:
Linus Rath
2026-05-16 17:59:07 +02:00
parent 0ff88f36ed
commit ca1108f455
8 changed files with 515 additions and 2 deletions
+3 -1
View File
@@ -60,6 +60,7 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useThemeStore } from "@/stores/theme-store";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
@@ -97,7 +98,7 @@ export default function Home() {
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil, username, serverUrl } = useAuthStore();
const { identities } = useIdentityStore();
useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
@@ -1960,6 +1961,7 @@ export default function Home() {
return (
<DragDropProvider>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<PluginSlot name="app-top-banner" extraProps={{ username, serverUrl }} />
{isRateLimited && rateLimitSecondsLeft !== null && (
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
<AlertTriangle className="h-3.5 w-3.5" />
+126
View File
@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import {
ImpersonationJwtError,
impersonationReplayCache,
verifyImpersonationJwt,
} from '@/lib/impersonation/jwt';
import {
readImpersonationConfig,
resolveImpersonationServerUrl,
} from '@/lib/impersonation/master-config';
export const runtime = 'nodejs';
const IMPERSONATION_SLOT = 0;
function sessionCookieOptions() {
return { ...getCookieOptions(), maxAge: SESSION_COOKIE_MAX_AGE };
}
/**
* GET /api/auth/impersonate?token=<jwt>
*
* Master-user impersonation via signed JWT. The token carries the target
* 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 "/" and the
* SPA hydrates as if the user had just logged in with master@target%master.
*
* Returns 404 when the feature is not configured so an unconfigured
* deployment does not advertise the endpoint.
*/
export async function GET(request: NextRequest) {
const config = readImpersonationConfig();
if (!config) {
// Not configured — behave exactly like an unknown route.
return new NextResponse('Not found', { status: 404 });
}
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
}
let claims;
try {
claims = verifyImpersonationJwt(token, config.jwtSecret, {
expectedIssuer: config.expectedIssuer,
});
} catch (err) {
if (err instanceof ImpersonationJwtError) {
logger.warn('Impersonation JWT rejected', { code: err.code });
return NextResponse.json({ error: err.message }, { status: err.status });
}
logger.error('Impersonation JWT error', {
error: err instanceof Error ? err.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
}
const serverUrl = await resolveImpersonationServerUrl();
if (!serverUrl) {
logger.error('Impersonation requested but jmapServerUrl is not configured');
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
let normalizedServerUrl: string;
try {
normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
} catch {
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
}
// Stalwart master-user impersonation: username = "<target>%<master>",
// password = <master_password>. Per Stalwart docs:
// https://stalw.art/docs/auth/authorization/administrator/
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
const authHeader = `Basic ${Buffer.from(
`${impersonatedUsername}:${config.masterPassword}`,
).toString('base64')}`;
const cookieStore = await cookies();
const sessionToken = encryptSession(
normalizedServerUrl,
impersonatedUsername,
config.masterPassword,
);
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, sessionCookieOptions());
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
serverUrl: normalizedServerUrl,
username: impersonatedUsername,
authHeader,
});
// Structured audit log — operators rely on this for security review.
logger.info('Impersonation session granted', {
event: 'impersonation_granted',
jti: claims.jti,
mailbox: claims.mailbox,
tenant_id: claims.tenant_id,
actor_user_id: claims.actor_user_id,
iss: claims.iss,
ip:
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
null,
referer: request.headers.get('referer'),
user_agent: request.headers.get('user-agent'),
});
void recordLogin(impersonatedUsername, normalizedServerUrl);
return NextResponse.redirect(new URL('/', request.url), 303);
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { createHmac } from 'node:crypto';
import {
ImpersonationJwtError,
verifyImpersonationJwt,
impersonationReplayCache,
} from '@/lib/impersonation/jwt';
const SECRET = 'a'.repeat(64);
const ISSUER = 'platform-api/webmail';
function base64Url(input: Buffer | string): string {
return Buffer.from(input)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function sign(payload: Record<string, unknown>, secret: string = SECRET, header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' }): string {
const h = base64Url(JSON.stringify(header));
const p = base64Url(JSON.stringify(payload));
const sig = createHmac('sha256', secret).update(`${h}.${p}`).digest();
return `${h}.${p}.${base64Url(sig)}`;
}
function basePayload(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
const now = Math.floor(Date.now() / 1000);
return {
iss: ISSUER,
iat: now,
exp: now + 120,
jti: 'jti-' + Math.random().toString(36).slice(2),
mailbox: 'alice@example.test',
...overrides,
};
}
describe('verifyImpersonationJwt', () => {
beforeEach(() => {
impersonationReplayCache.clear();
});
it('accepts a valid HS256 token', () => {
const token = sign(basePayload());
const claims = verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER });
expect(claims.mailbox).toBe('alice@example.test');
});
it('rejects non-HS256 algorithms', () => {
const header = { alg: 'none', typ: 'JWT' };
const h = base64Url(JSON.stringify(header));
const p = base64Url(JSON.stringify(basePayload()));
const token = `${h}.${p}.`;
expect(() => verifyImpersonationJwt(token, SECRET)).toThrow(ImpersonationJwtError);
});
it('rejects tokens with a forged signature', () => {
const token = sign(basePayload(), 'a-different-secret-that-is-also-long-enough-32');
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/signature/i);
});
it('rejects when secret is too short', () => {
const token = sign(basePayload());
expect(() => verifyImpersonationJwt(token, 'short')).toThrowError(/32 characters/);
});
it('rejects expired tokens', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ iat: now - 600, exp: now - 300 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/expired/i);
});
it('rejects tokens with lifetime over the 300s ceiling', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ iat: now, exp: now + 3600 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/lifetime/i);
});
it('rejects tokens with iss mismatch when expectedIssuer is set', () => {
const token = sign(basePayload({ iss: 'someone-else' }));
expect(() =>
verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER }),
).toThrowError(/issuer/i);
});
it("rejects mailbox containing '%'", () => {
const token = sign(basePayload({ mailbox: 'a%b@example.test' }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/'%'/);
});
it("rejects mailbox containing ':'", () => {
const token = sign(basePayload({ mailbox: 'a:b@example.test' }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/':'/);
});
it('rejects malformed tokens', () => {
expect(() => verifyImpersonationJwt('not.a.jwt.extra', SECRET)).toThrow();
expect(() => verifyImpersonationJwt('', SECRET)).toThrow();
});
it('honours nbf with skew', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ nbf: now + 600 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/not yet valid/i);
});
});
describe('impersonationReplayCache', () => {
beforeEach(() => {
impersonationReplayCache.clear();
});
it('accepts a jti once and rejects it on second use', () => {
const now = Math.floor(Date.now() / 1000);
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(true);
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(false);
});
it('prunes expired jtis on next consume', () => {
const now = Math.floor(Date.now() / 1000);
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
// Far in the future — pruning should clear the old entry.
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
// Re-using the old jti is allowed after pruning (security irrelevant since
// the token would fail signature/exp validation upstream).
expect(impersonationReplayCache.consume('jti-old', now + 60, now + 1000)).toBe(true);
});
});
+1
View File
@@ -44,6 +44,7 @@ function resetStore() {
plugins: [],
slots: {
'toolbar-actions': [],
'app-top-banner': [],
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
+189
View File
@@ -0,0 +1,189 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
export class ImpersonationJwtError extends Error {
status: number;
code: string;
constructor(code: string, message: string, status: number = 401) {
super(message);
this.name = 'ImpersonationJwtError';
this.code = code;
this.status = status;
}
}
export interface ImpersonationClaims {
iss: string;
iat: number;
exp: number;
nbf?: number;
jti: string;
mailbox: string;
tenant_id?: string;
actor_user_id?: string;
}
const MAX_TOKEN_LIFETIME_SEC = 300;
const CLOCK_SKEW_SEC = 60;
const MIN_SECRET_LENGTH = 32;
function base64UrlDecode(input: string): Buffer {
const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4);
const b64 = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad);
return Buffer.from(b64, 'base64');
}
function parseSegment(segment: string): unknown {
try {
return JSON.parse(base64UrlDecode(segment).toString('utf8'));
} catch {
throw new ImpersonationJwtError('malformed', 'Malformed JWT segment', 400);
}
}
function assertString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new ImpersonationJwtError('claims', `Missing or invalid '${field}' claim`);
}
return value;
}
function assertNumber(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new ImpersonationJwtError('claims', `Missing or invalid '${field}' claim`);
}
return value;
}
/**
* Verify an HS256 JWT for master-user impersonation. Returns the validated
* claims on success; throws ImpersonationJwtError otherwise.
*
* Caller must perform replay-protection (jti tracking) on the returned claims.
*/
export function verifyImpersonationJwt(
token: string,
secret: string,
options: { expectedIssuer?: string; now?: number } = {},
): ImpersonationClaims {
if (typeof token !== 'string' || token.length === 0) {
throw new ImpersonationJwtError('malformed', 'Missing token', 400);
}
if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH) {
throw new ImpersonationJwtError(
'config',
`BULWARK_JWT_AUTH_SECRET must be at least ${MIN_SECRET_LENGTH} characters`,
500,
);
}
const parts = token.split('.');
if (parts.length !== 3) {
throw new ImpersonationJwtError('malformed', 'Token must have 3 segments', 400);
}
const [headerB64, payloadB64, sigB64] = parts;
// Header — reject anything but HS256 BEFORE attempting signature verification.
const header = parseSegment(headerB64) as Record<string, unknown>;
if (header.alg !== 'HS256') {
throw new ImpersonationJwtError('alg', `Unsupported alg '${String(header.alg)}'`);
}
if (header.typ !== undefined && header.typ !== 'JWT') {
throw new ImpersonationJwtError('alg', `Unsupported typ '${String(header.typ)}'`);
}
// Signature — constant-time compare.
const expected = createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest();
const provided = base64UrlDecode(sigB64);
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
throw new ImpersonationJwtError('signature', 'Invalid signature');
}
// Claims.
const payload = parseSegment(payloadB64) as Record<string, unknown>;
const iss = assertString(payload.iss, 'iss');
if (options.expectedIssuer && iss !== options.expectedIssuer) {
throw new ImpersonationJwtError('iss', `Unexpected issuer '${iss}'`);
}
const iat = assertNumber(payload.iat, 'iat');
const exp = assertNumber(payload.exp, 'exp');
const jti = assertString(payload.jti, 'jti');
const mailbox = assertString(payload.mailbox, 'mailbox');
// Mailbox MUST NOT contain '%' or ':' — those would inject into the
// master-user auth header.
if (mailbox.includes('%') || mailbox.includes(':')) {
throw new ImpersonationJwtError('mailbox', "mailbox must not contain '%' or ':'");
}
const nowSec = options.now ?? Math.floor(Date.now() / 1000);
if (typeof payload.nbf === 'number' && nowSec + CLOCK_SKEW_SEC < payload.nbf) {
throw new ImpersonationJwtError('nbf', 'Token not yet valid');
}
if (nowSec - CLOCK_SKEW_SEC > exp) {
throw new ImpersonationJwtError('exp', 'Token expired');
}
if (iat - CLOCK_SKEW_SEC > nowSec) {
throw new ImpersonationJwtError('iat', 'Token issued in the future');
}
// Hard ceiling on lifetime — refuse long-lived handoff tokens even if the
// signer asked for one.
if (exp - iat > MAX_TOKEN_LIFETIME_SEC) {
throw new ImpersonationJwtError('lifetime', `Token lifetime exceeds ${MAX_TOKEN_LIFETIME_SEC}s ceiling`);
}
const claims: ImpersonationClaims = { iss, iat, exp, jti, mailbox };
if (typeof payload.nbf === 'number') claims.nbf = payload.nbf;
if (typeof payload.tenant_id === 'string') claims.tenant_id = payload.tenant_id;
if (typeof payload.actor_user_id === 'string') claims.actor_user_id = payload.actor_user_id;
return claims;
}
// ─── Replay protection ──────────────────────────────────────────
// In-memory LRU keyed by jti. Entries expire automatically once their
// underlying JWT could no longer be replayed (exp + skew). On a multi-pod
// deployment each pod has its own cache; that's acceptable because a token
// stolen mid-flight could only be replayed against the pod that already
// consumed it (and that pod will reject it). For stronger guarantees,
// platforms can issue per-pod-routed tokens or front Bulwark with a
// single-leader load balancer for the impersonate route.
const REPLAY_CACHE_MAX = 4096;
class ReplayCache {
private entries = new Map<string, number>(); // jti -> exp epoch seconds
/** Returns true if jti was not previously seen and has been recorded. */
consume(jti: string, exp: number, now: number = Math.floor(Date.now() / 1000)): boolean {
this.prune(now);
if (this.entries.has(jti)) return false;
if (this.entries.size >= REPLAY_CACHE_MAX) {
// Evict the oldest entry — Map preserves insertion order.
const first = this.entries.keys().next().value;
if (first !== undefined) this.entries.delete(first);
}
this.entries.set(jti, exp);
return true;
}
private prune(now: number): void {
for (const [jti, exp] of this.entries) {
if (exp + CLOCK_SKEW_SEC < now) {
this.entries.delete(jti);
} else {
// Insertion order means later entries are no older than this one — but
// exp isn't strictly monotonic with insertion, so we can't break here.
}
}
}
get size(): number {
return this.entries.size;
}
clear(): void {
this.entries.clear();
}
}
export const impersonationReplayCache = new ReplayCache();
+51
View File
@@ -0,0 +1,51 @@
import { configManager } from '@/lib/admin/config-manager';
export interface ImpersonationConfig {
jwtSecret: string;
masterUser: string;
masterPassword: string;
expectedIssuer: string;
}
/**
* Returns null when impersonation is not configured — the route MUST surface
* that as a 404 so an unconfigured deployment doesn't expose the endpoint.
*
* Required env:
* BULWARK_JWT_AUTH_SECRET (>= 32 chars)
* BULWARK_STALWART_MASTER_USER master account address (e.g. master@example.com)
* BULWARK_STALWART_MASTER_PASSWORD
*
* Optional env:
* BULWARK_JWT_AUTH_ISSUER (default: "platform-api/webmail")
*/
export function readImpersonationConfig(): ImpersonationConfig | null {
const jwtSecret = process.env.BULWARK_JWT_AUTH_SECRET ?? '';
const masterUser = process.env.BULWARK_STALWART_MASTER_USER ?? '';
const masterPassword = process.env.BULWARK_STALWART_MASTER_PASSWORD ?? '';
if (!jwtSecret || !masterUser || !masterPassword) return null;
return {
jwtSecret,
masterUser,
masterPassword,
expectedIssuer: process.env.BULWARK_JWT_AUTH_ISSUER ?? 'platform-api/webmail',
};
}
/**
* Resolves the upstream JMAP server URL the same way /api/auth/session does
* for trusted entries: the global `jmapServerUrl` admin setting, then the
* legacy env fallbacks. Returns null if none is configured.
*
* The impersonation flow is server-to-server (no user input), so we never
* accept a custom endpoint — only admin-configured URLs.
*/
export async function resolveImpersonationServerUrl(): Promise<string | null> {
await configManager.ensureLoaded();
const url =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
return url || null;
}
+14
View File
@@ -185,6 +185,15 @@ export interface PluginAPI {
i18n: PluginI18n;
ui: {
registerToolbarAction: (action: ToolbarAction) => Disposable;
/**
* Register a banner that renders at the very top of the authenticated app
* shell — above the navigation rail, sidebar and content panes. Used for
* persistent global notices (impersonation, maintenance, etc.). The
* component receives `{ username, serverUrl }` as props.
*
* Requires the `ui:app-top-banner` permission.
*/
registerAppTopBanner: (component: React.ComponentType<Record<string, unknown>>) => Disposable;
registerEmailBanner: (factory: BannerFactory) => Disposable;
registerEmailFooter: (component: React.ComponentType) => Disposable;
registerSettingsSection: (section: SettingsSection) => Disposable;
@@ -710,6 +719,11 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
return registerSlot(plugin.id, 'email-banner', factory.render as unknown as React.ComponentType<Record<string, unknown>>, 100);
},
registerAppTopBanner: (component: React.ComponentType<Record<string, unknown>>) => {
requirePermission(plugin, 'ui:app-top-banner');
return registerSlot(plugin.id, 'app-top-banner', component, 100);
},
registerEmailFooter: (component: React.ComponentType) => {
requirePermission(plugin, 'ui:email-footer');
return registerSlot(plugin.id, 'email-footer', component as React.ComponentType<Record<string, unknown>>, 100);
+2 -1
View File
@@ -230,6 +230,7 @@ export interface InstalledPlugin {
export type SlotName =
| 'toolbar-actions'
| 'app-top-banner'
| 'email-banner'
| 'email-footer'
| 'composer-toolbar'
@@ -778,7 +779,7 @@ export const ALL_PERMISSIONS = [
'security:read',
'auth:observe',
'http:post', 'http:fetch',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',