13 KiB
**# Authentication Flow - Frappe/ERPNext Integration
Overview
The authentication system integrates with Frappe/ERPNext API using a session-based approach with SID (Session ID) and CSRF tokens stored in FlutterSecureStorage.
Complete Flow
1. App Startup (Check Saved Session)
When: User opens the app
Process:
Authprovider'sbuild()method is called- Checks if user session exists in FlutterSecureStorage
- If logged-in session exists (userId != public_api@dbiz.com), returns User entity
- Otherwise returns
null(user not logged in) - Note: Public session is NOT fetched on startup to avoid provider disposal issues
Important: The public session will be fetched lazily when needed:
- Before login (on login page load)
- Before registration (when loading cities/customer groups)
- Before any API call that requires session (via
ensureSession())
API Endpoint: POST /api/method/dbiz_common.dbiz_common.api.auth.get_session
Request:
curl -X POST 'https://land.dbiz.com/api/method/dbiz_common.dbiz_common.api.auth.get_session' \
-H 'Content-Type: application/json' \
-d ''
Response:
{
"session_expired": 1,
"message": {
"data": {
"sid": "8c39b583...",
"csrf_token": "f8a7754a9ce5..."
}
},
"home_page": "/app",
"full_name": "Guest"
}
Storage (FlutterSecureStorage):
frappe_sid: "8c39b583..."frappe_csrf_token: "f8a7754a9ce5..."frappe_full_name: "Guest"frappe_user_id: "public_api@dbiz.com"
2. Initialize Public Session (When Needed)
When: Before login or registration, or before any API call
Process:
- Call
ref.read(initializeFrappeSessionProvider.future)on the page - Checks if session exists in FlutterSecureStorage
- If no session, calls
FrappeAuthService.getSession() - Stores public session (sid, csrf_token) in FlutterSecureStorage
Usage Example:
// In login page or registration page initState/useEffect
@override
void initState() {
super.initState();
// Initialize session when page loads
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(initializeFrappeSessionProvider.future);
});
}
Or use FutureBuilder:
FutureBuilder(
future: ref.read(initializeFrappeSessionProvider.future),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return LoadingIndicator();
}
return LoginForm(); // or RegistrationForm
},
)
3. Loading Cities & Customer Groups (Using Public Session)
When: User navigates to registration screen
Process:
- Session initialized (if not already) via
initializeFrappeSessionProvider AuthRemoteDataSource.getCities()is called- Gets stored session from FlutterSecureStorage
- Calls API with session headers
- Returns list of cities for address selection
API Endpoint: POST /api/method/frappe.client.get_list
Request:
curl -X POST 'https://land.dbiz.com/api/method/frappe.client.get_list' \
-H 'Cookie: sid=8c39b583...; full_name=Guest; system_user=no; user_id=public_api%40dbiz.com; user_image=' \
-H 'X-Frappe-CSRF-Token: f8a7754a9ce5...' \
-H 'Content-Type: application/json' \
-d '{
"doctype": "City",
"fields": ["city_name", "name", "code"],
"limit_page_length": 0
}'
Response:
{
"message": [
{"city_name": "Hồ Chí Minh", "name": "HCM", "code": "HCM"},
{"city_name": "Hà Nội", "name": "HN", "code": "HN"}
]
}
Similarly for Customer Groups:
{
"doctype": "Customer Group",
"fields": ["customer_group_name", "name", "value"],
"filters": {
"is_group": 0,
"is_active": 1,
"customer": 1
}
}
4. User Login (Get Authenticated Session)
When: User enters phone number and password, clicks login
Process:
Auth.login()is called with phone number- Gets current session from FlutterSecureStorage
- Calls
AuthRemoteDataSource.login()with phone + current session - API returns new authenticated session
FrappeAuthService.login()stores new session in FlutterSecureStorage- Dio interceptor automatically uses new session for all subsequent requests
- Returns
Userentity with user data
API Endpoint: POST /api/method/building_material.building_material.api.auth.login
Request:
curl -X POST 'https://land.dbiz.com/api/method/building_material.building_material.api.auth.login' \
-H 'Cookie: sid=8c39b583...' \
-H 'X-Frappe-CSRF-Token: f8a7754a9ce5...' \
-H 'Content-Type: application/json' \
-d '{
"username": "0123456789",
"googleid": null,
"facebookid": null,
"zaloid": null
}'
Response:
{
"session_expired": 1,
"message": {
"data": {
"sid": "new_authenticated_sid_123...",
"csrf_token": "new_csrf_token_456..."
}
},
"home_page": "/app",
"full_name": "Nguyễn Văn A"
}
Storage Update (FlutterSecureStorage):
frappe_sid: "new_authenticated_sid_123..."frappe_csrf_token: "new_csrf_token_456..."frappe_full_name: "Nguyễn Văn A"frappe_user_id: "0123456789"
5. Authenticated API Requests
When: User makes any API request after login
Process:
AuthInterceptor.onRequest()is called- Reads session from FlutterSecureStorage
- Builds cookie header with all required fields
- Adds headers to request
Cookie Header Format:
Cookie: sid=new_authenticated_sid_123...; full_name=Nguyễn Văn A; system_user=no; user_id=0123456789; user_image=
X-Frappe-CSRF-Token: new_csrf_token_456...
Example: Getting products
curl -X POST 'https://land.dbiz.com/api/method/frappe.client.get_list' \
-H 'Cookie: sid=new_authenticated_sid_123...; full_name=Nguyễn%20Văn%20A; system_user=no; user_id=0123456789; user_image=' \
-H 'X-Frappe-CSRF-Token: new_csrf_token_456...' \
-H 'Content-Type: application/json' \
-d '{
"doctype": "Item",
"fields": ["item_name", "item_code", "standard_rate"],
"limit_page_length": 20
}'
6. User Logout
When: User clicks logout button
Process:
Auth.logout()is called- Clears session from both:
AuthLocalDataSource(legacy Hive)FrappeAuthService(FlutterSecureStorage)
- Gets new public session for next login/registration
- Returns
null(user logged out)
Storage Cleared:
frappe_sidfrappe_csrf_tokenfrappe_full_namefrappe_user_id
New Public Session: Immediately calls getSession() again to get fresh public session
File Structure
Core Services
lib/core/services/frappe_auth_service.dart- Centralized session managementlib/core/models/frappe_session_model.dart- Session response modellib/core/network/api_interceptor.dart- Dio interceptor for adding session headers
Auth Feature
lib/features/auth/data/datasources/auth_remote_datasource.dart- API calls (login, getCities, getCustomerGroups, register)lib/features/auth/data/datasources/auth_local_datasource.dart- Legacy Hive storagelib/features/auth/presentation/providers/auth_provider.dart- State management
Key Components
FrappeAuthService:
class FrappeAuthService {
Future<FrappeSessionResponse> getSession(); // Get public session
Future<FrappeSessionResponse> login(String phone, {String? password}); // Login
Future<Map<String, String>?> getStoredSession(); // Read from storage
Future<Map<String, String>> ensureSession(); // Ensure session exists
Future<Map<String, String>> getHeaders(); // Get headers for API calls
Future<void> clearSession(); // Clear on logout
}
AuthRemoteDataSource:
class AuthRemoteDataSource {
Future<GetSessionResponse> getSession(); // Wrapper for Frappe getSession
Future<GetSessionResponse> login({phone, csrfToken, sid, password}); // Login API
Future<List<City>> getCities({csrfToken, sid}); // Get cities for registration
Future<List<CustomerGroup>> getCustomerGroups({csrfToken, sid}); // Get customer groups
Future<Map<String, dynamic>> register({...}); // Register new user
}
Auth Provider:
@riverpod
class Auth extends _$Auth {
@override
Future<User?> build(); // Initialize session on app startup
Future<void> login({phoneNumber, password}); // Login flow
Future<void> logout(); // Logout and get new public session
}
AuthInterceptor:
class AuthInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// Read from FlutterSecureStorage
// Build cookie header
// Add to request headers
}
}
Session Storage
All session data is stored in FlutterSecureStorage (encrypted):
| Key | Description | Example |
|---|---|---|
frappe_sid |
Session ID | "8c39b583..." |
frappe_csrf_token |
CSRF Token | "f8a7754a9ce5..." |
frappe_full_name |
User's full name | "Nguyễn Văn A" |
frappe_user_id |
User ID (phone or email) | "0123456789" or "public_api@dbiz.com" |
Public vs Authenticated Session
Public Session
- User ID:
public_api@dbiz.com - Full Name: "Guest"
- Used for: Registration, loading cities/customer groups
- Obtained: On app startup, after logout
Authenticated Session
- User ID: User's phone number (e.g., "0123456789")
- Full Name: User's actual name (e.g., "Nguyễn Văn A")
- Used for: All user-specific operations (orders, cart, profile)
- Obtained: After successful login
Error Handling
All API calls use proper exception handling:
- 401 Unauthorized:
UnauthorizedException- Session expired or invalid - 404 Not Found:
NotFoundException- Endpoint not found - Network errors:
NetworkException- Connection failed - Validation errors:
ValidationException- Invalid data
Future Enhancements
-
Password Support: Currently reserved but not sent. When backend supports password:
Future<GetSessionResponse> login({ required String phone, required String csrfToken, required String sid, String? password, // Remove nullable, make required }) async { // Add 'password': password to request body } -
Token Refresh: Implement automatic token refresh on 401 errors
-
Session Expiry: Add session expiry tracking and automatic re-authentication
-
Biometric Login: Store phone number and use biometric for quick re-login
Testing the Flow
1. Test Public Session
final frappeService = ref.read(frappeAuthServiceProvider).value!;
final session = await frappeService.getSession();
print('SID: ${session.sid}');
print('CSRF: ${session.csrfToken}');
2. Test Login
final auth = ref.read(authProvider.notifier);
await auth.login(
phoneNumber: '0123456789',
password: 'not_used_yet',
);
3. Test Authenticated Request
final remoteDataSource = ref.read(authRemoteDataSourceProvider).value!;
final cities = await remoteDataSource.getCities(
csrfToken: 'from_storage',
sid: 'from_storage',
);
4. Test Logout
await ref.read(authProvider.notifier).logout();
Debugging
Enable cURL logging to see all requests:
In dio_client.dart:
dio.interceptors.add(CurlLoggerDioInterceptor());
Console Output:
╔══════════════════════════════════════════════════════════════
║ POST https://land.dbiz.com/api/method/building_material.building_material.api.auth.login
║ Headers: {Cookie: [HIDDEN], X-Frappe-CSRF-Token: [HIDDEN], ...}
║ Body: {username: 0123456789, googleid: null, ...}
╚══════════════════════════════════════════════════════════════
╔══════════════════════════════════════════════════════════════
║ Response: {session_expired: 1, message: {...}, full_name: Nguyễn Văn A}
╚══════════════════════════════════════════════════════════════
Summary
The authentication flow is now fully integrated with Frappe/ERPNext:
- ✅ App startup checks for saved user session
- ✅ Public session fetched lazily when needed (via
initializeFrappeSessionProvider) - ✅ Public session used for cities/customer groups
- ✅ Login updates session to authenticated
- ✅ All API requests use session from FlutterSecureStorage
- ✅ Dio interceptor automatically adds headers
- ✅ Logout clears session and gets new public session
- ✅ cURL logging for debugging
- ✅ No provider disposal errors
All session management is centralized in FrappeAuthService with automatic integration via AuthInterceptor.**