Состояния сессии и ошибки — Versola Docs
/docs
0.1.0versola.ioGitHub

Состояния сессии и ошибки

Семь состояний сессии и четыре категории ошибок в мобильных SDK Versola и то, что приложению делать для каждой категории.

Сессия Versola SDK в любой момент находится в одном из семи состояний, а любой сбой относится к одной из четырех категорий ошибок. Категория подсказывает приложению, что делать, поэтому ветвиться нужно по ней, а не по кодам и текстам сообщений. Правила одинаковы на Android, iOS и Flutter.

Семь состояний сессии

Состояние — источник истины. Через него публикует каждая операция, в том числе авторизация, которая заканчивается в другом процессе, не в том, что ее начал. На Android оно доступно как Flow<SessionState>, на iOS как AsyncStream<SessionState> из sessionStates(), во Flutter как Stream<SessionState> из sessionStates(). Первое значение, которое видит наблюдатель, — восстановленное состояние. Своих состояний платформы не добавляют.

СостояниеЗначение
UnauthenticatedПригодного grant нет. Осмысленный вызов один: login()
AuthorizingЗапрос авторизации не завершен: браузер открыт или приложение убили, пока он был открыт
AuthenticatedЕсть refresh-токен, а access-токен доступен или может быть получен
RefreshingИдет запрос токена. Параллельные вызовы делят его и сами в это состояние не входят
StepUpRequiredСервер ресурса отказал в вызове с insufficient_user_authentication. Grant действителен, но его контекста аутентификации недостаточно
LockedУчетные данные на месте, но ключу, который их защищает, нужна аутентификация пользователя. Достижимо только при политике разблокировки OnAppUnlock
LoggingOutИдет выход

Refreshing, StepUpRequired, Locked и LoggingOut — подсостояния «grant есть». Ни в одном из них пользователь не вышел, и интерфейс не должен вести себя так, будто вышел.

Как состояние меняется

login() переводит Unauthenticated в Authorizing. Проверенный редирект и успешный обмен кода переводят в Authenticated. Закрытый браузер или ошибка авторизации возвращают в Unauthenticated. Step-up переводит Authenticated в StepUpRequired, а stepUp() снова входит в Authorizing.

Истекший access-токен или 401, не являющийся step-up, переводит Authenticated в Refreshing и обратно. invalid_grant при обновлении заканчивается в Unauthenticated: grant потерян, а все, что похоже на учетные данные, стирается. Сессия, в которой провайдер не выдал refresh-токен, сразу уходит в Unauthenticated, когда истекает access-токен. Запрос токена при этом не делается, а операция падает с Reauthenticate(SessionExpired).

logout() идет через LoggingOut в Unauthenticated, и локальное стирание происходит, даже если шаг в браузере упал или пользователь его закрыл.

Что переживает убитое приложение

Перед открытием браузера SDK сохраняет ожидающий запрос авторизации, потому что приложение могут убить между этими двумя шагами. При запуске ожидающий запрос моложе 10 минут восстанавливается как Authorizing, а редирект, доставленный перезапущенному процессу, проверяется по нему и завершается как обычно. Более старый запрос отбрасывается.

Сохраненный refresh-токен важнее ожидающего запроса. Если есть и то и другое, запрос — остаток входа, который убили после сохранения токена, и SDK восстанавливает Authenticated. Цена такая: повторная авторизация поверх живой сессии, например step-up, убитая посреди браузера, разрешается в пользу уже существующей сессии.

Access-токен и разобранные claims живут только в памяти и пропадают вместе с процессом. Refresh-токен, сырой ID-токен и ожидающий запрос хранятся зашифрованными. DPoP-ключ при выходе уничтожается, потому что один ключ привязывает один grant к одной установке на одном устройстве.

Четыре категории ошибок

КатегорияНагрузкаЧто делает приложение
RecoverableПричинаНичего не потеряно. SDK уже повторил то, что безопасно повторять, и ошибка всплывает, когда эти повторы исчерпаны или операция из тех, что он не повторяет. Покажите временный сбой и предложите повторить. Пользователя не разлогинивайте
ReauthenticateОснованиеОтправьте пользователя через login(). Grant потерян, и сколько ни повторяй, он не оживет
StepUpacrValues, maxAgeВызовите stepUp() с этим требованием, затем повторите вызов, которому отказали
ConfigurationДетальОшибка разработчика. В отладочных сборках падайте громко. В продакшене не перехватывайте: так ошибка развертывания прячется за экраном входа

Нагрузка говорит, какой именно случай внутри категории. На Android у Recoverable она называется reason, потому что cause занят в Throwable. Везде в остальных местах она называется cause.

Recoverable

Причины: UserCancelled, SystemCancelled, PendingRequestExpired, Network, Server, UnlockRequired и NonceLoop.

Закрытый браузер бывает двух видов, и приложению стоит вести себя по-разному. Отмена пользователем значит, что человек закрыл браузер, и SDK никогда не повторяет ее сам. Отмена системой значит, что сессию снесла операционная система, и тихий повтор уместен. При отмене пользователем SDK стирает ожидающий запрос, а при отмене системой оставляет, чтобы поток можно было возобновить.

Network значит, что никто не ответил: DNS, соединение, TLS или таймаут. Server значит, что провайдер ответил 408, 429 или 5xx либо статусом, который операции не подходит, и операция не завершилась. В причине лежат status, читаемый Retry-After провайдера, если он был, число неудавшихся запросов вызова, который поднял ошибку, и время, которое заняла операция. Приложение читает retryAfter и назначает повтор на это время, как показано на странице Ошибки сервера, 429 и Retry-After.

UnlockRequired значит, что при OnAppUnlock окно аутентификации для ключа хранилища истекло. Grant цел. Приложение показывает запрос и повторяет попытку: у SDK нет интерфейса, а показ запроса — решение интерфейса. NonceLoop значит, что сервер потребовал DPoP nonce при повторе, который его уже нес. Цикл завершается по замыслу, чтобы сервер, который всегда требует nonce, давал ошибку, а не бесконечный повтор.

Reauthenticate

Основания: AuthorizationDenied, InteractionRequired, CredentialsInvalidated, GrantRejected, SessionRejected и SessionExpired.

Reauthenticate — диагноз, а не команда. SDK никогда не открывает браузер сам, и когда запускать login(), решает приложение. Чаще всего причина — invalid_grant от эндпоинта токена. Он покрывает истекший код, несовпадение PKCE или редиректа, повторное использование refresh-токена, отзыв всего семейства токенов и несовпадение DPoP-ключа, и сервер намеренно отвечает на все это одним и тем же телом. Сюда же попадает смена биометрии, которая делает ключ хранилища недействительным: ее нельзя отличить от подмены, и она трактуется как выход.

StepUp

Сервер ресурса ответил 401 с insufficient_user_authentication (RFC 9470) и назвал нужный контекст аутентификации. Это не повод обновлять токен: клиент, который обновляет его здесь, будет крутиться вечно, ведь токен никогда не удовлетворит требование. stepUp() повторно авторизует с acr_values и max_age из требования, а приложение затем повторяет вызов, которому отказали. От отказанного вызова после смерти процесса ничего не остается, так что приложение отправляет его заново.

Configuration

Любая причина здесь — ошибка развертывания или атака, и повторная попытка пользователя не исправит ни то, ни другое. Типичные детали: StateMismatch, IssuerMismatch, IdTokenRejected, ProviderUnsupported, InvalidConfiguration, AuthorizationRejected и DiscoveryIssuerMismatch. При несовпадении state или iss запрос токена не делается вовсе.

Незарегистрированный redirect URI — самая частая ошибка первого запуска, и сообщить о ней как о Configuration SDK не может. Провайдер показывает страницу с ошибкой прямо в браузере, а Custom Tabs и ASWebAuthenticationSession эту страницу не отдают. Для приложения это выглядит как закрытая пользователем вкладка. Если провайдер объявляет pushed authorization requests, SDK сначала отправляет запрос этим способом, и отказ приходит как Configuration(AuthorizationRejected) еще до открытия браузера.

Куда попадают частые сбои

УсловиеКатегория
Пользователь закрывает браузерRecoverable, UserCancelled
ОС сносит сессию браузераRecoverable, SystemCancelled
Сбой сети или таймаут после исчерпания бюджета повторов либо сразу, если операцию SDK не повторяетRecoverable, Network
408, 429 или 5xx от провайдера после исчерпания бюджета повторов либо сразу для статуса, которому повтор не поможет (501, 505)Recoverable, Server со status
429 или 503 с Retry-After, ожидание которого не помещается в бюджет или на котором кончились попытки, либо запрос, отклоненный во время паузы после такого Retry-AfterRecoverable, Server с заданным retryAfter
Редирект (3xx) от discovery, набора ключей, эндпоинта токена, UserInfo или pushed authorization requestConfiguration
Окно аутентификации ключа хранилища истекло (только OnAppUnlock)Recoverable, UnlockRequired
Пользователь отказал или prompt=none требует взаимодействияReauthenticate, AuthorizationDenied или InteractionRequired
invalid_grant при обновленииReauthenticate, GrantRejected
Access-токен истек, а провайдер не выдал refresh-токенReauthenticate, SessionExpired
Сервер ресурса отвечает 401 дважды, второй раз после обновленияReauthenticate, SessionRejected
Сервер ресурса отвечает 401 с insufficient_user_authenticationStepUp
state или iss в редиректе не совпадаетConfiguration
ID-токен не прошел проверку (подпись, iss, aud, exp, nonce и так далее)Configuration, IdTokenRejected
Возможность, которую вы потребовали, не объявлена (DPoP, PAR)Configuration, ProviderUnsupported
Android: https-хост редиректа не входит в проверенные App LinkConfiguration, InvalidConfiguration

Коды статуса, кроме 401, не преобразуются. 403, 404, 429 или 5xx от вашего API — собственный ответ приложения, и SDK возвращает его как ответ.

Правила, общие для всех категорий

  • Исчерпывающе. Иерархия на каждой платформе закрытая (sealed), так что новая ошибка ломает компиляцию, а не проваливается молча в ветку default.
  • Одна категория на код. Нет ошибок «иногда восстановимых». Код, который SDK повторяет внутри, например use_dpop_nonce или invalid_dpop_proof, всплывает в одной категории, один раз и только если внутренний повтор тоже не удался.
  • Строк для сопоставления нет. error_description от сервера прикладывается для логов и по нему не ветвятся.
  • Повторы ограничены бюджетом. У каждого пути Recoverable есть потолок.

Что SDK повторяет, прежде чем вы увидите ошибку

Один видимый приложению вызов — одна операция: login(), logout(), обновление токена или вызов вроде userInfo(). Внутренние шаги, такие как повтор по nonce, обновление после 401 и повтор исходного запроса, считаются внутри бюджета этой операции. Лимиты расходуют только неудавшиеся запросы, а время, которое пользователь проводит в браузере во время login(), в общее время не входит.

ПараметрПо умолчаниюЖесткий максимум
Неудавшихся запросов к одному источнику на операцию, включая исходный35
Неудавшихся запросов ко всем источникам на операцию48
Задержка перед второй попыткой500 ms30 s
Множитель backoff2.04.0
Самая длинная одиночная задержка8 s30 s
Полное время одной операции, включая задержки20 s60 s

Менять эти значения можно через retryPolicy в конфигурации, в пределах максимумов. Jitter включен всегда и не настраивается. Значение выше максимума или ниже минимума дает ошибку Configuration до первого запроса, а не молча обрезается.

Три повтора фиксированы и не увеличиваются. use_dpop_nonce получает ровно один повтор на источник и операцию. invalid_dpop_proof получает ровно один повтор со свежим proof. 401, не являющийся step-up или требованием nonce, получает одно обновление и один повтор запроса.

Не повторяются никогда: invalid_grant, любая ошибка Configuration (редирект от эндпоинта — тоже она), insufficient_user_authentication, отмена пользователем, 4xx кроме 408 и 429, 501 и 505, а также обмен authorization code, в том числе после потерянного ответа. Код одноразовый, и вторая попытка — повтор, на который сервер может ответить отзывом того, что уже выдал. Пользователь входит заново. Повторы по nonce и proof к обмену по-прежнему применяются, потому что оба отвечают на требование, поднятое до того, как код был погашен.

Какие запросы SDK повторяет, зависит от операции, а полная таблица — на странице Ошибки сервера, 429 и Retry-After. Коротко:

  • Повторяются после сбоя транспорта, таймаута, ошибки DNS, 408, 429, 500, 502, 503 или 504: discovery, набор ключей, UserInfo, отзыв токена и pushed authorization request. Это чтение, либо повтор ничему не вредит.
  • Повторяется, только если запрос заведомо не был обработан: обновление токена. SDK не отправляет с обновлением Idempotency-Key, поэтому после таймаута, сброшенного соединения или 500, 502, 504 он не может понять, ротировал ли провайдер токен. Обновление повторяется после сбоя соединения до записи чего-либо или после 408, 429, 503.
  • Не повторяется никогда: обмен authorization code, потому что код работает один раз.
  • Не повторяется по статусу: запрос, который ваше приложение отправляет через аутентифицированный клиент. 429 или 5xx от вашего API возвращается вам как ответ, потому что SDK не знает, безопасно ли повторять вызов.

Retry-After SDK читает на 429 и 503. Если значение есть, SDK ждет ровно столько вместо собственной задержки. Ожидание, которое не помещается в остаток бюджета (по умолчанию 20 s), не сокращается: SDK останавливается, не отправляя новый запрос, а ошибка Server несет значение в retryAfter. Пока это время не прошло, SDK сразу роняет свои запросы к этому источнику, не отправляя их. login() проверяет это в самом начале, до открытия браузера.

Что дальше