Запрос авторизации: PAR и дополнительные параметры — Versola Docs
/docs
0.1.0versola.ioGitHub

Запрос авторизации: PAR и дополнительные параметры

Выберите, отправлять ли запрос авторизации через PAR, и передайте prompt, login_hint, resource и параметры провайдера в Versola OAuth SDK на всех платформах.

Каждый вход через 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Обычный запрос
RequiredPARConfiguration(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() вместо работы без него. С Hydra Required дает 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_typecodeВсегда
client_idclientId из конфигурацииВсегда
redirect_uriredirectUri из конфигурацииВсегда
scopeScope этого входа вместе с openidВсегда
state, nonceСвежие случайные значенияВсегда
code_challenge, code_challenge_methodPKCE 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, меняет способ открытия браузера.

ПолеПараметрПравило
scopesscopeЗаменяет настроенные scope для этого входа. openid добавляется, если его нет
promptpromptОдно из None, Login, Consent, SelectAccount, Create (в lowerCamelCase на iOS и Flutter). С None нужное взаимодействие заканчивается Reauthenticate(InteractionRequired) вместо страницы входа
loginHintlogin_hintОтправляется как передано
uiLocalesui_localesСписок, отправляется в вашем порядке одним значением через пробел
acrValuesacr_valuesСписок, отправляется одним значением через пробел. stepUp() задает его из запроса ресурса
maxAgemax_ageСекунды. ID-токен должен содержать auth_time, и SDK его проверяет
resourcesresourceRFC 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), сессия не начинается.

Что дальше