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>
389 lines
16 KiB
Swift
389 lines
16 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
/// Auth-Client gegen mana-auth. Beobachtbarer Status, Login-Flow,
|
|
/// proaktiver Token-Refresh.
|
|
///
|
|
/// Eine Instanz pro App, beim App-Start initialisiert. `bootstrap()`
|
|
/// füllt den Status aus dem Keychain (offline möglich).
|
|
///
|
|
/// Die Methoden für Sign-Up, Passwort-Reset, Account-Management etc.
|
|
/// leben in ``AuthClient+Account`` — gleiche Klasse, separate Datei
|
|
/// damit dieser File die Status-Maschine und den Login/Logout/Refresh-
|
|
/// Kern hält.
|
|
@MainActor
|
|
@Observable
|
|
public final class AuthClient {
|
|
public enum Status: Equatable, Sendable {
|
|
/// Vor `bootstrap()` — Keychain noch nicht ausgewertet.
|
|
case unknown
|
|
/// Kein Account, keine Guest-Identität. UI sollte Login/Sign-Up
|
|
/// anbieten oder via `enterGuestMode()` einen anonymen Modus
|
|
/// starten.
|
|
case signedOut
|
|
/// Anonyme lokale Identität ohne Server-Account. Apps können
|
|
/// lokale Daten unter dieser UUID persistieren und sie später
|
|
/// beim Sign-In einem Server-Account zuordnen. Read-only-
|
|
/// Server-Endpoints, die keinen User brauchen, sind in diesem
|
|
/// Status erreichbar; schreibende Endpoints nicht.
|
|
case guest(id: String)
|
|
case signingIn
|
|
/// Sign-In war erfolgreich aber der Account hat 2FA aktiviert.
|
|
/// UI fragt nun den TOTP-Code (oder einen Backup-Code) ab und
|
|
/// ruft `verifyTotp(code:trustDevice:)` / `verifyBackupCode(...)`
|
|
/// mit dem hier gespeicherten Token auf.
|
|
///
|
|
/// `token` ist der Better-Auth-`two_factor`-Cookie-Wert, vom
|
|
/// Server in der Login-Response als `twoFactorToken` mitgeliefert.
|
|
case twoFactorRequired(token: String, methods: [String], email: String)
|
|
case signedIn(email: String)
|
|
case error(String)
|
|
}
|
|
|
|
public private(set) var status: Status = .unknown
|
|
|
|
/// Strukturierter Fehler des letzten Sign-In-/Refresh-/Account-
|
|
/// Operations. Wird beim nächsten `signIn(...)`-Aufruf gelöscht.
|
|
///
|
|
/// Ergänzt `status == .error(String)`: während `status` einen für
|
|
/// die UI angezeigten Text trägt (deutsche Lokalisierung), liefert
|
|
/// `lastError` den klassifizierten ``AuthError``-Case — z.B. um
|
|
/// `.emailNotVerified` programmatisch zu erkennen und den Resend-
|
|
/// Mail-Gate freizuschalten.
|
|
public private(set) var lastError: AuthError?
|
|
|
|
let config: ManaAppConfig
|
|
let keychain: KeychainStore
|
|
let session: URLSession
|
|
|
|
public init(config: ManaAppConfig, session: URLSession = .shared) {
|
|
self.config = config
|
|
keychain = KeychainStore(service: config.keychainService, accessGroup: config.keychainAccessGroup)
|
|
self.session = session
|
|
}
|
|
|
|
public var currentEmail: String? {
|
|
if case let .signedIn(email) = status { return email }
|
|
return nil
|
|
}
|
|
|
|
/// Liest den persistierten Auth-Zustand aus dem Keychain. Reihenfolge:
|
|
/// vollwertige Session > Guest-Identität > komplett ausgeloggt.
|
|
///
|
|
/// Keine Server-Roundtrips — Apps starten offline-fähig und mana-auth-
|
|
/// Downtime kann den letzten gültigen Zustand nicht überschreiben.
|
|
/// Token-Gültigkeit wird erst beim nächsten authentifizierten Call
|
|
/// geprüft (siehe ``refreshAccessToken()``).
|
|
public func bootstrap() {
|
|
let email = keychain.getString(for: .email)
|
|
let hasToken = keychain.getString(for: .accessToken) != nil
|
|
if let email, hasToken {
|
|
status = .signedIn(email: email)
|
|
} else if let guestId = keychain.getString(for: .guestId) {
|
|
status = .guest(id: guestId)
|
|
} else {
|
|
status = .signedOut
|
|
}
|
|
}
|
|
|
|
/// Aktive Guest-Identität, falls vorhanden. Persistiert über
|
|
/// App-Starts hinweg. Existiert sowohl im ``Status/guest(id:)`` als
|
|
/// auch parallel zu einer eingeloggten Session — letzteres erlaubt
|
|
/// es einer App, beim Sign-In die zuvor lokal gesammelten anonymen
|
|
/// Daten dem neuen Server-Account zuzuordnen.
|
|
public func currentGuestId() -> String? {
|
|
keychain.getString(for: .guestId)
|
|
}
|
|
|
|
/// Setzt die App in den anonymen Modus. Idempotent: liefert eine
|
|
/// bestehende Guest-ID zurück, wenn schon eine im Keychain liegt;
|
|
/// sonst wird eine neue UUID erzeugt und persistiert.
|
|
///
|
|
/// Ändert den Status nur, wenn aktuell `.signedOut` oder `.unknown`
|
|
/// vorliegt. Bei aktiver Session (`.signedIn`/`.signingIn`) bleibt
|
|
/// der Status unverändert — die App kann trotzdem `currentGuestId()`
|
|
/// nutzen, um die anonyme ID zu lesen, ohne die Session zu stören.
|
|
///
|
|
/// - Throws: ``AuthError/keychain(_:)`` wenn der Keychain-Write
|
|
/// scheitert.
|
|
@discardableResult
|
|
public func enterGuestMode() throws -> String {
|
|
if let existing = keychain.getString(for: .guestId) {
|
|
if case .signedIn = status {} else if case .signingIn = status {} else {
|
|
status = .guest(id: existing)
|
|
}
|
|
return existing
|
|
}
|
|
let newId = UUID().uuidString
|
|
try keychain.setString(newId, for: .guestId)
|
|
if case .signedIn = status {} else if case .signingIn = status {} else {
|
|
status = .guest(id: newId)
|
|
}
|
|
CoreLog.auth.info("Entered guest mode")
|
|
return newId
|
|
}
|
|
|
|
/// Löscht die Guest-Identität aus dem Keychain. Genutzt nach einer
|
|
/// erfolgreichen Migration der lokalen Guest-Daten auf einen
|
|
/// Server-Account — die anonyme ID wird dann nicht mehr gebraucht.
|
|
/// Wenn der Status aktuell `.guest` ist, wechselt er auf `.signedOut`.
|
|
public func clearGuestId() {
|
|
keychain.remove(for: .guestId)
|
|
if case .guest = status {
|
|
status = .signedOut
|
|
}
|
|
}
|
|
|
|
public func signIn(email: String, password: String) async {
|
|
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, !password.isEmpty else {
|
|
let err = AuthError.validation(message: "Email und Passwort sind erforderlich")
|
|
lastError = err
|
|
status = .error(err.errorDescription ?? "")
|
|
return
|
|
}
|
|
|
|
lastError = nil
|
|
status = .signingIn
|
|
do {
|
|
let url = config.authBaseURL.appending(path: "/api/v1/auth/login")
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = try JSONEncoder().encode(LoginRequest(email: trimmed, password: password))
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let http = response as? HTTPURLResponse else {
|
|
status = .error("Ungültige Server-Antwort")
|
|
return
|
|
}
|
|
guard http.statusCode == 200 else {
|
|
let err = AuthError.classify(
|
|
status: http.statusCode,
|
|
data: data,
|
|
retryAfterHeader: http.retryAfterSeconds
|
|
)
|
|
lastError = err
|
|
status = .error(err.errorDescription ?? "Login fehlgeschlagen")
|
|
return
|
|
}
|
|
|
|
// 2FA-Pfad: Server hat statt Tokens einen
|
|
// `twoFactorRequired`-Response geliefert. UI muss jetzt
|
|
// den TOTP-Code abfragen und `verifyTotp(...)` aufrufen.
|
|
if let twoFactor = try? JSONDecoder().decode(TwoFactorChallenge.self, from: data),
|
|
twoFactor.twoFactorRequired == true,
|
|
let tfToken = twoFactor.twoFactorToken
|
|
{
|
|
status = .twoFactorRequired(
|
|
token: tfToken,
|
|
methods: twoFactor.twoFactorMethods ?? ["totp"],
|
|
email: trimmed
|
|
)
|
|
lastError = nil
|
|
CoreLog.auth.info("Sign-in needs 2FA challenge")
|
|
return
|
|
}
|
|
|
|
let token = try JSONDecoder().decode(TokenResponse.self, from: data)
|
|
try keychain.setString(token.accessToken, for: .accessToken)
|
|
try keychain.setString(token.refreshToken, for: .refreshToken)
|
|
try keychain.setString(trimmed, for: .email)
|
|
status = .signedIn(email: trimmed)
|
|
lastError = nil
|
|
CoreLog.auth.info("Sign-in successful")
|
|
} catch let error as URLError {
|
|
let err = AuthError.networkFailure(error.localizedDescription)
|
|
lastError = err
|
|
status = .error(err.errorDescription ?? "Netzwerk-Fehler")
|
|
CoreLog.auth.error("Sign-in network error: \(error.localizedDescription, privacy: .public)")
|
|
} catch {
|
|
let err = AuthError.networkFailure(String(describing: error))
|
|
lastError = err
|
|
status = .error(err.errorDescription ?? String(describing: error))
|
|
CoreLog.auth.error("Sign-in error: \(String(describing: error), privacy: .public)")
|
|
}
|
|
}
|
|
|
|
/// Logged den User aus. Versucht den Server zu informieren (Logout-
|
|
/// Endpoint), wischt den lokalen Keychain und setzt den Status.
|
|
///
|
|
/// - Parameter keepGuestMode: Wenn `true`, bleibt die App in einem
|
|
/// anonymen Local-First-Modus aktiv. Eine bestehende Guest-ID
|
|
/// wird beibehalten oder eine neue erzeugt; der Status wechselt
|
|
/// auf ``Status/guest(id:)``. Default `false` für strict-Logout
|
|
/// (Status → `.signedOut`, auch Guest-ID gelöscht).
|
|
public func signOut(keepGuestMode: Bool = false) async {
|
|
if let token = keychain.getString(for: .accessToken) {
|
|
let url = config.authBaseURL.appending(path: "/api/v1/auth/logout")
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
_ = try? await session.data(for: request)
|
|
}
|
|
keychain.wipe()
|
|
if keepGuestMode {
|
|
if let existing = keychain.getString(for: .guestId) {
|
|
status = .guest(id: existing)
|
|
} else {
|
|
let newId = UUID().uuidString
|
|
try? keychain.setString(newId, for: .guestId)
|
|
status = .guest(id: newId)
|
|
}
|
|
CoreLog.auth.info("Signed out — kept guest mode")
|
|
} else {
|
|
keychain.remove(for: .guestId)
|
|
status = .signedOut
|
|
CoreLog.auth.info("Signed out")
|
|
}
|
|
}
|
|
|
|
public func currentAccessToken() throws -> String {
|
|
guard let token = keychain.getString(for: .accessToken) else {
|
|
throw AuthError.notSignedIn
|
|
}
|
|
return token
|
|
}
|
|
|
|
/// Liefert den Session-Token (das wire-protocol `refreshToken`-Feld).
|
|
/// Wird von Better-Auth-`auth.api.*`-Endpoints akzeptiert wenn der
|
|
/// `bearer`-Plugin server-seitig aktiv ist — z.B. für `changeEmail`,
|
|
/// `changePassword`, `deleteAccount`. Der App-eigene JWT (aus
|
|
/// `currentAccessToken`) gilt für app-spezifische Backends, der
|
|
/// Session-Token nur für mana-auths Account-Aktionen.
|
|
public func currentSessionToken() throws -> String {
|
|
guard let token = keychain.getString(for: .refreshToken) else {
|
|
throw AuthError.notSignedIn
|
|
}
|
|
return token
|
|
}
|
|
|
|
/// Liefert einen Access-Token, der nicht innerhalb der nächsten
|
|
/// `refreshLeeway` Sekunden abläuft. Refreshed proaktiv, wenn nötig.
|
|
public func freshAccessToken(refreshLeeway: TimeInterval = 300) async throws -> String {
|
|
let token = try currentAccessToken()
|
|
if let expiry = JWT.expiry(of: token), expiry.timeIntervalSinceNow > refreshLeeway {
|
|
return token
|
|
}
|
|
CoreLog.auth.notice("Token expiring within \(Int(refreshLeeway), privacy: .public)s — refreshing proactively")
|
|
return try await refreshAccessToken()
|
|
}
|
|
|
|
public func refreshAccessToken() async throws -> String {
|
|
guard let refresh = keychain.getString(for: .refreshToken) else {
|
|
throw AuthError.notSignedIn
|
|
}
|
|
let url = config.authBaseURL.appending(path: "/api/v1/auth/refresh")
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = try JSONEncoder().encode(RefreshRequest(refreshToken: refresh))
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw AuthError.networkFailure("Keine HTTP-Antwort")
|
|
}
|
|
guard http.statusCode == 200 else {
|
|
let err = AuthError.classify(
|
|
status: http.statusCode,
|
|
data: data,
|
|
retryAfterHeader: http.retryAfterSeconds
|
|
)
|
|
// Wipe nur bei tatsächlich invalidierter Session. Transiente
|
|
// Fehler (5xx, Rate-Limit) lassen den Token erhalten — sonst
|
|
// wirft jeder mana-auth-Downtime-Moment alle Apps in den
|
|
// Login-Screen. Falls eine Guest-Identität existiert, wird
|
|
// beim Wipe in den Guest-Status zurückgefallen statt in
|
|
// strict `.signedOut`.
|
|
if err.invalidatesSession {
|
|
keychain.wipe()
|
|
if let guestId = keychain.getString(for: .guestId) {
|
|
status = .guest(id: guestId)
|
|
} else {
|
|
status = .signedOut
|
|
}
|
|
} else {
|
|
CoreLog.auth.notice(
|
|
"Refresh failed transiently (\(http.statusCode, privacy: .public)) — keeping session"
|
|
)
|
|
}
|
|
throw err
|
|
}
|
|
|
|
let token = try JSONDecoder().decode(TokenResponse.self, from: data)
|
|
try keychain.setString(token.accessToken, for: .accessToken)
|
|
if !token.refreshToken.isEmpty {
|
|
try keychain.setString(token.refreshToken, for: .refreshToken)
|
|
}
|
|
return token.accessToken
|
|
}
|
|
|
|
// MARK: - Internal Helpers (used by AuthClient+Account)
|
|
|
|
/// Persistiert ein frisch erhaltenes Token-Paar und setzt den Status
|
|
/// auf `.signedIn`. Genutzt von ``AuthClient+Account``-Flows, die
|
|
/// in den eingeloggten Zustand führen (z.B. `register` wenn der
|
|
/// Server bereits eine Session liefert).
|
|
func persistSession(email: String, accessToken: String, refreshToken: String) throws {
|
|
try keychain.setString(accessToken, for: .accessToken)
|
|
try keychain.setString(refreshToken, for: .refreshToken)
|
|
try keychain.setString(email, for: .email)
|
|
status = .signedIn(email: email)
|
|
}
|
|
|
|
/// Setzt den Status auf `.signedOut` und wirft den Keychain
|
|
/// vollständig leer — inklusive Guest-ID. Genutzt nach
|
|
/// `deleteAccount()`: Account ist gelöscht, also auch die
|
|
/// (potenziell verknüpfte) anonyme Identität.
|
|
func clearSession() {
|
|
keychain.wipeAll()
|
|
status = .signedOut
|
|
}
|
|
}
|
|
|
|
// MARK: - Wire-Format
|
|
|
|
struct LoginRequest: Encodable {
|
|
let email: String
|
|
let password: String
|
|
}
|
|
|
|
struct RefreshRequest: Encodable {
|
|
let refreshToken: String
|
|
}
|
|
|
|
/// Antwort von `/login`, `/refresh` und (manchmal) `/register`.
|
|
///
|
|
/// `register` liefert je nach Server-Konfig:
|
|
/// - mit `requireEmailVerification: true` (Default): nur `user`, keine Tokens
|
|
/// - mit `false`: vollständiges Token-Paar
|
|
///
|
|
/// Optionale Felder sind explizit `Optional` damit beide Pfade dekodierbar sind.
|
|
struct TokenResponse: Decodable {
|
|
let accessToken: String
|
|
let refreshToken: String
|
|
}
|
|
|
|
/// 2FA-Pfad aus `/api/v1/auth/login`. Wenn `twoFactorRequired == true`,
|
|
/// kein Token-Paar — UI muss `verifyTotp(...)` / `verifyBackupCode(...)`
|
|
/// aufrufen. `twoFactorToken` ist der Server-injecten `two_factor`-Cookie-
|
|
/// Wert (opaque).
|
|
struct TwoFactorChallenge: Decodable {
|
|
let twoFactorRequired: Bool?
|
|
let twoFactorMethods: [String]?
|
|
let twoFactorToken: String?
|
|
}
|
|
|
|
extension HTTPURLResponse {
|
|
/// Liest `Retry-After` als Anzahl Sekunden. Server schickt Integer-
|
|
/// Sekunden (siehe `lib/auth-errors.ts`). HTTP-Datum-Variante wird
|
|
/// nicht unterstützt — mana-auth nutzt sie nicht.
|
|
var retryAfterSeconds: TimeInterval? {
|
|
guard let raw = value(forHTTPHeaderField: "Retry-After"),
|
|
let seconds = TimeInterval(raw)
|
|
else {
|
|
return nil
|
|
}
|
|
return seconds
|
|
}
|
|
}
|