diff --git a/CHANGELOG.md b/CHANGELOG.md index b00a416..4996336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,76 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an [Keep a Changelog](https://keepachangelog.com), Versionierung nach [Semver](https://semver.org). +## [1.8.0] — 2026-05-21 + +Minor — **`RefreshFailurePolicy`** + Diagnostik-Logging in +`refreshAccessToken()`. + +### Hintergrund + +Eine mana-auth-Regression am 2026-05-19 (`project_auth_refresh_bug`) +hat ~80% aller `/refresh`-Calls 401en lassen — ManaCore hat daraufhin +**sofort** den Keychain gewiped und alle Apps in den Login-Screen +geworfen. Server-Bug war binnen Stunden gefixt, aber für User mit +einem Cold-Launch in genau diesem Fenster war's "logged out". + +Außerdem fragte Till mehrfach "warum muss ich mich nach jedem TestFlight- +Update neu einloggen" — Hypothese: transienter Server-/Deployment-Glitch +beim ersten Refresh nach Update löst Wipe aus, obwohl die Session +eigentlich gültig ist. + +### Neu + +- **`RefreshFailurePolicy`** (public enum, Sendable): + - `.immediateWipe` — Default. Bestehendes Verhalten: jeder Server- + "Session-tot" → sofort Keychain wipe → `.signedOut`. + - `.softFirst` — Erster Refresh-Fehler im Prozess wird nicht gewiped, + Session bleibt erhalten, Fehler wird geworfen. Wipe erst beim + zweiten Fehler ODER nach einem zuvor erfolgreichen Refresh im + selben Prozess (dann ist der invalidate-Response vertrauenswürdig). +- **`ManaAppConfig.refreshFailurePolicy`** (default `.immediateWipe`, + Protocol-Extension). Apps können opt-in per `DefaultManaAppConfig`- + Init-Parameter oder eigener Adopter-Implementierung. +- **`AuthClient.refreshOnceSucceeded`** + **`refreshFailureCount`** + (private(set)) — public-readable für Diagnose + Tests. + +### Geändert + +- `refreshAccessToken()` loggt jetzt jeden Attempt inklusive Token- + Länge, once-succeeded-Flag, Failure-Count und gewählter Policy. + Failure-Pfad loggt zusätzlich HTTP-Status und Response-Body-Excerpt + (erste 256 Zeichen). Logs gehen weiter über `CoreLog.auth`, also + `log show --predicate 'subsystem == "ev.mana.core"'` fängt sie. +- Erfolgreicher Refresh resettet `refreshFailureCount` auf 0. +- Nichts breaking. Default-Verhalten für alle bestehenden Apps + unverändert. + +### Tests + +- 4 neue Tests in `AuthClientGuestAndResilienceTests`: + - `softFirst`: erster 401 behält Session, zweiter wiped + - `softFirst`: 401 nach erfolgreichem Refresh wiped sofort + - `softFirst`: transienter 503 zählt nicht in Failure-Count + - `immediateWipe`: erster 401 wiped (Default unverändert) +- 89/89 grün (vorher 85/85). + +### Adoption + +Apps die opt-in wollen: + +```swift +static let manaAppConfig: ManaAppConfig = DefaultManaAppConfig( + authBaseURL: URL(string: "https://auth.mana.how")!, + keychainService: ManaSharedKeychainGroup, + keychainAccessGroup: ManaSharedKeychainGroup, + appGroup: "group.ev.mana.", + refreshFailurePolicy: .softFirst +) +``` + +Pageta-native ist der erste Konsument. Andere SSO-Apps können später +nachziehen, falls Till oder Logs es nahelegen. + ## [1.7.0] — 2026-05-17 Minor — **`ManaAppLog`** + `ManaAppConfig.appGroup`/`logSubsystem`. diff --git a/Sources/ManaCore/Auth/AuthClient.swift b/Sources/ManaCore/Auth/AuthClient.swift index 264b317..7c12ae6 100644 --- a/Sources/ManaCore/Auth/AuthClient.swift +++ b/Sources/ManaCore/Auth/AuthClient.swift @@ -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) ?? "" // 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 } diff --git a/Sources/ManaCore/Auth/ManaAppConfig.swift b/Sources/ManaCore/Auth/ManaAppConfig.swift index 53d535a..24a51d6 100644 --- a/Sources/ManaCore/Auth/ManaAppConfig.swift +++ b/Sources/ManaCore/Auth/ManaAppConfig.swift @@ -35,6 +35,21 @@ public protocol ManaAppConfig: Sendable { /// OSLog-Subsystem für App-Logger, üblich `ev.mana.`. 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.`. 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 +} diff --git a/Tests/ManaCoreTests/AuthClientGuestAndResilienceTests.swift b/Tests/ManaCoreTests/AuthClientGuestAndResilienceTests.swift index c322242..44319e2 100644 --- a/Tests/ManaCoreTests/AuthClientGuestAndResilienceTests.swift +++ b/Tests/ManaCoreTests/AuthClientGuestAndResilienceTests.swift @@ -210,6 +210,89 @@ struct AuthClientGuestAndResilienceTests { #expect(mocked.auth.currentGuestId() == id) } + // MARK: - RefreshFailurePolicy.softFirst + + @Test("softFirst: erster 401-Refresh behält Session, zweiter wiped") + func softFirstSecondFailureWipes() async throws { + let mocked = makeMockedAuth(refreshFailurePolicy: .softFirst) + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in + (401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8)) + } + + // Erster Versuch — wirft, aber Session bleibt + do { + _ = try await mocked.auth.refreshAccessToken() + Issue.record("Expected throw") + } catch let err as AuthError { + #expect(err.invalidatesSession) + } + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + #expect(mocked.auth.refreshFailureCount == 1) + + // Zweiter Versuch — wiped jetzt + do { + _ = try await mocked.auth.refreshAccessToken() + Issue.record("Expected throw") + } catch let err as AuthError { + #expect(err.invalidatesSession) + } + #expect(mocked.auth.status == .signedOut) + #expect(mocked.auth.refreshFailureCount == 2) + } + + @Test("softFirst: 401 nach erfolgreichem Refresh wiped sofort") + func softFirstAfterSuccessWipes() async throws { + let mocked = makeMockedAuth(refreshFailurePolicy: .softFirst) + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + + // Erst erfolgreich refreshen + mocked.setHandler { _ in + (200, Data(#"{"accessToken":"new-a","refreshToken":"new-r"}"#.utf8)) + } + _ = try await mocked.auth.refreshAccessToken() + #expect(mocked.auth.refreshOnceSucceeded) + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + + // Dann 401 — server sagt "Session tot", glaubhaft weil wir + // gerade vorher noch refreshen konnten + mocked.setHandler { _ in + (401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8)) + } + do { + _ = try await mocked.auth.refreshAccessToken() + Issue.record("Expected throw") + } catch let err as AuthError { + #expect(err.invalidatesSession) + } + #expect(mocked.auth.status == .signedOut) + } + + @Test("softFirst: transienter 503 ändert nichts an Counter") + func softFirstTransientDoesNotCount() async throws { + let mocked = makeMockedAuth(refreshFailurePolicy: .softFirst) + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in + (503, Data(#"{"error":"SERVICE_UNAVAILABLE","status":503}"#.utf8)) + } + + _ = try? await mocked.auth.refreshAccessToken() + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + #expect(mocked.auth.refreshFailureCount == 0) + } + + @Test("immediateWipe: erster 401 wiped sofort (Default-Verhalten)") + func immediateWipeFirstFailureWipes() async throws { + let mocked = makeMockedAuth(refreshFailurePolicy: .immediateWipe) + try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") + mocked.setHandler { _ in + (401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8)) + } + + _ = try? await mocked.auth.refreshAccessToken() + #expect(mocked.auth.status == .signedOut) + } + // MARK: - AuthError.invalidatesSession @Test("invalidatesSession unterscheidet Session-Tot vs. transient") diff --git a/Tests/ManaCoreTests/MockURLProtocol.swift b/Tests/ManaCoreTests/MockURLProtocol.swift index 289332b..1d79b22 100644 --- a/Tests/ManaCoreTests/MockURLProtocol.swift +++ b/Tests/ManaCoreTests/MockURLProtocol.swift @@ -93,7 +93,9 @@ struct MockedAuth { } @MainActor -func makeMockedAuth() -> MockedAuth { +func makeMockedAuth( + refreshFailurePolicy: RefreshFailurePolicy = .immediateWipe +) -> MockedAuth { let testID = UUID().uuidString let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [MockURLProtocol.self] @@ -102,7 +104,8 @@ func makeMockedAuth() -> MockedAuth { let config = DefaultManaAppConfig( authBaseURL: URL(string: "https://auth.test")!, keychainService: "ev.mana.test.\(testID)", - keychainAccessGroup: nil + keychainAccessGroup: nil, + refreshFailurePolicy: refreshFailurePolicy ) return MockedAuth(auth: AuthClient(config: config, session: session), testID: testID) }