learning_ai_common_plat/packages/swift-platform-sdk/Sources/BLAuthClient.swift
saravanakumardb1 067a23449f feat(auth): SmartAuth admin-web — OAuth proxy, MFA settings, devices, passkeys, security dashboard
- Add 15 API proxy routes for SmartAuth endpoints (OAuth, MFA, devices, passkeys, security)
- Add MFA Settings page (/settings/security) with TOTP setup/verify/disable flow
- Add Device Management page (/settings/devices) with trust badges and revoke actions
- Add Passkey Management page (/settings/passkeys) with WebAuthn registration
- Add Admin Security Dashboard (/ops/security) with stats, provider distribution, login events
- Update login page with Google Sign-In button (env-gated) and MFA challenge flow
- Add sidebar nav links for new security pages
- Fix sidebar nav highlighting for nested routes (exact match for parent items)
- Add NEXT_PUBLIC_GOOGLE_CLIENT_ID to .env.example
2026-03-12 11:13:14 -07:00

566 lines
20 KiB
Swift

// 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 id: String
public let name: String
public let platform: String
public let trustLevel: String
public let trustExpiresAt: String?
public let lastLoginAt: String
}
/// 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: - 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] {
return try await client.request(path: "/api/auth/devices", responseType: [BLDevice].self)
}
/// 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.
public func revokeDevice(deviceId: String) async throws {
_ = try await client.rawRequest(path: "/api/auth/devices/\(deviceId)", method: "DELETE")
}
/// Revoke all devices (requires step-up).
public func revokeAllDevices() async throws {
_ = try await client.rawRequest(path: "/api/auth/devices", method: "DELETE")
}
// 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] {
return try await client.request(
path: "/api/auth/login-events/me?limit=\(limit)",
responseType: [BLLoginEvent].self
)
}
/// 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
}
}