Каждый вход через Versola OAuth SDK начинается с одного запроса авторизации. На этой странице показано, где задать его содержимое: отправляет ли SDK запрос провайдеру заранее через PAR (RFC 9126) и какие дополнительные параметры уходят в браузер: prompt, login_hint, resource или параметр, который знает только ваш провайдер. Страница относится к Android, iOS и Flutter и предполагает, что OAuthClient уже настроен по странице платформы.
Где настраивается запрос авторизации?
В двух местах. OAuthConfiguration передается в OAuthClient.create(...) один раз. В ней то, что одинаково для каждого входа: издатель, ID клиента, redirect URI, scope по умолчанию и политика PAR. LoginOptions передается в каждый login() и хранит то, что может меняться от входа к входу. login() без аргумента использует LoginOptions с неустановленными полями.
Конфигурация ниже задает политику PAR. PushedAuthorization.Required — одно из трех значений, а значению по умолчанию строка не нужна вовсе.
import versola.oauth.OAuthClient
import versola.oauth.OAuthConfiguration
import versola.oauth.core.PushedAuthorization
val oauth = OAuthClient.create(
applicationContext,
OAuthConfiguration(
issuer = "https://id.example.com",
clientId = "acme-app",
redirectUri = "https://app.example.com/oauth",
pushedAuthorization = PushedAuthorization.Required,
),
)
import OAuthClient
let configuration = try OAuthConfiguration(
issuer: "https://id.example.com",
clientId: "acme-app",
redirectUri: "https://app.example.com/oauth",
pushedAuthorization: .required
)
let oauth = try await OAuthClient.create(
configuration: configuration,
presentationAnchor: { window }
)
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',
pushedAuthorization: PushedAuthorization.required,
));
У части запроса настройки нет. SDK всегда отправляет response_type=code, PKCE с S256 и свежие случайные state и nonce. Ни OAuthConfiguration, ни LoginOptions не могут их отключить или заменить.
Нужно ли отправлять запрос через PAR?
С PAR SDK отправляет параметры запроса POST-ом на pushed_authorization_request_endpoint провайдера, получает request_uri и открывает браузер только с client_id и request_uri. Параметры не проходят через браузер, а провайдер может отклонить запрос до того, как пользователь увидит страницу. Обмен кода остается обычным: PKCE verifier остается на устройстве, а запрос токена такой же, как без PAR.
Происходит ли это, решает pushedAuthorization. Что делает каждое значение, зависит от того, объявляет ли discovery-документ провайдера PAR-эндпоинт:
pushedAuthorization | Провайдер объявляет pushed_authorization_request_endpoint | Провайдер его не объявляет |
|---|---|---|
Never | Обычный запрос: все параметры в query /authorize | Обычный запрос |
WhenAdvertised (по умолчанию) | PAR, затем /authorize с client_id и request_uri | Обычный запрос |
Required | PAR | Configuration(ProviderUnsupported("par")) |
На iOS и Flutter значения пишутся в lowerCamelCase: .never, .whenAdvertised, .required и PushedAuthorization.whenAdvertised.
Требование провайдера сильнее настройки. Если discovery говорит require_pushed_authorization_requests: true, обычный запрос будет отклонен, поэтому SDK его не отправляет. Never тогда дает ProviderUnsupported("par"), как и любая настройка, если провайдер требует PAR, но не объявляет эндпоинт, куда отправлять.
SDK решает это при каждом login() по актуальным метаданным провайдера, а не при создании клиента. Провайдер, который начал требовать PAR, обнаружится при следующем входе.
Какое значение выбрать?
WhenAdvertised. Оставьте его, если нет причины менять. SDK отправляет запрос там, где провайдер поддерживает PAR, и обычный запрос там, где не поддерживает. Keycloak объявляет PAR и принимает публичных клиентов, поэтому значение по умолчанию там отправляет запрос, и интеграционные тесты шли именно так. Ory Hydra PAR не объявляет, и то же значение по умолчанию отправляет обычный запрос.Required. Выбирайте, когда политика безопасности требует отправлять каждый запрос через PAR, как у провайдера, который требует PAR. SDK тогда не откатывается к обычному запросу, а провайдер без PAR дает ошибку вlogin()вместо работы без него. С HydraRequiredдаетProviderUnsupported("par").Never. Выбирайте, когда провайдер объявляет PAR, а вам нужен именно обычный запрос. Если провайдер требует PAR, будетProviderUnsupported("par").
Что увидит приложение, если PAR не сработал?
Оба сбоя — ошибки Configuration из login(), они возникают до того, как что-либо сохранено и до открытия браузера:
| Ошибка | Причина |
|---|---|
ProviderUnsupported("par") | Настройка или провайдер требуют PAR, а PAR-эндпоинта у провайдера нет. Или Never столкнулся с провайдером, который требует PAR |
AuthorizationRejected | Провайдер отклонил отправленный запрос, например из-за незарегистрированного redirect URI. Несет код ошибки провайдера и описание |
Обе — ошибки развертывания, а не то, что пользователь исправит повтором. Отправленный запрос, который упал из-за сети или 5xx, — это Recoverable, как и любой другой запрос, и его сначала повторяют так, как описано на странице Ошибки сервера, 429 и Retry-After.
import versola.oauth.core.VersolaError
try {
oauth.login()
} catch (e: VersolaError.Configuration) {
when (val detail = e.detail) {
is VersolaError.Configuration.Detail.AuthorizationRejected ->
reportMisconfiguration("PAR refused: ${detail.code}")
is VersolaError.Configuration.Detail.ProviderUnsupported ->
reportMisconfiguration("provider lacks ${detail.capability}")
else -> throw e
}
}
do {
try await oauth.login()
} catch OAuthError.configuration(.authorizationRejected(let code, _)) {
reportMisconfiguration("PAR refused: \(code)")
} catch OAuthError.configuration(.providerUnsupported(let capability)) {
reportMisconfiguration("provider lacks \(capability)")
}
try {
await client.login();
} on ConfigurationError catch (error) {
switch (error.detail) {
case ConfigurationDetail.authorizationRejected:
reportMisconfiguration('PAR refused: ${error.code}');
case ConfigurationDetail.providerUnsupported:
reportMisconfiguration('provider lacks ${error.capability}');
default:
rethrow;
}
}
reportMisconfiguration — функция вашего приложения, в SDK ее нет.
Незарегистрированный redirect URI в обычном запросе — другой случай: провайдер показывает страницу ошибки в браузере, и SDK ее не видит. PAR переносит этот сбой в AuthorizationRejected до открытия браузера. Остальные причины Configuration перечислены на странице Состояния сессии и ошибки.
Как связаны PAR-запрос и запрос в /authorize?
Отправка по PAR — первый шаг, запрос в /authorize идет следом. В схемах ниже /par и /authorize обозначают pushed_authorization_request_endpoint и authorization_endpoint из discovery-документа провайдера.
Без PAR шаг один. Браузер открывает /authorize, и все параметры лежат в его query:
GET /authorize?response_type=code&client_id=acme-app&redirect_uri=...&scope=...&state=...&nonce=...&code_challenge=...&code_challenge_method=S256&...
С PAR SDK отправляет те же параметры формой, а затем открывает браузер с двумя:
POST /par
response_type=code&client_id=acme-app&redirect_uri=...&scope=...&state=...&nonce=...&code_challenge=...&code_challenge_method=S256&...
201 {"request_uri": "urn:ietf:params:oauth:request_uri:example", "expires_in": 60}
GET /authorize?client_id=acme-app&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Aexample
request_uri и expires_in выше — примеры. SDK нужны оба поля в ответе, причем expires_in больше нуля. У публичного клиента нет секрета, поэтому отправка его не содержит, а редиректы от PAR-эндпоинта SDK не выполняет.
Оба запроса строятся из одного списка параметров, так что параметры одинаковы в обоих случаях. В нем идут, в таком порядке:
| Параметр | Значение | Когда отправляется |
|---|---|---|
response_type | code | Всегда |
client_id | clientId из конфигурации | Всегда |
redirect_uri | redirectUri из конфигурации | Всегда |
scope | Scope этого входа вместе с openid | Всегда |
state, nonce | Свежие случайные значения | Всегда |
code_challenge, code_challenge_method | PKCE challenge, метод S256 | Всегда |
acr_values | Значения через пробел | acrValues не пуст |
max_age | Секунды | Задан maxAge |
dpop_jkt | Отпечаток ключа DPoP | Сессия привязана через DPoP (tokenBinding) |
prompt | Значение prompt | Задан prompt |
login_hint | Как передано | Задан loginHint |
ui_locales | Через пробел, в вашем порядке | uiLocales не пуст |
resource | По параметру на каждый элемент | resources не пуст |
| Дополнительные параметры | Дословно | extraParameters не пуст |
response_mode SDK не задает, поэтому провайдер отвечает в query редиректа.
Какие параметры можно передать в login()?
В LoginOptions девять полей. Каждое либо соответствует параметру авторизации, либо, как ephemeralBrowser, меняет способ открытия браузера.
| Поле | Параметр | Правило |
|---|---|---|
scopes | scope | Заменяет настроенные scope для этого входа. openid добавляется, если его нет |
prompt | prompt | Одно из None, Login, Consent, SelectAccount, Create (в lowerCamelCase на iOS и Flutter). С None нужное взаимодействие заканчивается Reauthenticate(InteractionRequired) вместо страницы входа |
loginHint | login_hint | Отправляется как передано |
uiLocales | ui_locales | Список, отправляется в вашем порядке одним значением через пробел |
acrValues | acr_values | Список, отправляется одним значением через пробел. stepUp() задает его из запроса ресурса |
maxAge | max_age | Секунды. ID-токен должен содержать auth_time, и SDK его проверяет |
resources | resource | RFC 8707, по одному resource на элемент. Также отправляется в запросе токена, который обменивает код |
extraParameters | Имена, которые вы выбрали | Словарь параметров провайдера, отправляется дословно после всех остальных |
ephemeralBrowser | Нет | По умолчанию false. Просит браузерную сессию, которая ничего не делит с браузером пользователя, из-за чего ломаются passkeys и единый вход. По возможности: если браузер так не умеет, SDK пишет одно предупреждение в лог и открывает общую сессию |
Следующий вызов передает prompt, loginHint, resources и словарь extraParameters. audience и kc_idp_hint изображают собственные параметры вашего провайдера. Вызов один и тот же, принимает ли провайдер отправку по PAR или обычный запрос.
import versola.oauth.LoginOptions
import versola.oauth.core.AuthorizationParameters.Prompt
oauth.login(
LoginOptions(
prompt = Prompt.Login,
loginHint = "ana@example.com",
resources = listOf("https://api.example.com"),
extraParameters = mapOf(
"audience" to "https://api.example.com",
"kc_idp_hint" to "acme-sso",
),
),
)
let options = try LoginOptions(
prompt: .login,
loginHint: "ana@example.com",
resources: ["https://api.example.com"],
extraParameters: [
"audience": "https://api.example.com",
"kc_idp_hint": "acme-sso",
]
)
try await oauth.login(options)
await client.login(const LoginOptions(
prompt: Prompt.login,
loginHint: 'ana@example.com',
resources: ['https://api.example.com'],
extraParameters: {
'audience': 'https://api.example.com',
'kc_idp_hint': 'acme-sso',
},
));
Эти пары завершают список параметров, они показаны в раскодированном виде. С PAR это поля формы отправки, без PAR — query в /authorize:
prompt=login
login_hint=ana@example.com
resource=https://api.example.com
audience=https://api.example.com
kc_idp_hint=acme-sso
stepUp() принимает те же опции, так что extraParameters и остальное проходят и через него. На iOS LoginOptions.init бросает ошибку, поэтому в примере на Swift стоит try. Он же отклоняет отрицательный maxAge.
Как работают дополнительные параметры?
- Дословно. SDK ничего не добавляет к значению и не меняет порядок в списке.
- В конце. Дополнительные параметры идут после всех параметров из таблицы выше, поэтому они не могут сдвинуть собственные параметры SDK.
- Зарезервированные имена отклоняются. Ключ, совпадающий с параметром, который SDK задает сам, дает
Configuration(InvalidConfiguration). Для тех, у кого есть поле вLoginOptions, напримерpromptилиresources, используйте это поле. - Только запрос авторизации. Дополнительные параметры в запрос токена не попадают. Единственное поле, которое попадает, —
resources, при обмене кода.
Зарезервированы имена:
client_id, redirect_uri, response_type, response_mode, scope, state, nonce,
code_challenge, code_challenge_method, request, request_uri, prompt, max_age,
acr_values, resource, login_hint, ui_locales, dpop_jkt, client_secret
Для трех из них поля в LoginOptions нет, потому что SDK не оставляет их вам. client_secret отправить нельзя, ведь нативное приложение не может его хранить. request — это request object, а response_mode увел бы ответ из query редиректа, который читает SDK. Request object и form-post для ответа SDK не использует.
Проверка выполняется при создании LoginOptions на Android и iOS. Такое значение падает сразу:
LoginOptions(extraParameters = mapOf("state" to "abc")) // throws VersolaError.Configuration
try LoginOptions(extraParameters: ["state": "abc"]) // throws OAuthError.configuration(.invalidConfiguration)
await client.login(const LoginOptions(extraParameters: {'state': 'abc'})); // throws ConfigurationError
На Flutter LoginOptions — обычное const-значение, и нативный SDK проверяет его, когда login() его получает. Результат — ConfigurationError с деталью invalidConfiguration из login(), браузер не открывается.
У maxAge есть второй эффект после возврата из браузера. ID-токен должен содержать auth_time, а auth_time старше maxAge с учетом clockSkewTolerance отклоняется. Отказ — это Configuration(IdTokenRejected), сессия не начинается.
Что дальше
- Android, iOS и Flutter: настройка платформы, которую предполагают эти примеры.
- Руководства по провайдерам: какие провайдеры объявляют PAR и что показали тесты на Keycloak и Ory Hydra.
- Состояния сессии и ошибки: четыре категории ошибок, включая все причины
Configuration.