iOS Integration

iOS Integration

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.

Prerequisites

  • 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

Quick Start

1. Install Dependencies

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

2. Configure Info.plist

<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>

3. Create Auth Configuration

// 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")!
}

4. Create Auth Manager

// 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"
        }
    }
}

5. Handle URL Callbacks

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
        }
    }
}

UI Implementation

SwiftUI Login View

// 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()
    }
}

Main App View

// 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)
        }
    }
}

API Integration

Network Manager

// 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
}

Using Network Manager in Views

// 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()
    }
}

Testing

Unit Tests

// 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: "[email protected]",
            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: "[email protected]",
            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: "[email protected]",
            name: "Test User",
            roles: ["user", "editor", "moderator"]
        )
        
        authManager.setValue(userInfo, forKey: "userInfo")
        
        XCTAssertTrue(authManager.hasAllRoles("user", "editor"))
        XCTAssertFalse(authManager.hasAllRoles("user", "admin"))
    }
}

UI Tests

// 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)
    }
}

Production Considerations

Security Configuration

// 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 and Monitoring

// 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
    }
}

Troubleshooting

Common Issues

  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 Utilities

// 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

Next Steps