Skip to content

Session & tokens

This page describes what the SDK does with tokens and sessions, not the platform-specific storage implementation — see Security for the threat model.

final auth = RakomiProvider.of(context);
User? user = auth.user;
Session? session = auth.session;
AuthState state = auth.authState; // current snapshot
bool valid = await auth.hasValidSession(); // in-memory token, not expired, JWKS-verified
String? token = await auth.getToken(); // current or freshly refreshed access token

Session carries the access token’s lifetime metadata (id, userId, tenantId, issuedAt, expiresAt) — never the access token itself. The access token is held only in the controller’s in-memory state.

getToken() returns the current access token if it is not within the refresh lead-time of expiry (kDefaultRefreshLeadtime, 60 seconds by default, configurable via RakomiProvider(refreshLeadtime:)); otherwise it refreshes first.

Refresh is:

  • Single-flight — concurrent callers awaiting getToken()/refreshTokens() during an in-progress refresh share the same outcome; the SDK never issues two concurrent refresh requests for one provider tree.
  • Triggered proactively — the controller listens for the app returning to the foreground and forces a refresh-if-stale check.
  • Rotating — a successful refresh persists the server-issued refresh token (which may differ from the one presented).
  • Verified end-to-end — the new access token is verified (RS256 against the cached JWKS) before the SDK considers the session authenticated; if verification fails, the refresh is treated as a failure, not a silent partial success.

refreshTokens() forces a refresh — useful after your app detects it has resumed from the background and wants an up-to-date token before making a request.

A 401/403 from the refresh endpoint is not retried: the SDK clears local session state and moves to AuthStateUnauthenticated. Any other non-2xx or network failure surfaces as SdkError(code: SdkErrorCode.refreshFailed | networkError, …) without clearing local state, so a transient failure does not force a re-login.

On mount, RakomiProvider calls hydrate() in the background (never blocking the first frame): it reads the persisted refresh token and, if present, performs a refresh to validate it end-to-end before considering the session restored. There is no separate “trust the cached session” path — the persisted refresh token is always re-validated against the API on cold start.

Tokens and session metadata are persisted through the pluggable RakomiNativeAdapter, which the default adapter wires to platform-native secure storage:

  • iOS — Keychain.
  • Android — encrypted storage via flutter_secure_storage.
  • Flutter Web — see Web security considerations; this platform does not have OS-level secure storage.

The access token is held in memory only and is never written to durable storage. Only the refresh token, a cached user snapshot, and cache metadata (JWKS, clock skew) are persisted, and every persisted key is namespaced per tenant so multiple RakomiProviders in the same app never collide.

On sign-out, or when a fresh-install is detected (see below), the SDK clears its persisted state for that tenant rather than relying on process memory being discarded.

iOS Keychain entries survive app uninstall. On mount, the SDK compares a paired identifier held in Keychain against a marker in non-secure storage; if the Keychain identifier is present but the marker is missing (or does not match), the SDK treats this as a post-uninstall reinstall and purges the tenant’s residual Keychain entries before reading any auth state — so a fresh install never silently inherits a previous install’s session.

Access tokens are RS256-signed JWTs, verified against the tenant’s JSON Web Key Set:

  • The signing algorithm is hardcoded to RS256 and is never read from the token header — a token whose header names any other algorithm (including none) is rejected before any signature check.
  • The JWKS document is cached with a 24-hour TTL and revalidated with ETag/ If-None-Match; an oversized response (over a fixed cap) is rejected rather than accepted.
  • If a token references an unknown kid, the SDK invalidates the cache and retries once with a forced refetch before giving up.
  • RakomiProvider(jwksUrl:) lets you override the JWKS endpoint; like apiBaseUrl, it must be https:// (or the same dev-loopback exception — see Configuration).

On the first successful network response, the SDK compares the server’s Date header against the device clock and applies the resulting (capped) offset to subsequent token expiry checks, so a device with an inaccurate clock does not spuriously reject valid tokens or accept already-expired ones. A skew beyond a small threshold is surfaced as AuthEventType.clockSkewDetected with AuthEventSeverity.security.

DPoP sender-constrained sessions (advanced)

Section titled “DPoP sender-constrained sessions (advanced)”

rakomi_flutter can bind a session’s refresh calls to a native-keystore key pair per RFC 9449 (DPoP) — the access token becomes useless to an attacker who exfiltrates it without also holding the device’s secure-element key. This is opt-in and session-scoped: construct a DpopSession with a DpopProver and pass it to your session setup. The production prover (NativeKeystoreDpopProver) never holds the private key in Dart — every signature is produced by the platform keystore (Android Keystore/StrongBox, iOS Keychain/Secure Enclave) over a native bridge you provide.

If the session is DPoP-bound and the native signer cannot produce a proof, the SDK never falls back to a proof-less request — it surfaces a distinct dpop_prover_unavailable failure so the app can prompt a re-login rather than silently downgrading security.

The SDK emits fine-grained lifecycle events over the events broadcast stream — sign-in attempts and outcomes, refresh attempts and outcomes, biometric failures, clock-skew detection, and more. The SDK does not forward these anywhere on its own; your app is responsible for forwarding audit-significant events to its own audit log. Recommended events to forward: signedIn / signedOut (with user and tenant id), refreshSucceeded (user and session id), signInFailed (with only hashed/redacted identifiers), and the security-severity events (clockSkewDetected, biometricFailed, deepLinkReceived).

If your backend receives Rakomi publisher-app webhooks and you want to verify them from Dart, verifyPublisherWebhook implements the Standard Webhooks HMAC-SHA256 contract (constant-time comparison, bounded replay window, no throw on malformed input). This is a standalone function — it does not depend on RakomiProvider or an authenticated session. See the publisher-webhook receiver contract for the wire format.