Shuffle/docs/specs/1-auth-spike-ergebnisse.md
cschlaefke 395816609f feat(db): enforce registration rules and add terms_version
Adds display name validation, terms_version, locale sync trigger and
pgTAP tests (KTO-01 criteria 1, 2, 10, 11). Includes GoTrue spike results.

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:36:21 +01:00

18 KiB
Raw Blame History

Spike B0 – Ergebnisse zu GoTrue (Spec #1, KTO-01)

Issue #1 (KTO-01), Aufgabe B0
Spec docs/specs/1-auth-registrierung-anmeldung.md (§ 4.1, 4.3, 5.4, 7.1, 8.2, 9.1)
Datum 2026-11-02
Autor backend-dev
Versionen Supabase CLI 3.0.0, GoTrue v2.197.0 (Image public.ecr.aws/supabase/gotrue:v2.197.0), Postgres 17.11, Mailpit v1.31.3

Vorgehen und Grenzen der Aussagekraft

  • Die lokale CLI wurde mit einer temporären config.toml gestartet: enable_confirmations = true, minimum_password_length = 10, email_sent = 100, max_frequency = "1s", Vorlagen für Bestätigung und Reset mit .Data.locale-Verzweigung (Vorlagen und Änderungen sind wieder entfernt; config.toml im Repo ist unverändert, das ist Aufgabe B2).
  • Alle Requests liefen gegen http://127.0.0.1:54321/auth/v1 (Kong, wie die App). Die Drosselungstests (c, d) liefen zusätzlich gegen einen zweiten GoTrue-Container desselben Images im Netz der CLI, mit den Umgebungsvariablen des CLI-Containers plus GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For und GOTRUE_RATE_LIMIT_EMAIL_SENT=5. Das entspricht dem Self-Hosting-Pfad (Env statt config.toml).
  • Korrektur während des Spikes: Die erste Messung zu Krit. 4 zeigte 422 user_already_exists; die Ursache war GOTRUE_SMS_AUTOCONFIRM=true (CLI-Standard), nicht GoTrue selbst. Ein Wiederholungstest mit [auth.sms] enable_confirmations = true bestätigt das Verhalten der Spec (siehe A1).
  • Nicht aussagekräftig lokal: SMTP-Anbieter, Verhalten hinter dem echten Produktions-Gateway, Header-Behandlung durch den Proxy, Mailzustellung. Die Mail-Rate-Limits der CLI gelten nicht (siehe d).
  • Die Versionsprüfung (h) basiert auf der docker-compose.yml im Supabase-Repository (Stand heute, Branch master); eine eigene Self-Hosting-Instanz gibt es noch nicht.

Kurzfassung: Abweichungen von der Spec

# Abweichung Wirkung
A1 Der Enumerationsschutz (Krit. 4) hängt an einer weiteren Einstellung: GOTRUE_SMS_AUTOCONFIRM muss false sein. Die CLI-Vorlage setzt [auth.sms] enable_confirmations = false und damit GOTRUE_SMS_AUTOCONFIRM=true. Dann liefert Signup mit einer bestätigten Adresse 422 user_already_exists. Mit [auth.sms] enable_confirmations = true (GOTRUE_SMS_AUTOCONFIRM=false) kommt wie in der Spec 200 mit erfundenem Nutzer (zufällige ID, identities leer, keine Mail, Log user_repeated_signup). Neue Zeile AUTH-CFG-24 für B2/B3 (auth.sms.enable_confirmations = true, Env GOTRUE_SMS_AUTOCONFIRM=false). Die Selbsthosting-Vorlage docker/.env.example setzt ENABLE_PHONE_AUTOCONFIRM=true, die Falle gilt dort also auch. Die Spec-Annahme 4.3 stimmt dann.
A2 Ohne GOTRUE_RATE_LIMIT_HEADER gibt es gar keine IP-Drosselung (weder Login noch Signup noch Refresh). Auch mit gesetzter Konfiguration wird ein Request ohne diesen Header nicht gedrosselt (60 Fehllogins ohne X-Forwarded-For gegen den Container mit Header-Konfiguration: kein 429). Die lokale CLI kann den Header nicht setzen. AUTH-CFG-13 ist Pflicht, nicht optional. GoTrue darf nur über den Proxy erreichbar sein, der den Header setzt (sonst umgeht ein direkter Zugriff die Drosselung). Drosselungstests (Krit. 7) sind gegen die CLI nicht möglich (Frage Q1). Betrifft B2, B3, D1, D2, T3.
A3 email_sent wirkt in der lokalen CLI nicht (GOTRUE_RATE_LIMIT_EMAIL_SENT=360000, obwohl email_sent = 100 gesetzt war). Wirkt im Self-Hosting-Env und ist dort instanzweit. AUTH-CFG-09 lokal nicht testbar; Seiteneffekt auf Enumeration (siehe d).
A4 Trigger-Fehler kommen als HTTP 500 mit der Meldung des Triggers im Body ({"code":"P0001","message":"display_name_invalid"}), nicht als generisches „Database error saving new user“. Der Client kann den Grund unterscheiden. Verhalten ist versionsabhängig, siehe (f).
A5 Das JWT einer Recovery-Sitzung trägt amr = [{"method":"otp"}], identisch zur Sitzung nach Bestätigungslink. Es ist also nicht als Recovery erkennbar. Der amr-Weg aus 5.4 entfällt; es gilt R12 (Pfad „Wenn nein“).
A6 Für den Linkablauf zählt auth.users.confirmation_sent_at bzw. recovery_sent_at, nicht auth.one_time_tokens.created_at. T3 setzt die Spalten an auth.users zurück.
A7 /otp und /magiclink lassen sich nicht abschalten, und ein neues Konto per /otp ist möglich, wenn der Aufrufer gültige Metadaten (data) mitschickt. Ohne Metadaten blockiert der Trigger (500). AUTH-CFG-20 (Gateway-Sperre) bleibt nötig; der Trigger ersetzt sie nicht.

Alle übrigen Annahmen der Spec wurden bestätigt (Details unten).

Ergebnis je Punkt

(a) Betreffzeile und Vorlage werten .Data.locale aus

  • Annahme: Vorlage und Betreff lassen sich mit {{ if and .Data (eq (index .Data "locale") "de") }} verzweigen; sonst Englisch.
  • Test: Signup mit locale de, en und ohne locale; Mails in Mailpit gelesen. Danach profiles.locale auf en gesetzt (Sync-Trigger) und Passwort-Reset angefordert.
  • Ergebnis: bestätigt. de führte zu deutschem Betreff und Text, en und fehlende Angabe zu Englisch. Betreff und Text verzweigen gleich. Nach der Profiländerung auf en kam die Reset-Mail englisch (Sync-Trigger wirkt Ende-zu-Ende). index .Data "locale" ist sicher, wenn der Schlüssel fehlt (leer, kein Fehler). Die CLI liefert die Vorlagen per interner URL (GOTRUE_MAILER_TEMPLATES_*=http://…kong:8088/email/…), wie beim Self-Hosting.
  • Konsequenz B2: Der Rückfall „zweisprachiger Betreff“ ist nicht nötig. {{ .Data.locale }} und index funktionieren beide; für Else-Zweig index verwenden.

(b) type-Werte für token_hash

  • Test: POST /verify mit token_hash aus der Mail.
  • Ergebnis: bestätigt. Bestätigung: type=email und type=signup werden akzeptiert (beide getestet, Sitzung wird ausgestellt, email_confirmed_at gesetzt). Reset: type=recovery. Ein Token ist nach der Einlösung verbraucht: zweiter Versuch 403 otp_expired („Email link is invalid or has expired“).
  • Konsequenz B2/A2/A4: Bestätigungsvorlage mit type=email, Reset mit type=recovery. Der Client mappt otp_expired (403) auf linkInvalidOrExpired, auch für „schon benutzt“.

(c) Limiter beim Passwort-Login (AUTH-CFG-10)

  • Annahme: Passwort-Login nutzt den Token-Limiter (150 je 5 min je IP, Burst 30).
  • Test: Fehlgeschlagene Logins in Schleife. (1) Gegen die CLI über Kong: 600 Logins, 300 Refresh-Aufrufe, 80 Signups, 80 Recovers in wenigen Sekunden, kein einziges 429. (2) Gegen den Spike-Container mit GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For und fester Quell-IP: Login #31 wird mit 429 over_request_rate_limit abgelehnt (Burst 30). Nach 20 s waren wieder 10 Logins möglich (etwa 0,5 pro Sekunde, also 150 je 300 s). Signup: ebenfalls Burst 30, danach 429 (Limiter RATE_LIMIT_OTP = 30 je 5 min). Eine zweite Quell-IP war währenddessen nicht betroffen. Die 429-Antwort hat keinen Retry-After- oder X-RateLimit-*-Header, nur X-Sb-Error-Code: over_request_rate_limit.
  • Ergebnis: Limiter bestätigt, aber mit schwerwiegender Einschränkung (A2). Passwort-Login nutzt GOTRUE_RATE_LIMIT_TOKEN_REFRESH (150 je 5 min, Burst 30); Signup/Recover/Resend nutzen GOTRUE_RATE_LIMIT_OTP (30 je 5 min). Ohne GOTRUE_RATE_LIMIT_HEADER greift keiner der Limiter. Die CLI setzt den Header nicht und bietet keinen Schalter dafür; lokal und im CI (CLI) gibt es deshalb keine Drosselung. Zusätzlich zeigen die Audit-Logs ip_address: "".
  • Konsequenz:
    • AUTH-CFG-13 ist für Staging/Prod zwingend (sonst keine Drosselung von Passwort-Raten, Krit. 7). Der Proxy muss den Header setzen und Client-Werte überschreiben.
    • T3/D2: Der Drosselungstest (Krit. 7) kann nicht gegen die CLI laufen. Optionen siehe Frage Q1.
    • Die Tabelle AUTH-CFG-10/11: Env-Namen stimmen (…_TOKEN_REFRESH, …_OTP). Die Aussage „Signups, Recover, Resend teilen sich diesen Limiter“ ist nur für Signup geprüft; /recover wurde bei unbekannten Adressen nicht gedrosselt, weil keine Mail ausgelöst wird (nur das IP-Limit gilt, und das greift ohne Header nicht).

(d) email_sent instanzweit?

  • Test: Spike-Container mit GOTRUE_RATE_LIMIT_EMAIL_SENT=5. Sechs Signups mit verschiedenen Adressen und verschiedenen Quell-IPs.
  • Ergebnis: bestätigt, instanzweit. Signup 1–5 ok, ab Signup 6 429 over_email_send_rate_limit („email rate limit exceeded“) für alle IPs. Das gilt auch für /recover an bestehende Adressen. In der lokalen CLI wirkt email_sent nicht: der CLI-Container läuft mit GOTRUE_RATE_LIMIT_EMAIL_SENT=360000, obwohl config.toml 100 enthielt (die CLI-Doku nennt „Requires auth.email.smtp to be enabled“). Der Standard der CLI-Vorlage (email_sent = 2) greift also lokal ebenfalls nicht.
  • Neue Nebenwirkung auf Krit. 4: Ist das Mail-Limit erschöpft, antwortet /recover für eine bestehende Adresse mit 429, für eine unbekannte Adresse mit 200 (es wird keine Mail ausgelöst). Das ist ein Enumerations-Seitenkanal während der Sperre. Mögliche Gegenmaßnahmen: Limit höher setzen oder ein Proxy vor GoTrue. Siehe Frage Q2.
  • Konsequenz B2/B3: AUTH-CFG-09 nur im Env-Block wirksam; in config.toml den Wert dokumentieren, aber als „lokal ohne Wirkung“ kennzeichnen. R3 bleibt bestehen.

(e) Mailfänger

  • Ergebnis: Mailpit v1.31.3 (Container supabase_inbucket_shuffle, Name historisch). Web-Oberfläche und API: http://127.0.0.1:54324. Relevante Endpunkte: GET /api/v1/messages (Liste mit To[].Address, Subject, ID), GET /api/v1/message/{id} (HTML, Text), DELETE /api/v1/messages. SMTP-Port nach außen nur mit smtp_port in [local_smtp].
  • Konsequenz T3: Der Mail-Helfer spricht die Mailpit-API. Das Token steht im HTML (und im Text) doppelt; das erste Vorkommen genügt. Mails je Test per To filtern (zufällige Adressen), nicht per globalem Löschen, wenn Tests parallel laufen.

(f) Trigger-Fehler bei signUp

  • Test: Signup ohne display_name, ohne Altersbestätigung (age_confirmed:"false"), ohne terms_version.
  • Ergebnis: abweichend (A4). HTTP 500, Header X-Sb-Error-Code: unexpected_failure, Body {"code":"P0001","message":"display_name_invalid"} (bzw. age_confirmation_required, …). Der Text des Triggers wird also durchgereicht. Im GoTrue-Log steht zusätzlich „current transaction is aborted“. Es entsteht kein Nutzer.
  • Konsequenz A1/A2 (Flutter): registrationRejected darf an AuthException.message unterscheiden, ist aber nur zum Logging und für Zusatzhinweise gedacht; die Hauptvalidierung bleibt im Client. Das Format ist nicht stabil vertraglich zugesichert (bei GoTrue-Updates erneut prüfen; T3 sichert es mit einem API-Test ab).

(g) weak_password

  • Test: Signup mit 9 Zeichen.
  • Ergebnis: bestätigt. HTTP 422, error_code: weak_password, msg: "Password should be at least 10 characters.", weak_password.reasons = ["length"]. Mit password_requirements = "" kommt kein Grund characters.
  • Konsequenz A1: Mapping 4.4 stimmt.

(h) Versionen CLI und Self-Hosting-Image

  • Ergebnis: bestätigt. CLI 3.0.0 startet GoTrue v2.197.0. Die docker-compose.yml des Supabase-Repos (Branch master, heute) verwendet supabase/gotrue:v2.197.0. Das Image public.ecr.aws/supabase/gotrue:v2.197.0 ließ sich mit den Env-Variablen der CLI direkt starten (zusätzlich erforderlich: API_EXTERNAL_URL).
  • Konsequenz B3/D1: Das Self-Hosting-Image beim Aufsetzen auf v2.197.0 pinnen (nicht latest) und bei jedem Update die Punkte (c), (d), (f) wiederholen. Die Postgres-Version im Compose (17.6.1.136) weicht von der CLI (17.11.0.011) im Patchstand ab; Hauptversion 17 stimmt mit config.toml überein.

(i) Welche Spalte begrenzt die Linkgültigkeit?

  • Test: Neuen Reset angefordert, nur auth.users.recovery_sent_at um 2 h zurückgesetzt, Token eingelöst. Dann neuen Reset, nur auth.one_time_tokens.created_at um 2 h zurückgesetzt, Token eingelöst. Für die Bestätigung confirmation_sent_at ebenso.
  • Ergebnis: bestätigt für auth.users, abweichend von der Tabelle. Zurückgesetztes recovery_sent_at (bzw. confirmation_sent_at) ergibt 403 otp_expired. Zurückgesetztes one_time_tokens.created_at allein hat keine Wirkung (Einlösung gelang).
  • Konsequenz T3: e2e/support/db.ts setzt auth.users.recovery_sent_at (Reset) bzw. confirmation_sent_at (Bestätigung) auf now() - (otp_expiry + 1 s). Direktzugriff als postgres (Port 54322) genügt.

(j) amr im Recovery-JWT

  • Test: Access-Token nach verify mit type=recovery, nach verify mit type=email und nach Passwort-Login dekodiert.
  • Ergebnis: abweichend (A5). Recovery: amr = [{"method":"otp"}], aal1. Bestätigung: ebenfalls otp. Passwort-Login: password. Recovery ist von einer Bestätigungssitzung nicht unterscheidbar; nach dem Passwortwechsel in der Sitzung bleibt der Eintrag gleich.
  • Konsequenz A4/R12: Pfad „Wenn nein“ aus 5.4: Der Guard passwordRecovery lebt nur im laufenden Prozess (Ereignis passwordRecovery). Eine Ableitung aus amr = otp würde auch frisch bestätigte Konten in den Reset-Screen zwingen und ist nicht zulässig. R12 bleibt als akzeptiertes Restrisiko.

Weitere geprüfte Annahmen (4.3 und Tabelle 4.1)

Annahme Test Ergebnis
Krit. 4, bestätigte Adresse: 200 mit erfundenem Nutzer, keine Mail Signup mit bestätigter Adresse, zuerst mit CLI-Standard, dann mit [auth.sms] enable_confirmations = true Nur mit GOTRUE_SMS_AUTOCONFIRM=false bestätigt (A1): Dann 200, zufällige ID (nicht die des echten Nutzers), identities leer, keine Mail, kein neues Konto, das ursprüngliche Passwort bleibt gültig. Mit dem CLI-Standard (SMS_AUTOCONFIRM=true) kommt stattdessen 422 user_already_exists.
Krit. 4, unbestätigte Adresse: Mail erneut, Passwort und Metadaten unverändert Signup mit neuem Passwort und neuer locale für unbestätigte Adresse Bestätigt: 200 mit echtem Nutzer, Bestätigungsmail erneut, Passwort-Hash und user_metadata unverändert, kein zweites Konto.
Resend / Reset immer 200 /resend und /recover mit bestätigter, unbestätigter und unbekannter Adresse Bestätigt (200 {}); Mail geht nur an eine existierende, passende Adresse. Einschränkung bei Mail-Limit: siehe (d).
Krit. 6/7: email_not_confirmed erst nach richtigem Passwort Login unbestätigt mit richtigem/falschem Passwort, unbekannte Adresse Bestätigt: richtig → 400 email_not_confirmed; falsch und unbekannt → 400 invalid_credentials, identische Meldung.
Krit. 8: Reset-Link einmalig Zweites Einlösen Bestätigt (403 otp_expired).
Krit. 9: Reset beendet andere Sitzungen Sitzungen A, B; Reset über Recovery-Sitzung; Passwort geändert Bestätigt: Refresh-Token von A und B → refresh_token_not_found. Der alte Access-Token von A wird von GoTrue (/user) mit 403 abgelehnt, von PostgREST aber weiter akzeptiert (200), bis er abläuft. Das ist R8 (bis 60 min), bewusst akzeptiert.
same_password Reset mit altem Passwort Bestätigt: 422 same_password.
Passwortmindestlänge 10 Signup mit 9 Zeichen Bestätigt (siehe g).
Benachrichtigungen aus (Krit. 11) Passwortänderung beobachtet Bestätigt: Es kommt keine Mail. Der CLI-Standard ist „aus“; in config.toml trotzdem explizit setzen.
Magic Link abschaltbar (AUTH-CFG-20) /otp und /magiclink für bestätigten Nutzer; /otp für neue Adresse Nicht abschaltbar (A7): Bestätigter Nutzer bekommt Mail „Your sign-in link“. Neue Adresse ohne Metadaten: 500 durch den Trigger (age_confirmation_required), kein Nutzer. Neue Adresse mit gültigen Metadaten: Konto wird angelegt (unbestätigt, mit Passwort-Hash eines Zufallswerts) und eine Bestätigungsmail geht raus. Ein zweiter Aufruf innerhalb von max_frequency ergibt 429.
Zusätzliche Auffälligkeit – GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true (CLI-Standard) bleibt für KTO-11 offen (AUTH-CFG-23).

Konsequenzen für B2, B3 und die übrigen Aufgaben

  • B2: Alle Werte aus 4.1 in config.toml. email_sent und die Rate-Limit-Einstellungen sind lokal wirkungslos bzw. nur mit Header wirksam; das im Kommentar kenntlich machen. Vorlagen: type=email / type=recovery, index .Data "locale".
  • B3: Der Env-Block für Self-Hosting muss GOTRUE_RATE_LIMIT_HEADER als Pflicht aufführen, plus GOTRUE_RATE_LIMIT_EMAIL_SENT, …_TOKEN_REFRESH, …_OTP, …_VERIFY. Release-Checkliste: Drosselung am echten Gateway mit 31 Fehllogins einer IP prüfen; Test, dass ein gefälschter X-Forwarded-For überschrieben wird.
  • Spec-Anpassungen (nach Entscheidung von Chris): 4.3 (Krit. 4, Enumeration), 5.4 (amr), 7.1 (Krit. 7, 8, 4), 8.2 (B0-Text), R2, R3, R6, R12.
  • B2/B3 (neu, A1): Zeile AUTH-CFG-24 ergänzen: auth.sms.enable_confirmations = true bzw. GOTRUE_SMS_AUTOCONFIRM=false (SMS ist ausgeschaltet, die Einstellung steuert trotzdem den Enumerationsschutz bei E-Mail-Signup). T3 sichert das mit einem API-Test für Krit. 4 ab (bestätigte Adresse → 200, identities leer).
  • T3: Drosselungstests nur gegen einen GoTrue mit gesetztem Header und Requests mit Header (Q1).
  • KTO-11-Hinweis: user_metadata.locale kann der Nutzer selbst über auth.updateUser(data: …) ändern, die Mailsprache kann so von profiles.locale abweichen (Wirkung nur auf das eigene Konto). Der Client ändert die Sprache ausschließlich über profiles; der Sync-Trigger zieht user_metadata bei der nächsten Profiländerung nach.

Offene Fragen an Chris

# Frage Hintergrund
Q1 Krit. 7: Die lokale CLI kann keine IP-Drosselung (kein RATE_LIMIT_HEADER). Soll der Drosselungstest im CI gegen einen zusätzlich gestarteten GoTrue-Container mit gesetztem Header laufen (wie in diesem Spike), oder nur als manuelle Prüfung auf Staging? A2, Aufwand für D2/T3.
Q2 Seitenkanal bei erschöpftem Mail-Limit (/recover bestehende Adresse 429, unbekannte 200). Akzeptiert (Limit 30/h, Angriff erzeugt vor allem Ausfälle), oder soll das Limit höher gesetzt bzw. durch einen Proxy ergänzt werden? (d), R3, R6.