Core Concepts

Architecture

How the two packages, AuthyraClient, AuthyraInstance, and the provider/storage system fit together.

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)    │
└─────────────────────────────────────────────────────────────┘
LayerRoleUse when
AuthyraClientPure business logic, injectableTests, backend APIs, multiple independent auth contexts
AuthyraInstanceSingleton with reactive streams and sync cacheFlutter 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:

ProviderPackageStrategy
CredentialsProviderauthyraEmail / password, any form-based flow
CredentialsProvider.withTokensauthyraJWT backend — stores access + refresh tokens
OAuth2Providerauthyra_flutterAuthorization Code + PKCE (any IdP)
GoogleProviderauthyra_flutterPrebuilt Google Sign-In
GitHubOAuth2Providerauthyra_flutterPrebuilt GitHub OAuth
AppleProviderauthyra_flutterSign in with Apple
ProxyOAuthProviderauthyra_flutterBackend-delegated — client secret never in app

AuthStorage

Pluggable persistence interface. Production implementations:

RuntimeImplementation
FlutterSecureAuthStorage (in authyra_flutter, wraps flutter_secure_storage)
Dart backendYour own Redis / DB implementation of AuthStorage
TestsInMemoryStorage (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 AuthyraClient can be shared between a Flutter app and a Dart backend in a monorepo.
  • The authyra pub.dev bundle stays small; Flutter-specific features are opt-in via authyra_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.
  • Equatable on AuthState provides 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.


Next steps

Copyright © 2026