// ── Auth Client ────────────────────────────────────────────── // Generic auth client matching @bytelyst/auth-client TypeScript interface. // Login, register, refresh, forgot/reset/change password, verify email, delete account. // Token storage via BLKeychain. Product apps configure via BLPlatformConfig. import Foundation // MARK: - Public Types public struct BLAuthUser: Codable, Sendable { public let id: String public let email: String public let displayName: String public let plan: String public let role: String enum CodingKeys: String, CodingKey { case id, email, displayName, plan, role } public init(id: String, email: String, displayName: String, plan: String = "free", role: String = "user") { self.id = id self.email = email self.displayName = displayName self.plan = plan self.role = role } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(String.self, forKey: .id) email = try c.decode(String.self, forKey: .email) displayName = try c.decode(String.self, forKey: .displayName) plan = try c.decodeIfPresent(String.self, forKey: .plan) ?? "free" role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" } } public enum BLAuthState: Sendable { case loading case loggedOut case loggedIn(BLAuthUser) case error(String) } // MARK: - Auth Client /// Generic auth client for all ByteLyst iOS apps. /// Handles login, register, token refresh, password operations, and account management. /// Stores tokens in Keychain. Notifies via `onAuthStateChanged` callback. public final class BLAuthClient { private let config: BLPlatformConfig private let client: BLPlatformClient private let keychainService: String /// Called whenever auth state changes. Set by the product app's view model / observable. public var onAuthStateChanged: ((BLAuthState) -> Void)? /// Called when tokens are updated (for wiring into sync managers). public var onTokensUpdated: ((String?) -> Void)? private var refreshTimer: Timer? private struct TokenResponse: Codable { let accessToken: String let refreshToken: String let user: BLAuthUser } private struct RefreshResponse: Codable { let accessToken: String let refreshToken: String } private struct MessageResponse: Codable { let message: String } public init(config: BLPlatformConfig, client: BLPlatformClient) { self.config = config self.client = client self.keychainService = config.bundleId } // MARK: - Token Access public var accessToken: String? { BLKeychain.read(service: keychainService, key: "access_token") } public var refreshTokenValue: String? { BLKeychain.read(service: keychainService, key: "refresh_token") } public var isAuthenticated: Bool { accessToken != nil && !(accessToken?.isEmpty ?? true) } // MARK: - Auth Operations /// Login with email and password. public func login(email: String, password: String) async throws -> BLAuthUser { let body: [String: String] = [ "email": email, "password": password, "productId": config.productId, ] let (data, _) = try await client.rawRequest(path: "/auth/login", method: "POST", body: body) let result = try JSONDecoder().decode(TokenResponse.self, from: data) saveTokens(access: result.accessToken, refresh: result.refreshToken) startRefreshTimer() return result.user } /// Register a new account. public func register(displayName: String, email: String, password: String) async throws -> BLAuthUser { let body: [String: String] = [ "displayName": displayName, "email": email, "password": password, "productId": config.productId, ] let (data, _) = try await client.rawRequest(path: "/auth/register", method: "POST", body: body) let result = try JSONDecoder().decode(TokenResponse.self, from: data) saveTokens(access: result.accessToken, refresh: result.refreshToken) startRefreshTimer() return result.user } /// Fetch current user profile. public func getMe() async throws -> BLAuthUser { return try await client.request(path: "/auth/me", responseType: BLAuthUser.self) } /// Refresh the access token using the stored refresh token. @discardableResult public func refreshAccessToken() async -> Bool { guard let rt = refreshTokenValue, !rt.isEmpty else { return false } let body = ["refreshToken": rt] do { let (data, _) = try await client.rawRequest(path: "/auth/refresh", method: "POST", body: body) let result = try JSONDecoder().decode(RefreshResponse.self, from: data) saveTokens(access: result.accessToken, refresh: result.refreshToken) return true } catch { if let netErr = error as? BLNetworkError, netErr.statusCode == 401 { logout() } return false } } /// Request password reset email. public func forgotPassword(email: String) async throws { let body = ["email": email, "productId": config.productId] _ = try await client.rawRequest(path: "/auth/forgot-password", method: "POST", body: body) } /// Reset password with token. public func resetPassword(token: String, newPassword: String) async throws { let body = ["token": token, "newPassword": newPassword] _ = try await client.rawRequest(path: "/auth/reset-password", method: "POST", body: body) } /// Change password (authenticated). public func changePassword(currentPassword: String, newPassword: String) async throws { let body = ["currentPassword": currentPassword, "newPassword": newPassword] _ = try await client.rawRequest(path: "/auth/change-password", method: "POST", body: body) } /// Verify email with token. public func verifyEmail(token: String) async throws { let body = ["token": token] _ = try await client.rawRequest(path: "/auth/verify-email", method: "POST", body: body) } /// Resend verification email. public func resendVerification(email: String) async throws { let body = ["email": email, "productId": config.productId] _ = try await client.rawRequest(path: "/auth/resend-verification", method: "POST", body: body) } /// Delete account (requires password confirmation). public func deleteAccount(password: String) async throws { let body = ["password": password] _ = try await client.rawRequest(path: "/auth/account", method: "DELETE", body: body) logout() } /// Logout — clear tokens and notify. public func logout() { stopRefreshTimer() clearTokens() onAuthStateChanged?(.loggedOut) } /// Restore session from stored tokens. Call on app launch. public func restoreSession() async { guard isAuthenticated else { onAuthStateChanged?(.loggedOut) return } onAuthStateChanged?(.loading) do { let user = try await getMe() onAuthStateChanged?(.loggedIn(user)) startRefreshTimer() } catch { // Try refresh let ok = await refreshAccessToken() if ok { do { let user = try await getMe() onAuthStateChanged?(.loggedIn(user)) startRefreshTimer() } catch { onAuthStateChanged?(.loggedOut) } } else { onAuthStateChanged?(.loggedOut) } } } // MARK: - Private private func saveTokens(access: String, refresh: String) { BLKeychain.save(service: keychainService, key: "access_token", value: access) BLKeychain.save(service: keychainService, key: "refresh_token", value: refresh) client.authToken = access onTokensUpdated?(access) } private func clearTokens() { BLKeychain.delete(service: keychainService, key: "access_token") BLKeychain.delete(service: keychainService, key: "refresh_token") client.authToken = nil onTokensUpdated?(nil) } private func startRefreshTimer() { stopRefreshTimer() // Refresh every 45 minutes (tokens typically expire in 1 hour) refreshTimer = Timer.scheduledTimer(withTimeInterval: 45 * 60, repeats: true) { [weak self] _ in guard let self else { return } Task { await self.refreshAccessToken() } } } private func stopRefreshTimer() { refreshTimer?.invalidate() refreshTimer = nil } }