BLAuthClient stored tokens as '{productId}_access_token' but all app
wrappers use KeychainHelper.read(key: "access_token") — the bare key.
This caused a critical mismatch: after login, BlobService/LicenseService
could not find the token, and token migration from UserDefaults was invisible
to BLAuthClient.isAuthenticated.
The Keychain service name (bundleId) already namespaces per product,
making the productId prefix redundant. Now uses bare 'access_token' and
'refresh_token' keys matching existing app conventions.
259 lines
8.9 KiB
Swift
259 lines
8.9 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: "/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
|
|
}
|
|
}
|