Extracts duplicated platform integration code from ChronoMind + LysnrAI into a single Swift Package. Eliminates ~1,100+ lines of copied code per product app. Components: - BLPlatformConfig — product-specific configuration (productId, baseURL, bundleId) - BLPlatformClient — generic HTTP client with auth injection, x-request-id, timeout - BLKeychain — Keychain CRUD for secure token storage - BLTelemetryClient — telemetry queue + batch flush (matches @bytelyst/telemetry-client) - BLAuthClient — full auth operations (matches @bytelyst/auth-client) - BLFeatureFlagClient — feature flag polling from platform-service /flags/poll - BLSyncEngine — generic offline-first sync with delta pull + batch push Platforms: iOS 17+, watchOS 10+, macOS 14+
259 lines
9.1 KiB
Swift
259 lines
9.1 KiB
Swift
// ── 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: "\(config.productId)_access_token")
|
|
}
|
|
|
|
public var refreshTokenValue: String? {
|
|
BLKeychain.read(service: keychainService, key: "\(config.productId)_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: "\(config.productId)_access_token", value: access)
|
|
BLKeychain.save(service: keychainService, key: "\(config.productId)_refresh_token", value: refresh)
|
|
client.authToken = access
|
|
onTokensUpdated?(access)
|
|
}
|
|
|
|
private func clearTokens() {
|
|
BLKeychain.delete(service: keychainService, key: "\(config.productId)_access_token")
|
|
BLKeychain.delete(service: keychainService, key: "\(config.productId)_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
|
|
}
|
|
}
|