← back to Norma
app/api/admin/impersonate/route.ts
125 lines
/**
* /api/admin/impersonate
*
* GET — current impersonation state (so the banner can render on load)
* POST { username } — start impersonating that user (admin-only)
* DELETE — exit impersonation, restore the real admin's session
*
* Mechanism:
* - Admin's REAL session lives in cookie `norma-auth` (same as always)
* - When admin impersonates, we KEEP the real cookie in `norma-imp-by` and
* overwrite `norma-auth` with a fresh session token for the target user.
* - On exit, we move `norma-imp-by` back into `norma-auth` and clear
* `norma-imp-by`.
* - Hard rule: only an `admin`-role session can START impersonation.
* Anyone in possession of the imp-by cookie can EXIT (so an exit
* mid-impersonation always works even if the cookie order is weird).
*/
import { NextRequest, NextResponse } from 'next/server';
import {
createSession, buildAuthCookie, AUTH_COOKIE_NAME, verifyAuth,
} from '@/lib/auth';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
const IMP_BY_COOKIE = 'norma-imp-by';
const MAX_AGE = 60 * 60 * 24; // 24h, same as norma-auth
function parseCookies(request: NextRequest): Record<string, string> {
const header = request.headers.get('cookie') || '';
return Object.fromEntries(
header.split(';').map((c) => {
const [k, ...rest] = c.trim().split('=');
return [k, rest.join('=')];
}),
);
}
function buildImpByCookie(token: string): string {
return `${IMP_BY_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${MAX_AGE}`;
}
function clearImpByCookie(): string {
return `${IMP_BY_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
export async function GET(request: NextRequest) {
const session = verifyAuth(request);
if (!session) return NextResponse.json({ authenticated: false });
const cookies = parseCookies(request);
const impByToken = cookies[IMP_BY_COOKIE];
return NextResponse.json({
currentUser: session.username,
currentRole: session.role,
impersonating: !!impByToken,
});
}
export async function POST(request: NextRequest) {
// Only the REAL admin can start impersonation.
// (If they're already impersonating, the current cookie says role=admin only
// if their target was also admin — which is fine; we still check.)
const adminSession = requireRole(request, 'admin');
if (adminSession instanceof NextResponse) return adminSession;
try {
const body = await request.json();
const targetUsername = String(body.username || '').trim();
if (!targetUsername) {
return NextResponse.json({ error: 'username is required' }, { status: 400 });
}
if (targetUsername === adminSession.username) {
return NextResponse.json({ error: 'Already that user' }, { status: 400 });
}
const result = await query<{ username: string; role: string; org_id: string | null }>(
'SELECT username, role, org_id FROM tier_credentials WHERE username = $1',
[targetUsername],
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const target = result.rows[0];
// Preserve admin's existing session in the imp-by cookie
const cookies = parseCookies(request);
const adminToken = cookies[AUTH_COOKIE_NAME];
if (!adminToken) {
return NextResponse.json({ error: 'No active session to preserve' }, { status: 400 });
}
// Mint a fresh session for the target
const targetToken = createSession(target.username, target.role, target.org_id);
const response = NextResponse.json({
success: true,
impersonating: target.username,
role: target.role,
orgId: target.org_id,
});
response.headers.append('Set-Cookie', buildAuthCookie(targetToken));
response.headers.append('Set-Cookie', buildImpByCookie(adminToken));
return response;
} catch (err) {
console.error('[admin/impersonate] POST error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to impersonate' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
// No role check — anyone holding the imp-by cookie should be allowed to
// exit cleanly. (Without imp-by we have nothing to restore anyway.)
const cookies = parseCookies(request);
const restoreToken = cookies[IMP_BY_COOKIE];
if (!restoreToken) {
return NextResponse.json({ error: 'Not currently impersonating' }, { status: 400 });
}
const response = NextResponse.json({ success: true, restored: true });
response.headers.append('Set-Cookie', buildAuthCookie(restoreToken));
response.headers.append('Set-Cookie', clearImpByCookie());
return response;
}