Flutter — Versola Docs
/docs
0.1.0versola.ioGitHub

Flutter

Используйте Versola OAuth SDK во Flutter через обертку над SDK для Android и iOS: настройка платформ, вход, поток состояний сессии, типизированные ошибки.

Это руководство подключает плагин versola_oauth из Versola OAuth SDK к Flutter-приложению и проводит вход. Плагин — обертка: логика протокола в Dart не выполняется, а поведение целиком определяют нативные SDK для Android и iOS.

Предварительные требования

  • Flutter 3.47 и Dart 3.13 — диапазоны, объявленные в pubspec.yaml плагина.
  • Android minSdk 26, что выше значения Flutter по умолчанию, и compileSdk 37. 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 не вызывает сама нативная сторона.

Что дальше