v1.3.0 — 2FA-Login-Challenge

Mini-Sprint A des 2FA-Vollausbaus. Apps mit aktivem TOTP-2FA können
sich nativ einloggen. Komplett additiv.

AuthClient.Status um .twoFactorRequired(token, methods, email)
erweitert. signIn() erkennt automatisch den Server-Pfad
{twoFactorRequired: true, ...} und routet zum neuen Status.

Neue Methoden in AuthClient+Account:
- verifyTotp(code:trustDevice:) — 6-stellige Codes aus Authenticator-
  App. Bei Erfolg .signedIn, bei Fehler bleibt Status im Challenge
  (User kann retry mit anderem Code).
- verifyBackupCode(code:trustDevice:) — einmalige Codes als Fallback.

Wire-Format: Client schickt {code, twoFactorToken, trustDevice} an
/api/v1/auth/two-factor/verify-{totp,backup-code}. Server (mana-auth)
re-injectet den twoFactorToken als better-auth.two_factor-Cookie und
delegiert an Better Auths Plugin.

5 neue Tests, 59/59 grün.

Setzt mana-auth-Server mit den entsprechenden Custom-Endpoints
voraus — siehe gleichzeitiger Commit im mana-Repo.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-14 00:20:05 +02:00
parent 923b5d06b5
commit 7526b807da
4 changed files with 294 additions and 0 deletions

View file

@ -0,0 +1,127 @@
import Foundation
import Testing
@testable import ManaCore
@Suite("AuthClient 2FA-Login-Challenge")
@MainActor
struct AuthClientTwoFactorTests {
@Test("signIn mit 2FA-Account → .twoFactorRequired statt .signedIn")
func signInRedirectsToTwoFactor() async {
let mocked = makeMockedAuth()
mocked.setHandler { _ in
(200, Data(#"""
{"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-abc123"}
"""#.utf8))
}
await mocked.auth.signIn(email: "u@x.de", password: "pw")
if case let .twoFactorRequired(token, methods, email) = mocked.auth.status {
#expect(token == "tf-abc123")
#expect(methods == ["totp"])
#expect(email == "u@x.de")
} else {
Issue.record("Expected .twoFactorRequired, got \(mocked.auth.status)")
}
}
@Test("verifyTotp erfolgreich → .signedIn")
func verifyTotpSuccess() async throws {
let mocked = makeMockedAuth()
// Schritt 1: signIn liefert 2FA-Challenge.
mocked.setHandler { _ in
(200, Data(#"""
{"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-xyz"}
"""#.utf8))
}
await mocked.auth.signIn(email: "u@x.de", password: "pw")
// Schritt 2: verifyTotp liefert Tokens.
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
let captured = MockURLProtocol.Capture()
mocked.setHandler { request in
captured.store(request)
return (200, Data(#"""
{"success":true,"accessToken":"\#(access)","refreshToken":"r1"}
"""#.utf8))
}
try await mocked.auth.verifyTotp(code: "123456")
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
let request = try #require(captured.request)
#expect(request.url?.path == "/api/v1/auth/two-factor/verify-totp")
// Body trägt code + twoFactorToken aus dem Challenge-Status
let body = request.httpBody ?? request.bodyStreamData() ?? Data()
let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] ?? [:]
#expect(json["code"] as? String == "123456")
#expect(json["twoFactorToken"] as? String == "tf-xyz")
}
@Test("verifyTotp ohne aktiven 2FA-Challenge wirft validation")
func verifyTotpRequiresChallenge() async {
let mocked = makeMockedAuth()
// Status ist initial .unknown kein 2FA-Challenge.
do {
try await mocked.auth.verifyTotp(code: "123456")
Issue.record("Expected throw")
} catch let AuthError.validation(message) {
#expect(message?.contains("2FA-Challenge") == true)
} catch {
Issue.record("Unexpected error: \(error)")
}
}
@Test("verifyTotp mit falschem Code → twoFactorFailed, bleibt im Challenge")
func verifyTotpWrongCode() async {
let mocked = makeMockedAuth()
mocked.setHandler { _ in
(200, Data(#"""
{"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-xyz"}
"""#.utf8))
}
await mocked.auth.signIn(email: "u@x.de", password: "pw")
mocked.setHandler { _ in
(401, Data(#"{"error":"TWO_FACTOR_FAILED","status":401}"#.utf8))
}
do {
try await mocked.auth.verifyTotp(code: "000000")
Issue.record("Expected throw")
} catch AuthError.twoFactorFailed {
// Status bleibt im Challenge User kann erneut versuchen
if case .twoFactorRequired = mocked.auth.status {
#expect(Bool(true))
} else {
Issue.record("Status should remain .twoFactorRequired, got \(mocked.auth.status)")
}
} catch {
Issue.record("Unexpected error: \(error)")
}
}
@Test("verifyBackupCode erfolgreich → .signedIn")
func verifyBackupCodeSuccess() async throws {
let mocked = makeMockedAuth()
mocked.setHandler { _ in
(200, Data(#"""
{"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-xyz"}
"""#.utf8))
}
await mocked.auth.signIn(email: "u@x.de", password: "pw")
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
let captured = MockURLProtocol.Capture()
mocked.setHandler { request in
captured.store(request)
return (200, Data(#"""
{"success":true,"accessToken":"\#(access)","refreshToken":"r1"}
"""#.utf8))
}
try await mocked.auth.verifyBackupCode(code: "abc-def-ghi")
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
let request = try #require(captured.request)
#expect(request.url?.path == "/api/v1/auth/two-factor/verify-backup-code")
}
}