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:
parent
573c93c104
commit
53d5dca45c
5 changed files with 253 additions and 10 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue