// ── Auth Client ────────────────────────────────────────────── // Generic auth client matching @bytelyst/auth-client TypeScript interface. // Login, register, refresh, forgot/reset/change password, verify email, delete account. // SmartAuth v2: social login, MFA (TOTP), passkeys, device trust. // 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 mfaRequired(BLMfaChallenge) case error(String) } // MARK: - SmartAuth Types /// MFA challenge returned when login requires multi-factor verification. public struct BLMfaChallenge: Codable, Sendable { public let mfaRequired: Bool public let mfaChallenge: String public let methods: [String] public init(mfaRequired: Bool, mfaChallenge: String, methods: [String]) { self.mfaRequired = mfaRequired self.mfaChallenge = mfaChallenge self.methods = methods } } /// TOTP setup response with secret URI and recovery codes. public struct BLTotpSetup: Codable, Sendable { public let otpauthUri: String public let qrCode: String public let recoveryCodes: [String] } /// MFA status for the current user. public struct BLMfaStatus: Codable, Sendable { public let mfaEnabled: Bool public let methods: [String] public let recoveryCodesRemaining: Int } /// Linked OAuth provider. public struct BLAuthProvider: Codable, Sendable { public let provider: String public let email: String public let linkedAt: String public let lastUsedAt: String? } /// Passkey metadata. public struct BLPasskey: Codable, Sendable { public let id: String public let friendlyName: String public let deviceType: String public let lastUsedAt: String? public let createdAt: String } /// Trusted/remembered device. public struct BLDevice: Codable, Sendable { public let fingerprint: String public let trustLevel: String public let deviceInfo: DeviceInfo? public let lastIp: String? public let lastLocation: String? public let trustExpiresAt: String? public let createdAt: String public let lastSeenAt: String public let isTrusted: Bool public struct DeviceInfo: Codable, Sendable { public let userAgent: String? public let platform: String? public let model: String? public let os: String? } /// Convenience: display name derived from device info. public var name: String { deviceInfo?.model ?? deviceInfo?.platform ?? fingerprint.prefix(8).description } /// Convenience: platform string. public var platform: String { deviceInfo?.platform ?? "unknown" } /// Stable identifier for SwiftUI ForEach. public var id: String { fingerprint } } /// Login event for security log. public struct BLLoginEvent: Codable, Sendable { public let id: String public let eventType: String public let method: String public let ip: String public let geo: BLGeo? public let riskScore: Int public let createdAt: String } /// Geo location. public struct BLGeo: Codable, Sendable { public let country: String public let city: String } // MARK: - Phase 5C–5E Types /// Decrypted TOTP secret for local code generation in the auth app. public struct BLTotpSecret: Codable, Sendable { public let secret: String public let issuer: String public let accountName: String public let digits: Int public let period: Int public let algorithm: String } /// Pending push MFA approval. public struct BLPushApproval: Codable, Sendable { public let id: String public let requestProductId: String public let requestPlatform: String public let requestIp: String public let requestGeo: BLGeo? public let createdAt: String public let expiresAt: String } /// Push approval response after approve/deny. public struct BLPushApprovalResponse: Codable, Sendable { public let id: String public let status: String public let respondedAt: String? } /// QR challenge for desktop/TV login. public struct BLQrChallenge: Codable, Sendable { public let id: String public let challengeToken: String public let expiresAt: String } /// QR challenge poll status. public struct BLQrStatus: Codable, Sendable { public let status: String public let accessToken: String? public let refreshToken: String? public let user: BLAuthUser? } // MARK: - Auth Errors /// Auth-specific errors for SmartAuth flows. public enum BLAuthError: LocalizedError { case mfaRequired(BLMfaChallenge) public var errorDescription: String? { switch self { case .mfaRequired: return "Multi-factor authentication required" } } } // MARK: - Auth Client /// Generic auth client for all ByteLyst iOS apps. /// Handles login, register, token refresh, password operations, and account management. /// SmartAuth v2: social login, MFA, passkeys, device trust, step-up auth. /// 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. /// If MFA is enabled, throws `BLAuthError.mfaRequired` with the challenge. 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) // Check for MFA challenge response if let challenge = try? JSONDecoder().decode(BLMfaChallenge.self, from: data), challenge.mfaRequired { onAuthStateChanged?(.mfaRequired(challenge)) throw BLAuthError.mfaRequired(challenge) } let result = try JSONDecoder().decode(TokenResponse.self, from: data) saveTokens(access: result.accessToken, refresh: result.refreshToken) startRefreshTimer() onAuthStateChanged?(.loggedIn(result.user)) 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() onAuthStateChanged?(.loggedIn(result.user)) 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) } // MARK: - Social Login (SmartAuth v2) /// Login with Google id_token. public func loginWithGoogle(idToken: String) async throws -> BLAuthUser { return try await socialLogin(provider: "google", idToken: idToken) } /// Login with Microsoft id_token. public func loginWithMicrosoft(idToken: String) async throws -> BLAuthUser { return try await socialLogin(provider: "microsoft", idToken: idToken) } /// Login with Apple id_token. public func loginWithApple(idToken: String) async throws -> BLAuthUser { return try await socialLogin(provider: "apple", idToken: idToken) } /// Generic social login — sends id_token to /auth/oauth/{provider}. private func socialLogin(provider: String, idToken: String) async throws -> BLAuthUser { let body: [String: String] = ["idToken": idToken, "productId": config.productId] let (data, _) = try await client.rawRequest( path: "/api/auth/oauth/\(provider)", method: "POST", body: body ) // Server may return MFA challenge or tokens if let challenge = try? JSONDecoder().decode(BLMfaChallenge.self, from: data), challenge.mfaRequired { onAuthStateChanged?(.mfaRequired(challenge)) throw BLAuthError.mfaRequired(challenge) } let result = try JSONDecoder().decode(TokenResponse.self, from: data) saveTokens(access: result.accessToken, refresh: result.refreshToken) startRefreshTimer() onAuthStateChanged?(.loggedIn(result.user)) return result.user } // MARK: - MFA (SmartAuth v2) /// Verify MFA challenge (TOTP code or recovery code). public func verifyMfa(challengeToken: String, code: String, method: String = "totp") async throws -> BLAuthUser { let body: [String: String] = [ "challengeToken": challengeToken, "code": code, "method": method, ] let (data, _) = try await client.rawRequest(path: "/api/auth/mfa/verify", method: "POST", body: body) let result = try JSONDecoder().decode(TokenResponse.self, from: data) saveTokens(access: result.accessToken, refresh: result.refreshToken) startRefreshTimer() return result.user } /// Begin TOTP setup — returns otpauth URI, QR code, and recovery codes. public func setupTotp() async throws -> BLTotpSetup { return try await client.request(path: "/api/auth/mfa/totp/setup", method: "POST", responseType: BLTotpSetup.self) } /// Verify TOTP setup with a code from the authenticator app. public func verifyTotpSetup(code: String) async throws { let body = ["code": code] _ = try await client.rawRequest(path: "/api/auth/mfa/totp/verify-setup", method: "POST", body: body) } /// Disable MFA (requires step-up token via X-Step-Up-Token header). public func disableMfa() async throws { _ = try await client.rawRequest(path: "/api/auth/mfa/totp", method: "DELETE") } /// Get current MFA status. public func getMfaStatus() async throws -> BLMfaStatus { return try await client.request(path: "/api/auth/mfa/status", responseType: BLMfaStatus.self) } /// Regenerate recovery codes (requires step-up). public func regenerateRecoveryCodes() async throws -> [String] { struct CodesResponse: Codable { let recoveryCodes: [String] } let result = try await client.request( path: "/api/auth/mfa/recovery/regenerate", method: "POST", responseType: CodesResponse.self ) return result.recoveryCodes } // MARK: - Providers (SmartAuth v2) /// List linked OAuth providers. public func getProviders() async throws -> [BLAuthProvider] { return try await client.request(path: "/api/auth/providers", responseType: [BLAuthProvider].self) } /// Link an OAuth provider to the current account. public func linkProvider(provider: String, idToken: String) async throws { let body = ["provider": provider, "idToken": idToken] _ = try await client.rawRequest(path: "/api/auth/providers/link", method: "POST", body: body) } /// Unlink an OAuth provider. public func unlinkProvider(provider: String) async throws { _ = try await client.rawRequest(path: "/api/auth/providers/\(provider)", method: "DELETE") } // MARK: - Passkeys (SmartAuth v2) /// Get passkey registration options from server. public func getPasskeyRegistrationOptions() async throws -> Data { let (data, _) = try await client.rawRequest( path: "/api/auth/passkeys/register/options", method: "POST" ) return data } /// Verify passkey registration with attestation response. public func verifyPasskeyRegistration(attestation: [String: Any], friendlyName: String) async throws { var payload = attestation payload["friendlyName"] = friendlyName let jsonData = try JSONSerialization.data(withJSONObject: payload) // Wrap raw JSON data in a Codable struct for BLPlatformClient _ = try await client.rawRequest( path: "/api/auth/passkeys/register/verify", method: "POST", rawBody: jsonData ) } /// Get passkey authentication options from server. public func getPasskeyAuthenticationOptions() async throws -> Data { let (data, _) = try await client.rawRequest( path: "/api/auth/passkeys/authenticate/options", method: "POST" ) return data } /// Verify passkey authentication with assertion response. public func verifyPasskeyAuthentication(assertion: [String: Any]) async throws -> BLAuthUser { let jsonData = try JSONSerialization.data(withJSONObject: assertion) let (responseData, _) = try await client.rawRequest( path: "/api/auth/passkeys/authenticate/verify", method: "POST", rawBody: jsonData ) let result = try JSONDecoder().decode(TokenResponse.self, from: responseData) saveTokens(access: result.accessToken, refresh: result.refreshToken) startRefreshTimer() onAuthStateChanged?(.loggedIn(result.user)) return result.user } /// List registered passkeys. public func listPasskeys() async throws -> [BLPasskey] { return try await client.request(path: "/api/auth/passkeys", responseType: [BLPasskey].self) } /// Delete a passkey (requires step-up). public func deletePasskey(passkeyId: String) async throws { _ = try await client.rawRequest(path: "/api/auth/passkeys/\(passkeyId)", method: "DELETE") } // MARK: - Devices (SmartAuth v2) /// List devices for current user. public func listDevices() async throws -> [BLDevice] { struct DevicesResponse: Codable { let devices: [BLDevice] } let result = try await client.request(path: "/api/auth/devices", responseType: DevicesResponse.self) return result.devices } /// Trust the current device (promotes to trusted, skips MFA for 90 days). public func trustDevice() async throws { _ = try await client.rawRequest(path: "/api/auth/devices/trust", method: "POST") } /// Revoke trust on a specific device by fingerprint. public func revokeDevice(fingerprint: String) async throws { _ = try await client.rawRequest(path: "/api/auth/devices/\(fingerprint)", method: "DELETE") } /// Revoke all device trust. public func revokeAllDevices() async throws { _ = try await client.rawRequest(path: "/api/auth/devices/revoke-all", method: "POST") } // MARK: - Step-Up Auth (SmartAuth v2) /// Perform step-up authentication. Returns a short-lived step-up token. public func stepUp(method: String, credential: String) async throws -> String { let body = ["method": method, "credential": credential] struct StepUpResponse: Codable { let stepUpToken: String } let result = try await client.request( path: "/api/auth/step-up", method: "POST", body: body, responseType: StepUpResponse.self ) return result.stepUpToken } // MARK: - Login History (SmartAuth v2) /// Get login events for the current user. public func getLoginHistory(limit: Int = 20) async throws -> [BLLoginEvent] { struct EventsResponse: Codable { let events: [BLLoginEvent] } let result = try await client.request( path: "/api/auth/login-events?limit=\(limit)", responseType: EventsResponse.self ) return result.events } // MARK: - TOTP Secret Retrieval (Phase 5C) /// Get the decrypted TOTP secret for local code generation (auth app). public func getTotpSecret() async throws -> BLTotpSecret { return try await client.request(path: "/api/auth/mfa/totp/secret", responseType: BLTotpSecret.self) } // MARK: - Push Approvals (Phase 5D) /// List pending push MFA approvals for the current user. public func getPendingApprovals() async throws -> [BLPushApproval] { return try await client.request(path: "/api/auth/mfa/push/pending", responseType: [BLPushApproval].self) } /// Respond to a push MFA approval (approve or deny). public func respondToApproval(approvalId: String, action: String) async throws -> BLPushApprovalResponse { let body = ["action": action] return try await client.request(path: "/api/auth/mfa/push/\(approvalId)/respond", method: "POST", body: body, responseType: BLPushApprovalResponse.self) } // MARK: - QR Auth (Phase 5E) /// Confirm a QR login challenge from the auth app. public func confirmQrLogin(challengeToken: String) async throws { let body = ["challengeToken": challengeToken] _ = try await client.rawRequest(path: "/api/auth/qr/confirm", method: "POST", body: body) } /// 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 12 minutes (access tokens expire in 15 minutes per PRD) refreshTimer = Timer.scheduledTimer(withTimeInterval: 12 * 60, repeats: true) { [weak self] _ in guard let self else { return } Task { await self.refreshAccessToken() } } } private func stopRefreshTimer() { refreshTimer?.invalidate() refreshTimer = nil } }