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>
127 lines
5.5 KiB
Swift
127 lines
5.5 KiB
Swift
import Foundation
|
||
|
||
/// App-spezifische Konfiguration für ManaCore. Wird von der konsumierenden
|
||
/// App beim Erzeugen eines `AuthClient` injiziert.
|
||
///
|
||
/// ManaCore hardcoded nichts App-Spezifisches. Bundle-ID, Auth-Server-URL
|
||
/// und Keychain-Adressierung kommen ausschließlich hierüber.
|
||
public protocol ManaAppConfig: Sendable {
|
||
/// Basis-URL des mana-auth-Servers, z.B. `https://auth.mana.how`.
|
||
var authBaseURL: URL { get }
|
||
|
||
/// Keychain-Service-Identifier, üblich `ev.mana.<app>`. Trennt
|
||
/// Token-Einträge verschiedener Apps voneinander, falls keine
|
||
/// shared Access-Group benutzt wird.
|
||
var keychainService: String { get }
|
||
|
||
/// Optional: Shared-Keychain-Access-Group für Cross-App-SSO.
|
||
/// `nil` bedeutet: nur App-eigener Keychain-Zugriff.
|
||
///
|
||
/// Wenn gesetzt, müssen alle teilnehmenden Apps unter derselben
|
||
/// Apple-Developer-Team-ID provisioniert sein und das Entitlement
|
||
/// `keychain-access-groups` mit demselben Wert tragen.
|
||
var keychainAccessGroup: String? { get }
|
||
|
||
/// App-Group für Daten-Sharing zwischen App ↔ Widget ↔ ShareExt.
|
||
/// Üblich `group.ev.mana.<app>`. `nil` für Apps ohne Extensions.
|
||
///
|
||
/// Single-Source für den App-Group-String, der heute in jeder App
|
||
/// 3-4× hardcoded steht (AppConfig + App-Entitlement + Widget-
|
||
/// Entitlement + ShareExt-Entitlement). Die Entitlements bleiben
|
||
/// hardcoded (das verlangt iOS), aber im Swift-Code ist der Wert
|
||
/// damit single-source.
|
||
var appGroup: String? { get }
|
||
|
||
/// 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
|
||
|
||
public extension ManaAppConfig {
|
||
/// Default `nil` — Apps ohne Widget/ShareExt müssen nichts setzen.
|
||
var appGroup: String? { nil }
|
||
|
||
/// 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
|
||
/// nutzen oder ein eigenes Type adoptieren.
|
||
public struct DefaultManaAppConfig: ManaAppConfig {
|
||
public let authBaseURL: URL
|
||
public let keychainService: String
|
||
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,
|
||
refreshFailurePolicy: RefreshFailurePolicy = .immediateWipe
|
||
) {
|
||
self.authBaseURL = authBaseURL
|
||
self.keychainService = keychainService
|
||
self.keychainAccessGroup = keychainAccessGroup
|
||
self.appGroup = appGroup
|
||
// 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
|
||
}
|