import type { Handler } from 'hono'; import { getUserById } from 'officerdb'; import { sign } from '@@/jwt'; import * as errors from '@@/custom-errors'; import { clientIp } from '../auth/client-ip'; /** * Sign in AS another account, from user management. * * The support tool this is for: "it does not work for them and it does for me". Reproducing that with a * password reset means locking the person out of their own account to look at it, which is worse than * the bug. * * ── The token says whose it is, and who is really holding it ── * * `impersonatedBy` is on the token and reaches every handler, because `user-middleware` puts the whole * payload on the context. Without it an impersonated action is indistinguishable from the person's own * — in a log, in a chat transcript, in anything keyed on the user id — and a support tool that can * forge activity as somebody else is a different and much worse thing. * * ── One hour, not thirty days ── * * A session token lasts a month because a person uses their own account daily. Impersonation is a task * you finish. An hour is long enough to reproduce a problem and short enough that a forgotten tab stops * being a live credential for somebody else's account by lunchtime. * * ── What it cannot do ── * * It cannot chain: `ownerGate` refuses a request whose token carries `impersonatedBy`, so an * impersonated session cannot impersonate a third account, and — the case that matters — impersonating * another Super Admin does not hand back user management. Otherwise this route would be a way to launder * owner actions through somebody else's id. * * It also cannot outlive the account: `user-middleware` re-reads the row on every request and refuses a * deleted or non-Active user, exactly as it does for an ordinary session. * * ── What it deliberately does NOT do ── * * It does not touch the owner's own token. Returning to yourself is the client putting back what it * kept, so there is no server state to leak, expire or get wrong — and closing the tab is not a way to * be stranded as somebody else. */ export const impersonateUserHandler: Handler = async (ctx) => { const actor = ctx.get('user') as { id: number; email?: string; impersonatedBy?: unknown }; // Belt to `ownerGate`'s braces: that gate already refuses an impersonated token, and this is the route // where being wrong about it is worst, so it is stated here too rather than assumed one file away. if (actor.impersonatedBy) throw errors.FORBIDDEN('An impersonated session cannot impersonate'); const targetId = Number(ctx.req.param('id')); if (!Number.isInteger(targetId) || targetId <= 0) throw errors.BAD_REQUEST('A user id is required'); if (targetId === actor.id) throw errors.BAD_REQUEST('You are already yourself'); const target = await getUserById(targetId); if (!target) throw errors.NOT_FOUND('No such user'); // The same refusal sign-in makes. Impersonating a blocked account would be a way to use it. if (target.status !== 'Active') throw errors.FORBIDDEN(`That account is ${target.status}`); const { id, email, name, username } = target; const token = await sign({ id, email, name, username, impersonatedBy: { id: actor.id, email: actor.email } }, '1h'); // Same channel and shape as the distress revoke, so both land in one place when somebody asks who did // what. Names both parties: the id in every downstream record from here on will be the TARGET's. console.warn( `[SECURITY] impersonation started — actor=${actor.email ?? actor.id}, target=${email ?? id}, ` + `ip=${clientIp(ctx)} — ${new Date().toISOString()}`, ); return ctx.json({ token, user: { id, email, name, username }, expiresInMinutes: 60 }); };