Aller au contenu

iOS Integration

Ce contenu n’est pas encore disponible dans votre langue.

This guide covers how to integrate Skycloak authentication into iOS applications using the AppAuth library, which implements OAuth 2.0 and OpenID Connect best practices.

  • Xcode 14.0 or later
  • iOS 13.0+ deployment target
  • Skycloak cluster with configured realm and mobile client
  • CocoaPods or Swift Package Manager
  • Basic understanding of Swift and iOS development

Using Swift Package Manager (recommended):

  1. In Xcode, go to File → Add Package Dependencies
  2. Add https://github.com/openid/AppAuth-iOS.git
  3. Select version 1.6.2 or later

Using CocoaPods:

# Podfile
platform :ios, '13.0'
use_frameworks!

target 'YourApp' do
  pod 'AppAuth', '~> 1.6'
  pod 'JWTDecode', '~> 3.1'
  pod 'KeychainAccess', '~> 4.2'
end
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>com.yourcompany.app</string>
        </array>
        <key>CFBundleURLName</key>
        <string>com.yourcompany.app</string>
    </dict>
</array>

<!-- Required for iOS 11+ -->
<key>LSApplicationQueriesSchemes</key>
<array>
    <string>https</string>
    <string>http</string>
</array>
// Auth/AuthConfig.swift
import Foundation

struct AuthConfig {
    static let issuer = URL(string: "https://your-cluster-id.app.skycloak.io/realms/your-realm")!
    static let clientId = "your-ios-app"
    static let redirectUri = URL(string: "com.yourcompany.app://oauth/callback")!
    static let postLogoutRedirectUri = URL(string: "com.yourcompany.app://oauth/logout")!
    static let scopes = ["openid", "profile", "email", "offline_access"]
    
    // Additional endpoints if needed
    static let authorizationEndpoint = URL(string: "\(issuer)/protocol/openid-connect/auth")!
    static let tokenEndpoint = URL(string: "\(issuer)/protocol/openid-connect/token")!
    static let userInfoEndpoint = URL(string: "\(issuer)/protocol/openid-connect/userinfo")!
    static let endSessionEndpoint = URL(string: "\(issuer)/protocol/openid-connect/logout")!
}
// Auth/AuthManager.swift
import Foundation
import AppAuth
import JWTDecode
import KeychainAccess

class AuthManager: NSObject, ObservableObject {
    static let shared = AuthManager()
    
    @Published private(set) var isAuthenticated = false
    @Published private(set) var userInfo: UserInfo?
    @Published private(set) var error: Error?
    @Published private(set) var isLoading = false
    
    private var authState: OIDAuthState?
    private var currentAuthorizationFlow: OIDExternalUserAgentSession?
    private let keychain = Keychain(service: "com.yourcompany.app.oauth")
    
    private override init() {
        super.init()
        loadAuthState()
    }
    
    // MARK: - Public Methods
    
    func authenticate(presentingViewController: UIViewController) {
        isLoading = true
        error = nil
        
        // Discover configuration
        OIDAuthorizationService.discoverConfiguration(for: AuthConfig.issuer) { [weak self] configuration, error in
            guard let self = self else { return }
            
            if let error = error {
                self.handleError(error)
                return
            }
            
            guard let configuration = configuration else {
                self.handleError(AuthError.configurationNotFound)
                return
            }
            
            self.performAuthentication(
                configuration: configuration,
                presentingViewController: presentingViewController
            )
        }
    }
    
    private func performAuthentication(
        configuration: OIDServiceConfiguration,
        presentingViewController: UIViewController
    ) {
        let request = OIDAuthorizationRequest(
            configuration: configuration,
            clientId: AuthConfig.clientId,
            clientSecret: nil,
            scopes: AuthConfig.scopes,
            redirectURL: AuthConfig.redirectUri,
            responseType: OIDResponseTypeCode,
            additionalParameters: ["prompt": "login consent"]
        )
        
        currentAuthorizationFlow = OIDAuthState.authState(
            byPresenting: request,
            presenting: presentingViewController
        ) { [weak self] authState, error in
            guard let self = self else { return }
            
            if let authState = authState {
                self.handleAuthSuccess(authState)
            } else if let error = error {
                self.handleError(error)
            }
            
            self.currentAuthorizationFlow = nil
        }
    }
    
    func refreshTokenIfNeeded() async throws {
        guard let authState = authState else {
            throw AuthError.notAuthenticated
        }
        
        return try await withCheckedThrowingContinuation { continuation in
            authState.setNeedsTokenRefresh()
            
            authState.performAction { [weak self] accessToken, idToken, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    self?.updateUserInfo(from: idToken)
                    continuation.resume()
                }
            }
        }
    }
    
    func logout(presentingViewController: UIViewController) {
        guard let idToken = authState?.lastTokenResponse?.idToken else {
            clearAuthState()
            return
        }
        
        isLoading = true
        
        // Create end session request
        let request = OIDEndSessionRequest(
            configuration: authState!.lastAuthorizationResponse.request.configuration,
            idTokenHint: idToken,
            postLogoutRedirectURL: AuthConfig.postLogoutRedirectUri,
            additionalParameters: nil
        )
        
        let userAgent = OIDExternalUserAgentIOS(presenting: presentingViewController)
        
        currentAuthorizationFlow = OIDAuthorizationService.present(
            request,
            externalUserAgent: userAgent
        ) { [weak self] response, error in
            self?.clearAuthState()
            self?.currentAuthorizationFlow = nil
        }
    }
    
    func getAccessToken() async throws -> String {
        guard let authState = authState else {
            throw AuthError.notAuthenticated
        }
        
        // Refresh if needed
        try await refreshTokenIfNeeded()
        
        guard let accessToken = authState.lastTokenResponse?.accessToken else {
            throw AuthError.noAccessToken
        }
        
        return accessToken
    }
    
    // MARK: - Role Management
    
    func hasRole(_ role: String) -> Bool {
        return userInfo?.roles.contains(role) ?? false
    }
    
    func hasAnyRole(_ roles: String...) -> Bool {
        guard let userRoles = userInfo?.roles else { return false }
        return roles.contains { userRoles.contains($0) }
    }
    
    func hasAllRoles(_ roles: String...) -> Bool {
        guard let userRoles = userInfo?.roles else { return false }
        return roles.allSatisfy { userRoles.contains($0) }
    }
    
    // MARK: - Private Methods
    
    private func handleAuthSuccess(_ authState: OIDAuthState) {
        self.authState = authState
        saveAuthState()
        
        if let idToken = authState.lastTokenResponse?.idToken {
            updateUserInfo(from: idToken)
        }
        
        isAuthenticated = true
        isLoading = false
        error = nil
        
        // Setup token refresh
        setupTokenRefreshTimer()
    }
    
    private func handleError(_ error: Error) {
        self.error = error
        isLoading = false
        isAuthenticated = false
        
        print("Authentication error: \(error.localizedDescription)")
    }
    
    private func updateUserInfo(from idToken: String?) {
        guard let idToken = idToken else { return }
        
        do {
            let jwt = try decode(jwt: idToken)
            
            userInfo = UserInfo(
                id: jwt.subject ?? "",
                username: jwt["preferred_username"]?.string ?? "",
                email: jwt["email"]?.string ?? "",
                name: jwt["name"]?.string ?? "",
                roles: extractRoles(from: jwt)
            )
        } catch {
            print("Failed to decode ID token: \(error)")
        }
    }
    
    private func extractRoles(from jwt: JWT) -> [String] {
        var roles: [String] = []
        
        // Extract realm roles
        if let realmAccess = jwt["realm_access"]?.dictionary,
           let realmRoles = realmAccess["roles"]?.array {
            roles.append(contentsOf: realmRoles.compactMap { $0.string })
        }
        
        // Extract client roles
        if let resourceAccess = jwt["resource_access"]?.dictionary,
           let clientAccess = resourceAccess[AuthConfig.clientId]?.dictionary,
           let clientRoles = clientAccess["roles"]?.array {
            roles.append(contentsOf: clientRoles.compactMap { $0.string })
        }
        
        return roles
    }
    
    // MARK: - State Persistence
    
    private func saveAuthState() {
        guard let authState = authState,
              let data = try? NSKeyedArchiver.archivedData(
                withRootObject: authState,
                requiringSecureCoding: false
              ) else { return }
        
        do {
            try keychain.set(data, key: "authState")
        } catch {
            print("Failed to save auth state: \(error)")
        }
    }
    
    private func loadAuthState() {
        do {
            guard let data = try keychain.getData("authState"),
                  let authState = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? OIDAuthState else {
                return
            }
            
            self.authState = authState
            
            // Check if still valid
            if authState.isAuthorized {
                isAuthenticated = true
                
                if let idToken = authState.lastTokenResponse?.idToken {
                    updateUserInfo(from: idToken)
                }
                
                setupTokenRefreshTimer()
            } else {
                clearAuthState()
            }
        } catch {
            print("Failed to load auth state: \(error)")
        }
    }
    
    private func clearAuthState() {
        authState = nil
        isAuthenticated = false
        userInfo = nil
        isLoading = false
        
        do {
            try keychain.remove("authState")
        } catch {
            print("Failed to clear auth state: \(error)")
        }
    }
    
    // MARK: - Token Refresh
    
    private var tokenRefreshTimer: Timer?
    
    private func setupTokenRefreshTimer() {
        tokenRefreshTimer?.invalidate()
        
        // Refresh token 5 minutes before expiry
        guard let expiresAt = authState?.lastTokenResponse?.accessTokenExpirationDate else { return }
        
        let refreshTime = expiresAt.addingTimeInterval(-300) // 5 minutes before
        let timeInterval = refreshTime.timeIntervalSinceNow
        
        if timeInterval > 0 {
            tokenRefreshTimer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { [weak self] _ in
                Task {
                    try? await self?.refreshTokenIfNeeded()
                    self?.setupTokenRefreshTimer()
                }
            }
        }
    }
}

// MARK: - Supporting Types

struct UserInfo {
    let id: String
    let username: String
    let email: String
    let name: String
    let roles: [String]
}

enum AuthError: LocalizedError {
    case configurationNotFound
    case notAuthenticated
    case noAccessToken
    case tokenExpired
    
    var errorDescription: String? {
        switch self {
        case .configurationNotFound:
            return "Failed to discover OpenID configuration"
        case .notAuthenticated:
            return "User is not authenticated"
        case .noAccessToken:
            return "No access token available"
        case .tokenExpired:
            return "Token has expired"
        }
    }
}

In your AppDelegate or SceneDelegate:

// AppDelegate.swift
import UIKit
import AppAuth

class AppDelegate: UIResponder, UIApplicationDelegate {
    
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
        // Handle OAuth redirect
        if let authFlow = AuthManager.shared.currentAuthorizationFlow,
           authFlow.resumeExternalUserAgentFlow(with: url) {
            return true
        }
        
        return false
    }
}

// SceneDelegate.swift (for iOS 13+)
import UIKit
import AppAuth

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        guard let url = URLContexts.first?.url else { return }
        
        // Handle OAuth redirect
        if let authFlow = AuthManager.shared.currentAuthorizationFlow,
           authFlow.resumeExternalUserAgentFlow(with: url) {
            return
        }
    }
}
// Views/LoginView.swift
import SwiftUI

struct LoginView: View {
    @StateObject private var authManager = AuthManager.shared
    @State private var showError = false
    
    var body: some View {
        ZStack {
            LinearGradient(
                gradient: Gradient(colors: [.blue, .purple]),
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()
            
            VStack(spacing: 30) {
                VStack(spacing: 10) {
                    Image(systemName: "lock.shield.fill")
                        .font(.system(size: 80))
                        .foregroundColor(.white)
                    
                    Text("Welcome to MyApp")
                        .font(.largeTitle)
                        .fontWeight(.bold)
                        .foregroundColor(.white)
                    
                    Text("Sign in to continue")
                        .font(.headline)
                        .foregroundColor(.white.opacity(0.8))
                }
                .padding(.bottom, 50)
                
                VStack(spacing: 20) {
                    Button(action: performLogin) {
                        HStack {
                            Image(systemName: "person.fill")
                            Text("Sign In with Skycloak")
                                .fontWeight(.semibold)
                        }
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.white)
                        .foregroundColor(.blue)
                        .cornerRadius(12)
                        .shadow(radius: 5)
                    }
                    .disabled(authManager.isLoading)
                    
                    Button(action: performRegister) {
                        Text("Create Account")
                            .fontWeight(.medium)
                            .foregroundColor(.white)
                            .underline()
                    }
                }
                .padding(.horizontal, 40)
                
                if authManager.isLoading {
                    ProgressView()
                        .progressViewStyle(CircularProgressViewStyle(tint: .white))
                        .scaleEffect(1.5)
                        .padding(.top, 20)
                }
            }
        }
        .alert("Authentication Error", isPresented: $showError) {
            Button("OK") { }
        } message: {
            Text(authManager.error?.localizedDescription ?? "An unknown error occurred")
        }
        .onChange(of: authManager.error) { error in
            showError = error != nil
        }
    }
    
    private func performLogin() {
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
              let rootViewController = windowScene.windows.first?.rootViewController else {
            return
        }
        
        authManager.authenticate(presentingViewController: rootViewController)
    }
    
    private func performRegister() {
        // Similar to login, but you might pass additional parameters
        performLogin()
    }
}
// Views/ContentView.swift
import SwiftUI

struct ContentView: View {
    @StateObject private var authManager = AuthManager.shared
    
    var body: some View {
        Group {
            if authManager.isAuthenticated {
                MainTabView()
            } else {
                LoginView()
            }
        }
        .animation(.easeInOut, value: authManager.isAuthenticated)
    }
}

struct MainTabView: View {
    @StateObject private var authManager = AuthManager.shared
    @State private var selectedTab = 0
    
    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house.fill")
                }
                .tag(0)
            
            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person.fill")
                }
                .tag(1)
            
            if authManager.hasRole("admin") {
                AdminView()
                    .tabItem {
                        Label("Admin", systemImage: "gear")
                    }
                    .tag(2)
            }
        }
    }
}

struct HomeView: View {
    @StateObject private var authManager = AuthManager.shared
    
    var body: some View {
        NavigationView {
            ScrollView {
                VStack(alignment: .leading, spacing: 20) {
                    Text("Welcome, \(authManager.userInfo?.name ?? "User")!")
                        .font(.largeTitle)
                        .fontWeight(.bold)
                        .padding(.horizontal)
                    
                    DashboardCard(
                        title: "Your Dashboard",
                        description: "View your personalized content here"
                    )
                    
                    if authManager.hasAnyRole("editor", "moderator") {
                        DashboardCard(
                            title: "Content Management",
                            description: "Manage your content",
                            icon: "doc.text.fill"
                        )
                    }
                }
                .padding(.vertical)
            }
            .navigationTitle("Home")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("Logout") {
                        performLogout()
                    }
                }
            }
        }
    }
    
    private func performLogout() {
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
              let rootViewController = windowScene.windows.first?.rootViewController else {
            return
        }
        
        authManager.logout(presentingViewController: rootViewController)
    }
}

struct DashboardCard: View {
    let title: String
    let description: String
    var icon: String = "star.fill"
    
    var body: some View {
        HStack {
            Image(systemName: icon)
                .font(.largeTitle)
                .foregroundColor(.blue)
                .frame(width: 60)
            
            VStack(alignment: .leading, spacing: 5) {
                Text(title)
                    .font(.headline)
                Text(description)
                    .font(.subheadline)
                    .foregroundColor(.secondary)
            }
            
            Spacer()
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(12)
        .shadow(radius: 3)
        .padding(.horizontal)
    }
}

struct ProfileView: View {
    @StateObject private var authManager = AuthManager.shared
    
    var body: some View {
        NavigationView {
            Form {
                Section("User Information") {
                    ProfileRow(label: "Username", value: authManager.userInfo?.username ?? "")
                    ProfileRow(label: "Email", value: authManager.userInfo?.email ?? "")
                    ProfileRow(label: "Name", value: authManager.userInfo?.name ?? "")
                    ProfileRow(label: "ID", value: authManager.userInfo?.id ?? "")
                }
                
                Section("Roles") {
                    ForEach(authManager.userInfo?.roles ?? [], id: \.self) { role in
                        HStack {
                            Image(systemName: "checkmark.shield.fill")
                                .foregroundColor(.green)
                            Text(role)
                        }
                    }
                }
            }
            .navigationTitle("Profile")
        }
    }
}

struct ProfileRow: View {
    let label: String
    let value: String
    
    var body: some View {
        HStack {
            Text(label)
                .foregroundColor(.secondary)
            Spacer()
            Text(value)
                .fontWeight(.medium)
        }
    }
}
// Network/NetworkManager.swift
import Foundation
import Combine

class NetworkManager: ObservableObject {
    static let shared = NetworkManager()
    
    private let baseURL = "https://api.example.com"
    private let authManager = AuthManager.shared
    private let decoder = JSONDecoder()
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        decoder.dateDecodingStrategy = .iso8601
        decoder.keyDecodingStrategy = .convertFromSnakeCase
    }
    
    // MARK: - Generic Request Method
    
    func request<T: Decodable>(
        _ endpoint: Endpoint,
        type: T.Type,
        authenticated: Bool = true
    ) async throws -> T {
        var request = URLRequest(url: URL(string: "\(baseURL)\(endpoint.path)")!)
        request.httpMethod = endpoint.method.rawValue
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        // Add authentication header if required
        if authenticated {
            let token = try await authManager.getAccessToken()
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        
        // Add body if present
        if let body = endpoint.body {
            request.httpBody = try JSONEncoder().encode(body)
        }
        
        // Add query parameters
        if let queryItems = endpoint.queryItems {
            var components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
            components.queryItems = queryItems
            request.url = components.url
        }
        
        let (data, response) = try await URLSession.shared.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }
        
        switch httpResponse.statusCode {
        case 200...299:
            return try decoder.decode(T.self, from: data)
        case 401:
            // Token might be expired, try refreshing
            try await authManager.refreshTokenIfNeeded()
            // Retry the request
            return try await self.request(endpoint, type: type, authenticated: authenticated)
        case 403:
            throw NetworkError.forbidden
        case 404:
            throw NetworkError.notFound
        default:
            throw NetworkError.serverError(statusCode: httpResponse.statusCode)
        }
    }
    
    // MARK: - Convenience Methods
    
    func getUserProfile() async throws -> UserProfile {
        try await request(.getUserProfile, type: UserProfile.self)
    }
    
    func updateUserProfile(_ profile: UpdateProfileRequest) async throws -> UserProfile {
        try await request(.updateUserProfile(profile), type: UserProfile.self)
    }
    
    func getUsers(page: Int = 1) async throws -> UsersResponse {
        try await request(.getUsers(page: page), type: UsersResponse.self)
    }
}

// MARK: - Endpoints

enum Endpoint {
    case getUserProfile
    case updateUserProfile(UpdateProfileRequest)
    case getUsers(page: Int)
    case deleteUser(id: String)
    
    var path: String {
        switch self {
        case .getUserProfile, .updateUserProfile:
            return "/user/profile"
        case .getUsers:
            return "/admin/users"
        case .deleteUser(let id):
            return "/admin/users/\(id)"
        }
    }
    
    var method: HTTPMethod {
        switch self {
        case .getUserProfile, .getUsers:
            return .get
        case .updateUserProfile:
            return .put
        case .deleteUser:
            return .delete
        }
    }
    
    var body: Encodable? {
        switch self {
        case .updateUserProfile(let request):
            return request
        default:
            return nil
        }
    }
    
    var queryItems: [URLQueryItem]? {
        switch self {
        case .getUsers(let page):
            return [URLQueryItem(name: "page", value: "\(page)")]
        default:
            return nil
        }
    }
}

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
    case patch = "PATCH"
}

enum NetworkError: LocalizedError {
    case invalidResponse
    case forbidden
    case notFound
    case serverError(statusCode: Int)
    
    var errorDescription: String? {
        switch self {
        case .invalidResponse:
            return "Invalid response from server"
        case .forbidden:
            return "Access forbidden"
        case .notFound:
            return "Resource not found"
        case .serverError(let statusCode):
            return "Server error: \(statusCode)"
        }
    }
}

// MARK: - Models

struct UserProfile: Codable {
    let id: String
    let username: String
    let email: String
    let name: String
    let avatar: String?
    let createdAt: Date
    let updatedAt: Date
}

struct UpdateProfileRequest: Codable {
    let name: String
    let avatar: String?
}

struct UsersResponse: Codable {
    let users: [UserProfile]
    let totalPages: Int
    let currentPage: Int
}
// Views/UserListView.swift
import SwiftUI

struct UserListView: View {
    @StateObject private var viewModel = UserListViewModel()
    @StateObject private var authManager = AuthManager.shared
    
    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.users) { user in
                    UserRow(user: user)
                }
                
                if viewModel.hasMorePages {
                    ProgressView()
                        .frame(maxWidth: .infinity)
                        .padding()
                        .onAppear {
                            Task {
                                await viewModel.loadMoreUsers()
                            }
                        }
                }
            }
            .navigationTitle("Users")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: { Task { await viewModel.refreshUsers() } }) {
                        Image(systemName: "arrow.clockwise")
                    }
                }
            }
            .refreshable {
                await viewModel.refreshUsers()
            }
            .alert("Error", isPresented: $viewModel.showError) {
                Button("OK") { }
            } message: {
                Text(viewModel.errorMessage)
            }
        }
        .task {
            await viewModel.loadUsers()
        }
    }
}

struct UserRow: View {
    let user: UserProfile
    
    var body: some View {
        HStack {
            AsyncImage(url: URL(string: user.avatar ?? "")) { image in
                image
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            } placeholder: {
                Image(systemName: "person.circle.fill")
                    .foregroundColor(.gray)
            }
            .frame(width: 50, height: 50)
            .clipShape(Circle())
            
            VStack(alignment: .leading) {
                Text(user.name)
                    .font(.headline)
                Text(user.email)
                    .font(.subheadline)
                    .foregroundColor(.secondary)
            }
            
            Spacer()
        }
        .padding(.vertical, 4)
    }
}

@MainActor
class UserListViewModel: ObservableObject {
    @Published var users: [UserProfile] = []
    @Published var showError = false
    @Published var errorMessage = ""
    @Published var isLoading = false
    @Published var hasMorePages = true
    
    private var currentPage = 1
    private let networkManager = NetworkManager.shared
    
    func loadUsers() async {
        guard !isLoading else { return }
        
        isLoading = true
        
        do {
            let response = try await networkManager.getUsers(page: currentPage)
            users.append(contentsOf: response.users)
            hasMorePages = currentPage < response.totalPages
            currentPage += 1
        } catch {
            showError = true
            errorMessage = error.localizedDescription
        }
        
        isLoading = false
    }
    
    func loadMoreUsers() async {
        await loadUsers()
    }
    
    func refreshUsers() async {
        users.removeAll()
        currentPage = 1
        hasMorePages = true
        await loadUsers()
    }
}
// Tests/AuthManagerTests.swift
import XCTest
@testable import YourApp
import AppAuth

class AuthManagerTests: XCTestCase {
    
    var authManager: AuthManager!
    
    override func setUp() {
        super.setUp()
        authManager = AuthManager.shared
    }
    
    func testHasRole() {
        // Create mock user info
        let userInfo = UserInfo(
            id: "123",
            username: "testuser",
            email: "test@example.com",
            name: "Test User",
            roles: ["user", "admin"]
        )
        
        // Set user info using reflection (for testing only)
        authManager.setValue(userInfo, forKey: "userInfo")
        
        XCTAssertTrue(authManager.hasRole("admin"))
        XCTAssertTrue(authManager.hasRole("user"))
        XCTAssertFalse(authManager.hasRole("superadmin"))
    }
    
    func testHasAnyRole() {
        let userInfo = UserInfo(
            id: "123",
            username: "testuser",
            email: "test@example.com",
            name: "Test User",
            roles: ["editor"]
        )
        
        authManager.setValue(userInfo, forKey: "userInfo")
        
        XCTAssertTrue(authManager.hasAnyRole("admin", "editor"))
        XCTAssertFalse(authManager.hasAnyRole("admin", "manager"))
    }
    
    func testHasAllRoles() {
        let userInfo = UserInfo(
            id: "123",
            username: "testuser",
            email: "test@example.com",
            name: "Test User",
            roles: ["user", "editor", "moderator"]
        )
        
        authManager.setValue(userInfo, forKey: "userInfo")
        
        XCTAssertTrue(authManager.hasAllRoles("user", "editor"))
        XCTAssertFalse(authManager.hasAllRoles("user", "admin"))
    }
}
// UITests/LoginUITests.swift
import XCTest

class LoginUITests: XCTestCase {
    
    var app: XCUIApplication!
    
    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }
    
    func testLoginButtonExists() {
        let loginButton = app.buttons["Sign In with Skycloak"]
        XCTAssertTrue(loginButton.exists)
        XCTAssertTrue(loginButton.isEnabled)
    }
    
    func testLoginFlow() {
        let loginButton = app.buttons["Sign In with Skycloak"]
        loginButton.tap()
        
        // Wait for web view to appear
        let webView = app.webViews.firstMatch
        XCTAssertTrue(webView.waitForExistence(timeout: 10))
        
        // Note: Actual login flow testing in web view is limited
        // Consider using mock auth for UI tests
    }
    
    func testCreateAccountButtonExists() {
        let createAccountButton = app.buttons["Create Account"]
        XCTAssertTrue(createAccountButton.exists)
        XCTAssertTrue(createAccountButton.isEnabled)
    }
}
// Security/SecurityManager.swift
import Foundation
import CryptoKit
import LocalAuthentication

class SecurityManager {
    static let shared = SecurityManager()
    
    private init() {}
    
    // MARK: - Biometric Authentication
    
    func authenticateWithBiometrics(reason: String) async throws -> Bool {
        let context = LAContext()
        var error: NSError?
        
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            throw SecurityError.biometricsNotAvailable
        }
        
        return try await withCheckedThrowingContinuation { continuation in
            context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, error in
                if success {
                    continuation.resume(returning: true)
                } else if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: false)
                }
            }
        }
    }
    
    // MARK: - Certificate Pinning
    
    func createPinnedURLSession() -> URLSession {
        let configuration = URLSessionConfiguration.default
        configuration.timeoutIntervalForRequest = 30
        configuration.timeoutIntervalForResource = 60
        
        return URLSession(configuration: configuration, delegate: PinningDelegate(), delegateQueue: nil)
    }
    
    // MARK: - Jailbreak Detection
    
    var isJailbroken: Bool {
        #if targetEnvironment(simulator)
        return false
        #else
        // Check for common jailbreak files
        let jailbreakPaths = [
            "/Applications/Cydia.app",
            "/Library/MobileSubstrate/MobileSubstrate.dylib",
            "/bin/bash",
            "/usr/sbin/sshd",
            "/etc/apt",
            "/private/var/lib/apt/"
        ]
        
        for path in jailbreakPaths {
            if FileManager.default.fileExists(atPath: path) {
                return true
            }
        }
        
        // Check if app can write to system directories
        let testString = "Jailbreak test"
        do {
            try testString.write(toFile: "/private/test.txt", atomically: true, encoding: .utf8)
            try FileManager.default.removeItem(atPath: "/private/test.txt")
            return true
        } catch {
            // Expected behavior on non-jailbroken devices
        }
        
        // Check for suspicious URL schemes
        if let url = URL(string: "cydia://package/com.example.package"),
           UIApplication.shared.canOpenURL(url) {
            return true
        }
        
        return false
        #endif
    }
}

// MARK: - Certificate Pinning Delegate

class PinningDelegate: NSObject, URLSessionDelegate {
    
    private let pinnedCertificates: [String: String] = [
        "your-cluster-id.app.skycloak.io": "sha256/YOUR_CERTIFICATE_HASH",
        "api.example.com": "sha256/YOUR_API_CERTIFICATE_HASH"
    ]
    
    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust,
              let host = challenge.protectionSpace.host else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }
        
        // Verify certificate
        if let pinnedHash = pinnedCertificates[host] {
            if let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) {
                let serverCertificateData = SecCertificateCopyData(serverCertificate) as Data
                let serverHash = "sha256/" + serverCertificateData.sha256()
                
                if serverHash == pinnedHash {
                    let credential = URLCredential(trust: serverTrust)
                    completionHandler(.useCredential, credential)
                    return
                }
            }
        }
        
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

extension Data {
    func sha256() -> String {
        let hash = SHA256.hash(data: self)
        return hash.compactMap { String(format: "%02x", $0) }.joined()
    }
}

// MARK: - Security Errors

enum SecurityError: LocalizedError {
    case biometricsNotAvailable
    case jailbreakDetected
    case certificatePinningFailed
    
    var errorDescription: String? {
        switch self {
        case .biometricsNotAvailable:
            return "Biometric authentication is not available"
        case .jailbreakDetected:
            return "This app cannot run on jailbroken devices"
        case .certificatePinningFailed:
            return "Certificate verification failed"
        }
    }
}
// Analytics/AnalyticsManager.swift
import Foundation
import os.log

class AnalyticsManager {
    static let shared = AnalyticsManager()
    
    private let logger = Logger(subsystem: "com.yourcompany.app", category: "Analytics")
    
    private init() {}
    
    // MARK: - Authentication Events
    
    func trackLoginAttempt() {
        logger.info("Login attempt initiated")
        // Send to analytics service
    }
    
    func trackLoginSuccess(userId: String) {
        logger.info("Login successful for user: \(userId, privacy: .private)")
        // Send to analytics service
    }
    
    func trackLoginFailure(error: Error) {
        logger.error("Login failed: \(error.localizedDescription)")
        // Send to analytics service
    }
    
    func trackLogout(userId: String) {
        logger.info("User logged out: \(userId, privacy: .private)")
        // Send to analytics service
    }
    
    func trackTokenRefresh(success: Bool) {
        logger.info("Token refresh: \(success ? "successful" : "failed")")
        // Send to analytics service
    }
    
    // MARK: - Performance Metrics
    
    func measureAuthenticationTime(start: Date) {
        let duration = Date().timeIntervalSince(start)
        logger.info("Authentication completed in \(duration, format: .fixed(precision: 2)) seconds")
        // Send metric to performance monitoring
    }
    
    func measureAPICallDuration(endpoint: String, duration: TimeInterval, success: Bool) {
        logger.info("API call to \(endpoint): \(duration, format: .fixed(precision: 2))s, success: \(success)")
        // Send metric to performance monitoring
    }
}
  1. App Transport Security (ATS) Issues

    • For development, you may need to add ATS exceptions
    • In production, always use HTTPS
    • Update Info.plist if needed:
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSExceptionDomains</key>
        <dict>
            <key>your-dev-server.local</key>
            <dict>
                <key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
                <true/>
            </dict>
        </dict>
    </dict>
  2. Redirect URL Not Working

    • Verify URL scheme in Info.plist matches exactly
    • Check for typos in redirect URI configuration
    • Ensure AppDelegate/SceneDelegate handles the URL
  3. Token Refresh Issues

    • Verify offline_access scope is included
    • Check refresh token expiration in Keycloak
    • Ensure keychain access is working properly
// Debug/AuthDebugger.swift
#if DEBUG
import Foundation
import os.log

class AuthDebugger {
    static let shared = AuthDebugger()
    private let logger = Logger(subsystem: "com.yourcompany.app", category: "AuthDebug")
    
    func logAuthState(_ authState: OIDAuthState?) {
        guard let authState = authState else {
            logger.debug("Auth state is nil")
            return
        }
        
        logger.debug("=== Auth State Debug ===")
        logger.debug("Is Authorized: \(authState.isAuthorized)")
        logger.debug("Access Token: \(String(describing: authState.lastTokenResponse?.accessToken?.prefix(20)))...")
        logger.debug("Expires At: \(String(describing: authState.lastTokenResponse?.accessTokenExpirationDate))")
        logger.debug("Refresh Token: \(authState.refreshToken != nil)")
    }
    
    func logTokenClaims(_ idToken: String?) {
        guard let idToken = idToken else {
            logger.debug("ID token is nil")
            return
        }
        
        do {
            let jwt = try decode(jwt: idToken)
            logger.debug("=== Token Claims ===")
            logger.debug("Subject: \(jwt.subject ?? "nil")")
            logger.debug("Issuer: \(jwt.issuer ?? "nil")")
            logger.debug("Audience: \(String(describing: jwt.audience))")
            logger.debug("Expires At: \(String(describing: jwt.expiresAt))")
        } catch {
            logger.error("Failed to decode token: \(error.localizedDescription)")
        }
    }
    
    func measurePerformance<T>(operation: String, block: () throws -> T) rethrows -> T {
        let start = Date()
        defer {
            let duration = Date().timeIntervalSince(start)
            logger.debug("\(operation) completed in \(duration * 1000, format: .fixed(precision: 2))ms")
        }
        return try block()
    }
}
#endif