After a master-user impersonation handoff (GET /api/auth/impersonate) the server swaps the slot-0 session cookie but the client's persisted account registry (account-registry / auth-storage in localStorage) still lists the previous account, so the top-left account chip keeps showing the old mailbox until a manual sign-out. Redirect impersonation to /?impersonated=1 and add a headless ImpersonationReconciler that drops the stale persisted account/auth state (and server-derived caches) then reloads to a clean URL, so the app rehydrates empty and re-derives the single account from the fresh session. Cookies untouched, so the just-granted session survives. Runs exactly once. Reported downstream: shukiv/jabali-panel#646.
143 lines
5.2 KiB
TypeScript
143 lines
5.2 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { cookies } from 'next/headers';
|
|
import { logger } from '@/lib/logger';
|
|
import { encryptSession } from '@/lib/auth/crypto';
|
|
import { 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;
|
|
|
|
/**
|
|
* Impersonation cookies deliberately omit Max-Age so the browser treats
|
|
* them as session cookies - the impersonated session ends when the user
|
|
* closes the browser, not 30 days later. Impersonation is a temporary
|
|
* support handoff; a normal password login is the only thing that should
|
|
* survive a browser restart.
|
|
*/
|
|
function impersonationCookieOptions() {
|
|
const { maxAge: _maxAge, ...rest } = getCookieOptions();
|
|
return rest;
|
|
}
|
|
|
|
/**
|
|
* 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 "/?impersonated=1" (see
|
|
* ImpersonationReconciler, GH #646) 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, impersonationCookieOptions());
|
|
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);
|
|
|
|
// Use a relative Location header so the browser resolves it against the
|
|
// public request URL. NextResponse.redirect(new URL('/', request.url))
|
|
// would absolutise to the container's internal bind (http://0.0.0.0:3000)
|
|
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
|
return new NextResponse(null, {
|
|
status: 303,
|
|
headers: { Location: '/?impersonated=1' },
|
|
});
|
|
}
|