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.
Prerequisites
Section titled “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
Section titled “Quick Start”1. Install Dependencies
Section titled “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'
end
2. Configure Info.plist
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “UI Implementation”SwiftUI Login View
Section titled “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
Section titled “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
Section titled “API Integration”Network Manager
Section titled “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
Section titled “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
Section titled “Testing”Unit Tests
Section titled “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: "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"))
}
}
UI Tests
Section titled “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
Section titled “Production Considerations”Security Configuration
Section titled “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
Section titled “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
Section titled “Troubleshooting”Common Issues
Section titled “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
Section titled “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
Section titled “Next 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