Authorization request: PAR and extra parameters — Versola Docs
/docs
0.1.0versola.ioGitHub

Authorization request: PAR and extra parameters

Choose whether the Versola OAuth SDK pushes the authorization request (PAR), and pass prompt, login_hint, resource and provider parameters.

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:

pushedAuthorizationProvider advertises pushed_authorization_request_endpointProvider does not advertise it
NeverPlain request: every parameter in the /authorize queryPlain request
WhenAdvertised (default)PAR, then /authorize with client_id and request_uriPlain request
RequiredPARConfiguration(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 at login() instead of working without it. Against Hydra, Required gives ProviderUnsupported("par").
  • Never. Use it when the provider advertises PAR but you want the plain request anyway. It fails with ProviderUnsupported("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:

ErrorCause
ProviderUnsupported("par")The setting or the provider requires PAR, and the provider advertises no PAR endpoint. Or Never meets a provider that requires PAR
AuthorizationRejectedThe 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:

ParameterValueSent when
response_typecodeAlways
client_idclientId of the configurationAlways
redirect_uriredirectUri of the configurationAlways
scopeThe scopes of the sign-in, with openidAlways
state, nonceFresh random valuesAlways
code_challenge, code_challenge_methodPKCE challenge, method S256Always
acr_valuesSpace-separated valuesacrValues is not empty
max_ageSecondsmaxAge is set
dpop_jktThumbprint of the DPoP keyThe session is bound with DPoP (tokenBinding)
promptThe prompt valueprompt is set
login_hintAs givenloginHint is set
ui_localesSpace-separated, in your orderuiLocales is not empty
resourceOne parameter per entryresources is not empty
Extra parametersVerbatimextraParameters 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.

FieldParameterRule
scopesscopeReplaces the configured scopes for this sign-in. openid is added if it is missing
promptpromptOne 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
loginHintlogin_hintSent as given
uiLocalesui_localesA list, sent in your order as one space-separated value
acrValuesacr_valuesA list, sent as one space-separated value. stepUp() sets it from the challenge
maxAgemax_ageSeconds. The ID token must carry auth_time, and the SDK checks it
resourcesresourceRFC 8707, one resource per entry. Also sent on the token request that redeems the code
extraParametersThe names you chooseA map of provider parameters, sent verbatim after all the others
ephemeralBrowserNonefalse 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 matching LoginOptions field for the ones that have one, such as prompt or resources.
  • Only the authorization request. Extra parameters don’t go to the token request. resources is 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