iOS — Versola Docs
/docs
0.1.0versola.ioGitHub

iOS

Подключите Versola OAuth SDK к iOS-приложению через Swift Package Manager, задайте presentation anchor, настройте Universal Link, войдите и разберите ошибки.

Это руководство доведет 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.

Что дальше