- 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
666 lines
24 KiB
Swift
666 lines
24 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 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
|
||
}
|
||
}
|