mana-swift-core/Sources/ManaCore/Auth/KeychainStore.swift
Till JS 923b5d06b5 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>
2026-05-13 22:16:08 +02:00

102 lines
3.4 KiB
Swift

import Foundation
import Security
/// Generische, in der App injizierbare Keychain-Wrapper. Eine Instanz
/// 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
private let accessGroup: String?
public init(service: String, accessGroup: String? = nil) {
self.service = service
self.accessGroup = accessGroup
}
public func setString(_ value: String, for key: Key) throws {
guard let data = value.data(using: .utf8) else {
throw AuthError.encoding
}
var query = baseQuery(for: key)
let attributes: [CFString: Any] = [
kSecValueData: data,
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
switch updateStatus {
case errSecSuccess:
return
case errSecItemNotFound:
for (attrKey, attrValue) in attributes {
query[attrKey] = attrValue
}
let addStatus = SecItemAdd(query as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw AuthError.keychain(addStatus)
}
default:
throw AuthError.keychain(updateStatus)
}
}
public func getString(for key: Key) -> String? {
var query = baseQuery(for: key)
query[kSecReturnData] = true
query[kSecMatchLimit] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
public func remove(for key: Key) {
let query = baseQuery(for: key)
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,
kSecAttrService: service,
kSecAttrAccount: key.rawValue,
]
if let accessGroup {
query[kSecAttrAccessGroup] = accessGroup
}
return query
}
}