Architecture
The big picture
Authyra is built on three core principles:
1. Separation of concerns — authentication logic lives in pure Dart, completely independent of UI and navigation.
2. Reactive by default — every state change is surfaced as a Stream. Wire it to anything: StreamBuilder, Riverpod, Bloc, GoRouter, or your own listener.
3. Platform agnostic — the core authyra package imports nothing from Flutter. It runs identically on mobile, web, desktop, backend (Shelf, Dart Frog), and CLI tools.
Two-package split
The monorepo ships two packages with a strict dependency direction:
┌──────────────────────────────────────────────────────────┐
│ authyra (pure Dart) │
│ │
│ AuthyraClient · AuthyraInstance · SessionManager │
│ AccountManager · CredentialsProvider │
│ AuthProvider interface · AuthStorage interface │
│ InMemoryStorage │
└──────────────────────────────────────────────────────────┘
▲ depends on
┌──────────────────────────────────────────────────────────┐
│ authyra_flutter (Flutter + re-exports authyra) │
│ │
│ OAuth2Provider · GoogleProvider · GitHubOAuth2Provider │
│ AppleProvider · ProxyOAuthProvider │
│ SecureAuthStorage · OAuth2CallbackHandler │
└──────────────────────────────────────────────────────────┘
Anything that touches Flutter platform APIs, url_launcher, or native platform channels lives in authyra_flutter. The core is pure Dart and has no knowledge of Flutter.
Flutter apps import authyra_flutter — it re-exports the entire core, so one import gives access to everything.
Two-layer design within the core
Within authyra, there are two distinct layers:
┌─────────────────────────────────────────────────────────────┐
│ AuthyraClient │
│ Stateless orchestrator — no global state, fully testable │
│ │
│ ┌───────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ AuthProvider │ │ AuthStorage │ │ SessionManager │ │
│ │ (interface) │ │ (interface) │ │ multi-account │ │
│ └───────────────┘ └──────────────┘ └──────────────────┘ │
└──────────────────────────────┬──────────────────────────────┘
│ wrapped by
┌──────────────────────────────▼──────────────────────────────┐
│ AuthyraInstance (typedef alias: Authyra) │
│ Singleton — global access, sync state cache, streams │
│ │
│ currentUser AuthUser? (synchronous) │
│ currentState AuthState (synchronous) │
│ isAuthenticated bool (synchronous) │
│ authStateChanges Stream<AuthState> (broadcast) │
│ sessionStream Stream<AuthSession?> (broadcast) │
│ accounts AccountManager (multi-account) │
└─────────────────────────────────────────────────────────────┘
| Layer | Role | Use when |
|---|---|---|
AuthyraClient | Pure business logic, injectable | Tests, backend APIs, multiple independent auth contexts |
AuthyraInstance | Singleton with reactive streams and sync cache | Flutter apps and Dart programs that need global auth state |
Component overview
AuthyraClient
The core orchestrator. It registers AuthProvider instances by id slug, delegates sign-in/sign-out/refresh to the matching provider, persists sessions via AuthStorage, and emits AuthState on a broadcast stream.
import 'package:authyra/authyra.dart';
final client = AuthyraClient(
providers: [
CredentialsProvider.withTokens(id: 'email', authorize: myCallback),
],
storage: InMemoryStorage(),
);
await client.initialize();
final user = await client.signIn('email', params: {...});
For OAuth2 providers, use authyra_flutter:
import 'package:authyra_flutter/authyra_flutter.dart';
final client = AuthyraClient(
providers: [
CredentialsProvider.withTokens(id: 'email', authorize: myCallback),
GoogleProvider(clientId: 'YOUR_CLIENT_ID'),
],
storage: SecureAuthStorage(),
);
AuthyraInstance
Singleton wrapper. One per process. Access via Authyra.instance (the Authyra typedef is a short alias for AuthyraInstance):
await Authyra.initialize(client: client);
// Synchronous — safe to call in build() without await
Authyra.instance.currentUser // AuthUser?
Authyra.instance.isAuthenticated // bool
// Async accessors
await Authyra.instance.getSession() // AuthSession?
await Authyra.instance.getAccessToken() // String?
// Reactive
Authyra.instance.authStateChanges.listen((state) { ... });
AuthProvider
The extension point. Every authentication strategy implements this interface:
abstract class AuthProvider {
String get id; // unique slug, e.g. 'google'
bool get supportsRefresh => false;
bool get supportsSignOut => false;
Future<AuthSignInResult?> signIn({Map<String, dynamic>? params});
Future<void> signOut({String? userId}) async {}
Future<AuthTokenResult?> refreshToken(String refreshToken) async => null;
}
Built-in providers and their package:
| Provider | Package | Strategy |
|---|---|---|
CredentialsProvider | authyra | Email / password, any form-based flow |
CredentialsProvider.withTokens | authyra | JWT backend — stores access + refresh tokens |
OAuth2Provider | authyra_flutter | Authorization Code + PKCE (any IdP) |
GoogleProvider | authyra_flutter | Prebuilt Google Sign-In |
GitHubOAuth2Provider | authyra_flutter | Prebuilt GitHub OAuth |
AppleProvider | authyra_flutter | Sign in with Apple |
ProxyOAuthProvider | authyra_flutter | Backend-delegated — client secret never in app |
AuthStorage
Pluggable persistence interface. Production implementations:
| Runtime | Implementation |
|---|---|
| Flutter | SecureAuthStorage (in authyra_flutter, wraps flutter_secure_storage) |
| Dart backend | Your own Redis / DB implementation of AuthStorage |
| Tests | InMemoryStorage (bundled in authyra) |
abstract class AuthStorage {
Future<void> initialize();
Future<String?> read(String key);
Future<void> write(String key, String value);
Future<bool> delete(String key);
Future<void> clear();
Future<bool> containsKey(String key);
Future<List<String>> getKeysWithPrefix(String prefix);
}
SessionManager
Internal component. Serialises/deserialises SessionRegistry to/from AuthStorage, uses a mutex to serialise concurrent writes, and exposes cleanExpiredSessions() (called via AccountManager.cleanExpired()).
AccountManager
Multi-account API, accessible via Authyra.instance.accounts:
await Authyra.instance.accounts.getAll(); // List<AuthUser>
await Authyra.instance.accounts.switchTo(userId); // activate a different account
await Authyra.instance.accounts.signOut(userId); // sign out one account
await Authyra.instance.accounts.signOutAll(); // clear every session
await Authyra.instance.accounts.cleanExpired(); // remove expired sessions
Data flow
Sign in flow
signIn('email', params: {...})
│
├─ _assertInitialized()
├─ _providerMap['email'].signIn(params) ← your callback
│ returns AuthSignInResult
├─ Build AuthSession from result
├─ SessionManager.saveSession(session) ← persisted to AuthStorage
├─ authStateStream.add(AuthState.authenticated(user))
└─ AuthyraInstance._currentState = new state
└─ authStateChanges emits
Sign out flow
signOut()
│
├─ SessionManager.getActiveSession()
├─ provider.signOut(userId) [only if supportsSignOut == true]
├─ SessionManager.clearActiveSession()
└─ authStateStream.add(AuthState.unauthenticated())
Token refresh flow
refreshSession()
│
├─ SessionManager.getActiveSession()
├─ provider.refreshToken(session.refreshToken)
│ returns AuthTokenResult
├─ session.refreshed(result) → new AuthSession
├─ SessionManager.updateSession(userId, newSession)
└─ authStateStream.add(AuthState.authenticated(user))
Key models
AuthState
enum AuthStateType { authenticated, unauthenticated, error }
class AuthState extends Equatable {
final AuthStateType type;
final AuthUser? user; // non-null when authenticated
final String? error; // non-null when type == error
bool get isAuthenticated => type == AuthStateType.authenticated;
}
Equatable ensures identical consecutive states are deduplicated — no spurious StreamBuilder rebuilds.
AuthSession
class AuthSession {
final AuthUser user;
final String? accessToken;
final String? refreshToken;
final DateTime? expiresAt;
final DateTime createdAt;
final DateTime lastUsedAt;
bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!);
bool get shouldRefresh => ...; // true when within the refresh threshold window
}
AuthUser
AuthUser(
id: 'usr_01j...',
email: 'alice@example.com',
name: 'Alice',
avatarUrl: 'https://...',
metadata: {'role': 'admin', 'plan': 'pro'},
)
Design decisions
Why no Flutter dependency in the core?
- Tests run with plain
dart test— no widget test runner, no platform channels. - The same
AuthyraClientcan be shared between a Flutter app and a Dart backend in a monorepo. - The
authyrapub.dev bundle stays small; Flutter-specific features are opt-in viaauthyra_flutter.
Why Singleton + Client?
Client = injectable, no side effects, ideal for tests and backend services. Instance = convenient global access, sync state cache, reactive streams.
Both are available. Use AuthyraClient in tests; use Authyra.instance in your app.
Why Streams instead of ChangeNotifier?
- Native to Dart — no framework dependency.
- Compatible with every state management library:
StreamBuilder, Riverpod, Bloc, RxDart. EquatableonAuthStateprovides free deduplication without manual==overrides.
Why is the SessionRegistry immutable?
Every mutation returns a new registry and atomically writes it to storage. There is no partial-write state, which eliminates a whole class of concurrency bugs.