mana-swift-core/Sources/ManaCore/Auth/AuthClient.swift
Till JS 3459c78731 v1.1.1 — Session-Token statt JWT für Account-Calls
Wire-Konvention für authenticated Account-Endpoints (changeEmail,
changePassword, deleteAccount) geklärt. Server-seitig wurde in
mana-auth Better Auths bearer-Plugin aktiviert (requireSignature:
false), das Session-Tokens zu Session-Cookies konvertiert. Native-
Apps senden daher jetzt den Session-Token (refreshToken-Feldwert)
statt des JWT als Authorization: Bearer für diese drei Endpoints.

Der JWT bleibt für app-eigene Backends (memoro-api, cardecky-api,
manaspur-api) der richtige Authorization-Header — die Trennung ist
nur für mana-auth interne Endpoints.

currentSessionToken() als public Helper hinzu (symmetrisch zu
currentAccessToken).

38/38 Tests grün.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:35:57 +02:00

248 lines
9.5 KiB
Swift

import Foundation
import Observation
/// Auth-Client gegen mana-auth. Beobachtbarer Status, Login-Flow,
/// proaktiver Token-Refresh.
///
/// Eine Instanz pro App, beim App-Start initialisiert. `bootstrap()`
/// füllt den Status aus dem Keychain (offline möglich).
///
/// Die Methoden für Sign-Up, Passwort-Reset, Account-Management etc.
/// leben in ``AuthClient+Account`` gleiche Klasse, separate Datei
/// damit dieser File die Status-Maschine und den Login/Logout/Refresh-
/// Kern hält.
@MainActor
@Observable
public final class AuthClient {
public enum Status: Equatable, Sendable {
case unknown
case signedOut
case signingIn
case signedIn(email: String)
case error(String)
}
public private(set) var status: Status = .unknown
/// Strukturierter Fehler des letzten Sign-In-/Refresh-/Account-
/// Operations. Wird beim nächsten `signIn(...)`-Aufruf gelöscht.
///
/// Ergänzt `status == .error(String)`: während `status` einen für
/// die UI angezeigten Text trägt (deutsche Lokalisierung), liefert
/// `lastError` den klassifizierten ``AuthError``-Case z.B. um
/// `.emailNotVerified` programmatisch zu erkennen und den Resend-
/// Mail-Gate freizuschalten.
public private(set) var lastError: AuthError?
let config: ManaAppConfig
let keychain: KeychainStore
let session: URLSession
public init(config: ManaAppConfig, session: URLSession = .shared) {
self.config = config
keychain = KeychainStore(service: config.keychainService, accessGroup: config.keychainAccessGroup)
self.session = session
}
public var currentEmail: String? {
if case let .signedIn(email) = status { return email }
return nil
}
public func bootstrap() {
let email = keychain.getString(for: .email)
let hasToken = keychain.getString(for: .accessToken) != nil
if let email, hasToken {
status = .signedIn(email: email)
} else {
status = .signedOut
}
}
public func signIn(email: String, password: String) async {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !password.isEmpty else {
let err = AuthError.validation(message: "Email und Passwort sind erforderlich")
lastError = err
status = .error(err.errorDescription ?? "")
return
}
lastError = nil
status = .signingIn
do {
let url = config.authBaseURL.appending(path: "/api/v1/auth/login")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(LoginRequest(email: trimmed, password: password))
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
status = .error("Ungültige Server-Antwort")
return
}
guard http.statusCode == 200 else {
let err = AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
lastError = err
status = .error(err.errorDescription ?? "Login fehlgeschlagen")
return
}
let token = try JSONDecoder().decode(TokenResponse.self, from: data)
try keychain.setString(token.accessToken, for: .accessToken)
try keychain.setString(token.refreshToken, for: .refreshToken)
try keychain.setString(trimmed, for: .email)
status = .signedIn(email: trimmed)
lastError = nil
CoreLog.auth.info("Sign-in successful")
} catch let error as URLError {
let err = AuthError.networkFailure(error.localizedDescription)
lastError = err
status = .error(err.errorDescription ?? "Netzwerk-Fehler")
CoreLog.auth.error("Sign-in network error: \(error.localizedDescription, privacy: .public)")
} catch {
let err = AuthError.networkFailure(String(describing: error))
lastError = err
status = .error(err.errorDescription ?? String(describing: error))
CoreLog.auth.error("Sign-in error: \(String(describing: error), privacy: .public)")
}
}
public func signOut() async {
if let token = keychain.getString(for: .accessToken) {
let url = config.authBaseURL.appending(path: "/api/v1/auth/logout")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
_ = try? await session.data(for: request)
}
keychain.wipe()
status = .signedOut
CoreLog.auth.info("Signed out")
}
public func currentAccessToken() throws -> String {
guard let token = keychain.getString(for: .accessToken) else {
throw AuthError.notSignedIn
}
return token
}
/// Liefert den Session-Token (das wire-protocol `refreshToken`-Feld).
/// Wird von Better-Auth-`auth.api.*`-Endpoints akzeptiert wenn der
/// `bearer`-Plugin server-seitig aktiv ist z.B. für `changeEmail`,
/// `changePassword`, `deleteAccount`. Der App-eigene JWT (aus
/// `currentAccessToken`) gilt für app-spezifische Backends, der
/// Session-Token nur für mana-auths Account-Aktionen.
public func currentSessionToken() throws -> String {
guard let token = keychain.getString(for: .refreshToken) else {
throw AuthError.notSignedIn
}
return token
}
/// Liefert einen Access-Token, der nicht innerhalb der nächsten
/// `refreshLeeway` Sekunden abläuft. Refreshed proaktiv, wenn nötig.
public func freshAccessToken(refreshLeeway: TimeInterval = 300) async throws -> String {
let token = try currentAccessToken()
if let expiry = JWT.expiry(of: token), expiry.timeIntervalSinceNow > refreshLeeway {
return token
}
CoreLog.auth.notice("Token expiring within \(Int(refreshLeeway), privacy: .public)s — refreshing proactively")
return try await refreshAccessToken()
}
public func refreshAccessToken() async throws -> String {
guard let refresh = keychain.getString(for: .refreshToken) else {
throw AuthError.notSignedIn
}
let url = config.authBaseURL.appending(path: "/api/v1/auth/refresh")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(RefreshRequest(refreshToken: refresh))
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw AuthError.networkFailure("Keine HTTP-Antwort")
}
guard http.statusCode == 200 else {
keychain.wipe()
status = .signedOut
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
let token = try JSONDecoder().decode(TokenResponse.self, from: data)
try keychain.setString(token.accessToken, for: .accessToken)
if !token.refreshToken.isEmpty {
try keychain.setString(token.refreshToken, for: .refreshToken)
}
return token.accessToken
}
// MARK: - Internal Helpers (used by AuthClient+Account)
/// Persistiert ein frisch erhaltenes Token-Paar und setzt den Status
/// auf `.signedIn`. Genutzt von ``AuthClient+Account``-Flows, die
/// in den eingeloggten Zustand führen (z.B. `register` wenn der
/// Server bereits eine Session liefert).
func persistSession(email: String, accessToken: String, refreshToken: String) throws {
try keychain.setString(accessToken, for: .accessToken)
try keychain.setString(refreshToken, for: .refreshToken)
try keychain.setString(email, for: .email)
status = .signedIn(email: email)
}
/// Setzt den Status auf `.signedOut` und wirft den Keychain leer.
/// Genutzt nach `deleteAccount()`.
func clearSession() {
keychain.wipe()
status = .signedOut
}
}
// MARK: - Wire-Format
struct LoginRequest: Encodable {
let email: String
let password: String
}
struct RefreshRequest: Encodable {
let refreshToken: String
}
/// Antwort von `/login`, `/refresh` und (manchmal) `/register`.
///
/// `register` liefert je nach Server-Konfig:
/// - mit `requireEmailVerification: true` (Default): nur `user`, keine Tokens
/// - mit `false`: vollständiges Token-Paar
///
/// Optionale Felder sind explizit `Optional` damit beide Pfade dekodierbar sind.
struct TokenResponse: Decodable {
let accessToken: String
let refreshToken: String
}
extension HTTPURLResponse {
/// Liest `Retry-After` als Anzahl Sekunden. Server schickt Integer-
/// Sekunden (siehe `lib/auth-errors.ts`). HTTP-Datum-Variante wird
/// nicht unterstützt mana-auth nutzt sie nicht.
var retryAfterSeconds: TimeInterval? {
guard let raw = value(forHTTPHeaderField: "Retry-After"),
let seconds = TimeInterval(raw)
else {
return nil
}
return seconds
}
}