diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 7c2201e5..229a4600 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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(null); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(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 (
+ {isRateLimited && rateLimitSecondsLeft !== null && (
diff --git a/app/api/auth/impersonate/route.ts b/app/api/auth/impersonate/route.ts new file mode 100644 index 00000000..12f522b0 --- /dev/null +++ b/app/api/auth/impersonate/route.ts @@ -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= + * + * 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 = "%", + // 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); +} diff --git a/lib/__tests__/impersonation-jwt.test.ts b/lib/__tests__/impersonation-jwt.test.ts new file mode 100644 index 00000000..9bba40fd --- /dev/null +++ b/lib/__tests__/impersonation-jwt.test.ts @@ -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, secret: string = SECRET, header: Record = { 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 { + 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); + }); +}); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 72105892..1a65e6ec 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -44,6 +44,7 @@ function resetStore() { plugins: [], slots: { 'toolbar-actions': [], + 'app-top-banner': [], 'email-banner': [], 'email-footer': [], 'composer-toolbar': [], diff --git a/lib/impersonation/jwt.ts b/lib/impersonation/jwt.ts new file mode 100644 index 00000000..c1462899 --- /dev/null +++ b/lib/impersonation/jwt.ts @@ -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; + 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; + 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(); // 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(); diff --git a/lib/impersonation/master-config.ts b/lib/impersonation/master-config.ts new file mode 100644 index 00000000..19928a35 --- /dev/null +++ b/lib/impersonation/master-config.ts @@ -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 { + await configManager.ensureLoaded(); + const url = + configManager.get('jmapServerUrl', '') || + process.env.JMAP_SERVER_URL || + process.env.NEXT_PUBLIC_JMAP_SERVER_URL || + ''; + return url || null; +} diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index 1d778fcb..66e43c01 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -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>) => 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>, 100); }, + registerAppTopBanner: (component: React.ComponentType>) => { + 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>, 100); diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index f4bd8232..abdb66ce 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -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',