learning_ai_common_plat/packages/swift-platform-sdk/Sources/BLAuthClient.swift
saravanakumardb1 b8f22be677 fix(auth): SDK device/login-events response wrappers + correct API paths
- Swift + Kotlin SDKs: listDevices() now unwraps { devices: [...] }
- Swift + Kotlin SDKs: getLoginHistory() now unwraps { events: [...] }
- Swift + Kotlin SDKs: revokeDevice() uses fingerprint param (not doc ID)
- Swift + Kotlin SDKs: revokeAllDevices() uses POST /revoke-all (not DELETE)
- Swift + Kotlin SDKs: getLoginHistory() path /login-events (not /login-events/me)
- Swift + Kotlin SDKs: Device model updated to match backend response fields
- All 53 auth tests passing
2026-03-12 15:42:54 -07:00

666 lines
24 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 5C5E 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
}
}