Documentation

Authentication

Customer and staff sign-in, JWT storage, automatic refresh, and logout for Flutter apps.

AppEngine separates customers (end-users of your app) from users (staff and operators). They have different sign-in endpoints, different token shapes, and different role checks. Picking the wrong endpoint is the most common integration mistake — get it right once and the rest of the auth code is the same for both.

Endpoints

FieldTypeDescription
POST /profile/customer/signinpublic + orgid

Customer sign-in. Returns access + refresh tokens for end-users of the tenant. Use this in reel_moment, dfw_errand, event_app — any consumer-facing app.

POST /profile/user/signinpublic + orgid

Staff/operator sign-in. Returns access + refresh tokens for users with admin roles (RootAdmin, ConfigAdmin, custom). Use this in appmint_mobile — operator app.

POST /profile/customer/refreshpublic + orgid

Refresh a customer access token. Body: { refresh_token }.

POST /profile/user/refreshpublic + orgid

Refresh a staff access token. Body: { refresh_token }.

POST /profile/customer/signuppublic + orgid

Customer self-registration. Body: { email, password, firstName?, lastName? }.

GET /profilejwt

Returns the current user/customer profile based on the JWT.

POST /profile/signoutjwt

Server-side logout (revokes the refresh token).

Don't mix endpoints

Calling /profile/user/signin from a customer app issues a token that won't pass admin-only checks but will look valid to your client. The error surfaces later, on the first protected admin endpoint, with a confusing 403. Always match endpoint to principal.

AuthService

A thin service wraps the HTTP client and handles token persistence. This is the consumer version — for staff, swap the paths to /profile/user/*.

// lib/services/auth_service.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import '../models/auth_response.dart';
import 'http_client.dart';

class AuthService {
  final _http = appmintHttp;
  final _secure = const FlutterSecureStorage();

  static const _kAccess = 'access_token';
  static const _kRefresh = 'refresh_token';
  static const _kOrgId = 'org_id';

  /// Customer sign-in. Use AuthService.signInStaff for /profile/user/signin.
  Future<AuthResponse> signInCustomer({
    required String orgId,
    required String email,
    required String password,
  }) async {
    _http.setAuth('', orgId); // orgid header without a JWT yet
    final response = await _http.post(
      '/profile/customer/signin',
      body: {'email': email, 'password': password},
      requireAuth: false,
    );
    final auth = AuthResponse.fromJson(response);
    await _persist(orgId, auth);
    return auth;
  }

  Future<AuthResponse> signInStaff({
    required String orgId,
    required String email,
    required String password,
  }) async {
    _http.setAuth('', orgId);
    final response = await _http.post(
      '/profile/user/signin',
      body: {'email': email, 'password': password},
      requireAuth: false,
    );
    final auth = AuthResponse.fromJson(response);
    await _persist(orgId, auth);
    return auth;
  }

  Future<AuthResponse> refreshCustomer() => _refresh('/profile/customer/refresh');
  Future<AuthResponse> refreshStaff() => _refresh('/profile/user/refresh');

  Future<AuthResponse> _refresh(String path) async {
    final refresh = await _secure.read(key: _kRefresh);
    if (refresh == null || refresh.isEmpty) {
      throw Exception('No refresh token');
    }
    final response = await _http.post(
      path,
      body: {'refresh_token': refresh},
      requireAuth: false,
    );
    final auth = AuthResponse.fromJson(response);
    final orgId = await _secure.read(key: _kOrgId);
    if (orgId != null) await _persist(orgId, auth);
    return auth;
  }

  Future<void> _persist(String orgId, AuthResponse auth) async {
    await _secure.write(key: _kAccess, value: auth.accessToken);
    await _secure.write(key: _kRefresh, value: auth.refreshToken);
    await _secure.write(key: _kOrgId, value: orgId);
    _http.setAuth(auth.accessToken, orgId);
  }

  Future<void> signOut() async {
    try {
      await _http.post('/profile/signout');
    } catch (_) {
      // Server logout is best-effort; the local clear below is the source of truth.
    }
    await _secure.delete(key: _kAccess);
    await _secure.delete(key: _kRefresh);
    _http.clearAuth();
  }

  /// Restore session on app start. Returns true if a valid session was loaded.
  Future<bool> restore() async {
    final access = await _secure.read(key: _kAccess);
    final orgId = await _secure.read(key: _kOrgId);
    if (access == null || orgId == null) return false;

    if (JwtDecoder.isExpired(access)) {
      try {
        await refreshCustomer(); // or refreshStaff() — pick per app
        return true;
      } catch (_) {
        await signOut();
        return false;
      }
    }
    _http.setAuth(access, orgId);
    return true;
  }
}

The AuthResponse model maps the server payload:

// lib/models/auth_response.dart
class AuthResponse {
  final String accessToken;
  final String refreshToken;
  final Map<String, dynamic> user;

  AuthResponse({
    required this.accessToken,
    required this.refreshToken,
    required this.user,
  });

  factory AuthResponse.fromJson(Map<String, dynamic> json) => AuthResponse(
        accessToken: json['access_token'] ?? json['accessToken'] ?? '',
        refreshToken: json['refresh_token'] ?? json['refreshToken'] ?? '',
        user: (json['user'] ?? json['data'] ?? {}) as Map<String, dynamic>,
      );
}

The server has historically returned both access_token (snake_case) and accessToken (camelCase) depending on the endpoint version. The factory above falls back through both — match what your tenant returns and trim the rest.

AuthProvider

A Provider holds auth state app-wide and wires the refresh callback into AppmintHttpClient.

// lib/providers/auth_provider.dart
import 'package:flutter/foundation.dart';
import '../services/auth_service.dart';
import '../services/http_client.dart';

class AuthProvider extends ChangeNotifier {
  final AuthService _service = AuthService();

  bool _isAuthenticated = false;
  Map<String, dynamic>? _user;

  bool get isAuthenticated => _isAuthenticated;
  Map<String, dynamic>? get user => _user;

  Future<void> bootstrap() async {
    // Wire the refresh + force-logout callbacks into the HTTP client.
    appmintHttp.onTokenRefresh = () async {
      try {
        final auth = await _service.refreshCustomer();
        return auth.accessToken;
      } catch (_) {
        return null;
      }
    };

    appmintHttp.onForceLogout = () async {
      await signOut();
    };

    final ok = await _service.restore();
    _isAuthenticated = ok;
    notifyListeners();
  }

  Future<void> signIn({
    required String orgId,
    required String email,
    required String password,
  }) async {
    final auth = await _service.signInCustomer(
      orgId: orgId,
      email: email,
      password: password,
    );
    _user = auth.user;
    _isAuthenticated = true;
    notifyListeners();
  }

  Future<void> signOut() async {
    await _service.signOut();
    _user = null;
    _isAuthenticated = false;
    notifyListeners();
  }
}

App startup

Call bootstrap() before the first frame so the rest of the app sees the right auth state.

// lib/main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // ... environment setup ...

  final auth = AuthProvider();
  await auth.bootstrap();

  runApp(
    ChangeNotifierProvider.value(
      value: auth,
      child: const MyApp(),
    ),
  );
}

In your top-level widget, gate routes on auth.isAuthenticated:

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return Consumer<AuthProvider>(
      builder: (_, auth, __) => MaterialApp(
        home: auth.isAuthenticated
            ? const HomeScreen()
            : const SignInScreen(),
      ),
    );
  }
}

Auto-refresh, in detail

The HTTP client refreshes opportunistically — only when a request comes back 401. The flow:

  1. 1

    Request hits 401

    The client sees _refreshing == false and sets it to true, then calls onTokenRefresh.

  2. 2

    Refresh runs

    AuthService.refreshCustomer() calls POST /profile/customer/refresh with the stored refresh token. On success it persists the new tokens and updates appmintHttp via setAuth.

  3. 3

    Original request retries

    The client rebuilds headers with the new token and replays the request. If it succeeds, the user never notices.

  4. 4

    Refresh fails → force logout

    If refresh throws (refresh token expired or revoked), onTokenRefresh returns null, the retry still fails with 401, and the client calls onForceLogout. The provider clears state, the app rebuilds, and the user lands on the sign-in screen.

A second concurrent 401 while a refresh is already in flight short-circuits — the _refreshing flag prevents thundering-herd refresh storms.

Logout

Two layers:

  1. Best-effort server call to POST /profile/signout. The server revokes the refresh token so it can't be reused if it leaks.
  2. Local clear: delete tokens from flutter_secure_storage, clear the in-memory client, notify listeners.

The local clear must always run even if the server call fails (no network, expired token, etc.) — otherwise the user is stuck on the home screen with a broken session.

Where the JWT lives

StorageWhat goes there
flutter_secure_storageaccess_token, refresh_token, org_id
shared_preferencesUser profile JSON, theme, last-seen timestamps
In-memory (AppmintHttpClient)Active access token + orgId for the running session
Hive (admin only)Cached collections — never tokens

Secrets only ever go in flutter_secure_storage — backed by Keychain on iOS and EncryptedSharedPreferences on Android. The user profile JSON in shared_preferences is plain text, so don't store anything sensitive there.

The next page covers adding biometrics on top — local_auth as a second factor before the app reads the secure-storage tokens at all.