This guide adds the versola_oauth plugin of the Versola OAuth SDK to a Flutter app and signs a user in. The plugin is a binding: no protocol logic runs in Dart, and the behaviour is that of the native Android and iOS SDKs.
Prerequisites
- Flutter 3.47 and Dart 3.13, the ranges the plugin’s
pubspec.yamldeclares. - Android
minSdk26, which is above Flutter’s default, andcompileSdk37. iOS 17.4 or later, which is above Flutter’s default deployment target. - On iOS, Swift Package Manager integration enabled. The plugin has no podspec, so CocoaPods can’t build it.
- A public client registered at your provider with the exact redirect URI you will use. A provider that needs
offline_accessfor a refresh token is listed on Provider guides. - A host you control for the verified link:
assetlinks.jsonfor Android, and the associated-domain file for iOS.
Install
Add the plugin to pubspec.yaml as a git dependency on the repository https://github.com/versolauth/versola-sdk, then run flutter pub get:
# pubspec.yaml of the app
dependencies:
versola_oauth:
git:
url: https://github.com/versolauth/versola-sdk.git
path: flutter/versola_oauth
ref: <version>
The plugin resolves its native halves itself. On Android it depends on io.versola:oauth-android from Maven Central, so your app needs no extra repository entry beyond the default google() and mavenCentral() of a Flutter project. On iOS its Swift package depends on the SDK’s VersolaOAuth product.
Set up Android
Raise compileSdk to 37 and minSdk to 26 in android/app/build.gradle.kts. A Flutter project’s defaults are lower, and the build fails on the library’s compileSdk requirement and on the manifest merger otherwise.
// android/app/build.gradle.kts
android {
compileSdk = 37
defaultConfig {
minSdk = 26
}
}
Then declare the redirect URI on versola.sdk.browser.VersolaRedirectActivity in your app manifest, and serve assetlinks.json on the host. The snippet and the verification rules are in Android. The example app in the SDK repository has the same snippet in its manifest.
Set up iOS
Set the app’s iOS deployment target to 17.4 (Minimum Deployments in Xcode, or IPHONEOS_DEPLOYMENT_TARGET = 17.4 in ios/Runner.xcodeproj). Xcode refuses to link the plugin’s product at a lower target.
The browser sheet is presented from a window the plugin supplies: by default the key window of the foreground scene. An app with its own window handling sets the hook once, in AppDelegate, before the first create:
VersolaOAuthPlugin.presentationAnchor = { myWindow } // () -> ASPresentationAnchor, main actor
The plugin forwards Universal Links (application and scene lifecycles, including the link that launched the app) to the native client’s handleRedirect. An https redirect URI needs the webcredentials associated domain for its host, as described in iOS.
The plugin does not call the native resumePendingAuthorization().
Sign in
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',
scopes: {'openid', 'profile', 'offline_access'},
protectedOrigins: {'https://api.example.com'},
));
try {
await client.login();
final token = await client.accessToken();
// Call your API with dio or http: Authorization: ${token.tokenType} ${token.value}
await client.logout();
} on StepUpError catch (e) {
await client.stepUp(e);
} on ReauthenticateError {
// sign in again
} on RecoverableError {
// retry, or ask the user again
} on ConfigurationError {
// a deployment mistake: fix it, do not catch it in production
}
create validates the configuration natively and throws a ConfigurationError for a bad value. It does no network I/O. The SDK never adds offline_access: put it in scopes when your provider needs it for a refresh token. A second create for the same issuer and client ID reaches the same native client.
| Field | Meaning |
|---|---|
pushedAuthorization | PushedAuthorization.whenAdvertised by default: the SDK pushes the request when the provider advertises a PAR endpoint. never and required are the other values, see Authorization request |
tokenBinding | TokenBinding.bearer by default. TokenBinding.dpop opts in to DPoP, see Sender-constrained tokens |
retryPolicy | Not set by default, and the SDK’s default bounds apply. A RetryPolicy changes how often and how long the SDK repeats a failed request, see Server errors, 429 and Retry-After |
login while an authorization is in progress is refused, so observe sessionStates() first.
To push the request with PAR, or to pass prompt, loginHint, resources or provider parameters, see Authorization request: PAR and extra parameters.
Observe the session state
sessionStates() returns a Stream<SessionState> that any number of listeners can share. There is one native subscription, which ends when the last listener cancels, and a late listener first gets the latest state.
client.sessionStates().listen((state) => render(state));
The seven states are on Session states and errors. currentSessionState() asks native for the state once.
claims() is a method, not a getter. The claims of the validated ID token stay native, so the answer is asynchronous, and a getter that returned a Future would make a client.claims != null check always true.
Methods
| Call | Result |
|---|---|
OAuthClient.create(configuration) | The client for that issuer and client ID |
sessionStates() | Stream<SessionState> shared by every listener |
currentSessionState() | Future<SessionState>, asked of native every time |
claims() | Future<IdTokenClaims?>: the validated ID token’s claims, null with no session |
login([options]) | Future<void>. The session is observable through sessionStates() |
stepUp(error, [options]) | Future<void>. Re-authorizes at the StepUpError’s acrValues and maxAge; your app then repeats the refused call |
logout([options]) | Future<LogoutOutcome> with sessionEnded and tokenRevoked. The local wipe is unconditional |
accessToken() | Future<AccessToken> (value, tokenType), refreshed natively first if needed |
refresh() | Future<void> |
userInfo() | Future<UserInfo> |
keyAttestation() | Future<String?>: the DPoP key tier (secureEnclave, strongBox, trustedEnvironment, software), null under bearer |
Handle errors
Every call throws an OAuthError and nothing else. It is a sealed class with the four categories, and the category is the contract; the payload is the native case. Switch on the category first:
try {
final token = await client.accessToken();
// use token.value
} on OAuthError catch (error) {
switch (error) {
case RecoverableError(:final cause, :final retryAfter):
// userCancelled, systemCancelled, pendingRequestExpired, network, server, unlockRequired,
// nonceLoop. Retry or ask again; do not sign out. `unknown` is a native cause this version
// does not know: do not loop on it.
if (cause == RecoverableCause.server) scheduleRetry(retryAfter); // null: the SDK spent its budget
if (cause == RecoverableCause.network) scheduleRetry();
case ReauthenticateError(:final reason):
// authorizationDenied, interactionRequired, credentialsInvalidated, grantRejected,
// sessionRejected, sessionExpired: the session is over, so show the sign-in screen.
showSignIn(reason);
case final StepUpError challenge:
await client.stepUp(challenge);
// then repeat the call that was refused
case ConfigurationError(:final detail):
// stateMismatch, issuerMismatch, idTokenRejected, providerUnsupported, invalidConfiguration...
// A deployment or integration mistake; no retry fixes it.
reportMisconfiguration(detail);
}
}
A native case this version of the binding doesn’t know arrives as unknown, with its name in detail or text. It never arrives as a different real case. Errors carry nothing credential-shaped, and AccessToken, IdTokenClaims and UserInfo redact themselves in toString. What each category means is on Session states and errors. When a provider answers 500, 503 or 429, the cause is server, with status and retryAfter on the error: see Server errors, 429 and Retry-After for what the SDK retries before you see it and for the full example.
Call your API
The access token is the only credential that crosses the bridge, and only as the reply of accessToken(). The refresh token, the raw ID token, key material, the pending request, the PKCE verifier and state stay native. The Dart side keeps no copy of the access token: it asks native every time, so single-flight refresh stays in one place.
There is no authenticated HTTP client in Flutter, and no protectedOrigins enforcement on your requests. protectedOrigins is accepted in the configuration and validated by the native client, but nothing in Dart sends a request for you. Use the token with dio or http, and send it only to the origins you intend. A token your app holds is one it can log.
DPoP can be selected (TokenBinding.dpop, TokenBinding.dpopRequireHardware), and the native client signs proofs. But proofs are made only for requests the native SDK sends itself, so a DPoP-bound token used from Dart needs a proof your app can’t obtain. Use bearer, the default, unless the native side is what calls your API.
Next steps
- Session states and errors: the seven states and four error categories.
- Server errors, 429 and Retry-After: what the SDK retries when a provider answers 500, 503 or 429.
- Android and iOS: the native setup the plugin builds on.
- Provider guides: the provider contract and the
offline_accessdifferences.