feat: master-user impersonation route + app-top-banner plugin slot
This commit is contained in:
@@ -60,6 +60,7 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { usePluginStore } from "@/stores/plugin-store";
|
import { usePluginStore } from "@/stores/plugin-store";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||||
@@ -97,7 +98,7 @@ export default function Home() {
|
|||||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
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();
|
const { identities } = useIdentityStore();
|
||||||
useIdentitySync();
|
useIdentitySync();
|
||||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
@@ -1960,6 +1961,7 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<DragDropProvider>
|
<DragDropProvider>
|
||||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
<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 && (
|
{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">
|
<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" />
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,7 @@ function resetStore() {
|
|||||||
plugins: [],
|
plugins: [],
|
||||||
slots: {
|
slots: {
|
||||||
'toolbar-actions': [],
|
'toolbar-actions': [],
|
||||||
|
'app-top-banner': [],
|
||||||
'email-banner': [],
|
'email-banner': [],
|
||||||
'email-footer': [],
|
'email-footer': [],
|
||||||
'composer-toolbar': [],
|
'composer-toolbar': [],
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -185,6 +185,15 @@ export interface PluginAPI {
|
|||||||
i18n: PluginI18n;
|
i18n: PluginI18n;
|
||||||
ui: {
|
ui: {
|
||||||
registerToolbarAction: (action: ToolbarAction) => Disposable;
|
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;
|
registerEmailBanner: (factory: BannerFactory) => Disposable;
|
||||||
registerEmailFooter: (component: React.ComponentType) => Disposable;
|
registerEmailFooter: (component: React.ComponentType) => Disposable;
|
||||||
registerSettingsSection: (section: SettingsSection) => 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);
|
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) => {
|
registerEmailFooter: (component: React.ComponentType) => {
|
||||||
requirePermission(plugin, 'ui:email-footer');
|
requirePermission(plugin, 'ui:email-footer');
|
||||||
return registerSlot(plugin.id, 'email-footer', component as React.ComponentType<Record<string, unknown>>, 100);
|
return registerSlot(plugin.id, 'email-footer', component as React.ComponentType<Record<string, unknown>>, 100);
|
||||||
|
|||||||
+2
-1
@@ -230,6 +230,7 @@ export interface InstalledPlugin {
|
|||||||
|
|
||||||
export type SlotName =
|
export type SlotName =
|
||||||
| 'toolbar-actions'
|
| 'toolbar-actions'
|
||||||
|
| 'app-top-banner'
|
||||||
| 'email-banner'
|
| 'email-banner'
|
||||||
| 'email-footer'
|
| 'email-footer'
|
||||||
| 'composer-toolbar'
|
| 'composer-toolbar'
|
||||||
@@ -778,7 +779,7 @@ export const ALL_PERMISSIONS = [
|
|||||||
'security:read',
|
'security:read',
|
||||||
'auth:observe',
|
'auth:observe',
|
||||||
'http:post', 'http:fetch',
|
'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:composer-toolbar', 'ui:composer-sidebar',
|
||||||
'ui:sidebar-widget', 'ui:settings-section',
|
'ui:sidebar-widget', 'ui:settings-section',
|
||||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||||
|
|||||||
Reference in New Issue
Block a user