Core Concepts

Sessions

AuthSession lifecycle, fields, expiry helpers, token refresh, and multi-account support.

AuthSession is the core data object that represents an authenticated user's active session. Token fields aren't stored directly on it: AuthSession is a pointer to the active AuthAccount inside linkedAccounts, which is where tokens actually live.


Fields

class AuthSession {
  final AuthUser user;
  final String activeAccountId;         // AuthAccount.id this session currently uses
  final List<AuthAccount> linkedAccounts;
  final SessionMetadata? metadata;
  final DateTime createdAt;
  final DateTime lastUsedAt;
}

class AuthAccount {
  final String id;                      // e.g. 'google_10987654321'
  final String userId;
  final String providerId;              // 'google', 'email', etc.
  final String providerAccountId;       // the provider's own id for this user
  final String? accessToken;
  final String? refreshToken;
  final DateTime? tokenExpiresAt;
  final Map<String, dynamic> providerData;
}
FieldDescription
userIdentity profile, id, email, name, avatarUrl, metadata
activeAccountIdThe AuthAccount.id currently in use. Token getters (accessToken, refreshToken, expiresAt, providerId) all delegate to this entry via activeAccount
linkedAccountsEvery provider-linked account entry for this user. activeAccount is the one matching activeAccountId
metadataOptional device/network context (ipAddress, userAgent, deviceId, country); never read internally, surfaced for your own audit logs
createdAtWhen this session was first created
lastUsedAtTimestamp of the most recent activity, used for recency sorting

accessToken, refreshToken, expiresAt, and providerId are computed getters that read from activeAccount, null when activeAccountId doesn't match any entry in linkedAccounts.


Lifecycle

signIn()
    └─ provider.signIn() returns AuthSignInResult
           └─ AuthyraClient builds AuthSession
                  └─ SessionManager.saveSession(session)
                         └─ persisted to AuthStorage
                                └─ authStateChanges emits AuthState.authenticated

        ↓  (token approaching expiry)

session.shouldRefresh → true
    └─ provider.refreshToken(session.refreshToken)
           └─ session.refreshed(newAccessToken, newExpiresAt)
                  └─ SessionManager.updateSession()
                         └─ authStateChanges emits refreshed state

        ↓  (sign-out)

signOut()
    └─ provider.signOut()  [if supportsSignOut]
           └─ SessionManager.clearActiveSession()
                  └─ storage cleared
                         └─ authStateChanges emits AuthState.unauthenticated

Expiry helpers

isExpired

bool get isExpired

true when DateTime.now() is past expiresAt. Returns false when expiresAt is null.

isExpiringSoon

bool isExpiringSoon([Duration threshold = const Duration(minutes: 5)])

true when the token expires within threshold. Use for manual pre-emptive refresh:

final session = await Authyra.instance.getSession();
if (session?.isExpiringSoon(const Duration(minutes: 10)) ?? false) {
  await Authyra.instance.refreshSession();
}

shouldRefresh

bool shouldRefresh({Duration threshold = const Duration(minutes: 5)})

Semantically identical to isExpiringSoon but accepts a named parameter, used internally by AuthyraClient's auto-refresh scheduler:

if (session.shouldRefresh(threshold: config.refreshThreshold)) {
  await client.refreshSession();
}

timeUntilExpiration

Duration get timeUntilExpiration

Remaining time until the access token expires. Returns Duration.zero when already expired or when expiresAt is null.

final remaining = session?.timeUntilExpiration ?? Duration.zero;
print('Token valid for ${remaining.inMinutes} more minutes');

Token refresh

When the session should be refreshed:

  1. AuthyraClient calls provider.refreshToken(session.refreshToken).
  2. The provider returns an AuthTokenResult.
  3. The session is updated via session.refreshed(...).
  4. The new session is persisted and authStateChanges emits the updated state.
// The refreshed() helper produces an updated AuthSession:
final renewed = session.refreshed(
  newAccessToken:  result.accessToken,
  newRefreshToken: result.refreshToken,  // null = keep existing
  newExpiresAt:    result.expiresAt,
);

When refreshToken returns null (expired or revoked), the session is cleared and AuthState.unauthenticated() is emitted.


canRefresh

bool get canRefresh  // refreshToken != null && refreshToken.isNotEmpty

Quick check before attempting a refresh. Providers that don't return a refresh token (e.g., cookie-session flows) will have canRefresh: false.


Reading the session

// Async, full session object
final session = await Authyra.instance.getSession();
print('Provider: ${session?.providerId}');
print('Expires: ${session?.expiresAt}');
print('Can refresh: ${session?.canRefresh}');

// Async, just the access token (triggers auto-refresh if needed)
final token = await Authyra.instance.getAccessToken();
myHttpClient.options.headers['Authorization'] = 'Bearer $token';

Serialisation

AuthSession (and each AuthAccount inside linkedAccounts) is serialised to/from JSON for persistence in AuthStorage. Timestamps are encoded as ISO 8601 strings:

{
  "user": { "id": "123", "email": "alice@example.com" },
  "activeAccountId": "google_109876543210",
  "linkedAccounts": [
    {
      "id": "google_109876543210",
      "userId": "123",
      "providerId": "google",
      "providerAccountId": "109876543210",
      "accessToken": "ya29.xxx",
      "refreshToken": "1//yyy",
      "tokenExpiresAt": "2026-03-01T12:00:00.000Z",
      "providerData": {}
    }
  ],
  "metadata": null,
  "createdAt": "2026-02-01T08:00:00.000Z",
  "lastUsedAt": "2026-02-23T10:30:00.000Z"
}

Each account is persisted under its own session:{userId} storage key (plus a small index for the active account), not as one combined blob, so refreshing or removing one account never rewrites another account's entry.

The serialised session contains access and refresh tokens. It must be stored in an encrypted backend, SecureAuthStorage in Flutter, or your own encrypted implementation for other runtimes.

Multi-account sessions

SessionRegistry maintains an in-memory map of active sessions keyed by user.id. AccountManager exposes the public API:

final accounts = Authyra.instance.accounts;

// All signed-in accounts, sorted by last activity
final users = await accounts.getAll();

// Activate a different account
await accounts.switchTo(userId);

// Sign out one account, keep others active
await accounts.signOut(userId);

// Sign out all accounts
await accounts.signOutAll();

// Remove expired sessions
await accounts.cleanExpired();

There is no hard cap on the number of concurrent accounts.


linkedAccounts and hasLinkedProvider

As of this writing, no built-in provider (GoogleProvider, GitHubProvider, etc.) populates linkedAccounts with more than one entry: each derives AuthUser.id from that provider's own subject claim, so signing in via two different providers registers as two separate accounts in SessionRegistry, not one linked identity. linkedAccounts is real, tested infrastructure for account-linking, but the linking flow itself (matching a new provider sign-in to an existing user) isn't built yet.

linkedProviderIds is a convenience getter over linkedAccounts:

final session = await Authyra.instance.getSession();
final providers = session?.linkedProviderIds ?? <String>{};

if (session?.hasLinkedProvider('github') ?? false) {
  showGitHubBadge();
}
Copyright © 2026