05/02 Phase C 2

This commit is contained in:
Nguyen Ngo
2026-05-03 09:29:12 -04:00
parent 59e698b5f7
commit 51d2923e6d
6 changed files with 439 additions and 309 deletions
+7 -37
View File
@@ -1,20 +1,16 @@
// Auth/AuthManager.swift
// ----------------------
// Observable class that manages the entire authentication lifecycle:
// - Login (POST /api/v1/auth/login)
// - Logout (POST /api/v1/auth/logout)
// - Session restoration on app launch (GET /api/v1/auth/me)
// - Keychain persistence of tokens and user info
import Foundation
import SwiftUI
import Combine
struct EmptyDecodable: Decodable, Sendable {
nonisolated init(from decoder: any Decoder) throws {}
}
@MainActor
class AuthManager: ObservableObject {
// Published State
@Published var isAuthenticated = false
@Published var isLoading = false
@Published var errorMessage: String?
@@ -24,21 +20,14 @@ class AuthManager: ObservableObject {
@Published var currentUserRole: String = ""
@Published var currentDisplayName: String = ""
// Singleton
static let shared = AuthManager()
private init() {}
// App Launch: Restore Session
/// Called once on app launch. Checks if a valid session exists in Keychain.
/// If an access token is present, verifies it with /api/v1/auth/me.
/// If expired, attempts a refresh. If all fails, shows the login screen.
func restoreSession() async {
guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else {
isAuthenticated = false
return
}
isLoading = true
defer { isLoading = false }
@@ -47,19 +36,14 @@ class AuthManager: ObservableObject {
applyUser(response.user)
isAuthenticated = true
} catch APIError.notAuthenticated {
// Refresh failed (APIClient tried automatically) need fresh login
KeychainHelper.clearAll()
isAuthenticated = false
} catch {
// Network error still show as authenticated using cached Keychain data
// so the inspector can work offline
restoreUserFromKeychain()
isAuthenticated = true
}
}
// Login
func login(username: String, password: String) async {
isLoading = true
errorMessage = nil
@@ -70,14 +54,10 @@ class AuthManager: ObservableObject {
"/api/v1/auth/login",
body: ["username": username, "password": password]
)
// Store tokens in Keychain
KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
applyUser(response.user)
isAuthenticated = true
} catch APIError.serverError(let msg) {
errorMessage = msg
} catch APIError.networkError {
@@ -87,17 +67,13 @@ class AuthManager: ObservableObject {
}
}
// Logout
func logout() async {
// Best-effort server-side logout (revokes refresh token)
if let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken) {
_ = try? await APIClient.shared.post(
"/api/v1/auth/logout",
body: ["refresh_token": refreshToken]
) as EmptyDecodable
}
KeychainHelper.clearAll()
isAuthenticated = false
currentUserId = 0
@@ -106,17 +82,14 @@ class AuthManager: ObservableObject {
currentDisplayName = ""
}
// Helpers
private func applyUser(_ user: APIUser) {
currentUserId = user.id
currentUsername = user.username
currentUserRole = user.role
currentDisplayName = user.displayName
KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId)
KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole)
KeychainHelper.set(user.username, forKey: Constants.Keychain.username)
KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId)
KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole)
KeychainHelper.set(user.username, forKey: Constants.Keychain.username)
KeychainHelper.set(user.displayName, forKey: Constants.Keychain.displayName)
}
@@ -127,6 +100,3 @@ class AuthManager: ObservableObject {
currentDisplayName = KeychainHelper.get(Constants.Keychain.displayName) ?? ""
}
}
// Used for logout response decoding (empty data field)
struct EmptyDecodable: Decodable {}
+12 -11
View File
@@ -3,8 +3,10 @@
// Thin wrapper around iOS Security framework for storing sensitive data
// (tokens) in the device Keychain.
//
// The Keychain persists across app reinstalls (on the same device) and
// is encrypted by the OS. Never store tokens in UserDefaults.
// All methods are marked nonisolated so they can be called from any
// actor context (including APIClient which is an actor) without
// Swift 6 actor-isolation warnings.
// Security framework calls are internally thread-safe.
import Foundation
import Security
@@ -15,10 +17,9 @@ enum KeychainHelper {
// Write
@discardableResult
static func set(_ value: String, forKey key: String) -> Bool {
nonisolated static func set(_ value: String, forKey key: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
// Delete any existing entry first to avoid duplicate-item errors
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
@@ -26,10 +27,10 @@ enum KeychainHelper {
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
return status == errSecSuccess
@@ -37,7 +38,7 @@ enum KeychainHelper {
// Read
static func get(_ key: String) -> String? {
nonisolated static func get(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
@@ -56,7 +57,7 @@ enum KeychainHelper {
// Delete
@discardableResult
static func delete(_ key: String) -> Bool {
nonisolated static func delete(_ key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
@@ -67,7 +68,7 @@ enum KeychainHelper {
// Delete All JQC Keys
static func clearAll() {
nonisolated static func clearAll() {
let keys = [
Constants.Keychain.accessToken,
Constants.Keychain.refreshToken,