MojoAuth Hosted Login Page with iOS (Swift)
Introduction
This guide demonstrates how to integrate MojoAuth's Hosted Login Page with an iOS application using Swift and the AppAuth framework. The AppAuth library is a client SDK for native apps to authenticate and authorize end-users using OAuth 2.0 and OpenID Connect.
By implementing MojoAuth authentication in your iOS app, you can provide a secure, passwordless login experience with support for multiple authentication methods including email magic links, SMS OTP, social login, and passkeys.
Links:
- MojoAuth Hosted Login Page Documentation (opens in a new tab)
- AppAuth for iOS Documentation (opens in a new tab)
- Apple Authentication Services Documentation (opens in a new tab)
Prerequisites
Before you begin, make sure you have:
- A MojoAuth account with an OIDC application configured
- Your OIDC Client ID, Client Secret (if needed), and Issuer URL (https://your-project.auth.mojoauth.com (opens in a new tab))
- Xcode 14+ installed
- iOS 13.0+ target
- Basic knowledge of Swift and iOS development
Project Setup
Create a New iOS Project
- Open Xcode and create a new iOS project
- Select "App" template
- Configure your project:
- Product Name: MojoAuthDemo
- Interface: SwiftUI
- Language: Swift
- Minimum Deployment: iOS 13.0
Install Dependencies
We'll use CocoaPods to manage dependencies. If you don't have CocoaPods installed, run:
sudo gem install cocoapods
Create a Podfile
in your project root:
platform :ios, '13.0' target 'MojoAuthDemo' do use_frameworks! pod 'AppAuth', '~> 1.6.0' pod 'JWTDecode', '~> 3.0.0' end
Install the dependencies:
pod install
Open the generated .xcworkspace
file:
open MojoAuthDemo.xcworkspace
Configure URL Scheme
- In Xcode, select your project in the Project Navigator
- Select your app target and navigate to the "Info" tab
- Expand "URL Types" and click "+"
- Add a URL scheme that will be used for redirects (e.g.,
com.example.mojoauthdemo
)
Set Up Associated Domains (Optional for Universal Links)
For a production app, you should set up Universal Links:
- In Xcode, select your project in the Project Navigator
- Select your app target and navigate to the "Signing & Capabilities" tab
- Click "+" and add "Associated Domains"
- Add
applinks:your-app.mojoauth.com
Project Structure
Your project will include:
- Authentication Service to manage OIDC flow
- User and Token Models to store authentication data
- Keychain Service for secure storage
- UI Views for login and profile display
- SwiftUI view model to manage state
Implementation
1. Create Configuration Files
Create a new Swift file named AuthConfig.swift
:
import Foundation struct AuthConfig { static let clientID = "YOUR_MOJOAUTH_CLIENT_ID" static let redirectURI = "com.example.mojoauthdemo:/oauth2callback" static let issuerURL = URL(string: "https://your-project.auth.mojoauth.com")! static let scopes = ["openid", "profile", "email"] // Configuration endpoints static let tokenEndpoint = URL(string: "https://your-project.auth.mojoauth.com/oauth2/token")! static let authEndpoint = URL(string: "https://your-project.auth.mojoauth.com/oauth/auth")! static let userInfoEndpoint = URL(string: "https://your-project.auth.mojoauth.com/oauth/userinfo")! // OIDC Discovery URL static let discoveryURL = URL(string: "https://your-project.auth.mojoauth.com/.well-known/openid-configuration")! }
2. Create User Model
Create a new Swift file named UserModel.swift
:
import Foundation struct UserInfo: Codable { let sub: String let name: String? let email: String? let emailVerified: Bool? let picture: String? enum CodingKeys: String, CodingKey { case sub case name case email case emailVerified = "email_verified" case picture } } struct TokenResponse: Codable { let accessToken: String let idToken: String let refreshToken: String? let expiresIn: Int let tokenType: String enum CodingKeys: String, CodingKey { case accessToken = "access_token" case idToken = "id_token" case refreshToken = "refresh_token" case expiresIn = "expires_in" case tokenType = "token_type" } }
3. Create KeychainService
Create a new Swift file named KeychainService.swift
:
import Foundation class KeychainService { enum KeychainError: Error { case noData case unexpectedData case unhandledError(status: OSStatus) } static func save(key: String, data: Data) -> OSStatus { let query = [ kSecClass as String: kSecClassGenericPassword as String, kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked ] as [String: Any] // Delete any existing items SecItemDelete(query as CFDictionary) // Add the new item return SecItemAdd(query as CFDictionary, nil) } static func load(key: String) throws -> Data { let query = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecReturnData as String: kCFBooleanTrue!, kSecMatchLimit as String: kSecMatchLimitOne ] as [String: Any] var dataTypeRef: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) if status == errSecItemNotFound { throw KeychainError.noData } if status != errSecSuccess { throw KeychainError.unhandledError(status: status) } if let data = dataTypeRef as? Data { return data } else { throw KeychainError.unexpectedData } } static func delete(key: String) -> OSStatus { let query = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key ] as [String: Any] return SecItemDelete(query as CFDictionary) } // Convenience methods for common types static func saveString(_ string: String, forKey key: String) -> OSStatus { let data = Data(string.utf8) return save(key: key, data: data) } static func loadString(forKey key: String) -> String? { do { let data = try load(key: key) return String(data: data, encoding: .utf8) } catch { return nil } } static func saveCodable<T: Encodable>(_ object: T, forKey key: String) -> OSStatus { do { let data = try JSONEncoder().encode(object) return save(key: key, data: data) } catch { print("Error encoding object: \(error)") return errSecParam } } static func loadCodable<T: Decodable>(forKey key: String) -> T? { do { let data = try load(key: key) return try JSONDecoder().decode(T.self, from: data) } catch { print("Error decoding object: \(error)") return nil } } }
4. Create AuthenticationService
Create a new Swift file named AuthenticationService.swift
:
import Foundation import AppAuth import JWTDecode class AuthenticationService { // Singleton instance static let shared = AuthenticationService() // Current auth state private var authState: OIDAuthState? // Key constants for storage private let authStateKey = "com.example.mojoauthdemo.authState" private let userInfoKey = "com.example.mojoauthdemo.userInfo" private init() { // Load any existing auth state loadState() } // MARK: - Public Methods var isAuthenticated: Bool { return authState != nil && authState!.isAuthorized } func login(from viewController: UIViewController, completion: @escaping (Result<UserInfo, Error>) -> Void) { // Create service configuration let configuration = OIDServiceConfiguration( authorizationEndpoint: AuthConfig.authEndpoint, tokenEndpoint: AuthConfig.tokenEndpoint ) // Create auth request let request = OIDAuthorizationRequest( configuration: configuration, clientId: AuthConfig.clientID, clientSecret: nil, scopes: AuthConfig.scopes, redirectURL: URL(string: AuthConfig.redirectURI)!, responseType: OIDResponseTypeCode, additionalParameters: nil ) // Present auth UI let appDelegate = UIApplication.shared.delegate as! AppDelegate appDelegate.currentAuthorizationFlow = OIDAuthState.authState( byPresenting: request, presenting: viewController ) { [weak self] authState, error in guard let self = self else { return } if let error = error { completion(.failure(error)) return } guard let authState = authState else { completion(.failure(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "No auth state received"]))) return } // Save the auth state self.authState = authState self.saveState() // Get user info self.getUserInfo { result in switch result { case .success(let userInfo): // Save user info _ = KeychainService.saveCodable(userInfo, forKey: self.userInfoKey) completion(.success(userInfo)) case .failure(let error): completion(.failure(error)) } } } } func logout() { // Clear auth state authState = nil // Clear saved state _ = KeychainService.delete(key: authStateKey) _ = KeychainService.delete(key: userInfoKey) } func getUserInfo(completion: @escaping (Result<UserInfo, Error>) -> Void) { guard let authState = authState else { completion(.failure(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Not authenticated"]))) return } // Check for valid tokens authState.performAction { (accessToken, idToken, error) in if let error = error { completion(.failure(error)) return } guard let accessToken = accessToken else { completion(.failure(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "No access token"]))) return } // Try to get user info from ID token first if let idToken = idToken { do { let jwt = try decode(jwt: idToken) let claims = jwt.body // Extract user info from claims if let sub = claims["sub"] as? String { let userInfo = UserInfo( sub: sub, name: claims["name"] as? String, email: claims["email"] as? String, emailVerified: claims["email_verified"] as? Bool, picture: claims["picture"] as? String ) completion(.success(userInfo)) return } } catch { print("Error decoding JWT: \(error)") // Fall back to userinfo endpoint } } // Make request to userinfo endpoint var request = URLRequest(url: AuthConfig.userInfoEndpoint) request.httpMethod = "GET" request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let task = URLSession.shared.dataTask(with: request) { (data, response, error) in if let error = error { completion(.failure(error)) return } guard let data = data else { completion(.failure(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data received"]))) return } do { let userInfo = try JSONDecoder().decode(UserInfo.self, from: data) completion(.success(userInfo)) } catch { completion(.failure(error)) } } task.resume() } } func getCurrentUserInfo() -> UserInfo? { return KeychainService.loadCodable(forKey: userInfoKey) } func refreshTokensIfNeeded(completion: @escaping (Result<Void, Error>) -> Void) { guard let authState = authState else { completion(.failure(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Not authenticated"]))) return } if authState.needsTokenRefresh { authState.setNeedsTokenRefresh() authState.performAction { (accessToken, idToken, error) in if let error = error { completion(.failure(error)) } else if accessToken != nil { self.saveState() completion(.success(())) } else { completion(.failure(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to refresh token"]))) } } } else { completion(.success(())) } } // MARK: - Private Methods private func saveState() { guard let authState = authState else { return } do { let data = try NSKeyedArchiver.archivedData(withRootObject: authState, requiringSecureCoding: true) _ = KeychainService.save(key: authStateKey, data: data) } catch { print("Failed to save auth state: \(error)") } } private func loadState() { do { let data = try KeychainService.load(key: authStateKey) if let authState = try NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data) { self.authState = authState } } catch { print("Failed to load auth state: \(error)") } } }
5. Update AppDelegate
Update AppDelegate.swift
to handle OAuth redirects:
import UIKit import AppAuth @main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? var currentAuthorizationFlow: OIDExternalUserAgentSession? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return true } // Handle the OAuth redirect func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { if let authorizationFlow = currentAuthorizationFlow, authorizationFlow.resumeExternalUserAgentFlow(with: url) { currentAuthorizationFlow = nil return true } return false } // For Universal Links (iOS 13+) func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { if userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL, let authorizationFlow = currentAuthorizationFlow, authorizationFlow.resumeExternalUserAgentFlow(with: url) { currentAuthorizationFlow = nil return true } return false } }
6. Create Authentication View Model
Create a new Swift file named AuthViewModel.swift
:
import SwiftUI import Combine class AuthViewModel: ObservableObject { @Published var isAuthenticated: Bool = false @Published var isLoading: Bool = false @Published var errorMessage: String? = nil @Published var userInfo: UserInfo? = nil private let authService = AuthenticationService.shared private var cancellables = Set<AnyCancellable>() init() { checkAuthentication() } func checkAuthentication() { isAuthenticated = authService.isAuthenticated userInfo = authService.getCurrentUserInfo() } func login(from viewController: UIViewController) { isLoading = true errorMessage = nil authService.login(from: viewController) { [weak self] result in DispatchQueue.main.async { guard let self = self else { return } self.isLoading = false switch result { case .success(let userInfo): self.isAuthenticated = true self.userInfo = userInfo case .failure(let error): self.errorMessage = error.localizedDescription } } } } func logout() { authService.logout() isAuthenticated = false userInfo = nil } func refreshUserInfo() { isLoading = true errorMessage = nil authService.refreshTokensIfNeeded { [weak self] result in guard let self = self else { return } switch result { case .success: self.authService.getUserInfo { [weak self] result in DispatchQueue.main.async { guard let self = self else { return } self.isLoading = false switch result { case .success(let userInfo): self.userInfo = userInfo case .failure(let error): self.errorMessage = error.localizedDescription } } } case .failure(let error): DispatchQueue.main.async { self.isLoading = false self.errorMessage = error.localizedDescription } } } } }
7. Create SwiftUI Views
LoginView.swift
import SwiftUI struct LoginView: View { @EnvironmentObject var authViewModel: AuthViewModel @Environment(\.colorScheme) var colorScheme var body: some View { VStack(spacing: 24) { Image(systemName: "lock.shield.fill") .resizable() .scaledToFit() .frame(width: 80, height: 80) .foregroundColor(.blue) .padding(.bottom, 16) Text("MojoAuth Demo") .font(.largeTitle) .fontWeight(.bold) Text("Secure, passwordless authentication for your iOS application") .font(.body) .multilineTextAlignment(.center) .padding(.horizontal) .foregroundColor(.secondary) if let errorMessage = authViewModel.errorMessage { Text(errorMessage) .font(.callout) .foregroundColor(.red) .padding() .background( RoundedRectangle(cornerRadius: 8) .fill(Color.red.opacity(0.1)) ) .padding(.horizontal) } Button(action: { if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootViewController = windowScene.windows.first?.rootViewController { authViewModel.login(from: rootViewController) } }) { HStack { Text("Login with MojoAuth") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding() .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) } .disabled(authViewModel.isLoading) .padding(.horizontal) if authViewModel.isLoading { ProgressView() .padding() } Spacer() Text("By logging in, you agree to our Terms of Service and Privacy Policy") .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) .padding(.bottom) } .padding(.top, 60) } }
ProfileView.swift
import SwiftUI struct ProfileView: View { @EnvironmentObject var authViewModel: AuthViewModel @State private var showingTokens = false var body: some View { NavigationView { ScrollView { VStack(alignment: .leading, spacing: 24) { // Profile header HStack(spacing: 16) { if let picture = authViewModel.userInfo?.picture, let url = URL(string: picture) { AsyncImage(url: url) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { ProgressView() } .frame(width: 80, height: 80) .clipShape(Circle()) } else { Circle() .fill(Color.blue) .frame(width: 80, height: 80) .overlay( Text(authViewModel.userInfo?.name?.prefix(1).uppercased() ?? "U") .font(.title) .fontWeight(.bold) .foregroundColor(.white) ) } VStack(alignment: .leading, spacing: 4) { Text(authViewModel.userInfo?.name ?? "User") .font(.title2) .fontWeight(.bold) Text(authViewModel.userInfo?.email ?? "") .foregroundColor(.secondary) if authViewModel.userInfo?.emailVerified ?? false { Text("Email Verified") .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.green.opacity(0.1)) .foregroundColor(Color.green) .cornerRadius(8) } } } .padding(.bottom, 8) Divider() // Profile information VStack(alignment: .leading, spacing: 16) { Text("Profile Information") .font(.headline) .foregroundColor(.blue) Group { infoRow(label: "ID", value: authViewModel.userInfo?.sub ?? "N/A") infoRow(label: "Name", value: authViewModel.userInfo?.name ?? "N/A") infoRow(label: "Email", value: authViewModel.userInfo?.email ?? "N/A") infoRow(label: "Email Verified", value: (authViewModel.userInfo?.emailVerified ?? false) ? "Yes" : "No") } } Divider() // Logout button Button(action: { authViewModel.logout() }) { HStack { Text("Logout") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding() .background(Color.red.opacity(0.1)) .foregroundColor(.red) .cornerRadius(10) } .padding(.top, 8) } .padding() .navigationBarTitle("Profile", displayMode: .inline) .navigationBarItems(trailing: Button(action: { authViewModel.refreshUserInfo() }) { Image(systemName: "arrow.clockwise") }) .overlay( Group { if authViewModel.isLoading { Color.black.opacity(0.1) .ignoresSafeArea() .overlay( ProgressView() .scaleEffect(1.5) ) } } ) } } .alert(isPresented: Binding( get: { authViewModel.errorMessage != nil }, set: { if !$0 { authViewModel.errorMessage = nil } } )) { Alert( title: Text("Error"), message: Text(authViewModel.errorMessage ?? "An unknown error occurred"), dismissButton: .default(Text("OK")) ) } } private func infoRow(label: String, value: String) -> some View { HStack(alignment: .top) { Text(label) .foregroundColor(.secondary) .frame(width: 120, alignment: .leading) Text(value) .foregroundColor(.primary) .fontWeight(.medium) } } }
ContentView.swift
import SwiftUI struct ContentView: View { @StateObject private var authViewModel = AuthViewModel() var body: some View { Group { if authViewModel.isAuthenticated { ProfileView() } else { LoginView() } } .environmentObject(authViewModel) } }
8. Update Info.plist
Add the following keys to your Info.plist
:
<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleTypeRole</key> <string>Editor</string> <key>CFBundleURLName</key> <string>com.example.mojoauthdemo</string> <key>CFBundleURLSchemes</key> <array> <string>com.example.mojoauthdemo</string> </array> </dict> </array>
Running the Application
- Build and run the application on your iOS device or simulator
- Tap the "Login with MojoAuth" button
- You'll be redirected to the MojoAuth Hosted Login Page in a web view
- After successful authentication, you'll be redirected back to your app
- You should now see your profile information
Production Considerations
1. Security
- Certificate Pinning: Consider implementing certificate pinning to prevent MITM attacks
- Jailbreak Detection: Add checks for jailbroken devices
- Secure Storage: Use the Keychain for sensitive data storage
- Biometric Protection: Add Face ID/Touch ID for re-authentication
- Ephemeral Sessions: Consider session timeouts for sensitive applications
2. Error Handling
Implement comprehensive error handling:
enum AuthError: Error { case invalidConfiguration case networkError(Error) case authorizationError(Error) case tokenError(Error) case userInfoError(Error) case unknown var localizedDescription: String { switch self { case .invalidConfiguration: return "Invalid OIDC configuration" case .networkError(let error): return "Network error: \(error.localizedDescription)" case .authorizationError(let error): return "Authorization error: \(error.localizedDescription)" case .tokenError(let error): return "Token error: \(error.localizedDescription)" case .userInfoError(let error): return "User info error: \(error.localizedDescription)" case .unknown: return "An unknown error occurred" } } }
3. Token Management
Implement proper token lifecycle management:
- Store tokens securely in the Keychain
- Refresh tokens proactively before they expire
- Clear tokens on logout
- Handle token revocation
4. Universal Links
For a seamless experience in production, configure Universal Links:
- Create an
apple-app-site-association
file on your domain - Configure Associated Domains in your app
- Handle universal link navigation
5. App State Restoration
Implement state restoration to handle app termination:
func application(_ application: UIApplication, shouldSaveSecureApplicationState coder: NSCoder) -> Bool { return true } func application(_ application: UIApplication, shouldRestoreSecureApplicationState coder: NSCoder) -> Bool { return true }
Advanced Features
1. Silent Authentication
Implement silent authentication to check if the user is still authenticated without UI:
func silentAuthentication(completion: @escaping (Result<Void, Error>) -> Void) { guard let authState = authState else { completion(.failure(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Not authenticated"]))) return } authState.performAction { (accessToken, idToken, error) in if let error = error { // Try to refresh tokens self.refreshTokensIfNeeded { result in completion(result) } } else if accessToken != nil { completion(.success(())) } else { completion(.failure(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "No valid access token"]))) } } }
2. Biometric Authentication
Add biometric authentication using LocalAuthentication framework:
import LocalAuthentication func authenticateWithBiometrics(reason: String, completion: @escaping (Bool, Error?) -> Void) { let context = LAContext() var error: NSError? if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, error in completion(success, error) } } else { completion(false, error) } }
3. Custom Authorization Parameters
Customize the OIDC authorization request with additional parameters:
let additionalParameters = [ "ui_locales": "en", "acr_values": "high", // Add other custom parameters as needed ] let request = OIDAuthorizationRequest( configuration: configuration, clientId: AuthConfig.clientID, clientSecret: nil, scopes: AuthConfig.scopes, redirectURL: URL(string: AuthConfig.redirectURI)!, responseType: OIDResponseTypeCode, additionalParameters: additionalParameters )
4. Multiple Identity Providers
If you're using multiple identity providers:
// Add to AuthConfig.swift static func authorizationRequestWithProvider(provider: String) -> OIDAuthorizationRequest { let configuration = OIDServiceConfiguration( authorizationEndpoint: AuthConfig.authEndpoint, tokenEndpoint: AuthConfig.tokenEndpoint ) return OIDAuthorizationRequest( configuration: configuration, clientId: AuthConfig.clientID, clientSecret: nil, scopes: AuthConfig.scopes, redirectURL: URL(string: AuthConfig.redirectURI)!, responseType: OIDResponseTypeCode, additionalParameters: ["connection": provider] // Use "google", "facebook", "apple", etc. ) }
5. Handling Refresh Tokens with App Background States
Handle token refreshing when the app is in the background:
func applicationDidEnterBackground(_ application: UIApplication) { let taskIdentifier = UIApplication.shared.beginBackgroundTask { // Handle expiration } AuthenticationService.shared.refreshTokensIfNeeded { _ in UIApplication.shared.endBackgroundTask(taskIdentifier) } }