v1.2.0 — Guest-Mode + Refresh-Resilience

Native-Apps werden gegen mana-auth-Downtime gehärtet und können
einen anonymen Local-First-Modus anbieten. Komplett additiv.

AuthClient.Status um `.guest(id: String)` erweitert — persistente
lokale UUID ohne Server-Account, gleichberechtigt mit `.signedIn` als
"App ist nutzbar"-Zustand.

Neue Methoden:
- enterGuestMode() throws -> String — idempotent
- currentGuestId() -> String?
- clearGuestId()
- signOut(keepGuestMode: Bool = false) — Default-Verhalten unverändert

KeychainStore.Key.guestId neu. wipe() löscht nur Session-Felder
(accessToken/refreshToken/email); Guest-ID überlebt. Für komplettes
Vergessen: neue wipeAll().

refreshAccessToken() wipt nicht mehr blind bei jedem Nicht-200.
Heuristik via AuthError.invalidatesSession:
- Wipe bei invalidCredentials/unauthorized/tokenExpired/tokenInvalid/
  emailNotVerified — Session ist tatsächlich tot.
- Behalten bei serviceUnavailable/serverInternal/networkFailure/
  rateLimited — Apps werden bei mana-auth-Downtime nicht mehr in
  Login geworfen.

Beim Wipe fällt der Status auf .guest(id) zurück, falls eine
Guest-Identität existiert; sonst auf .signedOut.

Tests:
- Mock-Setup auf per-test-ID-Routing migriert (analog mana-swift-ui),
  löst Cross-Suite-Pollution zwischen AuthClient+Account und
  AuthClient Guest-Mode + Resilience.
- 15 neue Tests für Guest-Mode + Refresh-Resilience.
- 54/54 Tests grün.

🤖 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-13 22:16:08 +02:00
parent 3459c78731
commit 923b5d06b5
7 changed files with 647 additions and 160 deletions

View file

@ -15,8 +15,18 @@ import Observation
@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
case signedIn(email: String)
case error(String)
@ -49,16 +59,73 @@ public final class AuthClient {
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 {
@ -113,7 +180,15 @@ public final class AuthClient {
}
}
public func signOut() async {
/// 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)
@ -122,8 +197,20 @@ public final class AuthClient {
_ = try? await session.data(for: request)
}
keychain.wipe()
status = .signedOut
CoreLog.auth.info("Signed out")
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 {
@ -172,13 +259,30 @@ public final class AuthClient {
throw AuthError.networkFailure("Keine HTTP-Antwort")
}
guard http.statusCode == 200 else {
keychain.wipe()
status = .signedOut
throw AuthError.classify(
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)
@ -202,10 +306,12 @@ public final class AuthClient {
status = .signedIn(email: email)
}
/// Setzt den Status auf `.signedOut` und wirft den Keychain leer.
/// Genutzt nach `deleteAccount()`.
/// 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.wipe()
keychain.wipeAll()
status = .signedOut
}
}

View file

@ -126,6 +126,49 @@ public enum AuthError: Error, LocalizedError, Sendable, Equatable {
}
}
/// `true` wenn dieser Fehler bedeutet, dass die gespeicherten
/// Tokens nicht mehr gültig sind und die App den Nutzer ausloggen
/// muss. `false` für *transiente* Fehler (Server-Downtime, Netzwerk-
/// Fehler, Rate-Limiting), bei denen die Session erhalten bleiben
/// soll die App sollte den Versuch nur wiederholen.
///
/// Genutzt von ``AuthClient/refreshAccessToken()`` um zu entscheiden,
/// ob der Keychain gewiped werden muss. Apps können denselben
/// Test auf Fehler aus ``AuthenticatedTransport`` anwenden, um
/// zwischen "neuer Login nötig" und "später nochmal probieren" zu
/// unterscheiden.
public var invalidatesSession: Bool {
switch self {
case .invalidCredentials,
.unauthorized,
.tokenExpired,
.tokenInvalid,
.emailNotVerified:
true
case .notSignedIn,
.encoding,
.keychain,
.decoding,
.networkFailure,
.emailAlreadyRegistered,
.weakPassword,
.accountLocked,
.signupLimitReached,
.rateLimited,
.twoFactorRequired,
.twoFactorFailed,
.passkeyNotEnabled,
.passkeyCancelled,
.passkeyVerificationFailed,
.validation,
.notFound,
.serviceUnavailable,
.serverInternal,
.serverError:
false
}
}
/// Klassifiziert eine `mana-auth`-Fehler-Antwort. `data` ist der
/// Response-Body, `status` der HTTP-Status, `retryAfterHeader` der
/// `Retry-After`-Header-Wert in Sekunden (falls vorhanden).

View file

@ -5,10 +5,18 @@ import Security
/// pro `ManaAppConfig`.
public final class KeychainStore: Sendable {
/// Bekannte Keys für mana-auth-Tokens.
///
/// `.guestId` ist eine lokal generierte anonyme UUID für Apps mit
/// Local-First-/Guest-Modus. Sie wird unabhängig von den Session-
/// Tokens persistiert und vom Default-``wipe()`` *nicht* gelöscht
/// damit eine App nach einem Logout im Guest-Modus weiterlaufen
/// kann, ohne dass die lokalen anonymen Daten ihre Besitzer-Spur
/// verlieren. Vollständiger Reset über ``wipeAll()``.
public enum Key: String, Sendable {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case email
case guestId = "guest_id"
}
private let service: String
@ -64,12 +72,22 @@ public final class KeychainStore: Sendable {
SecItemDelete(query as CFDictionary)
}
/// Löscht Session-Daten (accessToken, refreshToken, email). Behält
/// die ``Key/guestId``, damit lokale Guest-Daten nach einem Logout
/// erhalten bleiben können.
public func wipe() {
remove(for: .accessToken)
remove(for: .refreshToken)
remove(for: .email)
}
/// Löscht *alles* inklusive der Guest-Identität. Genutzt nach
/// `deleteAccount()` oder bei explizitem "anonyme Daten vergessen".
public func wipeAll() {
wipe()
remove(for: .guestId)
}
private func baseQuery(for key: Key) -> [CFString: Any] {
var query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,