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 / AuthStorage │
│ InMemoryStorage │
└───────────────┬────────────────────┘
depends on │ depends on
┌──────────────────────┴──────────────────────┐
▼ ▼
┌────────────────────────────────┐ ┌────────────────────────────────┐
│ authyra_flutter (Flutter) │ │ authyra_apple (Flutter) │
│ re-exports authyra │ │ native sign_in_with_apple SDK │
│ │ │ │
│ OAuth2Provider · GoogleProvider│ │ AppleProvider │
│ GitHubProvider │ │ │
│ ProxyOAuthProvider │ │ │
│ SecureAuthStorage │ │ │
│ OAuth2CallbackHandler │ │ │
└────────────────────────────────┘ └────────────────────────────────┘
Anything that touches Flutter platform APIs, url_launcher, or native platform channels lives in authyra_flutter or authyra_apple. 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 except Sign in with Apple, which is a separate, optional dependency (authyra_apple) to keep native-only dependencies out of authyra_flutter for apps that don't use it.
Two-layer design within the core
Within authyra, there are two distinct layers:
┌─────────────────────────────────────────────────────────────┐
│ AuthyraClient │
│ Dependency-injected orchestrator, no global state │
│ │
│ ┌───────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 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: CredentialsSignInParams(...));
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({AuthSignInParams? 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, returns access + refresh tokens for SessionManager to persist |
OAuth2Provider | authyra_flutter | Authorization Code + PKCE (any IdP) |
GoogleProvider | authyra_flutter | Prebuilt Google Sign-In |
GitHubProvider | authyra_flutter | Prebuilt GitHub OAuth |
ProxyOAuthProvider | authyra_flutter | Backend-delegated, client secret never in app |
AppleProvider | authyra_apple | Native Sign in with Apple SDK |
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. Persists each account under its own session:{userId} storage key (plus a small index for the active account) rather than one combined blob, uses a mutex to serialise concurrent writes within a process, and exposes cleanExpiredSessions() (called via AccountManager.cleanExpired()). See Storage → for the key layout.
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
A pointer to the active AuthAccount, not a flat token holder: token getters delegate to whichever entry in linkedAccounts matches activeAccountId. See Sessions → for the full model.
class AuthSession {
final AuthUser user;
final String activeAccountId;
final List<AuthAccount> linkedAccounts;
final SessionMetadata? metadata;
final DateTime createdAt;
final DateTime lastUsedAt;
// Computed, delegate to the entry in linkedAccounts matching activeAccountId:
String? get accessToken;
String? get refreshToken;
DateTime? get expiresAt;
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 rather than mutating in place, so a mutex-guarded read-modify-write can't race against itself within one isolate. Persistence itself writes one storage key per changed account rather than the whole registry as a single blob, so a mutation only ever touches the accounts it actually changed. If a write fails partway through, the in-memory registry rolls back to its pre-mutation state instead of claiming a save succeeded that storage doesn't actually have.