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

@ -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.<app>",
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`.

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
}

View file

@ -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")

View file

@ -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)
}