Это руководство доведет iOS-приложение до вошедшего пользователя и аутентифицированного вызова API через OAuthClient из Versola OAuth SDK. Минимальная версия — iOS 17.4, а пакет подключается через Swift Package Manager.
Предварительные требования
- Xcode со Swift 6 и целью развертывания iOS 17.4 или новее.
- Публичный клиент, зарегистрированный у вашего провайдера, с точным redirect URI, который вы будете использовать. Провайдеры, которым для refresh-токена нужен
offline_access, перечислены на странице Руководства по провайдерам. - Домен под вашим контролем для Universal Link, где можно отдавать файл
apple-app-site-association.
Установка
Добавьте пакет https://github.com/versolauth/versola-sdk текущей версии. Вы зависите от продукта VersolaOAuth и импортируете модуль OAuthClient, который заодно реэкспортирует OAuthCore. Пакет поставляется исходниками, а не бинарным фреймворком, и подключается через Swift Package Manager.
// Package.swift of the app
dependencies: [
.package(url: "https://github.com/versolauth/versola-sdk", from: "<version>"),
],
targets: [
.target(
name: "App",
dependencies: [.product(name: "VersolaOAuth", package: "versola-sdk")]
),
]
В Xcode откройте File > Add Package Dependencies, укажите тот же URL и добавьте VersolaOAuth в цель приложения. Значение package: — это идентичность зависимости, то есть последний компонент URL: versola-sdk.
Какие версии iOS поддерживает SDK?
SDK рассчитан на iOS 17.4 и новее. Причина в браузерном шаге: ASWebAuthenticationSession принимает https-колбэк (Universal Link) только через Callback.https(host:path:), а Apple дала его с iOS 17.4. Universal Link — редирект, который SDK предпочитает.
В манифесте указан еще macOS 14.4, но только как тестовый хост: благодаря ему swift test собирает те же API AuthenticationServices, Keychain и CryptoKit на Mac. Платформа SDK — iOS.
Настройка клиента
import OAuthClient
import UIKit
let configuration = try OAuthConfiguration(
issuer: "https://id.example.com",
clientId: "acme-app",
redirectUri: "https://app.example.com/oauth",
postLogoutRedirectUri: "https://app.example.com/signed-out",
scopes: ["openid", "profile", "offline_access"],
protectedOrigins: ["https://api.example.com"]
)
let oauth = try await OAuthClient.create(
configuration: configuration,
presentationAnchor: { window } // a UIWindow the app owns
)
OAuthConfiguration проверяет значения сразу: плохое значение бросает OAuthError.configuration(.invalidConfiguration) из инициализатора, а не потом, за экраном входа. Параметра clientSecret нет, и это сделано намеренно. В scopes добавляется openid, если его нет, и больше ничего: offline_access SDK не добавляет никогда.
create статический и async. Он возвращает уже существующий клиент для того же издателя и ID клиента и отклоняет другую конфигурацию для той же пары. Он не обращается к сети и не создает ключей.
| Поле | Значение |
|---|---|
pushedAuthorization | По умолчанию .whenAdvertised: SDK отправляет запрос заранее, если провайдер объявляет PAR-эндпоинт. Остальные значения — .never и .required, см. Запрос авторизации |
tokenBinding | По умолчанию .bearer. .dpop() включает DPoP, см. Токены с привязкой к отправителю |
retryPolicy | .default: сколько раз и как долго SDK повторяет неудавшийся запрос, см. Ошибки сервера, 429 и Retry-After |
Показ окна браузера
Окно браузера показывается из окна, которое вы передаете. Сам SDK его никогда не ищет, так что UIApplication.shared.keyWindow в нем нет. Замыкание presentationAnchor имеет тип @escaping @MainActor @Sendable и возвращает ASPresentationAnchor, то есть на iOS — UIWindow.
Настройка редиректа
ASWebAuthenticationSession возвращает свой редирект сам, так что в обычном потоке кода для редиректа вы не пишете. Для redirect URI с https нужны две вещи:
- Связанный домен
webcredentialsдля хоста redirect URI в вашем приложении и соответствующий файлapple-app-site-associationна этом хосте. Одногоapplinksдляhttps-колбэкаASWebAuthenticationSessionнедостаточно. - Точный redirect URI, зарегистрированный у провайдера.
Если авторизация переживает процесс, который ее начал, передайте клиенту ссылку, полученную приложением, из вашего обработчика URL, а при запуске возобновите авторизацию:
// In the handler for a Universal Link (for example onOpenURL or application(_:continue:)).
let handled = await oauth.handleRedirect(url) // true when the URL was this client's redirect
// On launch, when the restored state is .authorizing.
try await oauth.resumePendingAuthorization()
handleRedirect сопоставляет URL со схемой, хостом и путем настроенного redirect URI. Редиректу при холодном старте нужен явный вызов resumePendingAuthorization().
Редирект со схемой private-use принимается с предупреждением в логе при каждом create, потому что такую же схему может зарегистрировать любое приложение на устройстве. Открытый http-редирект ASWebAuthenticationSession получить не может, поэтому он отклоняется.
LoginOptions(ephemeralBrowser: true) соответствует prefersEphemeralWebBrowserSession. По умолчанию выключено: ломает passkeys и единый вход.
Вход, вызов API, выход
do {
try await oauth.login()
let session = try oauth.authenticatedSession() // origins default to protectedOrigins
let request = URLRequest(url: URL(string: "https://api.example.com/me")!)
let (data, response) = try await session.data(for: request)
let claims = await oauth.claims // the validated ID token's claims
let outcome = await oauth.logout() // sessionEnded, tokenRevoked
} catch let error as OAuthError {
// errors are handled below
}
AuthenticatedSession.data(for:) делает следующее:
- Подставляет токен только в запросы на защищенные источники, остальные запросы пропускает нетронутыми.
- На 401 один раз обновляет токен и один раз повторяет запрос.
- Один раз повторяет запрос при вызове DPoP nonce.
- Бросает
stepUpпри запросе RFC 9470. - Никогда не следует редиректам.
oauth.accessToken() возвращает токен значением для других HTTP-стеков. Токен, переданный в код приложения, приложение может залогировать.
Как отправить запрос через PAR и как передать prompt, loginHint, resources или параметры провайдера, описано на странице Запрос авторизации: PAR и дополнительные параметры.
Наблюдение за состоянием сессии
sessionStates() создает на каждый вызов свой AsyncStream<SessionState>, так что несколько наблюдателей не мешают друг другу. Первое значение — восстановленное состояние.
Task {
for await state in await oauth.sessionStates() { render(state) }
}
Семь состояний описаны на странице Состояния сессии и ошибки.
Обработка ошибок
В OAuthError четыре категории — это четыре case перечисления, у каждого полезная нагрузка с названием конкретной причины. Категория подсказывает, что делать:
catch let error as OAuthError {
switch error {
case .recoverable(.server(_, let retryAfterMillis, _, _)): retryLater(retryAfterMillis) // nil: the SDK spent its budget
case .recoverable(let cause): retryOrAskAgain(cause)
case .reauthenticate(let reason): showSignIn(reason)
case .stepUp(let acrValues, let maxAge):
try await oauth.stepUp(acrValues: acrValues, maxAge: maxAge) // then repeat the call
case .configuration: throw error
}
}
switch исчерпывающий. Что значит каждая категория, написано на странице Состояния сессии и ошибки. Когда провайдер отвечает 500, 503 или 429, причина в recoverable — server: что SDK повторяет до того, как вы увидите ошибку, и полный пример смотрите на странице Ошибки сервера, 429 и Retry-After.
Где хранятся токены и ключи?
- Refresh-токен, сырой ID-токен и ожидающий запрос — элементы Keychain с
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyиkSecAttrSynchronizable = false. Фоновое обновление может пользоваться ими после первой разблокировки, на другое устройство они не восстанавливаются и в связку ключей iCloud не попадают. ПриunlockPolicy: .onAppUnlockэлементы получают вместо этогоkSecAttrAccessibleWhenUnlockedThisDeviceOnly. - DPoP включается явно через
tokenBinding: .dpop()и работает, если провайдер объявляетES256. Ключ — P-256 в Secure Enclave. Его зашифрованноеdataRepresentationхранится в Keychain какAfterFirstUnlockThisDeviceOnly, без синхронизации, при любой политике разблокировки. К присутствию пользователя он не привязан: подпись ставится на каждый запрос. - Устройство без Secure Enclave и симулятор получают программный ключ.
try await oauth.keyAttestation()сообщает уровень загруженного ключа:secureEnclaveилиsoftware. .dpop(required: true)падает сproviderUnsupported("dpop"), если провайдер не объявляет DPoP, вместо отката на bearer.- Кэши (метаданные discovery и JWKS) — файлы в Application Support, исключенные из резервного копирования.
- В логи не попадает ничего, что похоже на учетные данные. Логгер по умолчанию,
OSLogLogger, пишет только предупреждения.
Как SDK работает с конкурентностью Swift?
Пакет собирается в режиме языка Swift 6, так что замечание по конкурентности — это ошибка компиляции. OAuthClient — финальный класс Sendable. Изменяемое состояние живет в акторах, координатор сессии один, поэтому обновление идет в одном полете, а вызовы делят его результат. Каждая публичная операция — async или async throws, API с completion handler нет.
Отмена вызывающей задачи закрывает окно браузера, но токен, который уже пришел от провайдера, все равно сохраняется. Фоновой работы SDK не планирует: BGAppRefreshTask — дело вашего приложения, и обновление, начатое из нее, можно отменить.
Как тестируется iOS SDK?
- Модульные тесты XCTest идут на симуляторе iOS.
- Интеграционные тесты прогоняют
OAuthClientна контейнерах Keycloak 26.4 и Ory Hydra v26.2.0.
Что дальше
- Состояния сессии и ошибки: чего ждет приложение от каждого состояния и каждой категории ошибок.
- Ошибки сервера, 429 и Retry-After: что SDK повторяет, когда провайдер отвечает 500, 503 или 429.
- Руководства по провайдерам: контракт провайдера, различия в
offline_accessи выходе. - Flutter: тот же клиент из Dart, поверх этого пакета.