The OAuthClient of the Versola OAuth SDK (Android, iOS, Flutter) works with any provider that meets a short contract. This page lists that contract, the providers with a guide, how each provider’s rate limits reach your app, and the redirect setup that is the same for all of them. The condition: your provider must be an OpenID Provider that supports the authorization code flow with PKCE for a public client.
The guides
| Provider | Guide | Version in the SDK’s tests |
|---|---|---|
| Keycloak | Keycloak | 26.4 |
| Ory Hydra | Ory Hydra | v26.2.0 (self-hosted, open source image) |
The Android integration tests drive the full flow against the provider’s container in the SDK’s CI: sign-in, an authenticated call, UserInfo, revocation and sign-out, plus tampered ID tokens, PAR, explicit endpoints and a client without refresh tokens. The iOS and Flutter test drivers run the same tests against the same containers. The browser leg is the provider’s login conversation driven over HTTP. Each guide says what the tests observed.
The minimum contract
A provider works with OAuthClient when it offers all of the following.
- Discovery, or explicit endpoints. A document at
{issuer}/.well-known/openid-configurationwhoseissuermember equals the issuer you configured. One trailing slash on either side is ignored for that comparison. Without discovery you giveProviderEndpointsinstead, and then you cannot also giveissuer. - Authorization code flow with PKCE
S256for a public client. The client has no secret and the token request carries onlyclient_id. The SDK always sendsS256. response_type=codewithstateechoed on the redirect.- An asymmetrically signed ID token (
RS256,PS256,ES256,ES384,ES512,RS384,RS512,PS384orPS512) with its keys atjwks_uri.noneand HMAC algorithms are refused. - An
issclaim in the ID token equal to the discovered issuer, byte for byte.
Everything else is optional, and the SDK adapts to what discovery says.
| Provider does not offer | What the SDK does |
|---|---|
| A refresh token | The session ends with Reauthenticate(SessionExpired) when the access token expires. No token request is made. |
| A revocation endpoint | Sign-out skips revocation and reports tokenRevoked = false. |
An end_session_endpoint | Sign-out wipes local state and reports sessionEnded = false, unless you supply the URL with endSessionEndpoint. |
| A UserInfo endpoint | userInfo() fails with Configuration(ProviderUnsupported). |
| PAR | The SDK sends the plain authorization request. |
| DPoP | Tokens are bearer tokens. |
A provider is unsupported when it requires a client secret from a native app, accepts only plain PKCE or none, signs ID tokens only with HMAC, or announces an issuer that does not match its discovery document. A templated issuer is such a mismatch.
Redirect setup shared by every provider
What you register with the provider is provider-specific and is in each guide. What the app does with the redirect is the same everywhere.
The SDK accepts three kinds of redirectUri: an https URL (a verified App Link or Universal Link, preferred), a loopback URL, or a private-use scheme such as com.example.app:/oauth. A private-use scheme works, but the SDK logs a warning on every create(), because any app on the device can register the same scheme. Cleartext http to a non-loopback host is refused. On iOS a cleartext loopback URL is refused too, because ASWebAuthenticationSession cannot receive it.
Platform setup that does not depend on the provider is on the Android, iOS and Flutter pages. The parts that decide whether a redirect arrives are below.
Android: App Links
Register the redirect URI with the provider exactly as it appears in OAuthConfiguration.redirectUri. Then re-declare the SDK’s redirect activity in the app manifest with an intent filter for it. The SDK’s own manifest declares the activity without a filter, and the manifest merger unions the two. The filter below also covers the sign-out path.
<activity
android:name="versola.sdk.browser.VersolaRedirectActivity"
android:exported="true"
tools:node="merge">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.example.com" android:path="/oauth" />
<data android:scheme="https" android:host="app.example.com" android:path="/signed-out" />
</intent-filter>
</activity>
The host must be one you control, and it must serve Digital Asset Links (https://app.example.com/.well-known/assetlinks.json) for the app’s package name and signing certificate. The SDK opens an Auth Tab where the device’s browser supports it and a Custom Tab otherwise. Under an Auth Tab the browser checks that the redirect host belongs to your app. If it cannot, login() fails with Configuration(InvalidConfiguration), and the cause is the assetlinks.json, not the provider. The same applies to postLogoutRedirectUri when it is an https URL: without a verified host the tab closes unverified and logout() reports sessionEnded = false.
For a private-use scheme the intent filter carries android:scheme="com.example.app" instead.
The Android artefact is io.versola:oauth-android on Maven Central, and minSdk is 26.
iOS: Universal Links
For an https redirect URI the SDK starts an ASWebAuthenticationSession with an https(host:path:) callback, which needs iOS 17.4 (the package’s platform floor). The app needs an Associated Domains entitlement for the redirect host, and the apple-app-site-association file on that host must list the app under webcredentials. An applinks association alone is not enough. AppAuth-iOS, which uses the same system API, documents the same requirement (README of AppAuth-iOS).
You supply the window the sheet is presented from, once, when you create the client. A minimal anchor for a single-window app, as an illustration:
import AuthenticationServices
import UIKit
@MainActor
func appKeyWindow() -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first(where: \.isKeyWindow) ?? ASPresentationAnchor()
}
A Universal Link that reaches the app while it is cold goes to client.handleRedirect(url), which returns true when the URL was the redirect the SDK was waiting for. In SwiftUI:
.onOpenURL { url in
Task { _ = await client.handleRedirect(url) }
}
Flutter
The plugin is a binding over the native SDKs and has no redirect handling of its own. Do the Android manifest work above in the app’s android/ project, and the iOS entitlement work in the Runner target. On iOS the plugin presents the sheet from the key window of the foreground scene. An app with its own window handling sets VersolaOAuthPlugin.presentationAnchor before the first create. Dart has no authenticated HTTP client: accessToken() is the one call that hands you a credential.
How do provider rate limits reach your app?
Providers answer an overloaded token endpoint with a 429, and some add Retry-After. The Versola OAuth SDK honours Retry-After on 429 and 503, seconds or HTTP date, in preference to its own backoff, and never shortens the wait to fit its budget. A value that doesn’t fit stops the operation, and your app gets Recoverable with the cause Server and retryAfter set. Until it has passed, the SDK sends no request to that origin. Without the header, a 429 is repeated under the ordinary retry budget like a 5xx.
| The provider sends | What the SDK does |
|---|---|
Retry-After on a 429 or 503 | Waits for it, or hands it to your app |
Only its own headers (X-RateLimit-*, X-Rate-Limit-*, x-ratelimit-reset) | Ordinary backoff. The SDK doesn’t read those headers |
| No rate-limit headers | Ordinary backoff |
There is nothing to configure per provider. Don’t raise the retry maxima to ride out a limit: they are hard bounds. The retry rules, with the code for each platform, are on Server errors, 429 and Retry-After.
Reading a failure
Errors fall into four categories, and the category tells the app what to do: Recoverable (offer a retry, or wait for retryAfter), Reauthenticate (send the user to login()), StepUp (call stepUp) and Configuration (a deployment mistake: fix it, don’t catch it in production). Most first-run provider problems arrive as Configuration.
| You see | Usually means |
|---|---|
DiscoveryIssuerMismatch | The configured issuer is not what the provider’s discovery document announces. Copy the issuer member exactly. |
InvalidConfiguration after the browser closes (Android) | The redirect host is not a verified App Link of this app. |
IdTokenRejected(reason) | The ID token failed validation: algorithm, iss, aud, exp, nonce or signature. Retrying never fixes it. |
ProviderUnsupported(capability) | The configuration asks for something the provider does not advertise (dpop, par, S256), or userInfo() has no endpoint. |
AuthorizationRejected | The provider refused a pushed authorization request (PAR), for example an unregistered redirect URI. |
An unregistered redirect URI on a plain (non-PAR) request shows an error page in the browser that the SDK cannot see. It looks the same as the user closing the tab. Check the registered string first. The full list is on Session states and errors.