Bapp Auth

Cross-platform Flutter authentication for BAPP framework platforms. Handles Keycloak SSO, automatic token management, and provides a pre-configured API client via bapp_api_client.

Features

  • One-line setup - BappAuth orchestrates Keycloak auth, token lifecycle, and API client
  • Automatic SSO - Silent login via existing Keycloak browser session using prompt=none
  • Token management - Persistence, proactive refresh, 498 retry, concurrent refresh prevention
  • Multi-app support - Storage scoped by clientId so multiple BAPP apps coexist on one device
  • Shared device support - sessionId scoping for kiosk/POS where multiple users share a device
  • All platforms - Android, iOS, macOS, Web, Windows, Linux
  • Reactive state - authStateStream for driving UI

Installation

dependencies:
  bapp_auth:
    git:
      url: https://git.pidginhost.net/bapp-cloud/packages/flutter-bapp-auth.git

Quick Start

import 'package:bapp_auth/bapp_auth.dart';

final auth = BappAuth(
  config: BappAuthConfig(
    clientId: 'my-flutter-app',        // Keycloak client ID
    host: 'https://my-company.bapp.ro/api',  // BAPP API host (per project)
    app: 'my-app',                     // App slug
  ),
);

// Initialize: restores saved token, refreshes if expired, attempts SSO if enabled
await auth.initialize();

// If not authenticated, trigger interactive login
if (!auth.isAuthenticated) {
  await auth.loginWithSSO();
}

// Use the pre-configured API client - auth is handled automatically
final me = await auth.apiClient.me();
final items = await auth.apiClient.list('myapp.item');

Configuration

BappAuthConfig controls all behavior. The Keycloak hostname (id.bapp.ro) is fixed; the API host is configurable per project.

BappAuthConfig(
  // Required
  clientId: 'my-flutter-app',

  // BAPP API (configurable per project)
  host: 'https://my-company.bapp.ro/api',  // default: https://panel.bapp.ro/api
  app: 'my-app',                            // default: account

  // Keycloak (fixed)
  keycloakHostname: 'id.bapp.ro',           // fixed, cannot change
  realm: 'bapp',                            // default: bapp
  scopes: ['openid', 'profile', 'email'],   // default scopes

  // SSO behavior
  ssoAutoLogin: true,       // attempt silent SSO on initialize()
  redirectUri: null,        // auto-detected per platform if null
  customScheme: null,       // custom URL scheme (e.g. com.myapp.auth)
  clientSecret: null,       // for confidential clients only

  // Shared device support
  sessionId: null,          // user-scoped storage for kiosk/POS
)

Authentication

SSO Login (Authorization Code Flow with PKCE)

Uses the system browser. If the user is already logged in to Keycloak, they're authenticated without seeing a login form.

// Interactive login - shows browser with login form if no session
await auth.loginWithSSO();

// Force fresh login (private/incognito mode)
await auth.loginWithSSO(preferEphemeral: true);

Device Flow

For devices without a browser (TV, IoT) or for cross-device auth.

await auth.loginWithDevice(
  onUserAction: (userCode, verificationUri) {
    // Show this to the user
    print('Go to $verificationUri and enter: $userCode');
  },
  onStatusUpdate: (status) {
    print(status); // "Waiting for user authorization..."
  },
);

Logout

await auth.logout(); // Revokes Keycloak session + clears local token

Auth State

Listen reactively to auth state changes:

auth.authStateStream.listen((state) {
  switch (state.status) {
    case AuthStatus.initial:
      // Not yet initialized
    case AuthStatus.loading:
      // Auth operation in progress
    case AuthStatus.authenticated:
      // Ready - state.token has the token
    case AuthStatus.unauthenticated:
      // Need login - state.error has the reason
  }
});

Automatic SSO (Silent Login)

When ssoAutoLogin: true, initialize() attempts a silent SSO login if no saved token is found. This uses prompt=none so Keycloak returns immediately:

  • If the user has an active Keycloak session: authenticated silently, no visible UI.
  • If no session: Keycloak returns login_required instantly, falls through to unauthenticated.

Platform behavior

Platform Behavior
Web A popup opens and closes almost instantly
Mobile System browser opens and closes quickly. On iOS, first use shows "Allow X to sign you in" dialog
Desktop Browser opens and closes quickly

Recommendation

ssoAutoLogin defaults to false. Enable it when:

  • Your Keycloak client is configured for SSO
  • The brief browser flash is acceptable for your UX
  • On web, it's generally safe since the popup is barely visible
BappAuthConfig(
  clientId: 'my-app',
  ssoAutoLogin: true,               // enable for all platforms
  // Or conditionally:
  // ssoAutoLogin: PlatformConfig.isWeb,  // only on web
)

Initialization Flow

When auth.initialize() is called:

  1. Load saved token from local storage (scoped by clientId + sessionId)
  2. If valid (not expired) - authenticate immediately
  3. If expired - attempt token refresh via Keycloak
  4. If refresh fails and ssoAutoLogin: true - attempt silent SSO with prompt=none
  5. Otherwise - transition to unauthenticated

Token refresh and saved token restoration are invisible to the user. Only the SSO auto-login step involves a brief browser interaction.

API Client

After authentication, auth.apiClient is a pre-configured BappApiClient with automatic bearer token injection and refresh.

// User profile
final me = await auth.apiClient.me();

// CRUD
final items = await auth.apiClient.list('myapp.item');
final item = await auth.apiClient.get('myapp.item', '42');
final created = await auth.apiClient.create('myapp.item', {'name': 'New'});
await auth.apiClient.patch('myapp.item', '42', {'name': 'Updated'});
await auth.apiClient.delete('myapp.item', '42');

// Tasks
final tasks = await auth.apiClient.listTasks();
final result = await auth.apiClient.runTask('myapp.export', {'format': 'csv'});
final asyncResult = await auth.apiClient.runTaskAsync('myapp.long_export');

// Documents
final url = auth.apiClient.getDocumentUrl(record, output: 'pdf');
final bytes = await auth.apiClient.getDocumentContent(record, output: 'pdf');

Multi-tenancy

// Set tenant for all subsequent requests
auth.setTenant('42');

// Change app slug
auth.setApp('erp');

Automatic Token Refresh

All requests through auth.apiClient are transparently authenticated:

  • Proactive refresh: Before each request, if the token is expiring within 30 seconds, it's refreshed automatically.
  • 498 retry: If Keycloak returns 498 (Token Expired), the token is refreshed and the request retried once.
  • Concurrent prevention: Multiple simultaneous requests that trigger refresh only refresh once (lock-based).
  • Auth required callback: If refresh fails, the auth state transitions to unauthenticated so the UI can show a login screen.

Shared Device Support (Kiosk / POS)

On shared devices where multiple users log in on the same app, use sessionId to scope token storage per user. Each user gets their own saved token that won't be overwritten by another user.

Storage key format

Config Storage key
clientId: 'pos' bapp_auth_token_pos
clientId: 'pos', sessionId: 'user-a' bapp_auth_token_pos_user-a
clientId: 'pos', sessionId: 'user-b' bapp_auth_token_pos_user-b

Setting sessionId at creation

final auth = BappAuth(
  config: BappAuthConfig(
    clientId: 'pos-terminal',
    sessionId: 'cashier-1',  // Scoped to this user
  ),
);
await auth.initialize(); // Loads cashier-1's saved token

Switching users at runtime

Use switchSession() to change users without recreating BappAuth:

final auth = BappAuth(
  config: BappAuthConfig(clientId: 'pos-terminal'),
);

// Cashier A starts shift
await auth.switchSession('cashier-a');
await auth.loginWithSSO();
// ... cashier A works ...

// Cashier B takes over
await auth.switchSession('cashier-b');
// If cashier B logged in before, their token is restored.
// Otherwise, auth.isAuthenticated == false and they need to log in.
if (!auth.isAuthenticated) {
  await auth.loginWithSSO();
}

switchSession() will:

  1. Logout the current user (revoke Keycloak session)
  2. Switch to the new session's storage key
  3. Re-initialize (load saved token, attempt refresh, SSO as configured)

Platform Configuration

Each platform requires specific configuration for OAuth redirects. PlatformConfig auto-detects the correct redirect URI:

final redirectUri = PlatformConfig.getRedirectUri();
// Android/iOS/macOS: com.bapp.auth://callback
// Web: https://yourdomain.com/auth/callback
// Windows/Linux: http://localhost:8080/auth/callback

Platform helpers

PlatformConfig.isWeb;                // true on web
PlatformConfig.platformName;         // "Android", "iOS", "Web", etc.
PlatformConfig.supportsCustomScheme; // false on web, Windows, Linux
PlatformConfig.getConfigurationInstructions(); // setup guide for current platform

For detailed platform-specific setup instructions, see PLATFORM_CONFIGURATION.md.

Direct Keycloak Access

For advanced use cases, the underlying KeycloakAuth is available:

final keycloak = auth.keycloakAuth;

// Direct token refresh
final newToken = await keycloak.refreshToken(refreshToken);

// Direct logout
await keycloak.logout(refreshToken: refreshToken);

// Device auth flow control
final deviceAuth = await keycloak.initiateDeviceAuth();
final token = await keycloak.pollForDeviceToken(
  deviceCode: deviceAuth.deviceCode,
  interval: deviceAuth.interval,
);

Example

See the example directory for a complete demo app.

License

MIT License

Libraries

bapp_auth
Authentication package for BAPP framework platforms.