mana-swift-core/Sources/ManaCore/Auth/ManaAppConfig.swift
Till JS f9f740ff98 ManaCore 1.16.0: Shared-Keychain-Logout-Kaskade gefixt
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>
2026-06-02 13:04:26 +02:00

156 lines
7.1 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}