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
70
CHANGELOG.md
70
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.<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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue