Это руководство подключает плагин versola_oauth из Versola OAuth SDK к Flutter-приложению и проводит вход. Плагин — обертка: логика протокола в Dart не выполняется, а поведение целиком определяют нативные SDK для Android и iOS.
Предварительные требования
- Flutter 3.47 и Dart 3.13 — диапазоны, объявленные в
pubspec.yamlплагина. - Android
minSdk26, что выше значения Flutter по умолчанию, иcompileSdk37. iOS 17.4 или новее, что тоже выше цели развертывания Flutter по умолчанию. - На iOS включенная интеграция Swift Package Manager. У плагина нет podspec, поэтому CocoaPods его не соберет.
- Публичный клиент, зарегистрированный у вашего провайдера, с точным redirect URI, который вы будете использовать. Провайдеры, которым для refresh-токена нужен
offline_access, перечислены на странице Руководства по провайдерам. - Хост под вашим контролем для проверяемой ссылки:
assetlinks.jsonдля Android и файл связанного домена для iOS.
Установка
Добавьте плагин в pubspec.yaml как git-зависимость от репозитория https://github.com/versolauth/versola-sdk и выполните flutter pub get:
# pubspec.yaml of the app
dependencies:
versola_oauth:
git:
url: https://github.com/versolauth/versola-sdk.git
path: flutter/versola_oauth
ref: <version>
Нативные части плагин подтягивает сам. На Android он зависит от io.versola:oauth-android из Maven Central, так что кроме google() и mavenCentral(), которые во Flutter-проекте есть по умолчанию, репозиториев добавлять не нужно. На iOS Swift-пакет плагина зависит от продукта VersolaOAuth из SDK.
Настройка Android
Поднимите compileSdk до 37 и minSdk до 26 в android/app/build.gradle.kts. Значения по умолчанию во Flutter-проекте ниже, и без этого сборка падает на требовании библиотеки к compileSdk и на слиянии манифестов.
// android/app/build.gradle.kts
android {
compileSdk = 37
defaultConfig {
minSdk = 26
}
}
Затем объявите redirect URI для versola.sdk.browser.VersolaRedirectActivity в манифесте приложения и отдавайте assetlinks.json на хосте. Фрагмент и правила проверки — на странице Android. Тот же фрагмент есть в манифесте примера приложения в репозитории SDK.
Настройка iOS
Задайте цель развертывания приложения iOS 17.4 (Minimum Deployments в Xcode или IPHONEOS_DEPLOYMENT_TARGET = 17.4 в ios/Runner.xcodeproj). При меньшем значении Xcode отказывается подключать продукт плагина.
Окно браузера показывается из окна, которое подставляет плагин: по умолчанию это ключевое окно активной сцены. Приложение со своей работой с окнами один раз задает хук в AppDelegate, до первого create:
VersolaOAuthPlugin.presentationAnchor = { myWindow } // () -> ASPresentationAnchor, main actor
Плагин пересылает Universal Links (жизненные циклы приложения и сцены, включая ссылку, с которой запустилось приложение) в handleRedirect нативного клиента. Для redirect URI с https нужен связанный домен webcredentials для его хоста, как описано в разделе iOS.
Плагин не вызывает нативный resumePendingAuthorization().
Вход
import 'package:versola_oauth/versola_oauth.dart';
final client = await OAuthClient.create(const OAuthConfiguration(
issuer: 'https://id.example.com',
clientId: 'acme-app',
redirectUri: 'https://app.example.com/oauth',
scopes: {'openid', 'profile', 'offline_access'},
protectedOrigins: {'https://api.example.com'},
));
try {
await client.login();
final token = await client.accessToken();
// Call your API with dio or http: Authorization: ${token.tokenType} ${token.value}
await client.logout();
} on StepUpError catch (e) {
await client.stepUp(e);
} on ReauthenticateError {
// sign in again
} on RecoverableError {
// retry, or ask the user again
} on ConfigurationError {
// a deployment mistake: fix it, do not catch it in production
}
create проверяет конфигурацию на нативной стороне и бросает ConfigurationError при плохом значении. К сети он не обращается. offline_access SDK не добавляет никогда: положите его в scopes, если провайдеру он нужен для refresh-токена. Повторный create для того же издателя и ID клиента приводит к тому же нативному клиенту.
| Поле | Значение |
|---|---|
pushedAuthorization | По умолчанию PushedAuthorization.whenAdvertised: SDK отправляет запрос заранее, если провайдер объявляет PAR-эндпоинт. Остальные значения — never и required, см. Запрос авторизации |
tokenBinding | По умолчанию TokenBinding.bearer. TokenBinding.dpop включает DPoP, см. Токены с привязкой к отправителю |
retryPolicy | По умолчанию не задан, действуют границы SDK по умолчанию. RetryPolicy меняет, сколько раз и как долго SDK повторяет неудавшийся запрос, см. Ошибки сервера, 429 и Retry-After |
login во время идущей авторизации отклоняется, так что сначала подпишитесь на sessionStates().
Как отправить запрос через PAR и как передать prompt, loginHint, resources или параметры провайдера, описано на странице Запрос авторизации: PAR и дополнительные параметры.
Наблюдение за состоянием сессии
sessionStates() возвращает Stream<SessionState>, который могут делить сколько угодно слушателей. Нативная подписка одна, и она заканчивается, когда отменяется последний слушатель, а опоздавший слушатель первым получает последнее состояние.
client.sessionStates().listen((state) => render(state));
Семь состояний описаны на странице Состояния сессии и ошибки. currentSessionState() один раз спрашивает состояние у нативной стороны.
claims() — метод, а не геттер. Claims проверенного ID-токена остаются на нативной стороне, поэтому ответ асинхронный, а геттер, возвращающий Future, сделал бы проверку client.claims != null всегда истинной.
Методы
| Вызов | Результат |
|---|---|
OAuthClient.create(configuration) | Клиент для этого издателя и ID клиента |
sessionStates() | Stream<SessionState>, общий для всех слушателей |
currentSessionState() | Future<SessionState>, каждый раз спрашивается у нативной стороны |
claims() | Future<IdTokenClaims?>: claims проверенного ID-токена, null без сессии |
login([options]) | Future<void>. Сессию можно наблюдать через sessionStates() |
stepUp(error, [options]) | Future<void>. Повторно авторизует с acrValues и maxAge из StepUpError; отказанный вызов приложение затем повторяет |
logout([options]) | Future<LogoutOutcome> с sessionEnded и tokenRevoked. Локальное стирание безусловно |
accessToken() | Future<AccessToken> (value, tokenType), при необходимости сначала обновленный на нативной стороне |
refresh() | Future<void> |
userInfo() | Future<UserInfo> |
keyAttestation() | Future<String?>: уровень DPoP-ключа (secureEnclave, strongBox, trustedEnvironment, software), null при bearer |
Обработка ошибок
Любой вызов бросает OAuthError и ничего другого. Это sealed-класс с четырьмя категориями, и контракт — это категория, а полезная нагрузка — нативная причина. Сначала смотрите на категорию:
try {
final token = await client.accessToken();
// use token.value
} on OAuthError catch (error) {
switch (error) {
case RecoverableError(:final cause, :final retryAfter):
// userCancelled, systemCancelled, pendingRequestExpired, network, server, unlockRequired,
// nonceLoop. Retry or ask again; do not sign out. `unknown` is a native cause this version
// does not know: do not loop on it.
if (cause == RecoverableCause.server) scheduleRetry(retryAfter); // null: the SDK spent its budget
if (cause == RecoverableCause.network) scheduleRetry();
case ReauthenticateError(:final reason):
// authorizationDenied, interactionRequired, credentialsInvalidated, grantRejected,
// sessionRejected, sessionExpired: the session is over, so show the sign-in screen.
showSignIn(reason);
case final StepUpError challenge:
await client.stepUp(challenge);
// then repeat the call that was refused
case ConfigurationError(:final detail):
// stateMismatch, issuerMismatch, idTokenRejected, providerUnsupported, invalidConfiguration...
// A deployment or integration mistake; no retry fixes it.
reportMisconfiguration(detail);
}
}
Нативный case, которого эта версия обертки не знает, приходит как unknown, а его имя лежит в detail или text. Под видом другого настоящего case он не приходит никогда. В ошибках нет ничего похожего на учетные данные, а AccessToken, IdTokenClaims и UserInfo скрывают себя в toString. Что значит каждая категория, написано на странице Состояния сессии и ошибки. Когда провайдер отвечает 500, 503 или 429, причина — server, а в ошибке есть status и retryAfter: что SDK повторяет до того, как вы увидите ошибку, и полный пример смотрите на странице Ошибки сервера, 429 и Retry-After.
Вызовы вашего API
Единственные учетные данные, которые пересекают мост, — access-токен, и только как ответ accessToken(). Refresh-токен, сырой ID-токен, ключевой материал, ожидающий запрос, верификатор PKCE и state остаются на нативной стороне. Копию access-токена Dart не хранит: он каждый раз спрашивает нативную сторону, так что обновление в одном полете остается в одном месте.
Аутентифицированного HTTP-клиента во Flutter нет, и protectedOrigins на ваши запросы не распространяется. protectedOrigins принимается в конфигурации и проверяется нативным клиентом, но запросов за вас Dart ничего не отправляет. Используйте токен с dio или http и отправляйте его только на те источники, которые имеете в виду. Токен, который держит приложение, приложение может залогировать.
DPoP выбрать можно (TokenBinding.dpop, TokenBinding.dpopRequireHardware), и нативный клиент подписывает proof. Но proof делаются только для запросов, которые нативный SDK отправляет сам, поэтому токену с привязкой DPoP при использовании из Dart нужен proof, которого приложение получить не может. Пользуйтесь bearer, это значение по умолчанию, если только ваш API не вызывает сама нативная сторона.
Что дальше
- Состояния сессии и ошибки: семь состояний и четыре категории ошибок.
- Ошибки сервера, 429 и Retry-After: что SDK повторяет, когда провайдер отвечает 500, 503 или 429.
- Android и iOS: нативная настройка, на которой стоит плагин.
- Руководства по провайдерам: контракт провайдера и различия в
offline_access.