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
| Field | Type | Description |
|---|---|---|
| POST /profile/customer/signin | public + orgid | Customer sign-in. Returns access + refresh tokens for end-users of the tenant. Use this in |
| POST /profile/user/signin | public + orgid | Staff/operator sign-in. Returns access + refresh tokens for users with admin roles ( |
| POST /profile/customer/refresh | public + orgid | Refresh a customer access token. Body: |
| POST /profile/user/refresh | public + orgid | Refresh a staff access token. Body: |
| POST /profile/customer/signup | public + orgid | Customer self-registration. Body: |
| GET /profile | jwt | Returns the current user/customer profile based on the JWT. |
| POST /profile/signout | jwt | Server-side logout (revokes the refresh token). |
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
Request hits 401
The client sees
_refreshing == falseand sets it totrue, then callsonTokenRefresh. - 2
Refresh runs
AuthService.refreshCustomer()callsPOST /profile/customer/refreshwith the stored refresh token. On success it persists the new tokens and updatesappmintHttpviasetAuth. - 3
Original request retries
The client rebuilds headers with the new token and replays the request. If it succeeds, the user never notices.
- 4
Refresh fails → force logout
If refresh throws (refresh token expired or revoked),
onTokenRefreshreturns null, the retry still fails with 401, and the client callsonForceLogout. 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:
- Best-effort server call to
POST /profile/signout. The server revokes the refresh token so it can't be reused if it leaks. - 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
| Storage | What goes there |
|---|---|
flutter_secure_storage | access_token, refresh_token, org_id |
shared_preferences | User 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.