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 { 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" />
|
||||
|
||||
@@ -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: [],
|
||||
slots: {
|
||||
'toolbar-actions': [],
|
||||
'app-top-banner': [],
|
||||
'email-banner': [],
|
||||
'email-footer': [],
|
||||
'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;
|
||||
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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user