mana-swift-core/Sources/ManaCore/Auth/ManaAppConfig.swift
Till JS 53d5dca45c 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>
2026-05-21 15:41:15 +02:00

127 lines
5.5 KiB
Swift
Raw 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 }
}
// 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
}