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):
- In Xcode, go to File → Add Package Dependencies
- Add
https://github.com/openid/AppAuth-iOS.git - 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'
end2. 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
-
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> -
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
-
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()
}
}
#endifNext Steps
- Configure Security Settings - Add extra security layers including MFA options
- Set Up Applications - Configure your iOS app client settings
- User Management - Manage users and their access
- Explore Other Integrations - See integration guides for other platforms