OAuth 2.0 и JWT авторизация на Swift: практическое руководство

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: почему OAuth 2.0 и JWT — стандарт для iOS-приложений

Современные мобильные приложения редко работают изолированно. Большинство из них взаимодействуют с удалёнными API, требуя безопасной аутентификации и авторизации пользователя. В экосистеме Apple (iOS, macOS, watchOS) стандартом де-факто стала связка OAuth 2.0 (протокол делегирования доступа) и JWT (JSON Web Token — компактный формат токена). Эта комбинация обеспечивает гибкость, безопасность и удобство интеграции.

В этой статье мы не будем углубляться в сухую теорию. Вместо этого разберём практическую реализацию на Swift. Вы узнаете, как получить токен доступа через OAuth 2.0, как его безопасно хранить в Keychain, как декодировать JWT на клиенте и как обновлять токены без участия пользователя. Все примеры кода готовы к использованию в реальном проекте.

1. Архитектура OAuth 2.0 на Swift: от запроса до токена

Для начала определимся с потоком (flow). В мобильных приложениях чаще всего используется Authorization Code Flow with PKCE (Proof Key for Code Exchange). Это самый безопасный вариант для публичных клиентов (мобильные приложения не могут хранить секрет).

Создадим структуру для конфигурации OAuth:

struct OAuthConfig {
    let clientID: String
    let redirectURI: String
    let authorizationEndpoint: String
    let tokenEndpoint: String
    let scope: String
}

Первый шаг — формирование URL для авторизации. Для PKCE нам понадобится сгенерировать codeVerifier и его SHA256-хеш — codeChallenge:

import Foundation
import CommonCrypto

class PKCEGenerator {
    static func generateCodeVerifier() -> String {
        var buffer = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
        return Data(buffer).base64URLEncodedString()
    }
    
    static func generateCodeChallenge(from verifier: String) -> String {
        guard let data = verifier.data(using: .utf8) else { return "" }
        var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
        data.withUnsafeBytes { 
            _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
        }
        return Data(hash).base64URLEncodedString()
    }
}

extension Data {
    func base64URLEncodedString() -> String {
        return self.base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}

Теперь формируем URL для открытия в браузере (через ASWebAuthenticationSession):

func buildAuthorizationURL(config: OAuthConfig, codeChallenge: String) -> URL? {
    var components = URLComponents(string: config.authorizationEndpoint)
    components?.queryItems = [
        URLQueryItem(name: "response_type", value: "code"),
        URLQueryItem(name: "client_id", value: config.clientID),
        URLQueryItem(name: "redirect_uri", value: config.redirectURI),
        URLQueryItem(name: "scope", value: config.scope),
        URLQueryItem(name: "code_challenge", value: codeChallenge),
        URLQueryItem(name: "code_challenge_method", value: "S256")
    ]
    return components?.url
}

После успешной авторизации пользователь будет перенаправлен на redirectURI с параметром code. Этот код обменивается на токены:

func exchangeCodeForToken(config: OAuthConfig, code: String, codeVerifier: String) async throws -> TokenResponse {
    let url = URL(string: config.tokenEndpoint)!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
    
    let body = "grant_type=authorization_code" +
               "&code=\(code)" +
               "&redirect_uri=\(config.redirectURI)" +
               "&client_id=\(config.clientID)" +
               "&code_verifier=\(codeVerifier)"
    request.httpBody = body.data(using: .utf8)
    
    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(TokenResponse.self, from: data)
}

struct TokenResponse: Codable {
    let accessToken: String
    let refreshToken: String?
    let expiresIn: Int
    let tokenType: String
    
    enum CodingKeys: String, CodingKey {
        case accessToken = "access_token"
        case refreshToken = "refresh_token"
        case expiresIn = "expires_in"
        case tokenType = "token_type"
    }
}

2. Безопасное хранение токенов в Keychain

Хранить токены в UserDefaults или в памяти приложения — плохая практика. Используйте Keychain Services от Apple. Это зашифрованное хранилище, доступное только вашему приложению.

Создадим простой менеджер для работы с Keychain:

import Security

class KeychainManager {
    static let service = "com.yourapp.tokens"
    
    static func save(token: String, forKey key: String) {
        guard let data = token.data(using: .utf8) else { return }
        
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecValueData as String: data
        ]
        
        SecItemDelete(query as CFDictionary)
        SecItemAdd(query as CFDictionary, nil)
    }
    
    static func getToken(forKey key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        
        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        guard status == errSecSuccess, let data = item as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }
    
    static func deleteToken(forKey key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(query as CFDictionary)
    }
}

Использование простое: после получения токенов сохраняем их:

KeychainManager.save(token: tokenResponse.accessToken, forKey: "access_token")
KeychainManager.save(token: tokenResponse.refreshToken ?? "", forKey: "refresh_token")

3. Декодирование JWT на клиенте: проверка и извле

Блоги

Рекомендаций по книгам