Введение: почему 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")