@redbroomsoftware/dev-impersonation
v0.1.0
Published
Time-boxed, HMAC-signed support/developer impersonation tokens. Pylon/Frontegg-style support tooling primitive.
Maintainers
Readme
@redbroomsoftware/dev-impersonation
Time-boxed, HMAC-signed support / developer impersonation tokens for multi-app SaaS deployments. A small, dependency-free primitive in the spirit of Pylon and Frontegg — the control-plane mints a short-lived signed token; the target app verifies it and mints a bounded session cookie so a support engineer can "act as" a customer without going through their login flow.
- HMAC-SHA256, constant-time comparison (
crypto.timingSafeEqual). - Cross-domain token + same-domain cookie, both from one shared secret.
- Generic over your payload type — bring your own
{ agent, target, ... }. - Zero runtime dependencies. Pure Node
node:crypto. - Apache 2.0 licensed.
Install
npm install @redbroomsoftware/dev-impersonationQuickstart
// === support-tool / control-plane side ===
import { createIssuer } from '@redbroomsoftware/dev-impersonation';
interface Payload {
agentEmail: string;
targetTenantId: string;
reason: string;
}
const issuer = createIssuer<Payload>({
secret: process.env.IMPERSONATE_SECRET!,
tokenTtlMs: 5 * 60 * 1000, // 5 minutes
});
const token = issuer.issueToken({
agentEmail: '[email protected]',
targetTenantId: 'tenant-42',
reason: 'PMS migration walkthrough',
});
// Hand the agent a one-shot URL.
const redirectUrl = `https://app.example.com/impersonate?token=${token}`;// === target-app / consumer side ===
import { createReceiver } from '@redbroomsoftware/dev-impersonation';
const receiver = createReceiver<Payload>({
secret: process.env.IMPERSONATE_SECRET!,
cookieName: 'support_session',
cookieTtlMs: 60 * 60 * 1000, // 1 hour
validatePayload: (p) => p.agentEmail.endsWith('@acme.com'),
});
// 1) Receiver route — runs once when the agent lands.
export async function GET(req: Request) {
const token = new URL(req.url).searchParams.get('token') ?? '';
const verify = receiver.verifyToken(token);
if (!verify.valid) return new Response(`invalid: ${verify.reason}`, { status: 401 });
const { name, value, attributes } = receiver.createSessionCookie(verify.payload);
return new Response(null, {
status: 303,
headers: {
Location: `/dashboard?impersonated_by=${encodeURIComponent(verify.payload.agentEmail)}`,
'Set-Cookie': serializeCookie(name, value, attributes),
},
});
}
// 2) Middleware — runs on every request to detect an active impersonation.
export function readImpersonation(req: Request) {
const cookie = readCookie(req, receiver.cookieName);
const result = receiver.verifyCookie(cookie);
return result.valid ? result.payload : null;
}(serializeCookie / readCookie are intentionally out of scope here — use
your framework's primitives or the cookie npm package.)
API
createIssuer<T>({ secret, tokenTtlMs? })
Returns { issueToken(payload: T): string }. The returned token is a
base64url-encoded { payload, sig } envelope where payload has had nbf
(not-before, ms) and exp (expiry, ms) merged in by the issuer.
| Option | Default | Notes |
| ------------- | ------------ | ---------------------------------------------- |
| secret | — (required) | At least 32 bytes of entropy. Treat as a key. |
| tokenTtlMs | 900_000 | 15 minutes. Tokens are deliberately short. |
createReceiver<T>({ secret, cookieName?, cookieTtlMs?, cookieAttributes?, validatePayload? })
Returns:
{
cookieName: string;
verifyToken(token: string): VerifyResult<T>;
createSessionCookie(payload: T & { exp; nbf }): CookieMintResult;
verifyCookie(cookieValue: string | undefined | null): VerifyResult<T>;
}| Option | Default |
| ------------------- | ---------------------------------------------------- |
| secret | — (required, must match issuer) |
| cookieName | 'impersonation_session' |
| cookieTtlMs | 3_600_000 (1 hour, capped at payload exp) |
| cookieAttributes | { path: '/', httpOnly: true, secure: true, sameSite: 'lax' } |
| validatePayload | none — accepts any signed payload |
VerifyResult<T> is a discriminated union:
type VerifyResult<T> =
| { valid: true; payload: T & { exp: number; nbf: number } }
| { valid: false; reason: 'invalid_encoding' | 'malformed_envelope'
| 'invalid_signature' | 'expired'
| 'not_yet_valid' | 'custom_reject' };Security notes
- HMAC-SHA256 signing over
JSON.stringify(payload). Tampering any field (including the injectedexp/nbf) invalidates the signature. - Constant-time signature comparison via
crypto.timingSafeEqual. The helper short-circuits on length mismatch to sidestep Node's throw-on-mismatch behaviour. - Two clocks, one secret: token
expis checked atverifyToken, and the cookie carries the sameexpfield which is re-checked atverifyCookie. CookiemaxAgeis capped atpayload.exp - nowso the browser drops the cookie when the underlying authority lapses. - No replay store. Reuse is bounded by
exp; if you need single-use semantics, layer ajtifield into your payload and dedupe at the receiver viavalidatePayload+ your own datastore. - Use a long, random secret (32+ bytes). Rotate by deploying issuer and receiver simultaneously, or support both old and new secrets with a small shim at the receiver.
Why this exists
This package is extracted from Red Broom Software's internal multi-app SaaS
ecosystem (21 apps, one developer, ~3 paying tenants pre-PMF). Our support
console at hub.redbroomsoftware.com issues impersonation tokens; each
consuming app (caracol, garita, comal, camino, constanza, ...) verifies
them at /dev-access-hub. We open-sourced the primitive because (a) we needed
to anyway to share the contract publicly, and (b) the moving parts here are
generic — any team running more than one app behind a single support team will
end up writing this twice.
The internal RBS-specific wrapper is in
@r-bsoftware/ecosystem-sdk (createHubDevAccessReceiver); it's a thin layer
that hard-codes the cookie name and payload shape we use across the ecosystem.
License
Apache License 2.0. See LICENSE.
