AppAuth for iOS
Authentication Library
AppAuth for iOS
Overview
AppAuth for iOS is the official OpenID Foundation iOS, macOS, and tvOS OAuth 2.0 and OpenID Connect client implementation. As of 2025, it supports iOS 12+, macOS 10.14+, and is actively developed as version 2.0.0. It fully complies with RFC 8252 "OAuth 2.0 for Native Apps" latest best practices, providing secure authentication flows using SFAuthenticationSession and SFSafariViewController. With automatic PKCE (Proof Key for Code Exchange) implementation, support for both Custom URI Schemes and Universal Links, Device Authorization Grant for tvOS, and other Apple platform-optimized comprehensive authentication solutions. Swift Package Manager support enables easy integration into modern Swift development environments.
Details
AppAuth for iOS 2.0 series provides direct mapping of RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), and OpenID Connect specifications, enabling enterprise-grade authentication capabilities integration into iOS applications. The architecture consists of the platform-independent AppAuthCore module and platform-specific implementations. Authentication flows use SFSafariViewController (iOS 9+) or SFAuthenticationSession (iOS 12+), explicitly excluding UIWebView and WKWebView support to ensure security. Core classes including OIDServiceConfiguration, OIDAuthorizationRequest, OIDTokenRequest, and OIDAuthState properly manage all standard OAuth 2.0 flows, supporting automatic token refresh and persistence.
Key Features
- RFC Compliant Implementation: Complete compliance with OAuth 2.0, OpenID Connect, and PKCE specifications
- Apple Optimized: SFSafariViewController, SFAuthenticationSession, and Universal Links support
- tvOS Support: Device Authorization Grant (RFC 8628) for tvOS authentication
- Automatic PKCE: Built-in PKCE implementation to automatically prevent authorization code attacks
- Token Management: Comprehensive support for automatic refresh, persistence, and state management
- Swift Integration: Swift Package Manager support and Swift app-optimized design
Advantages and Disadvantages
Advantages
- OpenID Foundation official library ensuring industry-standard reliability and continued support
- RFC 8252 compliance meets Apple Store review requirements and security standards
- SFSafariViewController usage balances improved user experience with enhanced security
- Automatic PKCE implementation achieves high protection levels without additional security configuration
- Flexible choice between Universal Links and Custom URI Schemes
- Comprehensive documentation and rich sample code improve development efficiency
Disadvantages
- Minimum requirements of iOS 12+, macOS 10.14+ prevent usage on older devices
- Only supports OAuth 2.0/OpenID Connect, not other authentication methods (custom auth, etc.)
- Cannot be used with embedded WebView applications due to constraints
- Customizing complex authentication flows requires deep OAuth knowledge
- Enterprise environments require additional implementation for advanced configurations
- Platform-specific implementation limits portability to other operating systems
Reference Pages
Usage Examples
Installation with CocoaPods and Swift Package Manager
# Podfile - Using CocoaPods
platform :ios, '12.0'
target 'YourApp' do
use_frameworks!
pod 'AppAuth', '~> 2.0'
end
// Package.swift - Using Swift Package Manager
// Xcode -> File -> Add Package Dependencies...
// Repository URL: https://github.com/openid/AppAuth-iOS
import PackageDescription
let package = Package(
name: "YourApp",
platforms: [
.iOS(.v12)
],
dependencies: [
.package(
url: "https://github.com/openid/AppAuth-iOS",
from: "2.0.0"
)
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "AppAuth", package: "AppAuth-iOS")
]
)
]
)
URL Scheme Configuration and Redirect Handling
<!-- Info.plist - Custom URL Scheme configuration -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.example.myapp.oauth</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.example.myapp</string>
</array>
</dict>
</array>
<!-- Universal Links configuration (recommended) -->
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:myapp.example.com</string>
</array>
// AppDelegate.swift - Redirect handling for iOS 13 and earlier
import UIKit
import AppAuth
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var currentAuthorizationFlow: OIDExternalUserAgentSession?
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
// AppAuth OAuth redirect handling
if let authorizationFlow = self.currentAuthorizationFlow,
authorizationFlow.resumeExternalUserAgentFlow(with: url) {
self.currentAuthorizationFlow = nil
return true
}
return false
}
}
// SceneDelegate.swift - Redirect handling for iOS 13+
import UIKit
import AppAuth
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var currentAuthorizationFlow: OIDExternalUserAgentSession?
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }
// AppAuth OAuth redirect handling
if let authorizationFlow = self.currentAuthorizationFlow,
authorizationFlow.resumeExternalUserAgentFlow(with: url) {
self.currentAuthorizationFlow = nil
}
}
}
Basic OAuth 2.0 Authentication Flow Implementation
// OAuthManager.swift - OAuth authentication manager
import Foundation
import AppAuth
class OAuthManager: ObservableObject {
@Published var authState: OIDAuthState?
@Published var isAuthenticated: Bool = false
@Published var errorMessage: String?
// OAuth configuration
private let clientID = "your-client-id"
private let redirectURI = URL(string: "com.example.myapp://oauth2redirect")!
private let issuer = URL(string: "https://accounts.example.com")!
// Auth state persistence key
private let authStateKey = "AuthState"
init() {
loadAuthState()
}
func startAuthentication(from viewController: UIViewController) {
// 1. Dynamic service configuration discovery
OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { [weak self] configuration, error in
guard let self = self else { return }
if let error = error {
DispatchQueue.main.async {
self.errorMessage = "Discovery failed: \(error.localizedDescription)"
}
return
}
guard let config = configuration else {
DispatchQueue.main.async {
self.errorMessage = "Configuration is nil"
}
return
}
// 2. Create authorization request
let request = OIDAuthorizationRequest(
configuration: config,
clientId: self.clientID,
scopes: [OIDScopeOpenID, OIDScopeProfile, OIDScopeEmail],
redirectURL: self.redirectURI,
responseType: OIDResponseTypeCode,
additionalParameters: nil
)
// 3. Start authorization flow
self.performAuthorizationRequest(request, from: viewController)
}
}
private func performAuthorizationRequest(
_ request: OIDAuthorizationRequest,
from viewController: UIViewController
) {
// Get AppDelegate or SceneDelegate reference
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
self.errorMessage = "Could not get app delegate"
return
}
// Execute authorization flow
appDelegate.currentAuthorizationFlow = OIDAuthState.authState(
byPresenting: request,
presenting: viewController
) { [weak self] authState, error in
DispatchQueue.main.async {
guard let self = self else { return }
if let authState = authState {
print("Authorization success! Access token: \(authState.lastTokenResponse?.accessToken ?? "nil")")
self.setAuthState(authState)
} else {
print("Authorization error: \(error?.localizedDescription ?? "Unknown error")")
self.errorMessage = error?.localizedDescription ?? "Unknown error"
self.setAuthState(nil)
}
}
}
}
func refreshTokens() {
guard let authState = authState else {
self.errorMessage = "No auth state available"
return
}
authState.performAction(freshTokens: { [weak self] accessToken, idToken, error in
DispatchQueue.main.async {
if let error = error {
self?.errorMessage = "Token refresh failed: \(error.localizedDescription)"
} else {
print("Tokens refreshed successfully")
print("Access token: \(accessToken ?? "nil")")
}
}
})
}
func makeAuthenticatedRequest(to url: URL, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
guard let authState = authState else {
completion(nil, nil, NSError(domain: "OAuthManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Not authenticated"]))
return
}
authState.performAction(freshTokens: { accessToken, idToken, error in
if let error = error {
completion(nil, nil, error)
return
}
guard let accessToken = accessToken else {
completion(nil, nil, NSError(domain: "OAuthManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No access token available"]))
return
}
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request, completionHandler: completion).resume()
})
}
func logout() {
// Clear authentication state
setAuthState(nil)
// Additional: Call End Session Endpoint (if supported)
// Note: Implementation varies by provider
}
private func setAuthState(_ authState: OIDAuthState?) {
if self.authState != authState {
self.authState = authState
self.isAuthenticated = authState?.isAuthorized ?? false
saveAuthState()
}
}
private func saveAuthState() {
if let authState = authState {
let data = NSKeyedArchiver.archivedData(withRootObject: authState)
UserDefaults.standard.set(data, forKey: authStateKey)
} else {
UserDefaults.standard.removeObject(forKey: authStateKey)
}
}
private func loadAuthState() {
guard let data = UserDefaults.standard.data(forKey: authStateKey),
let authState = try? NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data) else {
return
}
setAuthState(authState)
}
}
SwiftUI Integration and Profile Display
// ContentView.swift - SwiftUI authentication UI
import SwiftUI
import AppAuth
struct ContentView: View {
@StateObject private var oauthManager = OAuthManager()
@State private var showingLogin = false
@State private var userProfile: UserProfile?
var body: some View {
NavigationView {
VStack(spacing: 20) {
if oauthManager.isAuthenticated {
// Authenticated state
authenticatedView
} else {
// Unauthenticated state
unauthenticatedView
}
}
.padding()
.navigationTitle("AppAuth Demo")
.alert("Error", isPresented: .constant(oauthManager.errorMessage != nil)) {
Button("OK") {
oauthManager.errorMessage = nil
}
} message: {
Text(oauthManager.errorMessage ?? "")
}
}
}
private var authenticatedView: some View {
VStack(spacing: 20) {
Text("Welcome!")
.font(.title)
.foregroundColor(.green)
if let profile = userProfile {
UserProfileView(profile: profile)
} else {
ProgressView("Loading profile...")
.onAppear {
loadUserProfile()
}
}
HStack(spacing: 15) {
Button("Refresh Tokens") {
oauthManager.refreshTokens()
}
.buttonStyle(.bordered)
Button("Logout") {
oauthManager.logout()
userProfile = nil
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
}
}
private var unauthenticatedView: some View {
VStack(spacing: 20) {
Image(systemName: "lock.shield")
.font(.system(size: 60))
.foregroundColor(.blue)
Text("OAuth 2.0 Authentication")
.font(.title)
.multilineTextAlignment(.center)
Text("Sign in to access your account using OAuth 2.0 with AppAuth")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button("Sign In") {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootViewController = window.rootViewController {
oauthManager.startAuthentication(from: rootViewController)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
private func loadUserProfile() {
guard let userInfoURL = URL(string: "https://api.example.com/userinfo") else { return }
oauthManager.makeAuthenticatedRequest(to: userInfoURL) { data, response, error in
DispatchQueue.main.async {
if let error = error {
oauthManager.errorMessage = "Failed to load profile: \(error.localizedDescription)"
return
}
guard let data = data else {
oauthManager.errorMessage = "No data received"
return
}
do {
let profile = try JSONDecoder().decode(UserProfile.self, from: data)
self.userProfile = profile
} catch {
oauthManager.errorMessage = "Failed to parse profile: \(error.localizedDescription)"
}
}
}
}
}
struct UserProfileView: View {
let profile: UserProfile
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("User Profile")
.font(.headline)
HStack {
AsyncImage(url: URL(string: profile.picture ?? "")) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
Image(systemName: "person.fill")
.foregroundColor(.gray)
)
}
.frame(width: 60, height: 60)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
Text(profile.name ?? "Unknown")
.font(.title3)
.fontWeight(.semibold)
Text(profile.email ?? "No email")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
Divider()
VStack(alignment: .leading, spacing: 8) {
ProfileRowView(label: "User ID", value: profile.sub)
ProfileRowView(label: "Name", value: profile.name)
ProfileRowView(label: "Email", value: profile.email)
ProfileRowView(label: "Email Verified", value: profile.emailVerified?.description)
ProfileRowView(label: "Locale", value: profile.locale)
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
struct ProfileRowView: View {
let label: String
let value: String?
var body: some View {
HStack {
Text(label + ":")
.fontWeight(.medium)
.frame(width: 100, alignment: .leading)
Text(value ?? "N/A")
.foregroundColor(.secondary)
Spacer()
}
.font(.caption)
}
}
// UserProfile model
struct UserProfile: Codable {
let sub: String
let name: String?
let email: String?
let emailVerified: Bool?
let picture: String?
let locale: String?
enum CodingKeys: String, CodingKey {
case sub, name, email, picture, locale
case emailVerified = "email_verified"
}
}
tvOS Device Authorization Grant Implementation
// tvOSAuthManager.swift - tvOS Device Authorization Grant
import Foundation
import AppAuth
@available(tvOS 12.0, *)
class tvOSAuthManager: ObservableObject {
@Published var authState: OIDAuthState?
@Published var isAuthenticated: Bool = false
@Published var userCode: String?
@Published var verificationURI: String?
@Published var errorMessage: String?
private let clientID = "your-tv-client-id"
private let issuer = URL(string: "https://accounts.example.com")!
func startDeviceAuthorization() {
// 1. Dynamic service configuration discovery
OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { [weak self] configuration, error in
guard let self = self else { return }
if let error = error {
DispatchQueue.main.async {
self.errorMessage = "Discovery failed: \(error.localizedDescription)"
}
return
}
guard let config = configuration else {
DispatchQueue.main.async {
self.errorMessage = "Configuration is nil"
}
return
}
// 2. Create Device Authorization Request
let request = OIDDeviceAuthorizationRequest(
configuration: config,
clientId: self.clientID,
scopes: [OIDScopeOpenID, OIDScopeProfile, OIDScopeEmail],
additionalParameters: nil
)
// 3. Start Device Authorization
self.performDeviceAuthorizationRequest(request)
}
}
private func performDeviceAuthorizationRequest(_ request: OIDDeviceAuthorizationRequest) {
OIDAuthorizationService.perform(request) { [weak self] response, error in
DispatchQueue.main.async {
guard let self = self else { return }
if let error = error {
self.errorMessage = "Device authorization failed: \(error.localizedDescription)"
return
}
guard let response = response else {
self.errorMessage = "No response received"
return
}
// Display user code and verification URL
self.userCode = response.userCode
self.verificationURI = response.verificationURI?.absoluteString
// Start token polling
self.startTokenPolling(with: response)
}
}
}
private func startTokenPolling(with deviceAuthResponse: OIDDeviceAuthorizationResponse) {
let tokenRequest = OIDTokenRequest(
configuration: deviceAuthResponse.request.configuration,
grantType: OIDGrantTypeDeviceCode,
authorizationCode: nil,
redirectURL: nil,
clientID: clientID,
clientSecret: nil,
scope: nil,
refreshToken: nil,
codeVerifier: nil,
additionalParameters: [
"device_code": deviceAuthResponse.deviceCode ?? ""
]
)
// Polling interval (typically 5 seconds)
let pollingInterval = TimeInterval(deviceAuthResponse.interval?.intValue ?? 5)
let expirationDate = Date().addingTimeInterval(TimeInterval(deviceAuthResponse.expiresIn?.intValue ?? 1800))
pollForToken(request: tokenRequest, interval: pollingInterval, expirationDate: expirationDate)
}
private func pollForToken(request: OIDTokenRequest, interval: TimeInterval, expirationDate: Date) {
// Check for expiration
if Date() > expirationDate {
DispatchQueue.main.async {
self.errorMessage = "Device authorization expired"
self.cleanup()
}
return
}
OIDAuthorizationService.perform(request) { [weak self] response, error in
guard let self = self else { return }
if let tokenResponse = response {
// Success: Set authentication state
DispatchQueue.main.async {
let authState = OIDAuthState(authorizationResponse: nil, tokenResponse: tokenResponse)
self.setAuthState(authState)
self.cleanup()
}
} else if let error = error as NSError? {
// Error handling
if error.domain == OIDOAuthTokenErrorDomain {
switch error.code {
case OIDTokenErrorCode.authorizationPending.rawValue:
// Still awaiting authorization: Poll again
DispatchQueue.main.asyncAfter(deadline: .now() + interval) {
self.pollForToken(request: request, interval: interval, expirationDate: expirationDate)
}
case OIDTokenErrorCode.slowDown.rawValue:
// Increase polling interval and retry
DispatchQueue.main.asyncAfter(deadline: .now() + interval * 2) {
self.pollForToken(request: request, interval: interval * 2, expirationDate: expirationDate)
}
default:
// Other errors
DispatchQueue.main.async {
self.errorMessage = "Token request failed: \(error.localizedDescription)"
self.cleanup()
}
}
} else {
DispatchQueue.main.async {
self.errorMessage = "Token request failed: \(error.localizedDescription)"
self.cleanup()
}
}
}
}
}
private func setAuthState(_ authState: OIDAuthState?) {
self.authState = authState
self.isAuthenticated = authState?.isAuthorized ?? false
}
private func cleanup() {
userCode = nil
verificationURI = nil
}
func logout() {
setAuthState(nil)
}
}
// tvOS authentication UI
struct tvOSAuthView: View {
@StateObject private var authManager = tvOSAuthManager()
var body: some View {
VStack(spacing: 30) {
if authManager.isAuthenticated {
// Authenticated
Text("Authentication Successful!")
.font(.title)
.foregroundColor(.green)
Button("Logout") {
authManager.logout()
}
} else if let userCode = authManager.userCode, let verificationURI = authManager.verificationURI {
// Display authentication code
VStack(spacing: 20) {
Text("Complete Authentication")
.font(.title)
Text("Go to \(verificationURI) on your phone or computer")
.font(.headline)
Text("Enter this code:")
.font(.title2)
Text(userCode)
.font(.largeTitle)
.fontWeight(.bold)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
Text("Waiting for authentication...")
.font(.caption)
.foregroundColor(.secondary)
}
} else {
// Start authentication
VStack(spacing: 20) {
Text("Sign In")
.font(.title)
Button("Start Authentication") {
authManager.startDeviceAuthorization()
}
.buttonStyle(.borderedProminent)
}
}
if let errorMessage = authManager.errorMessage {
Text("Error: \(errorMessage)")
.foregroundColor(.red)
}
}
.padding()
}
}
Custom Browser and Advanced Configuration
// CustomBrowserManager.swift - Custom browser implementation
import Foundation
import AppAuth
import SafariServices
class CustomBrowserManager {
static func createChromeUserAgent() -> OIDExternalUserAgent? {
// Chrome custom browser (if Chrome is installed)
return OIDExternalUserAgentIOSCustomBrowser.customBrowserChrome()
}
static func createFirefoxUserAgent() -> OIDExternalUserAgent? {
// Firefox custom browser (if Firefox is installed)
return OIDExternalUserAgentIOSCustomBrowser.customBrowserFirefox()
}
static func createSafariUserAgent() -> OIDExternalUserAgent {
// Default Safari
return OIDExternalUserAgentIOS(presenting: UIApplication.shared.windows.first?.rootViewController)!
}
}
// EnterpriseOAuthManager.swift - Enterprise configuration
class EnterpriseOAuthManager: OAuthManager {
override func startAuthentication(from viewController: UIViewController) {
// Create service configuration with custom settings
let configuration = createEnterpriseConfiguration()
let request = OIDAuthorizationRequest(
configuration: configuration,
clientId: clientID,
scopes: [OIDScopeOpenID, OIDScopeProfile, OIDScopeEmail, "custom:enterprise"],
redirectURL: redirectURI,
responseType: OIDResponseTypeCode,
additionalParameters: [
"prompt": "consent",
"access_type": "offline",
"include_granted_scopes": "true"
]
)
// Use custom browser
let customUserAgent = CustomBrowserManager.createChromeUserAgent() ??
CustomBrowserManager.createSafariUserAgent()
// Get AppDelegate reference
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
self.errorMessage = "Could not get app delegate"
return
}
// Execute authorization flow with custom user agent
appDelegate.currentAuthorizationFlow = OIDAuthState.authState(
byPresenting: request,
externalUserAgent: customUserAgent
) { [weak self] authState, error in
DispatchQueue.main.async {
guard let self = self else { return }
if let authState = authState {
print("Enterprise authorization success!")
self.setAuthState(authState)
} else {
print("Enterprise authorization error: \(error?.localizedDescription ?? "Unknown error")")
self.errorMessage = error?.localizedDescription ?? "Unknown error"
self.setAuthState(nil)
}
}
}
}
private func createEnterpriseConfiguration() -> OIDServiceConfiguration {
// Enterprise endpoint configuration
let authorizationEndpoint = URL(string: "https://enterprise.example.com/oauth2/authorize")!
let tokenEndpoint = URL(string: "https://enterprise.example.com/oauth2/token")!
let registrationEndpoint = URL(string: "https://enterprise.example.com/oauth2/register")!
let endSessionEndpoint = URL(string: "https://enterprise.example.com/oauth2/logout")!
return OIDServiceConfiguration(
authorizationEndpoint: authorizationEndpoint,
tokenEndpoint: tokenEndpoint,
registrationEndpoint: registrationEndpoint,
endSessionEndpoint: endSessionEndpoint
)
}
}