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 / 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)    │
└─────────────────────────────────────────────────────────────┘
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: 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:

ProviderPackageStrategy
CredentialsProviderauthyraEmail / password, any form-based flow
CredentialsProvider.withTokensauthyraJWT backend, returns access + refresh tokens for SessionManager to persist
OAuth2Providerauthyra_flutterAuthorization Code + PKCE (any IdP)
GoogleProviderauthyra_flutterPrebuilt Google Sign-In
GitHubProviderauthyra_flutterPrebuilt GitHub OAuth
ProxyOAuthProviderauthyra_flutterBackend-delegated, client secret never in app
AppleProviderauthyra_appleNative Sign in with Apple SDK

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. 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 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 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.

Copyright © 2026