Every sign-in with the Versola OAuth SDK starts with one authorization request. This page shows where you set what goes into it: whether the SDK first pushes the request to the provider with PAR (RFC 9126), and which extra parameters the browser carries, such as prompt, login_hint, resource or a parameter only your provider knows. It applies to Android, iOS and Flutter, and it assumes the OAuthClient from the platform page.
Where do you configure the authorization request?
In two places. OAuthConfiguration goes to OAuthClient.create(...) once. It holds what stays the same for every sign-in: the issuer, the client ID, the redirect URI, the default scopes and the PAR policy. LoginOptions goes to each login() and holds what can change per sign-in. login() with no argument uses LoginOptions with every field unset.
The configuration below sets the PAR policy. PushedAuthorization.Required is one of three values, and the default needs no line at all.
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,
));
Some parts of the request have no setting. The SDK always sends response_type=code, PKCE with S256, and a fresh random state and nonce. Nothing in OAuthConfiguration or LoginOptions can turn them off or replace them.
Should the SDK push the request with PAR?
With PAR, the SDK sends the request parameters to the provider’s pushed_authorization_request_endpoint in a POST, gets back a request_uri, and opens the browser with only client_id and request_uri. The parameters never cross the browser, and the provider can refuse the request before the user sees a page. The code exchange stays the ordinary one: the PKCE verifier stays on the device, and the token request is the same as without PAR.
pushedAuthorization decides whether this happens. What each value does depends on whether the provider’s discovery document advertises a PAR endpoint:
pushedAuthorization | Provider advertises pushed_authorization_request_endpoint | Provider does not advertise it |
|---|---|---|
Never | Plain request: every parameter in the /authorize query | Plain request |
WhenAdvertised (default) | PAR, then /authorize with client_id and request_uri | Plain request |
Required | PAR | Configuration(ProviderUnsupported("par")) |
On iOS and Flutter the values are lower camel case: .never, .whenAdvertised, .required and PushedAuthorization.whenAdvertised.
The provider’s own demand wins over the setting. When discovery says require_pushed_authorization_requests: true, a plain request would be refused, so the SDK doesn’t send one. Never then gives ProviderUnsupported("par"), and so does any setting when the provider requires PAR but advertises no endpoint to push to.
The SDK decides at every login() from the provider’s current metadata, not when you create the client. A provider that starts requiring PAR shows up at the next sign-in.
Which value should you pick?
WhenAdvertised. Keep it unless you have a reason. The SDK pushes the request where the provider supports it and sends a plain request where it doesn’t. Keycloak advertises PAR and accepts public clients, so the default pushes there, and the integration tests ran that way. Ory Hydra advertises none, so the same default sends a plain request.Required. Use it when your security policy says every request must be pushed, as it does for a provider that requires PAR. The SDK then refuses to fall back to a plain request, and a provider without PAR fails atlogin()instead of working without it. Against Hydra,RequiredgivesProviderUnsupported("par").Never. Use it when the provider advertises PAR but you want the plain request anyway. It fails withProviderUnsupported("par")if the provider requires PAR.
What does your app see when PAR fails?
Both failures are Configuration errors from login(), raised before anything is stored and before the browser opens:
| Error | Cause |
|---|---|
ProviderUnsupported("par") | The setting or the provider requires PAR, and the provider advertises no PAR endpoint. Or Never meets a provider that requires PAR |
AuthorizationRejected | The provider refused the pushed request, for example for an unregistered redirect URI. It carries the provider’s error code and description |
Both are deployment mistakes, not something the user can fix by trying again. A pushed request that fails on the network or with a 5xx is Recoverable, like any other request, and is repeated first as Server errors, 429 and Retry-After describes.
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 is your app’s own function, not part of the SDK.
An unregistered redirect URI on a plain request is different: the provider shows an error page in the browser, which the SDK can’t see. PAR moves that failure to AuthorizationRejected, before the browser. Session states and errors lists the other Configuration causes.
How do the PAR push and the authorize request relate?
The push is step one, and the authorize request follows it. In the sequences below, /par and /authorize stand for the pushed_authorization_request_endpoint and the authorization_endpoint from the provider’s discovery document.
Without PAR, there is one step. The browser opens /authorize and every parameter is in its query:
GET /authorize?response_type=code&client_id=acme-app&redirect_uri=...&scope=...&state=...&nonce=...&code_challenge=...&code_challenge_method=S256&...
With PAR, the SDK posts the same parameters as a form, then opens the browser with two:
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
The request_uri and expires_in above are examples. The SDK needs both in the answer, with expires_in above zero. A public client has no credential, so the push carries none, and the SDK doesn’t follow redirects from the PAR endpoint.
One list of parameters feeds both requests, so the same parameters are sent either way. It holds, in this order:
| Parameter | Value | Sent when |
|---|---|---|
response_type | code | Always |
client_id | clientId of the configuration | Always |
redirect_uri | redirectUri of the configuration | Always |
scope | The scopes of the sign-in, with openid | Always |
state, nonce | Fresh random values | Always |
code_challenge, code_challenge_method | PKCE challenge, method S256 | Always |
acr_values | Space-separated values | acrValues is not empty |
max_age | Seconds | maxAge is set |
dpop_jkt | Thumbprint of the DPoP key | The session is bound with DPoP (tokenBinding) |
prompt | The prompt value | prompt is set |
login_hint | As given | loginHint is set |
ui_locales | Space-separated, in your order | uiLocales is not empty |
resource | One parameter per entry | resources is not empty |
| Extra parameters | Verbatim | extraParameters is not empty |
The SDK leaves response_mode unset, so the provider answers in the redirect query.
Which login options can you pass?
LoginOptions has nine fields. Each one either maps to an authorization parameter or, for ephemeralBrowser, changes how the browser opens.
| Field | Parameter | Rule |
|---|---|---|
scopes | scope | Replaces the configured scopes for this sign-in. openid is added if it is missing |
prompt | prompt | One of None, Login, Consent, SelectAccount, Create (lower camel case on iOS and Flutter). With None, a needed interaction ends in Reauthenticate(InteractionRequired) instead of a login page |
loginHint | login_hint | Sent as given |
uiLocales | ui_locales | A list, sent in your order as one space-separated value |
acrValues | acr_values | A list, sent as one space-separated value. stepUp() sets it from the challenge |
maxAge | max_age | Seconds. The ID token must carry auth_time, and the SDK checks it |
resources | resource | RFC 8707, one resource per entry. Also sent on the token request that redeems the code |
extraParameters | The names you choose | A map of provider parameters, sent verbatim after all the others |
ephemeralBrowser | None | false by default. Asks for a browser session that shares nothing with the user’s browser, which breaks passkeys and single sign-on. Best effort: where the browser can’t do it, the SDK logs one warning and opens a shared session |
The next call passes prompt, loginHint, resources and an extraParameters map. audience and kc_idp_hint stand for your provider’s own parameters. The call is the same whether the provider takes a PAR push or a plain request.
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',
},
));
These are the pairs that end the parameter list, decoded for reading. With PAR they are form fields of the push. Without it they are in the /authorize query:
prompt=login
login_hint=ana@example.com
resource=https://api.example.com
audience=https://api.example.com
kc_idp_hint=acme-sso
stepUp() takes the same options, so it passes extraParameters and the rest through. On iOS, LoginOptions.init throws, which is why the Swift example uses try. It also refuses a negative maxAge.
How do extra parameters work?
- Verbatim. The SDK adds nothing to a value and doesn’t reorder a list.
- Last. Extra parameters follow every parameter in the table above, so they can’t push the SDK’s own parameters out of place.
- Reserved names are refused. A key that names a parameter the SDK sets itself is
Configuration(InvalidConfiguration). Use the matchingLoginOptionsfield for the ones that have one, such aspromptorresources. - Only the authorization request. Extra parameters don’t go to the token request.
resourcesis the one field that does, on the code exchange.
The reserved names are:
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
Three of them have no LoginOptions field because the SDK keeps them out of your reach. client_secret can’t be sent, because a native app can’t keep one. request is a request object, and response_mode would move the answer out of the redirect query that the SDK reads. The SDK uses no request objects and no form-post response mode.
The check runs when LoginOptions is created, on Android and iOS. A value like this fails at once:
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
On Flutter, LoginOptions is a plain const value, and the native SDK checks it when login() receives it. The result is a ConfigurationError with the detail invalidConfiguration from login(), and no browser opens.
maxAge has a second effect after the browser returns. The ID token must carry auth_time, and an auth_time older than maxAge, within clockSkewTolerance, is refused. A refusal is Configuration(IdTokenRejected), and no session starts.
Next steps
- Android, iOS and Flutter: the platform setup these snippets assume.
- Provider guides: which providers advertise PAR, with what Keycloak and Ory Hydra did in the tests.
- Session states and errors: the four error categories, including every
Configurationcause.