Skip to content

About

A smart Flutter package for automatic Dio tokenrefresh handling. This package simplifies managing access and refresh tokens in your Flutter apps and automatically retries failed requests after refreshing tokens.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Repository files navigation

smart_refresh_token

pub package license: MIT

Race-free automatic access/refresh token handling for Dio. Add one interceptor, write one function that calls your refresh endpoint, done.

  • Single-flight refresh. 20 requests hit a 401 at once, and your refresh endpoint is called once. Everyone waits for that one refresh and is replayed with the new token, or they all fail together.
  • Proactive refresh. Tokens are refreshed before they expire, using expires_in, your own expiresAt or the JWT exp claim, with a buffer for clock skew.
  • No surprise logouts. Network errors and timeouts during a refresh keep the tokens. Only a rejected refresh token (400/401/403, null, or InvalidRefreshTokenException) ends the session.
  • No loops, no deadlocks. Each request is replayed at most once, and requests made by the refresher never trigger another refresh, even through the same Dio.
  • Logout wins. Logging out during a refresh discards the refresh result, so the user is never silently logged back in.
  • Flutter-friendly. An authStatus stream for StreamBuilder, an app-resume observer, pluggable (secure) storage, Options().skipAuth(), and OAuth response parsing.
  • Every platform. Android, iOS, macOS, Windows, Linux and web. No dart:io anywhere.

Install

dependencies:
  smart_refresh_token: ^0.2.0

Quick start

import 'package:dio/dio.dart';
import 'package:smart_refresh_token/smart_refresh_token.dart';

final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));

final auth = RefreshTokenInterceptor(
  dio: dio,
  tokenRefresher: (current, client) async {
    // `client` is a plain Dio (same base URL and adapter, no interceptors).
    final res = await client.post<Map<String, dynamic>>(
      '/auth/refresh',
      data: {'refresh_token': current.refreshToken},
    );
    // Parses access_token / refresh_token / expires_in / token_type, and
    // keeps the old refresh token if the server did not rotate it.
    return Credentials.fromOAuthJson(res.data!, previous: current);
  },
);

void setUpApi() => dio.interceptors.add(auth);

Log in and out:

// Login: public endpoint, no token attached.
final res = await dio.post<Map<String, dynamic>>(
  '/auth/login',
  data: {'email': email, 'password': password},
  options: Options().skipAuth(),
);
await auth.setCredentials(Credentials.fromOAuthJson(res.data!));

// Every other request gets "Authorization: Bearer <token>" automatically.
final me = await dio.get('/me');

// Logout.
await auth.clearTokens();

Switch screens on auth state

authStatus emits the current state right away (loading saved tokens if needed), then every change, including when a refresh token is rejected:

StreamBuilder<AuthStatus>(
  stream: auth.authStatus,
  builder: (context, snapshot) => switch (snapshot.data) {
    AuthStatus.authenticated => const HomeScreen(),
    AuthStatus.unauthenticated => const LoginScreen(),
    _ => const SplashScreen(),
  },
);

Want a callback instead? Pass onAuthFailure: (reason) => router.go('/login'). It runs once per ended session, not once per failed request.

Refresh when the app comes back to the foreground

late final lifecycle = TokenRefreshLifecycleObserver(auth)..start();
// in dispose(): lifecycle.stop();

On resume, an expiring token is refreshed immediately, so the first screen doesn't wait for it. A failed refresh here never throws or logs out on network errors.

Secure storage

Tokens are kept in memory by default (InMemoryTokenStorage). To persist them securely, implement the three-method TokenStorage, for example with flutter_secure_storage:

class SecureTokenStorage extends TokenStorage {
  const SecureTokenStorage([this._storage = const FlutterSecureStorage()]);
  final FlutterSecureStorage _storage;
  static const _key = 'auth_credentials';

  @override
  Future<Credentials?> read() async {
    final raw = await _storage.read(key: _key);
    return raw == null
        ? null
        : Credentials.fromJson(jsonDecode(raw) as Map<String, dynamic>);
  }

  @override
  Future<void> write(Credentials c) =>
      _storage.write(key: _key, value: jsonEncode(c.toJson()));

  @override
  Future<void> delete() => _storage.delete(key: _key);
}

final auth = RefreshTokenInterceptor(
  dio: dio,
  tokenStorage: const SecureTokenStorage(),
  tokenRefresher: refresher,
);

Storage is read once (then cached in memory) and written after every refresh. Change credentials through setCredentials / clearTokens rather than writing to the storage directly.

Storage failures:

Failure What happens
read() throws The request fails with RefreshFailureType.storage. The next request tries reading again.
write() throws after a refresh The new tokens stay in memory, so the app keeps working (important with rotation, because the old refresh token is already dead). The error is logged.
delete() throws in clearTokens() The user is still logged out in memory, and the error is rethrown to you.

How the concurrency guarantees work

request A ─▶ 401 ─┐
request B ─▶ 401 ─┼─▶ one refresh ─▶ new token ─▶ A, B, C replayed (once each)
request C ─▶ wait ┘        │
                           └─ fails ─▶ A, B, C fail with the same RefreshTokenException
  1. One refresh at a time. The first request that needs a refresh starts it. Everything else that needs a token while it runs (new requests, other 401s, refreshToken(), validCredentials()) awaits the same future.
  2. Stale-token detection. Each request remembers which token it was sent with. If it gets a 401 but the current token is already different (a refresh finished meanwhile), it is replayed with the current token, without another refresh.
  3. Replay once. A replayed request that is rejected again is returned to you as-is. No loops.
  4. Refresh isolation. The refresher runs in a marked zone, and requests made from it skip this interceptor. So a refresh endpoint answering 401 ends the session instead of recursing, even if you pass the intercepted Dio as refreshDio.
  5. Session generations. clearTokens() and setCredentials() start a new generation. A refresh from an older generation can't write its result. Waiters get the new login's token, or RefreshFailureType.sessionChanged after a logout.
  6. Timeouts. A refresh is limited by refreshTimeout (30 s by default). On timeout, the tokens are kept. If the slow refresh succeeds later, its result is still adopted, so a rotated refresh token is never lost.
  7. Cancellation. A request whose CancelToken is cancelled while it waits for a refresh fails immediately with a cancel error. The shared refresh keeps going for everyone else.

Handling failures

When a token can't be refreshed, the request fails with a DioException. Its error is a RefreshTokenException, and response is the original 401 when there was one.

try {
  await dio.get('/orders');
} on DioException catch (e) {
  final error = e.error;
  if (error is RefreshTokenException && error.isSessionEnded) {
    // invalidRefreshToken or sessionChanged: show the login screen
  } else if (error is RefreshTokenException && error.isTransient) {
    // timeout / offline / 5xx / storage: show "try again"
  }
}
RefreshFailureType Cause Tokens
invalidRefreshToken refresher returned null, threw InvalidRefreshTokenException, got 400/401/403, or the refresh token expired deleted, onAuthFailure called
timeout refresh took longer than refreshTimeout kept
transient network error, 5xx, any other exception kept
sessionChanged logout during the refresh already cleared
storage TokenStorage.read() threw kept

Configuration

RefreshTokenInterceptor(
  dio: dio,
  tokenRefresher: refresher,
  tokenStorage: const SecureTokenStorage(), // default: InMemoryTokenStorage()
  onAuthFailure: (reason) {},               // once per ended session
  refreshDio: authDio,                      // optional client for the refresher
  retryConfig: const RetryConfig(),         // optional network retries (off by default)
  logger: const RefreshTokenLogger(enabled: true), // never logs token values
  refreshConfig: RefreshConfig(
    proactiveRefresh: true,                       // refresh before expiry
    expirationBuffer: const Duration(minutes: 1), // also covers clock skew
    refreshTimeout: const Duration(seconds: 30),
    authorizationHeaderKey: 'Authorization',
    headerBuilder: (c) => 'Bearer ${c.accessToken}', // or e.g. (c) => c.accessToken
    isAuthFailure: (response) => response.statusCode == 401, // e.g. also 403
    isRefreshTokenRejected: RefreshConfig.defaultIsRefreshTokenRejected,
    onRefreshStart: () {},
    onRefreshSuccess: (credentials) {},
    onRefreshFailure: (error) {},
  ),
);
  • Expiry sources, in order: Credentials.accessTokenExpireAt, then the JWT exp claim. If neither is known, the token is only refreshed after a 401.
  • Short-lived tokens: the buffer is capped at half of a freshly issued token's lifetime, so a 30-second token with a 1-minute buffer doesn't refresh on every request.
  • Clock skew: Credentials.fromOAuthJson turns expires_in into an absolute time using the device clock, so there's no skew. For absolute JWT exp values, make expirationBuffer at least as large as the clock drift you expect.
  • Skip auth per request: Options().skipAuth(), or extra: {RefreshTokenInterceptor.skipAuthKey: true}.
  • Tokens outside Dio (WebSockets, image headers): await auth.validCredentials() returns credentials that are valid now, refreshing first if needed.
  • Cookie-based refresh (common on web): refreshToken may be null. Your refresher just calls the endpoint and the browser sends the cookie.
  • Network retries: RetryConfig retries timeouts, connection errors and 408/429/5xx with exponential backoff and jitter. By default only idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) are retried, and retries stop as soon as the request is cancelled.
  • FormData bodies are cloned for replays. Requests with a Stream body can't be replayed.

Testing your app

The interceptor works with any Dio HttpClientAdapter, so tests can use an in-process fake server. This package's own tests are good templates: test/support/fake_backend.dart. It works under testWidgets and fake time, and the clock: constructor parameter lets you control expiry.

Example app

example/ is a complete Flutter app with secure storage, an authStatus-driven UI, the lifecycle observer, and an in-app fake server. It runs offline on every platform. Its buttons let you expire the access token or revoke the refresh token server-side and watch "10 parallel requests → 1 refresh" happen.

cd example && flutter run

Migrating from 0.1.x

0.2.0 is a breaking release that fixes several correctness bugs (see the CHANGELOG).

0.1.x 0.2.0
RefreshTokenInterceptor(tokenStorage: s, tokenRefresher: f, onAuthFailure: g) RefreshTokenInterceptor(dio: dio, tokenStorage: s, tokenRefresher: f, onAuthFailure: g). dio is the Dio you add it to, and tokenStorage / onAuthFailure are now optional.
tokenRefresher: (String refreshToken, Dio client) async {...} tokenRefresher: (Credentials current, Dio client) async {...}. Use current.refreshToken.
onAuthFailure: () {...} onAuthFailure: (RefreshTokenException reason) {...}
Credentials.refreshToken is String, accessTokenExpireAt is DateTime both are nullable now (cookie refresh, JWT exp fallback)
RefreshConfig.parallelRefresh, refreshHeaders, enableLogging removed. Refreshes are always single-flight; set headers in your refresher; use logger:
onRefreshSuccess(dynamic) / onRefreshFailure(dynamic) typed: Credentials / RefreshTokenException
retryDio, RetryPolicy removed. Replays and retries go through your own dio, so they keep its adapter and interceptors.
RetryConfig retried every method 3 times by default network retries are opt-in (retryConfig: RetryConfig()) and idempotent-only by default
TokenStorage.hasCredentials() await auth.credentials != null
refreshToken() returned bool returns Credentials and throws RefreshTokenException
authorizationHeaderKey default 'authorization' 'Authorization' (HTTP headers are case-insensitive; this only matters if you read the map yourself)
expirationBuffer default 5 minutes 1 minute (and capped at half the token lifetime)
Requests without credentials were rejected and onAuthFailure was called they are sent without a token; the server decides

Stored JSON from 0.1.x is still readable by Credentials.fromJson.

License

MIT

About

A smart Flutter package for automatic Dio tokenrefresh handling. This package simplifies managing access and refresh tokens in your Flutter apps and automatically retries failed requests after refreshing tokens.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages