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>
102 lines
3.4 KiB
Swift
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
|
|
}
|
|
}
|