- 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
566 lines
20 KiB
Swift
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
|
|
}
|
|
}
|