Alle 16 nativen mana-Apps teilen EINEN Keychain (ev.mana.session) — ein keychain.wipe() loggt die ganze Flotte aus. Vier zusammenhängende Ursachen: - Default RefreshFailurePolicy .immediateWipe → .softFirst (13/16 Apps liefen auf dem gefährlichen Default). - softFirst korrigiert: zählt rein Failure-Count, erst der zweite invalidierende Fehler in Folge wiped. refreshOnceSucceeded triggert keinen Wipe mehr — der scenePhase-Heartbeat setzte es beim App-Start sofort true und entwertete softFirst praktisch komplett (der Bug). - performRefresh liest vor jedem Wipe den Keychain neu: hat ein anderer Prozess (App/Widget) frisch geschrieben, retry statt Flotten-Wipe. - Transport refresht nur noch bei echtem JWT-Ablauf (JWT.expiry > 60s → 401 durchreichen) statt blind bei jedem 401. Plus TokenResponse.refreshToken optional (best-effort get-session kann fehlen → kein DecodingError-Loop). Keine API-Signatur-Änderungen. 4 neue/aktualisierte Tests in AuthClientGuestAndResilienceTests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
156 lines
7.1 KiB
Swift
156 lines
7.1 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 }
|
||
|
||
/// Theme-Pass: stabiler App-Slug dieser App (z.B. `pageta`, `seepuls`)
|
||
/// — gleich über web + native. ``ThemePass/recordOwnVisit()`` markiert
|
||
/// damit die App als „ausprobiert". `nil` (Default) = keine Teilnahme.
|
||
var appSlug: String? { get }
|
||
}
|
||
|
||
// MARK: - Default-Implementationen
|
||
|
||
public extension ManaAppConfig {
|
||
/// Default `nil` — Apps ohne Widget/ShareExt müssen nichts setzen.
|
||
var appGroup: String? { nil }
|
||
|
||
/// Default `nil` — Apps ohne Theme-Pass-Teilnahme müssen nichts setzen.
|
||
var appSlug: String? { nil }
|
||
|
||
/// Default = `keychainService`. Beide folgen heute in allen Apps
|
||
/// derselben Konvention `ev.mana.<app>`.
|
||
var logSubsystem: String { keychainService }
|
||
|
||
/// Default `softFirst`. Geändert 2026-06-02 von `immediateWipe`:
|
||
/// alle nativen mana-Apps teilen sich EINEN Keychain
|
||
/// (`ev.mana.session`, Service + Access-Group). Ein `keychain.wipe()`
|
||
/// löscht damit den Token der **ganzen Flotte** — ein einzelner 401
|
||
/// (transienter mana-auth-Glitch, Authz-401 eines App-Backends) darf
|
||
/// das nicht auslösen. `softFirst` verlangt einen bestätigten zweiten
|
||
/// invalidierenden Fehler. Apps mit isoliertem Keychain können
|
||
/// explizit `immediateWipe` wählen.
|
||
var refreshFailurePolicy: RefreshFailurePolicy { .softFirst }
|
||
}
|
||
|
||
/// 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 let appSlug: String?
|
||
|
||
public init(
|
||
authBaseURL: URL,
|
||
keychainService: String,
|
||
keychainAccessGroup: String? = nil,
|
||
appGroup: String? = nil,
|
||
logSubsystem: String? = nil,
|
||
refreshFailurePolicy: RefreshFailurePolicy = .softFirst,
|
||
appSlug: String? = nil
|
||
) {
|
||
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
|
||
self.appSlug = appSlug
|
||
}
|
||
}
|
||
|
||
/// 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 invalidierende Fehler in
|
||
/// Folge** löst den Wipe aus.
|
||
///
|
||
/// **Korrektur 2026-06-02:** früher wipte `softFirst` auch schon, sobald
|
||
/// in diesem Prozess **einmal** ein Refresh erfolgreich war
|
||
/// (`refreshOnceSucceeded`). Das entwertete die Policy praktisch komplett,
|
||
/// weil der `scenePhase`-Heartbeat beim App-Start sofort einen
|
||
/// erfolgreichen Refresh macht — danach verhielt sich `softFirst` wie
|
||
/// `immediateWipe`. Diese Bedingung ist entfernt; jetzt zählt rein der
|
||
/// Failure-Count. Zusätzlich liest `AuthClient.performRefresh()` vor
|
||
/// jedem Wipe den Keychain neu: hat ein anderer Prozess (andere mana-App,
|
||
/// Widget) zwischenzeitlich einen frischen Token geschrieben, war der 401
|
||
/// ein Stale-Token-Artefakt und wird mit dem neuen Token wiederholt statt
|
||
/// die Flotte auszuloggen.
|
||
///
|
||
/// 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
|
||
}
|