fix(auth): address review findings for smtp-neutral handling
Some checks failed
CI / flutter (pull_request) Failing after 44s
CI / web-build (pull_request) Successful in 51s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 29s
CI / e2e (pull_request) Successful in 2m44s

Refs #37

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 12:24:25 +01:00
parent de093b68ed
commit 3805c71225
14 changed files with 168 additions and 56 deletions

View file

@ -7,8 +7,11 @@ enum WeakPasswordReason { length, characters, pwned, unknown }
/// Grund einer serverseitig abgelehnten Registrierung (Trigger-Meldung).
///
/// Nur zum Loggen und für Zusatzhinweise: Das Format der Meldung ist nicht
/// zugesichert (Spec #1 § 4.4), unbekannte Meldungen ergeben [unknown].
/// Die Meldung entscheidet über Banner oder neutrale Seite (Spec #1 § 4.4,
/// F13): Nur ein HTTP 500 mit bekannter Trigger-Meldung wird zu
/// `registrationRejected` mit Grund, jedes andere 500 (z. B. SMTP-Ausfall)
/// zu `unexpected` und ist neutral. Das Format der Meldung ist nicht
/// zugesichert, unbekannte Meldungen ergeben [unknown].
enum RegistrationRejectionReason {
displayName,
ageConfirmation,

View file

@ -12,15 +12,19 @@ enum ResendOrigin {
/// Wendet die Regel aus F12/F13 für „E-Mail erneut senden“ an.
///
/// `null` heißt: wie Erfolg behandeln. Sichtbar bleiben nur Eingabe- und
/// Transportfehler sowie die kontounabhängige IP-Drosselung (Allowlist);
/// alles andere (auch `unexpected`, z. B. SMTP-Ausfall) ist neutral, weil es
/// zwischen bekannter und unbekannter Adresse unterscheiden kann. Aus dem
/// LoginScreen wird `emailSendLimited` zum sichtbaren Drosselungshinweis
/// ([RateLimited]).
/// Transportfehler sowie die kontounabhängige IP-Drosselung (Allowlist).
/// Alles andere (auch `unexpected`, z. B. SMTP-Ausfall) ist auf dem
/// CheckEmailScreen neutral, weil es zwischen bekannter und unbekannter
/// Adresse unterscheiden kann. Im LoginScreen ist das Konto durch das richtige
/// Passwort bewiesen: `unexpected` bleibt sichtbar, `emailSendLimited` wird
/// zum Drosselungshinweis ([RateLimited]).
AuthFailure? resendFailureFor(AuthFailure failure, ResendOrigin origin) {
return switch (failure) {
EmailSendLimited() =>
origin == ResendOrigin.login ? const AuthFailure.rateLimited() : null,
// Login: the password was right, the account is proven, so a server
// failure (e.g. SMTP outage) is shown instead of hidden (S4).
Unexpected() => origin == ResendOrigin.login ? failure : null,
InvalidEmail() || RateLimited() || NetworkFailure() => failure,
_ => null,
};
@ -35,8 +39,12 @@ AuthFailure? resendFailureFor(AuthFailure failure, ResendOrigin origin) {
AuthFailure? openEmailInputFailureFor(AuthFailure failure) => switch (failure) {
InvalidEmail() ||
WeakPassword() ||
RegistrationRejected() ||
RateLimited() ||
NetworkFailure() => failure,
// Defense in depth: only with a known trigger reason (the account-dependent
// INSERT trigger); an unknown reason could be an SMTP failure.
RegistrationRejected(:final reason)
when reason != RegistrationRejectionReason.unknown =>
failure,
_ => null,
};

View file

@ -13,8 +13,8 @@ abstract class ResendState with _$ResendState {
const factory ResendState({
@Default(false) bool isSending,
/// The last request counts as sent (also for `emailSendLimited` from the
/// check-email screen, F12).
/// The last request counts as sent (also for `emailSendLimited` and server
/// failures from the check-email screen, F12/F13).
@Default(false) bool sent,
AuthFailure? failure,
@ -26,8 +26,9 @@ abstract class ResendState with _$ResendState {
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
///
/// The caller states where the request comes from: only there the controller
/// decides whether `emailSendLimited` is shown (login) or treated as success
/// (check-email screen).
/// decides (`resendFailureFor`): `emailSendLimited` is shown at login and
/// treated as success on the check-email screen; `unexpected` (e.g. SMTP
/// outage) is shown at login (account proven) and neutral on check-email.
@riverpod
class ResendController extends _$ResendController {
@override

View file

@ -10,7 +10,9 @@ part 'forgot_password_controller.g.dart';
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
///
/// The reaction is the same for known and unknown addresses: every 200
/// response, and `emailSendLimited`, count as success.
/// response, `emailSendLimited` and server failures (`unexpected`, e.g. SMTP
/// outage, F13) count as success. Only input, transport and IP-throttling
/// failures are shown (`openEmailInputFailureFor`).
@riverpod
class ForgotPasswordController extends _$ForgotPasswordController {
@override
@ -27,7 +29,7 @@ class ForgotPasswordController extends _$ForgotPasswordController {
/// Validates and requests the reset mail. Returns `true` if the screen
/// should continue to "check your inbox" (every 200 response, and
/// `emailSendLimited`, F12).
/// `emailSendLimited` or a server failure, F12/F13).
Future<bool> submit() async {
if (state.isSubmitting) {
return false;

View file

@ -80,7 +80,8 @@ class RegisterController extends _$RegisterController {
}
/// Validates and submits. Returns `true` if the screen should continue to
/// "check your inbox" (every 200 response, and `emailSendLimited`, F12).
/// "check your inbox" (every 200 response, and `emailSendLimited` or a
/// server failure such as an SMTP outage, F12/F13).
Future<bool> submit() async {
if (state.isSubmitting) {
return false;

View file

@ -151,6 +151,73 @@ void main() {
);
});
group('F13: SMTP outage (HTTP 500) through the repository', () {
const smtpBody =
'{"code":500,"error_code":"unexpected_failure",'
'"msg":"Error sending confirmation email"}';
final smtp = AuthRetryableFetchException(
message: smtpBody,
statusCode: '500',
);
test('signUp -> unexpected', () async {
when(
() => auth.signUp(
email: any(named: 'email'),
password: any(named: 'password'),
data: any(named: 'data'),
),
).thenThrow(smtp);
expect(
(await repository.signUp(_input)).errorOrNull,
const AuthFailure.unexpected(),
);
});
test('resend -> unexpected', () async {
when(
() => auth.resend(
type: any(named: 'type'),
email: any(named: 'email'),
),
).thenThrow(smtp);
expect(
(await repository.resendConfirmation('anna@example.com')).errorOrNull,
const AuthFailure.unexpected(),
);
});
test('resetPasswordForEmail -> unexpected', () async {
when(() => auth.resetPasswordForEmail(any())).thenThrow(smtp);
expect(
(await repository.requestPasswordReset('anna@example.com')).errorOrNull,
const AuthFailure.unexpected(),
);
});
test('signIn with 500/502/504 stays network', () async {
for (final status in ['500', '502', '504']) {
when(
() => auth.signInWithPassword(
email: any(named: 'email'),
password: any(named: 'password'),
),
).thenThrow(
AuthRetryableFetchException(message: 'x', statusCode: status),
);
expect(
(await repository.signIn('anna@example.com', 'x')).errorOrNull,
const AuthFailure.network(),
reason: status,
);
}
});
});
group('other calls', () {
test(
'KTO-01 Kriterium 6: resend uses OtpType.signup and a trimmed email',

View file

@ -18,12 +18,15 @@ void main() {
);
});
test('F13: server-side failures (SMTP outage) count as success', () {
test('F13: on the check-email screen unexpected (SMTP) is neutral', () {
expect(
resendFailureFor(
const AuthFailure.unexpected(),
ResendOrigin.checkEmail,
),
isNull,
);
for (final origin in ResendOrigin.values) {
expect(
resendFailureFor(const AuthFailure.unexpected(), origin),
isNull,
);
expect(
resendFailureFor(const AuthFailure.emailNotConfirmed(), origin),
isNull,
@ -31,6 +34,13 @@ void main() {
}
});
test('S4: at login (account proven) unexpected stays visible', () {
expect(
resendFailureFor(const AuthFailure.unexpected(), ResendOrigin.login),
const AuthFailure.unexpected(),
);
});
test('input errors stay visible', () {
for (final origin in ResendOrigin.values) {
expect(
@ -40,7 +50,7 @@ void main() {
}
});
test('other failures pass through unchanged', () {
test('allowlisted failures stay visible', () {
for (final origin in ResendOrigin.values) {
expect(
resendFailureFor(const AuthFailure.rateLimited(), origin),
@ -90,6 +100,13 @@ void main() {
}
});
test('registrationRejected without a known reason is neutral', () {
expect(
openEmailInputFailureFor(const AuthFailure.registrationRejected()),
isNull,
);
});
test('rateLimited stays visible', () {
expect(
openEmailInputFailureFor(const AuthFailure.rateLimited()),

View file

@ -74,17 +74,27 @@ void main() {
});
});
group('F13: SMTP outage (unexpected) counts as sent', () {
for (final origin in ResendOrigin.values) {
test('from $origin', () async {
stub(failed(const AuthFailure.unexpected()));
group('F13: SMTP outage (unexpected)', () {
test('from the check-email screen counts as sent', () async {
stub(failed(const AuthFailure.unexpected()));
await notifier().resend('anna@example.com', origin: origin);
await notifier().resend(
'anna@example.com',
origin: ResendOrigin.checkEmail,
);
expect(state().sent, isTrue);
expect(state().failure, isNull);
});
}
expect(state().sent, isTrue);
expect(state().failure, isNull);
});
test('from the login screen stays visible (account proven)', () async {
stub(failed(const AuthFailure.unexpected()));
await notifier().resend('anna@example.com', origin: ResendOrigin.login);
expect(state().sent, isFalse);
expect(state().failure, const AuthFailure.unexpected());
});
});
group('KTO-01 Kriterium 4, 6 (F12): emailSendLimited', () {

View file

@ -388,29 +388,20 @@ void main() {
expect(find.byKey(const Key('login_resent_banner')), findsNothing);
});
testWidgets('F13: an SMTP failure on resend shows the success feedback', (
testWidgets('S4: a server failure on resend stays visible at login', (
tester,
) async {
await open(tester);
await fill(tester);
await submit(tester);
await tester.tap(find.text(_de.authActionResendConfirmation));
await tester.pumpAndSettle();
final success = visibleTexts(tester);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => failed(const AuthFailure.unexpected()));
await open(tester);
await fill(tester);
await submit(tester);
await tester.tap(find.text(_de.authActionResendConfirmation));
await tester.pumpAndSettle();
expect(visibleTexts(tester), success);
expect(find.byKey(const Key('login_resent_banner')), findsOneWidget);
expect(find.text(_de.authErrorUnexpected), findsNothing);
expect(find.text(_de.authErrorUnexpected), findsOneWidget);
expect(find.byKey(const Key('login_resent_banner')), findsNothing);
});
testWidgets('the resend action is locked while sending', (tester) async {

View file

@ -389,7 +389,9 @@ void main() {
group('Zustände Kriterium 2, 3: failures keep the input', () {
for (final failure in [
const AuthFailure.network(),
const AuthFailure.registrationRejected(),
const AuthFailure.registrationRejected(
reason: RegistrationRejectionReason.displayName,
),
]) {
test('${failure.runtimeType} becomes a banner', () async {
when(() => repository.signUp(any()))

View file

@ -398,7 +398,11 @@ void main() {
tester,
) async {
when(() => repository.signUp(any())).thenAnswer(
(_) async => failed(const AuthFailure.registrationRejected()),
(_) async => failed(
const AuthFailure.registrationRejected(
reason: RegistrationRejectionReason.displayName,
),
),
);
await open(tester);
await fillValid(tester);

View file

@ -216,4 +216,4 @@ Messung gegen einen zusätzlichen GoTrue-Container, dessen SMTP-Port 1 die Verbi
| `POST /resend` (signup) | unbestätigt: **500** „Error sending confirmation email“ | **200** |
| `POST /signup` | unbestätigt: **500**; bestätigt: **200** (erfundener Nutzer, kein Versand) | neu: **500** „Error sending confirmation email“, Nutzer wird **nicht** angelegt (Rollback) |
Antwortzeiten liegen alle im einstelligen bis niedrigen zweistelligen Millisekundenbereich (Verbindung wird sofort abgelehnt), also kein Zeit-Seitenkanal. **Befund:** Bei nicht erreichbarem SMTP unterscheiden sich die Antworten von `/recover`, `/resend` und `/signup` je nach Existenz des Kontos (500 vs. 200), und zwar nicht nur bei erschöpftem Mail-Limit (R3, F12). Die rohe API verrät dann die Existenz; laut Mapping 4.4 (nicht getestet) würde die App bei Reset 500 als `unexpected`, bei Registrierung als `registrationRejected` anzeigen, also einen Fehler statt des neutralen Bildschirms. Ob die App bei HTTP 500 aus Sendefehlern neutral bleiben soll (wie bei `over_email_send_rate_limit`), ist offen (Issue #37). Nicht untersucht: ein SMTP-Server, der die Verbindung annimmt, aber nicht antwortet (Timeout), und GoTrue hinter dem Produktions-Gateway. Dafür den Container mit einer nicht routbaren SMTP-Adresse (z. B. `GOTRUE_SMTP_HOST=10.255.255.1`) starten und `GOTRUE_SMTP_PORT` bis zum Timeout beobachten.
Antwortzeiten liegen alle im einstelligen bis niedrigen zweistelligen Millisekundenbereich (Verbindung wird sofort abgelehnt), also kein Zeit-Seitenkanal. **Befund:** Bei nicht erreichbarem SMTP unterscheiden sich die Antworten von `/recover`, `/resend` und `/signup` je nach Existenz des Kontos (500 vs. 200), und zwar nicht nur bei erschöpftem Mail-Limit (R3, F12). Die rohe API verrät dann die Existenz; die App zeigt seit F13 (Issue #37, Spec R17) bei `signUp`, `recover` und `resend` auf CheckEmail dafür den neutralen Bildschirm (im LoginScreen bleibt der Resend-Fehler sichtbar, das Konto ist dort bewiesen). Die ursprüngliche Befürchtung, die App zeige einen Fehler statt des neutralen Bildschirms, ist damit behoben. Ob die App bei HTTP 500 aus Sendefehlern neutral bleiben soll (wie bei `over_email_send_rate_limit`), ist offen (Issue #37). Nicht untersucht: ein SMTP-Server, der die Verbindung annimmt, aber nicht antwortet (Timeout), und GoTrue hinter dem Produktions-Gateway. Dafür den Container mit einer nicht routbaren SMTP-Adresse (z. B. `GOTRUE_SMTP_HOST=10.255.255.1`) starten und `GOTRUE_SMTP_PORT` bis zum Timeout beobachten.

View file

@ -165,10 +165,10 @@ Alle Werte stehen identisch in `supabase/config.toml` (lokal/CI) und als GoTrue-
| `same_password` | `AuthFailure.samePassword` | am Passwortfeld: „Wähle ein anderes als dein bisheriges Passwort.“ |
| `otp_expired`, `invalid` / 403 bei `verifyOTP` | `AuthFailure.linkInvalidOrExpired` | „Der Link ist abgelaufen oder wurde schon benutzt.“ + Aktion „Neuen Link anfordern“ |
| `email_address_invalid`, `validation_failed` | `AuthFailure.invalidEmail` | am E-Mail-Feld |
| HTTP 500 bei `signUp` (Trigger; Body `{"code":"P0001","message":"<grund>"}`, Header `X-Sb-Error-Code: unexpected_failure`) | `AuthFailure.registrationRejected` | allgemein: „Registrierung nicht möglich. Bitte prüfe deine Angaben.“ (Im Normalfall verhindert der Client das.) **Mapping-Regel:** maßgeblich ist der HTTP-Status 500 beim `signUp`, **nicht** `code == 'P0001'` (welches Feld `AuthException.code` in `supabase_flutter` füllt, ist offen; vermutlich `unexpected_failure`). Die Trigger-Meldung (`display_name_invalid`, `age_confirmation_required`, `terms_acceptance_required`, `terms_version_required`) steht in `AuthException.message` und darf nur fürs Log und für Zusatzhinweise genutzt werden; das Format ist nicht zugesichert. A1 prüft, welche Felder der Client tatsächlich befüllt; T3 sichert das Body-Format mit einem API-Test ab. |
| HTTP 500 **mit bekannter Trigger-Meldung** bei `signUp` (Trigger; Body `{"code":"P0001","message":"<grund>"}`, Header `X-Sb-Error-Code: unexpected_failure`) | `AuthFailure.registrationRejected` | allgemein: „Registrierung nicht möglich. Bitte prüfe deine Angaben.“ (Im Normalfall verhindert der Client das.) **Mapping-Regel (F13):** maßgeblich sind der HTTP-Status 500 beim `signUp` **und eine bekannte Trigger-Meldung** (sie entscheidet über Banner oder neutral; ein 500 ohne bekannte Meldung, z. B. SMTP-Ausfall, ist `unexpected` und neutral), **nicht** `code == 'P0001'` (welches Feld `AuthException.code` in `supabase_flutter` füllt, ist offen; vermutlich `unexpected_failure`). Die Trigger-Meldung (`display_name_invalid`, `age_confirmation_required`, `terms_acceptance_required`, `terms_version_required`) steht in `AuthException.message` und wird (nur) zur Unterscheidung Eingabefehler/SMTP-Ausfall und fürs Log genutzt; das Format ist nicht zugesichert (T3 sichert es ab). A1 prüft, welche Felder der Client tatsächlich befüllt; T3 sichert das Body-Format mit einem API-Test ab. |
| HTTP 5xx **mit Status** bei `signUp` (ohne bekannte Trigger-Meldung), `resetPasswordForEmail`, `resend` (z. B. SMTP-Ausfall, `unexpected_failure`; **F13**) | `AuthFailure.unexpected` | **Kein Fehler sichtbar (F13):** Register-, ForgotPassword- und Resend-Controller behandeln ihn wie Erfolg (neutraler CheckEmailScreen bzw. Resend-Bestätigung, auch im LoginScreen). Das Repository loggt Operation, Fehlerart, Status und Code (ohne E-Mail). Sichtbar bleiben nur eine **Allowlist**: `invalidEmail`, `weakPassword`, `registrationRejected` (bekannter Grund), `network` (ohne Status) und `rateLimited`. |
| `AuthRetryableFetchException`, `SocketException`, `ClientException`, Timeout | `AuthFailure.network` | Offline-Hinweis „Keine Verbindung. Für diesen Schritt brauchst du Internet.“ |
| sonst | `AuthFailure.unexpected` | allgemein + „Erneut versuchen“; Details nur ins Log (ohne E-Mail und Passwort) |
| `AuthRetryableFetchException`, `SocketException`, `ClientException`, Timeout (**nicht** HTTP 5xx mit Status bei `signUp`/`resetPasswordForEmail`/`resend`, siehe F13-Zeile oben) | `AuthFailure.network` | Offline-Hinweis „Keine Verbindung. Für diesen Schritt brauchst du Internet.“ |
| sonst (**außer** `signUp`/`recover`/`resend` auf CheckEmail, Register und Forgot: dort neutral, F13) | `AuthFailure.unexpected` | allgemein + „Erneut versuchen“; Details nur ins Log (ohne E-Mail und Passwort) |
## 5. Frontend
@ -441,8 +441,8 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge
| R13 | Drosselungstests leeren IP-Buckets für bis zu 5 min | Nachfolgende Login-Tests scheitern zufällig | Weitgehend entschärft: Drosselungstests laufen gegen den Extra-Container (D2) mit eigenen, im Speicher gehaltenen Buckets; die CLI drosselt ohnehin nicht. Im Projekt `throttle` nutzt jeder Test eine eigene zufällige `X-Forwarded-For`-IP (simuliert den Proxy, gewollt). Gegen die gemeinsame CLI-Instanz werden **keine** gefälschten `X-Forwarded-For`-Header verwendet. |
| R14 | Ohne HIBP prüft nur der Client die Liste häufiger Passwörter (F2) | Über die rohe API lassen sich häufige Passwörter mit mindestens 10 Zeichen setzen | Bewusst akzeptiert; der Server erzwingt die Mindestlänge 10, die Drosselung (AUTH-CFG-10) bremst Rateversuche |
| R15 | Bundle-/Application-ID ist ein Platzhalter (F8) | Nach der ersten Store-Veröffentlichung nicht mehr änderbar | Vor dem Store-Release festlegen (Release-Checkliste) |
| R17 | **SMTP-Ausfall verrät Konten auf API-Ebene** (Spike #37/#38, GoTrue v2.197.0): `/recover` bekannt 500 `unexpected_failure` / unbekannt 200; `/resend` unbestätigt 500 / unbekannt 200; `/signup` neu 500, bestätigt 200. Kein Zeit-Seitenkanal (< 60 ms). Offen: SMTP, der die Verbindung annimmt, aber nicht antwortet (Timeout). | Konten-Enumeration über die rohe API; in der App durch F13 verdeckt. Nebenwirkung: Bei echtem SMTP-Ausfall erfahren Nutzer nichts und warten auf eine Mail. | **F13 (Chris, 2026-11-03, Option 1):** Die App behandelt jeden Nicht-Eingabe-, Nicht-Transport-Fehler dieser Aufrufe wie Erfolg (4.4). **Restrisiko rohe API bleibt akzeptiert** (wie R3, R6). **Betrieb:** Alarm/Monitoring auf GoTrue-Logs `unexpected_failure`/500 bei recover/resend/signup (`docs/auth-konfiguration.md` § 5, Release-Blocker). Der neutrale Zusatz „kann einige Minuten dauern“ bleibt. |
| R16 | SMTP-Anbieter und Absender-Domain sind offen (F7) | Ohne SMTP keine Mails auf Staging/Prod | Lokal Mailpit; D1 ist Release-Blocker, kein Merge-Blocker |
| R17 | **SMTP-Ausfall verrät Konten auf API-Ebene** (Spike #37/#38, GoTrue v2.197.0): `/recover` bekannt 500 `unexpected_failure` / unbekannt 200; `/resend` unbestätigt 500 / unbekannt 200; `/signup` neu 500, bestätigt 200. Kein Zeit-Seitenkanal (< 60 ms). Offen: SMTP, der die Verbindung annimmt, aber nicht antwortet (Timeout). | Konten-Enumeration über die rohe API; in der App durch F13 verdeckt. Nebenwirkungen: Bei echtem SMTP-Ausfall erfahren Nutzer nichts und warten auf eine Mail. **Bewusst:** Auch Gateway-502/503/504 (Antwort mit Status) wirken bei `signUp`/`recover`/`resend` wie Erfolg, es gibt dort keinen Offline-Hinweis; nur Fehler ohne Status bleiben `network`. | **F13 (Chris, 2026-11-03, Option 1):** Die App behandelt jeden Nicht-Eingabe-, Nicht-Transport-Fehler dieser Aufrufe wie Erfolg (4.4). **Restrisiko rohe API bleibt akzeptiert**; zusätzlich ist `registrationRejected` kontoabhängig (der Trigger läuft nur beim INSERT), relevant nur, wenn Client- und Server-Regeln auseinanderlaufen (der Client verhindert die Fälle sonst vorab) (wie R3, R6). **Betrieb:** Alarm/Monitoring auf GoTrue-Logs `unexpected_failure`/500 bei recover/resend/signup (`docs/auth-konfiguration.md` § 5, Release-Blocker). Der neutrale Zusatz „kann einige Minuten dauern“ bleibt. |
### 9.2 Fragen an Chris (alle entschieden am 2026-11-02)
@ -460,7 +460,7 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge
| F10 | Fundament als eigenes Issue? | entschieden, umgesetzt | Ja: Issue #31 angelegt, #1 hängt davon ab | E1, 5.1, 8.1, 8.4 |
| F11 | (Spike Q1) Krit. 7: Wo läuft der Drosselungstest, da die CLI keine IP-Drosselung kann? | entschieden (2026-11-02) | Im CI gegen einen zusätzlich gestarteten GoTrue-Container mit gesetztem `GOTRUE_RATE_LIMIT_HEADER`; zusätzlich manuelle Prüfung am echten Gateway vor dem Release | 7, 7.1 Krit. 7, D2, T3, R2, R13 |
| F12 | (Spike Q2) Seitenkanal bei erschöpftem Mail-Limit (`/recover`: bestehende Adresse 429, unbekannte 200) | entschieden (2026-11-02) | In der App verdeckt: Bei `over_email_send_rate_limit` zeigen Registrierung und Passwort-Reset denselben neutralen Screen „Prüfe dein Postfach … falls …“ (Krit. 4 bleibt in der App erfüllt), mit neutralem Zusatz „kann einige Minuten dauern“. Ergänzt (Chris, 2026-11-02): Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt neutral; nach Login mit richtigem Passwort (Krit. 6) bleibt der Drosselungshinweis. Über die rohe API bleibt der Kanal sichtbar: akzeptiert und als Risiko dokumentiert (Limit bleibt bei 30/h laut F4). | 4.2, 4.3, 4.4, 5.4, 7.1 Krit. 4, R3, R6 |
| F13 | (Issue #37, Spike #38) SMTP-Ausfall verrät, ob ein Konto existiert (500 vs. 200 bei `/recover`, `/resend`, `/signup`) | entschieden (2026-11-03, Chris: Option 1) | Neutrale Seite im Client: Jeder Fehler dieser Aufrufe, der nicht von der Eingabe oder vom Transport kommt und nicht die IP-Drosselung ist, zählt wie Erfolg (analog F12), auch „E-Mail erneut senden“ im LoginScreen. Das Logging bleibt über das zentrale Logger-Utility. Betriebshinweis: Monitoring auf SMTP-Fehler. | 4.4, R17, `docs/auth-konfiguration.md` § 5 |
| F13 | (Issue #37, Spike #38) SMTP-Ausfall verrät, ob ein Konto existiert (500 vs. 200 bei `/recover`, `/resend`, `/signup`) | entschieden (2026-11-03, Chris: Option 1) | Neutrale Seite im Client: Jeder Fehler dieser Aufrufe, der nicht von der Eingabe oder vom Transport kommt und nicht die IP-Drosselung ist, zählt wie Erfolg (analog F12), auch „E-Mail erneut senden“ auf dem CheckEmailScreen (im LoginScreen bleibt `unexpected` sichtbar, das Konto ist dort bewiesen). Das Logging bleibt über das zentrale Logger-Utility. Betriebshinweis: Monitoring auf SMTP-Fehler. | 4.4, R17, `docs/auth-konfiguration.md` § 5 |
Es gibt keine offenen fachlichen Fragen mehr, die die Umsetzung von #1 blockieren. F7 blockiert nur das Release.
@ -488,7 +488,7 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
1. **Statuskopf und Quellen:** „freigegeben 2026-11-02, angepasst an Spike-Ergebnisse“; Spike-Dokument und Migration `20261102212208_auth_registration_rules.sql` als Quellen.
2. **Neue Konfiguration AUTH-CFG-24** (A1): `[auth.sms] enable_confirmations = true` / `GOTRUE_SMS_AUTOCONFIRM=false`, im Self-Hosting `ENABLE_PHONE_AUTOCONFIRM=false` (Vorlage `docker/.env.example` setzt `true`). Sonst 422 `user_already_exists` statt Enumerationsschutz (Krit. 4). AUTH-CFG-04 verweist darauf.
3. **4.1:** Einleitung nicht mehr „Entwurf“; AUTH-CFG-09 instanzweit und lokal wirkungslos; AUTH-CFG-10 Schlüssel `token_refresh` bestätigt, kein `Retry-After`; AUTH-CFG-11 nur für Signup bestätigt; AUTH-CFG-10 bis -12 nur mit AUTH-CFG-13 wirksam; AUTH-CFG-13 **Pflicht**; AUTH-CFG-20 um A7 ergänzt; AUTH-CFG-22 Mailpit; Mailvorlagen: kein zweisprachiger Rückfall-Betreff, `type`-Werte bestätigt. E5 entsprechend angepasst.
4. **Fehlerbehandlung (E6, 4.2, 4.4)** (A4): Trigger-Fehler kommen als HTTP 500 mit `{"code":"P0001","message":"<grund>"}` im Body (Header `X-Sb-Error-Code: unexpected_failure`). Mapping auf `registrationRejected` über HTTP 500 bei `signUp`, nicht über `P0001`; die Meldung nur für Log/Zusatzhinweise. 429 ohne Dauerangabe.
4. **Fehlerbehandlung (E6, 4.2, 4.4)** (A4): Trigger-Fehler kommen als HTTP 500 mit `{"code":"P0001","message":"<grund>"}` im Body (Header `X-Sb-Error-Code: unexpected_failure`). Mapping auf `registrationRejected` über HTTP 500 bei `signUp` **mit bekannter Meldung** (F13), nicht über `P0001`. 429 ohne Dauerangabe.
5. **4.3:** als verifiziert markiert; Krit. 4 mit beiden Autoconfirm-Voraussetzungen und dem Seitenkanal bei erschöpftem Mail-Limit; Krit. 7 Drosselung nur mit Header; Krit. 8 maßgebliche Spalte `auth.users.*_sent_at`.
6. **5.4** (A5): Der `amr`-Weg entfällt (Recovery-JWT `amr = otp`, nicht unterscheidbar); Guard nur im laufenden Prozess, Ableitung aus `amr` unzulässig.
7. **7 / 7.1:** neues Playwright-Projekt `throttle` gegen Extra-Container; Krit. 2 und 10 erwarten 500 mit Body-Meldung; Krit. 4 prüft `identities` leer (Regressionstest AUTH-CFG-24) und nennt den akzeptierten Seitenkanal; Krit. 7 echter Test im Projekt `throttle` (Login #31 → 429, zweite IP frei), Web-Hinweis per gemocktem 429; Krit. 8 Spalten `recovery_sent_at` / `confirmation_sent_at`, zweites Einlösen 403 `otp_expired`; manuelle Checkliste erweitert (AUTH-CFG-09, -13, -24).
@ -527,5 +527,5 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
### 2026-11-03 – F13 SMTP-Ausfall neutral behandeln (Issue #37, Branch `fix/37-smtp-neutral`)
1. **Entscheidung Chris (Option 1):** Bei `recover`, `resend` und `signUp` zeigt die App für jeden Fehler, der weder Eingabe- noch Transportfehler noch IP-Drosselung ist (insbesondere HTTP 5xx / `unexpected_failure`), dieselbe neutrale Seite wie bei Erfolg. Umsetzung als Allowlist in `openEmailInputFailureFor` und `resendFailureFor`; der Mapper liefert 5xx mit Status bei diesen Aufrufen als `unexpected` (statt `network`), ein `signUp`-500 ohne bekannte Trigger-Meldung ebenfalls (statt `registrationRejected`).
2. **Login-Resend (Krit. 6):** Auch dort ist ein SMTP-Fehler neutral (Spike: `/resend` bekannt 500 / unbekannt 200). `emailSendLimited` zeigt dort weiter den Drosselungshinweis (F12).
2. **Login-Resend (Krit. 6), Entscheidung Orchestrator (S4, Chris wird informiert):** Dort bleibt `unexpected` **sichtbar** (allgemeiner Fehler mit „Erneut versuchen“), weil das Konto durch das richtige Passwort bereits bewiesen ist und Neutralisieren nur einen echten SMTP-Ausfall verdecken würde. `emailSendLimited` zeigt dort weiter den Drosselungshinweis (F12). Neutral bleibt es auf CheckEmail-Resend, Forgot und Register. `registrationRejected` ist nur mit bekanntem Grund sichtbar.
3. **4.4, R17, F13** ergänzt; Betriebshinweis in `docs/auth-konfiguration.md`.

View file

@ -10,7 +10,9 @@ test.describe('KTO-01 Kriterium 2: Altersbestaetigung (api)', () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: undefined }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.ageConfirmationRequired);
// Body-Format (Spike B0 (f), Spec 4.4/F13): die App unterscheidet daran Eingabefehler von SMTP-Ausfall.
expect(res.body).toEqual({ code: 'P0001', message: triggerErrors.ageConfirmationRequired });
expect(res.headers.get('x-sb-error-code')).toBe('unexpected_failure');
expect(await userByEmail(email)).toBeUndefined();
});
@ -28,7 +30,9 @@ test.describe('QUE-04 Kriterium 1 / F5: Zustimmung und Version der Rechtstexte (
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_accepted: undefined }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.termsAcceptanceRequired);
// Body-Format (Spike B0 (f), Spec 4.4/F13): die App unterscheidet daran Eingabefehler von SMTP-Ausfall.
expect(res.body).toEqual({ code: 'P0001', message: triggerErrors.termsAcceptanceRequired });
expect(res.headers.get('x-sb-error-code')).toBe('unexpected_failure');
expect(await userByEmail(email)).toBeUndefined();
});
@ -36,7 +40,9 @@ test.describe('QUE-04 Kriterium 1 / F5: Zustimmung und Version der Rechtstexte (
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_version: undefined }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.termsVersionRequired);
// Body-Format (Spike B0 (f), Spec 4.4/F13): die App unterscheidet daran Eingabefehler von SMTP-Ausfall.
expect(res.body).toEqual({ code: 'P0001', message: triggerErrors.termsVersionRequired });
expect(res.headers.get('x-sb-error-code')).toBe('unexpected_failure');
expect(await userByEmail(email)).toBeUndefined();
});
});