@all-or-nothing-hc/impersonate
v0.2.2
Published
Generic Supabase admin "login as user" (impersonation) — Edge Function + React hook + banner component.
Downloads
78
Readme
impersonate
Generisches Supabase "Admin-Login-als-User" (Assume Identity / Impersonation) — eine Edge Function + ein React-Hook + eine Banner-Komponente. Für mehrere Projekte gedacht (Vocab, Taskly, Trainingsapps) statt in ein einzelnes Projekt fest verdrahtet.
Status: läuft produktiv in Taskly (Start+Stop-Roundtrip bestätigt, 2026-09-03). Dieses Repo selbst ist gerade im Umbau von reiner Datei-Kopie zu echtem npm-Package (
@all-or-nothing-hc/impersonate, öffentlich auf npm) — Taskly nutzt aktuell noch eine lokale Kopie von Client- und Server-Code, siehe "Distribution" unten. Ein Logik-Review- Spike (2026-08-26) hat mehrere Bugs gefunden und behoben (siehe unten unter "Sicherheitsannahmen").
Wie es funktioniert
Kein Token/keine Session wird jemals clientseitig zwischengespeichert.
Das einzige, was lokal (in localStorage) liegt, ist eine nicht-geheime
Referenz — die id der Audit-Log-Zeile (auditLogId). Start und Stop sind
beide symmetrische Session-Mint-Roundtrips über den Server; die Datenbank
ist die alleinige Quelle der Wahrheit dafür, wer gerade wen impersoniert.
- Admin ruft
useImpersonation().startImpersonation(targetUserId)auf. - Der Hook ruft die Edge Function (
action: 'start') mit dem eigenen Admin-Token auf. - Die Function prüft serverseitig: ist der Aufrufer Admin? Ist das Ziel im
gleichen Scope (Standard:
family_id)? Ist das Ziel selbst kein Admin? Erst dann wird eine Session für das Ziel gemintet und ein Audit-Log- Eintrag geschrieben (dessenidan den Client zurückgeht). - Der Client übernimmt die zurückgegebene Session direkt
(
supabase.auth.setSession({ access_token, refresh_token })) — ab hier läuft die App exakt wie für den echten Ziel-User, mit allen echten Konsequenzen. Lokal wird nur{ auditLogId, email, startedAt }inlocalStorageabgelegt (Keyimpersonate:active), zusammen mit einem Auto-Stop-Timer (maxDurationMs, Default 30 Minuten), der auch über einen Reload hinweg korrekt weiterläuft (berechnet ausstartedAt). - Weil
localStorage(nichtsessionStorage) verwendet wird, sieht ein neuer Tab im selben Browser die aktive Impersonation sofort — einstorage-Event-Listener hält alle offenen Tabs synchron (Banner erscheint/verschwindet überall gleichzeitig, Timer wird überall neu geplant). stopImpersonation()ruft die Function (action: 'end') mit der aktuell aktiven Session (also der des Ziel-Users) und der gestashtenauditLogIdauf. Die Function verifiziert, dass der Aufrufer wirklich der impersonierte Ziel-User ist, setztended_atim Audit-Log (best effort) und mintet eine frische Session für den ursprünglichen Admin. Der Client übernimmt auch diese Session ganz normal übersetSession()— kein Unterschied zum Start-Vorgang, nur in umgekehrter Richtung. Schlägt irgendein Schritt fehl, wird niemals stillschweigend die Ziel-User-Session belassen: als Fallback greiftsupabase.auth.signOut().
Session-Minting ist pluggable
Wie die Function eine Session tatsächlich mintet, ist server-seitig über
sessionMint konfigurierbar (ImpersonateHandlerConfig in
src/server/index.ts) — der Client sieht in beiden Fällen dieselbe
{ accessToken, refreshToken }-Antwort und muss die Strategie nicht kennen:
{ strategy: 'generate-link' }(Standard). Der normale Weg für Supabase Cloud und jedes gesunde self-hosted GoTrue:admin.generateLink()erzeugt einen Magic-Link-Token, der serverseitig sofort perverifyOtp()gegen eine echte Session eingelöst wird.{ strategy: 'direct-db', jwtSecret, dbUrl, sessionTtlSeconds? }(optional). Workaround für self-hosted GoTrue-Instanzen, die den Upstream-Bug supabase/auth#2057 (Regression aus v2.175.0) treffen — dort schlägtadmin.generateLink()für JEDEN existierenden User mitduplicate key value violates unique constraint "users_email_partial_key"fehl. Diese Strategie signiert den Access-Token selbst (HS256, gleicher Secret wie GoTrue —GOTRUE_JWT_SECRET/PGRST_JWT_SECRET) und schreibt Session + Refresh-Token direkt per Postgres-Verbindung inauth.sessions/auth.refresh_tokens. Das Paketpostgresist eine reguläre Dependency (nicht optional) — läuft beim Auflösen vonnpm:@all-or-nothing-hc/impersonate/serverautomatisch mit, ohne dass Projekte, die diese Strategie gar nicht nutzen, dadurch nennenswert mehr installieren. (War in 0.1.x/0.2.0 fälschlich eine optionale Peer- Dependency — Denos npm-Resolver löst optionale Peer-Deps nicht auf, solange nichts im Import-Graph sie referenziert, wasdirect-dbbeim ersten echten Einsatz kaputt gemacht hat. Seit 0.2.1 gefixt.) Supabase- Cloud-Projekte treffen diesen Bug vermutlich nicht und sollten beim Standardpfad bleiben.
Setup pro Projekt
1. Edge Function deployen
supabase functions deploy impersonate --project-ref <dein-projekt-ref>Falls eure Tabellen-/Spaltennamen von den Defaults abweichen, oder um die
CORS-Origin einzuschränken (dringend empfohlen vor Produktiveinsatz —
Default ist *), als Secrets setzen:
supabase secrets set \
IMPERSONATE_ROLES_TABLE=user_roles \
IMPERSONATE_ADMIN_COLUMN=is_admin \
IMPERSONATE_SCOPE_COLUMN=family_id \
IMPERSONATE_USER_ID_COLUMN=user_id \
IMPERSONATE_AUDIT_TABLE=impersonation_log \
IMPERSONATE_ALLOWED_ORIGIN=https://eure-app.example.comNur nötig auf self-hosted GoTrue-Instanzen, die den generateLink-Bug
(supabase/auth#2057) treffen — sonst weglassen, Default bleibt
generate-link:
supabase secrets set \
IMPERSONATE_SESSION_STRATEGY=direct-db \
JWT_SECRET=<gleicher-wert-wie-GOTRUE_JWT_SECRET> \
SUPABASE_DB_URL=<direkte-postgres-connection-string> \
IMPERSONATE_SESSION_TTL_SECONDS=3600postgres müsst ihr dafür nicht separat einrichten — ist eine reguläre
Dependency des Pakets, läuft beim Import über
npm:@all-or-nothing-hc/impersonate/server automatisch mit.
Nicht-boolesche Admin-Spalte (z.B. eine role-Textspalte statt eines
booleschen is_admin):
supabase secrets set \
IMPERSONATE_ADMIN_COLUMN=role \
IMPERSONATE_ADMIN_VALUE=adminIMPERSONATE_ADMIN_VALUE ist der Wert, der adminColumn mit ===
vergleicht (Default: true, passend für eine boolesche Spalte).
Kein Scope-Konzept (z.B. genau eine globale Admin-Ebene ohne
family_id/org_id-Äquivalent — Vorsicht, das ist eine echte
Autorisierungs-Entscheidung, kein Config-Detail, siehe Kommentar bei
scopeColumn in src/server/index.ts):
supabase secrets set IMPERSONATE_SCOPE_COLUMN=Ein explizit leerer Wert deaktiviert den Scope-Check komplett — dann darf
jeder Admin jeden Nicht-Admin impersonieren. Env-Var einfach weglassen
lässt den Default (family_id) aktiv.
2. Migration übernehmen
supabase/migrations/0001_impersonation_log.sql in die eigene
Migrations-Historie des Projekts kopieren/anpassen und ausführen.
3. Paket installieren
npm install @all-or-nothing-hc/impersonate4. Im Frontend verwenden
import { useImpersonation, ImpersonationBar } from '@all-or-nothing-hc/impersonate'
import { supabase } from './lib/supabase'
function AdminShell() {
// functionName defaults to 'impersonate' — only pass it if you deployed
// under a different name. The hook calls supabase.functions.invoke()
// under the hood, so it inherits the URL/apikey already configured on
// your `supabase` client — no separate function URL to build by hand.
const impersonation = useImpersonation({ supabase })
return (
<>
<ImpersonationBar
isImpersonating={impersonation.isImpersonating}
impersonatingEmail={impersonation.impersonatingEmail}
onStop={impersonation.stopImpersonation}
/>
{/* ... restliche App ... */}
</>
)
}5. Eigene Edge Function statt supabase functions deploy aus diesem Repo
Wer die Function lieber im eigenen Projekt-Repo deployt (z.B. weil dort
schon eine CI dafür existiert), kann sie als dünnen Wrapper um das
./server-Subpath des Pakets bauen, statt Schritt 1 aus diesem Repo zu
nutzen:
// supabase/functions/impersonate/index.ts im eigenen Projekt
import { createImpersonateHandler, impersonateConfigFromEnv } from 'npm:@all-or-nothing-hc/impersonate/server'
Deno.serve(createImpersonateHandler(impersonateConfigFromEnv()))impersonateConfigFromEnv() liest dieselben IMPERSONATE_*/SUPABASE_*/
JWT_SECRET-Secrets wie oben. Alternativ createImpersonateHandler({...})
direkt mit einem Objekt aufrufen statt über Env-Vars zu konfigurieren.
Sicherheitsannahmen (nicht verhandelbar, siehe Edge Function)
- Aufrufer muss
is_admin === truesein. - Ziel-User muss im gleichen Scope sein (Spalte konfigurierbar).
- Ziel-User darf selbst kein Admin sein (Privilege-Escalation-Schutz).
- Jeder Start-Aufruf wird auditiert (
impersonation_log), inklusiveended_at. maxDurationMs(Default 30 Min) stoppt eine vergessene, offene Session automatisch — aber nur clientseitig, solange der Tab offen bleibt. Das ist keine echte Sicherheitskontrolle, nur ein Sicherheitsnetz gegen "Tab offen vergessen".- Es werden keine Auth-Tokens oder Session-Objekte im Browser
zwischengespeichert — weder für den Ziel-User noch für den Admin. Die
einzige lokal gehaltene Information ist eine nicht-geheime UUID
(
auditLogId), die ohne den serverseitigen DB-Lookup + Live-Auth-Check wertlos ist. Wichtig dabei (Fix 2026-09-03, siehe Deep-Dive-Review):action: 'end'prüft zusätzlich, dass die Session noch nicht beendet ist (ended_at == null) — ohne diesen Check hätte jeder, der eine alteauditLogIdUND ein gültiges JWT des damaligen Ziel-Users kennt, beliebig oft eine frische Admin-Session nachmagen können, auch lange nach dem eigentlichen Ende, und ohne dass das im Audit-Log auftaucht (ein Replay liest nur die bestehende Zeile, schreibt keine neue). Genau dieser Check macht die "nicht-geheim, weil wertlos ohne Server-Lookup"-Aussage oben erst wahr. - Ein Start ohne funktionierenden Audit-Log-Eintrag ist unmöglich
(fail-closed): der Audit-Insert passiert, bevor irgendein Token gemintet
wird; schlägt er fehl, wird der Start abgebrochen. Schlägt danach das
Minten fehl, wird die Audit-Zeile wieder best-effort gelöscht (kein
Datenmüll für nie gewährte Sessions). Das Schließen (
ended_at) am Ende bleibt bewusst best-effort — die Session ist zu diesem Zeitpunkt schon auditiert, nur der Abschluss-Zeitstempel könnte fehlen, und den Admin deswegen als Ziel-User stranden zu lassen wäre schlimmer. - Alle Autorisierungs-Ablehnungen (Aufrufer kein Admin, Ziel nicht gefunden, Ziel ist selbst Admin, Ziel außerhalb des Scopes, Aufrufer ist nicht der Session-Ziel-User beim Beenden) liefern nach außen dieselbe generische Meldung ("Not authorized"). Der genaue Grund landet nur im Server-Log — von außen lässt sich nicht mehr per Trial-and-Error herausfinden, ob ein fremder User existiert, Admin ist oder in welchem Scope er liegt.
Design-Entscheidungen aus dem Logik-Review-Spike (2026-08-26) — alle drei erledigt
- Session-Persistenz über Tab-Grenzen hinweg — entschieden: keine lokalen Sessions mehr, alles über die DB. Siehe "Wie es funktioniert" oben.
- Audit-Log-Fail-Open — entschieden: fail-closed beim Start (kein
Audit-Eintrag → kein Token, kein Start), best-effort/fail-open bleibt nur
beim
ended_at-Update am Ende (bewusste Asymmetrie, siehe oben unter "Sicherheitsannahmen"). - Fehlermeldungs-Granularität — entschieden: eine generische Autorisierungs-Meldung für alle Ablehnungsgründe, Details nur im Server-Log.
Distribution
Öffentliches npm-Paket (@all-or-nothing-hc/impersonate, publishConfig.access: "public")
— öffentlich, damit Deno-Edge-Functions es ohne Registry-Auth-Token per
npm:-Spezifikation direkt importieren können (npm:@all-or-nothing-hc/impersonate/server).
Enthält keine Secrets; alle projektspezifischen Werte (Tabellennamen,
JWT-Secret, DB-URL) kommen über Config/Env-Vars vom Konsumenten. Zwei
Subpaths: . (React-Hook + Banner-Komponente, src/index.ts) und
./server (Edge-Function-Handler, src/server/index.ts).
Konsumierende Projekte werden per Dependabot (.github/dependabot.yml,
npm-Ecosystem) auf neue Versionen hingewiesen — bewusst kein Silent-Auto-
Update; ein PR mergen ist gleichzeitig die Benachrichtigung, dass
aktualisiert wurde. npm publish selbst läuft immer über Carstens eigenen
npm-Account, nicht automatisiert.
