Session states and errors — Versola Docs
/docs
0.1.0versola.ioGitHub

Session states and errors

The seven session states and four error categories shared by the Versola mobile SDKs, and what your app does for each category.

A Versola SDK session is always in one of seven states, and every failure belongs to one of four error categories. The category tells your app what to do, so you branch on it instead of on codes or message strings. The rules are the same on Android, iOS and Flutter.

Seven session states

The state is the source of truth. Every operation publishes through it, including an authorization that finishes in a process other than the one that started it. Android exposes it as a Flow<SessionState>, iOS as an AsyncStream<SessionState> from sessionStates(), and Flutter as a Stream<SessionState> from sessionStates(). The first value an observer sees is the restored state. The platforms add no states of their own.

StateMeaning
UnauthenticatedNo usable grant. login() is the only meaningful call
AuthorizingAn authorization request is outstanding: the browser is open, or the app was killed while it was
AuthenticatedA refresh token is held and an access token is available or derivable
RefreshingA token request is in flight. Concurrent callers share it and don’t each enter this state
StepUpRequiredA resource server refused a call with insufficient_user_authentication. The grant is valid, but its authentication context is not sufficient
LockedCredentials are present, but the key protecting them needs user authentication. Reachable only under the OnAppUnlock unlock policy
LoggingOutSign-out is in flight

Refreshing, StepUpRequired, Locked and LoggingOut are sub-states of holding a grant. The user is not signed out in any of them, and your UI shouldn’t treat them as signed out.

How the state moves

login() takes Unauthenticated to Authorizing. A validated redirect and a successful code exchange take it to Authenticated. A cancelled browser or an authorization error returns it to Unauthenticated. A step-up moves Authenticated to StepUpRequired, and stepUp() re-enters Authorizing.

An expired access token, or a 401 that is not a step-up, takes Authenticated to Refreshing and back. invalid_grant on refresh ends in Unauthenticated: the grant is gone, and everything credential-shaped is wiped. A session whose provider issued no refresh token goes straight to Unauthenticated when the access token expires. No token request is made, and the operation fails with Reauthenticate(SessionExpired).

logout() moves through LoggingOut to Unauthenticated, and the local wipe happens even if the browser leg fails or is dismissed.

What survives a killed app

Before the browser opens, the SDK persists the pending authorization request, because the app can be killed between the two. On startup a pending request younger than 10 minutes restores as Authorizing, and a redirect delivered to the restarted process is validated against it and completes normally. An older request is discarded.

A stored refresh token outranks a pending request. With both present, the request is the residue of a login that was killed after the token was stored, so the SDK restores Authenticated. The cost is a re-authorization started over a live session, such as a step-up, that is killed mid-browser: it resolves in favour of the session that already exists.

The access token and the parsed claims are memory only and are gone after process death. The refresh token, the raw ID token and the pending request are stored encrypted. The DPoP key is destroyed on logout, because one key binds one grant to one device installation.

Four error categories

CategoryPayloadWhat the app does
RecoverableA causeNothing is lost. The SDK has already repeated what is safe to repeat, and the error surfaces when those retries are exhausted or the operation isn’t one it repeats. Show a transient failure and offer a retry. Don’t sign the user out
ReauthenticateA reasonSend the user through login(). The grant is gone, and no amount of retrying revives it
StepUpacrValues, maxAgeCall stepUp() with the challenge, then repeat the call that was refused
ConfigurationA detailA developer error. Fail loudly in debug builds. Don’t catch it in production: doing so hides a deployment mistake behind a login screen

The payload says which case inside the category. On Android the payload of Recoverable is named reason, because cause is taken by Throwable. Everywhere else it is cause.

Recoverable

The causes are UserCancelled, SystemCancelled, PendingRequestExpired, Network, Server, UnlockRequired and NonceLoop.

A cancelled browser comes in two kinds that your app should treat differently. A user cancel means the user dismissed the browser, and the SDK never retries it automatically. A system cancel means the operating system tore the session down, and a silent retry is appropriate. The SDK wipes the pending request on a user cancel and keeps it on a system cancel, so the flow can resume.

Network means nothing answered: DNS, connection, TLS or a timeout. Server means the provider answered with a 408, 429 or 5xx, or with a status the operation couldn’t use, and the operation didn’t finish. It carries the status, the provider’s Retry-After when it sent a readable one, how many failed requests the call that raised the error spent, and the time the operation took. Your app reads retryAfter and schedules the retry for then, as shown on Server errors, 429 and Retry-After.

UnlockRequired means the storage key’s authentication window has elapsed under OnAppUnlock. The grant is intact. Your app presents the prompt and retries, because the SDK holds no UI and a prompt is a UI decision. NonceLoop means a server demanded a DPoP nonce on a retry that already carried one. It terminates by design, so a server that always demands a nonce produces an error, never an unbounded loop.

Reauthenticate

The reasons are AuthorizationDenied, InteractionRequired, CredentialsInvalidated, GrantRejected, SessionRejected and SessionExpired.

Reauthenticate is a diagnosis, not a command. The SDK never opens the browser on its own, and your app decides when login() runs. The most common cause is invalid_grant from the token endpoint. It covers code expiry, a PKCE or redirect mismatch, refresh-token replay, family revocation and a DPoP key mismatch, and the server deliberately returns the same body for all of them. A biometric enrolment change that invalidates the storage key also lands here, because it is indistinguishable from tampering and is treated as a sign-out.

StepUp

A resource server answered 401 with insufficient_user_authentication (RFC 9470) and named the authentication context it needs. This is not a refresh condition: a client that refreshes here loops forever against a token that can never satisfy the requirement. stepUp() re-authorizes with the challenge’s acr_values and max_age, and your app then repeats the refused call. Nothing about the refused call survives a process death, so your app re-issues it.

Configuration

Every cause is a deployment mistake or an attack, and the user trying again fixes neither. Typical details are StateMismatch, IssuerMismatch, IdTokenRejected, ProviderUnsupported, InvalidConfiguration, AuthorizationRejected and DiscoveryIssuerMismatch. When a state or iss mismatch occurs, no token request is made.

An unregistered redirect URI is the most common first-run mistake, and the SDK can’t report it as Configuration. The provider shows an error page in the browser, and neither Custom Tabs nor ASWebAuthenticationSession expose that page. To the app it looks like the user closing the tab. When the provider advertises pushed authorization requests, the SDK pushes the request first, and a rejection there arrives as Configuration(AuthorizationRejected) before the browser opens.

Where common failures land

ConditionCategory
The user dismisses the browserRecoverable, UserCancelled
The OS tears the browser session downRecoverable, SystemCancelled
Network failure or timeout after the retry budget, or at once on an operation the SDK doesn’t repeatRecoverable, Network
A 408, 429 or 5xx from the provider after the retry budget, or at once for a status a repeat can’t help (501, 505)Recoverable, Server with the status
A 429 or 503 with a Retry-After that doesn’t fit the budget, or that the attempts ran out on, or a request refused during the pause after such a Retry-AfterRecoverable, Server with retryAfter set
A redirect (3xx) from discovery, the key set, the token endpoint, UserInfo or the pushed authorization requestConfiguration
The storage key’s authentication window elapsed (OnAppUnlock only)Recoverable, UnlockRequired
The user declines, or prompt=none needs interactionReauthenticate, AuthorizationDenied or InteractionRequired
invalid_grant on refreshReauthenticate, GrantRejected
The access token expired and the provider issued no refresh tokenReauthenticate, SessionExpired
A resource server answers 401 twice, once after a refreshReauthenticate, SessionRejected
A resource server answers 401 with insufficient_user_authenticationStepUp
state or iss on the redirect does not matchConfiguration
The ID token fails validation (signature, iss, aud, exp, nonce and so on)Configuration, IdTokenRejected
A capability you required is not advertised (DPoP, PAR)Configuration, ProviderUnsupported
Android: the redirect’s https host is not a verified App LinkConfiguration, InvalidConfiguration

Status codes other than 401 are not converted. A 403, 404, 429 or 5xx from your API is the application’s own answer, and the SDK returns it as a response.

Rules that hold for every category

  • Exhaustive. The hierarchy is sealed on every platform, so a new error is a compile-time break, not a silent default branch.
  • One category per code. No error is “sometimes recoverable”. A code the SDK retries internally, such as use_dpop_nonce or invalid_dpop_proof, surfaces one category, once, and only if the internal retry also fails.
  • No strings to match. The error_description from the server is attached for logging and never matched on.
  • Retries are budgeted. Every Recoverable path has a ceiling.

What the SDK retries before you see an error

One application-visible call is one operation: a login(), a logout(), a refresh, or a call such as userInfo(). Internal steps, such as a nonce retry, a refresh after a 401 and the replay of the original request, count inside that operation’s budget. Only requests that failed spend the allowances, and the time the user spends in the browser during a login() doesn’t count against the total time.

SettingDefaultHard maximum
Failed requests to one origin per operation, original included35
Failed requests to all origins combined, per operation48
Delay before the second attempt500 ms30 s
Backoff multiplier2.04.0
Longest single delay8 s30 s
Total time for one operation, delays included20 s60 s

You can change these through retryPolicy in the configuration, up to the maxima. Jitter is always on and can’t be configured. A value above a maximum, or below a minimum, is a Configuration error before any request is made, not a silent clamp.

Three retries are fixed and can’t be raised. use_dpop_nonce gets exactly one retry per origin and operation. invalid_dpop_proof gets exactly one retry, with a fresh proof. A 401 that is not a step-up or a nonce demand gets one refresh and one replay.

These are never retried: invalid_grant, any Configuration error (a redirect from an endpoint is one), insufficient_user_authentication, a user cancel, a 4xx other than 408 and 429, a 501 or 505, and the authorization-code exchange, including after a dropped response. The code is single use, and a second attempt is a replay the server may answer by revoking what it already issued. The user signs in again instead. The nonce and proof retries above still apply to the exchange, because both answer a challenge raised before the code was redeemed.

Which requests the SDK repeats depends on the operation, and the full table is on Server errors, 429 and Retry-After. In short:

  • Repeated after a transport failure, a timeout, a DNS failure, a 408, 429, 500, 502, 503 or 504: discovery, the key set, UserInfo, revocation and the pushed authorization request. These are reads, or repeating them does no harm.
  • Repeated only when the request provably wasn’t processed: the token refresh. The SDK sends no Idempotency-Key with a refresh, so after a timeout, a reset connection or a 500, 502 or 504 it can’t tell whether the provider already rotated the token. It repeats a refresh after a connection failure before anything was written, or after a 408, 429 or 503.
  • Never repeated: the authorization-code exchange, because the code works once.
  • Not repeated on a status: a request your app sends through the authenticated client. A 429 or a 5xx from your own API is returned to you as the response, since the SDK can’t know whether the call was safe to repeat.

The SDK reads Retry-After on 429 and 503. With a value, it waits that long instead of its own delay. A wait that doesn’t fit what is left of the budget (20 s by default) is never shortened: the SDK stops without sending another request, and the Server error carries the value in retryAfter. Until that time has passed, the SDK fails its own requests to that origin at once, without sending them. A login() checks this at its start, before the browser opens.

Next steps