learning_ai_common_plat/packages/swift-platform-sdk/Sources/BLAuthClient.swift

259 lines
9.0 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: "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: "/api/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: "/api/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: "/api/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: "/api/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: "/api/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: "/api/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: "/api/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: "/api/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: "/api/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: "/api/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
}
}