v1.0.0 — initiale Extraktion aus memoro-native
ManaCore + ManaTokens als Swift-Package für alle nativen mana-e.V.-Apps. Phase α aus mana/docs/MANA_SWIFT.md durch. ManaCore: - AuthClient gegen mana-auth (Login, Refresh, Status-Maschine) - AuthenticatedTransport (URLSession + 401-Retry) - ManaAppConfig-Protocol für App-injizierbare Konfig - KeychainStore mit optionaler Shared-Access-Group - JWT-Parser für lokale Expiry-Prüfung - AuthError, CoreLog (interne OSLog-Logger) ManaTokens: - 12 Vereins-Tokens als dynamic Light/Dark Colors - 5 Brand-Literale (mana-yellow, spectrum-orange, ...) - Spacing, Radius, Typography aus mana/docs/THEMING.md Tests: 12 Unit-Tests grün via swift test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
df6f67ee45
23 changed files with 1151 additions and 0 deletions
61
Sources/ManaCore/API/AuthenticatedTransport.swift
Normal file
61
Sources/ManaCore/API/AuthenticatedTransport.swift
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import Foundation
|
||||
|
||||
/// Authentifizierter HTTP-Transport. Setzt automatisch den `Authorization: Bearer`-
|
||||
/// Header, refreshed bei `401` einmal proaktiv und wiederholt den Request.
|
||||
///
|
||||
/// Eine Instanz pro App-Backend (z.B. memoro-api, cards-api). Beim Init
|
||||
/// die Basis-URL des jeweiligen App-Servers übergeben, nicht die mana-auth-URL.
|
||||
public actor AuthenticatedTransport {
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
private let auth: AuthClient
|
||||
|
||||
public init(baseURL: URL, auth: AuthClient, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
self.auth = auth
|
||||
}
|
||||
|
||||
/// Führt einen authentifizierten Request aus. Bei `401` wird der
|
||||
/// Access-Token einmal refreshed und der Call wiederholt.
|
||||
public func request(
|
||||
path: String,
|
||||
method: String = "GET",
|
||||
body: Data? = nil,
|
||||
contentType: String = "application/json"
|
||||
) async throws -> (Data, HTTPURLResponse) {
|
||||
let token = try await auth.freshAccessToken()
|
||||
let response = try await send(path: path, method: method, body: body, contentType: contentType, token: token)
|
||||
if response.1.statusCode == 401 {
|
||||
let refreshed = try await auth.refreshAccessToken()
|
||||
return try await send(path: path, method: method, body: body, contentType: contentType, token: refreshed)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private func send(
|
||||
path: String,
|
||||
method: String,
|
||||
body: Data?,
|
||||
contentType: String,
|
||||
token: String
|
||||
) async throws -> (Data, HTTPURLResponse) {
|
||||
var request = URLRequest(url: baseURL.appending(path: path))
|
||||
request.httpMethod = method
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw AuthError.networkFailure("Keine HTTP-Antwort")
|
||||
}
|
||||
return (data, http)
|
||||
} catch let error as URLError {
|
||||
throw AuthError.networkFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
172
Sources/ManaCore/Auth/AuthClient.swift
Normal file
172
Sources/ManaCore/Auth/AuthClient.swift
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
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).
|
||||
@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
|
||||
|
||||
private let config: ManaAppConfig
|
||||
private let keychain: KeychainStore
|
||||
private 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 {
|
||||
status = .error("Email und Passwort sind erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
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 message = (try? JSONDecoder().decode(ServerError.self, from: data))?.message
|
||||
if http.statusCode == 401 {
|
||||
status = .error("Email oder Passwort falsch")
|
||||
} else {
|
||||
status =
|
||||
.error("Login fehlgeschlagen (HTTP \(http.statusCode))" + (message.map { " — \($0)" } ?? ""))
|
||||
}
|
||||
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)
|
||||
CoreLog.auth.info("Sign-in successful")
|
||||
} catch let error as URLError {
|
||||
status = .error("Netzwerk: \(error.localizedDescription)")
|
||||
CoreLog.auth.error("Sign-in network error: \(error.localizedDescription, privacy: .public)")
|
||||
} catch {
|
||||
status = .error(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 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.serverError(
|
||||
status: http.statusCode,
|
||||
message: (try? JSONDecoder().decode(ServerError.self, from: data))?.message
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginRequest: Encodable {
|
||||
let email: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
private struct RefreshRequest: Encodable {
|
||||
let refreshToken: String
|
||||
}
|
||||
|
||||
private struct TokenResponse: Decodable {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
}
|
||||
|
||||
private struct ServerError: Decodable {
|
||||
let message: String?
|
||||
}
|
||||
31
Sources/ManaCore/Auth/AuthError.swift
Normal file
31
Sources/ManaCore/Auth/AuthError.swift
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import Foundation
|
||||
|
||||
/// Fehler-Typen des Auth- und Transport-Layers.
|
||||
public enum AuthError: Error, LocalizedError, Sendable {
|
||||
case notSignedIn
|
||||
case invalidCredentials
|
||||
case networkFailure(String)
|
||||
case serverError(status: Int, message: String?)
|
||||
case decoding(String)
|
||||
case keychain(OSStatus)
|
||||
case encoding
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .notSignedIn:
|
||||
"Nicht angemeldet"
|
||||
case .invalidCredentials:
|
||||
"Ungültige Anmeldedaten"
|
||||
case let .networkFailure(message):
|
||||
"Netzwerkfehler: \(message)"
|
||||
case let .serverError(status, message):
|
||||
"Server-Fehler (\(status))" + (message.map { ": \($0)" } ?? "")
|
||||
case let .decoding(detail):
|
||||
"Antwort konnte nicht gelesen werden: \(detail)"
|
||||
case let .keychain(status):
|
||||
"Keychain-Fehler (OSStatus \(status))"
|
||||
case .encoding:
|
||||
"Datenkodierung fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Sources/ManaCore/Auth/JWT.swift
Normal file
25
Sources/ManaCore/Auth/JWT.swift
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Foundation
|
||||
|
||||
/// JWT-Parsing-Helfer. **Verifiziert keine Signatur** — der Server bleibt
|
||||
/// die Wahrheitsinstanz. Hier nur lokale Expiry-Prüfung für proaktiven
|
||||
/// Refresh.
|
||||
public enum JWT {
|
||||
/// Liest `exp`-Claim aus einem JWT, falls vorhanden.
|
||||
public static func expiry(of token: String) -> Date? {
|
||||
let parts = token.split(separator: ".")
|
||||
guard parts.count == 3 else { return nil }
|
||||
guard let payloadData = base64URLDecode(String(parts[1])) else { return nil }
|
||||
guard let dict = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any] else { return nil }
|
||||
guard let exp = dict["exp"] as? TimeInterval else { return nil }
|
||||
return Date(timeIntervalSince1970: exp)
|
||||
}
|
||||
|
||||
private static func base64URLDecode(_ value: String) -> Data? {
|
||||
var base64 = value
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let paddingNeeded = (4 - base64.count % 4) % 4
|
||||
base64.append(String(repeating: "=", count: paddingNeeded))
|
||||
return Data(base64Encoded: base64)
|
||||
}
|
||||
}
|
||||
84
Sources/ManaCore/Auth/KeychainStore.swift
Normal file
84
Sources/ManaCore/Auth/KeychainStore.swift
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Generische, in der App injizierbare Keychain-Wrapper. Eine Instanz
|
||||
/// pro `ManaAppConfig`.
|
||||
public final class KeychainStore: Sendable {
|
||||
/// Bekannte Keys für mana-auth-Tokens.
|
||||
public enum Key: String, Sendable {
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
case email
|
||||
}
|
||||
|
||||
private let service: String
|
||||
private let accessGroup: String?
|
||||
|
||||
public init(service: String, accessGroup: String? = nil) {
|
||||
self.service = service
|
||||
self.accessGroup = accessGroup
|
||||
}
|
||||
|
||||
public func setString(_ value: String, for key: Key) throws {
|
||||
guard let data = value.data(using: .utf8) else {
|
||||
throw AuthError.encoding
|
||||
}
|
||||
|
||||
var query = baseQuery(for: key)
|
||||
let attributes: [CFString: Any] = [
|
||||
kSecValueData: data,
|
||||
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
|
||||
]
|
||||
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
switch updateStatus {
|
||||
case errSecSuccess:
|
||||
return
|
||||
case errSecItemNotFound:
|
||||
for (attrKey, attrValue) in attributes {
|
||||
query[attrKey] = attrValue
|
||||
}
|
||||
let addStatus = SecItemAdd(query as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw AuthError.keychain(addStatus)
|
||||
}
|
||||
default:
|
||||
throw AuthError.keychain(updateStatus)
|
||||
}
|
||||
}
|
||||
|
||||
public func getString(for key: Key) -> String? {
|
||||
var query = baseQuery(for: key)
|
||||
query[kSecReturnData] = true
|
||||
query[kSecMatchLimit] = kSecMatchLimitOne
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
public func remove(for key: Key) {
|
||||
let query = baseQuery(for: key)
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
public func wipe() {
|
||||
remove(for: .accessToken)
|
||||
remove(for: .refreshToken)
|
||||
remove(for: .email)
|
||||
}
|
||||
|
||||
private func baseQuery(for key: Key) -> [CFString: Any] {
|
||||
var query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key.rawValue,
|
||||
]
|
||||
if let accessGroup {
|
||||
query[kSecAttrAccessGroup] = accessGroup
|
||||
}
|
||||
return query
|
||||
}
|
||||
}
|
||||
42
Sources/ManaCore/Auth/ManaAppConfig.swift
Normal file
42
Sources/ManaCore/Auth/ManaAppConfig.swift
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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 }
|
||||
}
|
||||
|
||||
/// 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 init(
|
||||
authBaseURL: URL,
|
||||
keychainService: String,
|
||||
keychainAccessGroup: String? = nil
|
||||
) {
|
||||
self.authBaseURL = authBaseURL
|
||||
self.keychainService = keychainService
|
||||
self.keychainAccessGroup = keychainAccessGroup
|
||||
}
|
||||
}
|
||||
9
Sources/ManaCore/Telemetry/CoreLog.swift
Normal file
9
Sources/ManaCore/Telemetry/CoreLog.swift
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Interne OSLog-Logger von ManaCore. Apps haben ihre eigenen Logger
|
||||
/// unter eigenem Subsystem — diese hier sind für die Bibliothek selbst.
|
||||
enum CoreLog {
|
||||
static let auth = Logger(subsystem: "ev.mana.core", category: "auth")
|
||||
static let transport = Logger(subsystem: "ev.mana.core", category: "transport")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue