feat(auth): RefreshFailurePolicy + Diagnostik (v1.8.0)

Neue opt-in Policy verhindert Logout durch einen einzelnen transienten
/refresh-Fehler beim Cold-Launch. Default-Verhalten unverändert.

- `RefreshFailurePolicy.immediateWipe` (Default) — wie bisher: jeder
  invalidierende Server-Response → keychain.wipe() + .signedOut.
- `RefreshFailurePolicy.softFirst` — erster invalidierender Fehler
  im Prozess wird nicht gewiped, Session bleibt. Wipe erst beim
  zweiten Fehler oder nach einem zuvor erfolgreichen Refresh im
  selben Prozess.

Plus erweiterte Diagnostik in refreshAccessToken(): jeder Attempt
loggt Token-Länge, once-succeeded, failure-count, policy, und bei
Failure HTTP-Status + Body-Excerpt (256 chars). Subsystem ev.mana.core.

Pageta-native ist erster Konsument (opt-in `.softFirst`) wegen
wiederholten TestFlight-Update-Logouts — Hypothese: transienter
Server-Glitch beim ersten Refresh nach Cold-Launch.

89/89 Tests (vorher 85/85), 4 neue für die Policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-21 15:41:15 +02:00
parent 573c93c104
commit 53d5dca45c
5 changed files with 253 additions and 10 deletions

View file

@ -56,6 +56,19 @@ public final class AuthClient {
let keychain: KeychainStore
let session: URLSession
/// True nach mindestens einem erfolgreichen Refresh in diesem
/// Prozess. Genutzt von `RefreshFailurePolicy.softFirst` um zu
/// erkennen, ob ein "Session tot"-Response vertrauenswürdig ist
/// (= die Session war in diesem Prozess schon mal nachweislich
/// gültig, also ist sie jetzt echt invalidiert) oder ob er ein
/// transienter Server-Glitch sein könnte.
private(set) var refreshOnceSucceeded: Bool = false
/// Zählt die Anzahl Session-invalidierender Refresh-Fehler im
/// aktuellen Prozess. Genutzt von `RefreshFailurePolicy.softFirst`
/// um beim zweiten Fehler in Folge dann doch zu wipen.
private(set) var refreshFailureCount: Int = 0
public init(config: ManaAppConfig, session: URLSession = .shared) {
self.config = config
keychain = KeychainStore(service: config.keychainService, accessGroup: config.keychainAccessGroup)
@ -279,6 +292,8 @@ public final class AuthClient {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(RefreshRequest(refreshToken: refresh))
CoreLog.auth.info("Refresh attempt — token len \(refresh.count, privacy: .public), once-succeeded=\(self.refreshOnceSucceeded, privacy: .public), failure-count=\(self.refreshFailureCount, privacy: .public), policy=\(String(describing: self.config.refreshFailurePolicy), privacy: .public)")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw AuthError.networkFailure("Keine HTTP-Antwort")
@ -289,6 +304,7 @@ public final class AuthClient {
data: data,
retryAfterHeader: http.retryAfterSeconds
)
let bodyExcerpt = String(data: data.prefix(256), encoding: .utf8) ?? "<binary>"
// 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
@ -296,16 +312,32 @@ public final class AuthClient {
// 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)
refreshFailureCount += 1
let shouldWipe: Bool
switch config.refreshFailurePolicy {
case .immediateWipe:
shouldWipe = true
case .softFirst:
// Vertraue dem Server-"Session-tot" nur, wenn er
// bereits einmal in diesem Prozess "Session-OK" gesagt
// hat (= Session war nachweislich gültig, ist also
// jetzt echt invalidiert) ODER es ist mindestens der
// zweite invalidierende Fehler in Folge.
shouldWipe = refreshOnceSucceeded || refreshFailureCount >= 2
}
if shouldWipe {
CoreLog.auth.error("Wiping keychain — refresh \(http.statusCode, privacy: .public) invalidates session (attempt #\(self.refreshFailureCount, privacy: .public), once-succeeded=\(self.refreshOnceSucceeded, privacy: .public)). Body: \(bodyExcerpt, privacy: .public)")
keychain.wipe()
if let guestId = keychain.getString(for: .guestId) {
status = .guest(id: guestId)
} else {
status = .signedOut
}
} else {
status = .signedOut
CoreLog.auth.notice("Refresh \(http.statusCode, privacy: .public) invalidates session, but softFirst policy retains keychain (attempt #\(self.refreshFailureCount, privacy: .public)). Body: \(bodyExcerpt, privacy: .public)")
}
} else {
CoreLog.auth.notice(
"Refresh failed transiently (\(http.statusCode, privacy: .public)) — keeping session"
)
CoreLog.auth.notice("Refresh failed transiently (\(http.statusCode, privacy: .public)) — keeping session. Body: \(bodyExcerpt, privacy: .public)")
}
throw err
}
@ -315,6 +347,9 @@ public final class AuthClient {
if !token.refreshToken.isEmpty {
try keychain.setString(token.refreshToken, for: .refreshToken)
}
refreshOnceSucceeded = true
refreshFailureCount = 0
CoreLog.auth.info("Refresh succeeded")
return token.accessToken
}

View file

@ -35,6 +35,21 @@ public protocol ManaAppConfig: Sendable {
/// OSLog-Subsystem für App-Logger, üblich `ev.mana.<app>`. Default
/// ist `keychainService` (der schon der Konvention folgt).
var logSubsystem: String { get }
/// Was ``AuthClient/refreshAccessToken()`` macht, wenn der Server
/// einen Session-invalidierenden Fehler zurückgibt (401, tokenExpired,
/// tokenInvalid, ...). Default ``RefreshFailurePolicy/immediateWipe``
/// für Quellkompatibilität mit allen bestehenden Apps.
///
/// Apps, die einen TestFlight-/Cold-Launch-Logout durch eine
/// transiente Server-/Deployment-Glitch verhindern wollen, setzen
/// ``RefreshFailurePolicy/softFirst`` dann überlebt die persistierte
/// Session den ersten Refresh-Fehler im Prozess und wird erst gewiped,
/// wenn der Server beim nächsten Versuch nochmal "Session tot" sagt
/// (oder wenn vorher schon ein erfolgreicher Refresh in diesem
/// Prozess passiert ist dann ist der invalidate-Response
/// vertrauenswürdig).
var refreshFailurePolicy: RefreshFailurePolicy { get }
}
// MARK: - Default-Implementationen
@ -46,6 +61,9 @@ public extension ManaAppConfig {
/// Default = `keychainService`. Beide folgen heute in allen Apps
/// derselben Konvention `ev.mana.<app>`.
var logSubsystem: String { keychainService }
/// Default `immediateWipe` bestehendes Verhalten.
var refreshFailurePolicy: RefreshFailurePolicy { .immediateWipe }
}
/// Standard-Implementierung von ``ManaAppConfig``. Apps können diese
@ -56,13 +74,15 @@ public struct DefaultManaAppConfig: ManaAppConfig {
public let keychainAccessGroup: String?
public let appGroup: String?
public let logSubsystem: String
public let refreshFailurePolicy: RefreshFailurePolicy
public init(
authBaseURL: URL,
keychainService: String,
keychainAccessGroup: String? = nil,
appGroup: String? = nil,
logSubsystem: String? = nil
logSubsystem: String? = nil,
refreshFailurePolicy: RefreshFailurePolicy = .immediateWipe
) {
self.authBaseURL = authBaseURL
self.keychainService = keychainService
@ -71,5 +91,37 @@ public struct DefaultManaAppConfig: ManaAppConfig {
// Konvention: log-Subsystem = keychainService, falls nicht
// explizit anders gewünscht.
self.logSubsystem = logSubsystem ?? keychainService
self.refreshFailurePolicy = refreshFailurePolicy
}
}
/// Policy für ``AuthClient/refreshAccessToken()``-Verhalten bei
/// Session-invalidierenden Server-Antworten.
///
/// `immediateWipe` ist das historische Verhalten von ManaCore: jeder
/// Server-Hinweis "Session tot" Keychain wipe User wird ausgeloggt.
/// Problem: ein transienter Server-Bug (z.B. mana-auth-Regression
/// 2026-05-19, siehe `project_auth_refresh_bug` in der Memory) kann
/// dann **alle** ManaCore-Apps gleichzeitig auswerfen.
///
/// `softFirst` macht den ersten Refresh-Fehler eines Prozesses zu einem
/// "Vielleicht" Session bleibt im Keychain, App kann es beim nächsten
/// Request nochmal probieren. Erst der **zweite** Fehler in Folge
/// (oder ein Fehler nach einem zuvor erfolgreichen Refresh im selben
/// Prozess) löst den Wipe aus.
///
/// Trade-off: bei `softFirst` sieht ein User mit echt invalider
/// Session beim ersten Request einen Auth-Fehler statt direkt im
/// Login-Screen zu landen. Akzeptabel der zweite Request wiped
/// dann sauber und User landet im Login.
public enum RefreshFailurePolicy: Sendable {
/// Default Server-"Session-tot"-Antworten führen sofort zu
/// `keychain.wipe()` und Status `.signedOut`.
case immediateWipe
/// Erster invalidierender Refresh-Fehler im Prozess wird **nicht**
/// gewiped Session bleibt erhalten, Fehler wird geworfen. Wipe
/// passiert beim zweiten Fehler oder nach mindestens einem
/// erfolgreichen Refresh in diesem Prozess.
case softFirst
}