Domain 01: Authentication & Authorization
Domain Owner: Backend (ASP.NET Core Identity + JWT) / Mobile (Flutter + Dio + SecureStorage) Last Updated: 2026-03-10 Workflows: WF-AUTH-01 through WF-AUTH-07
Domain Introduction
The Authentication & Authorization domain handles user identity, credential management, token lifecycle, permission enforcement, and multi-role dashboard routing across the Almafrica platform. The backend uses ASP.NET Core Identity with JWT bearer tokens, while the mobile client uses Dio HTTP interceptors, Flutter Secure Storage for token persistence, and a layered permission system that combines backend-sourced permissions with a local role-based feature matrix fallback.
Key architectural principles:
- JWT-based stateless auth: Access tokens carry all role and permission claims; the backend validates against the full permission set.
- No active role restriction: Users keep ALL permissions from ALL their roles. The mobile app never scopes permissions to a single active role.
- Dashboard Hub navigation: Multi-role users pick where to navigate (which dashboard), not which role to lock into.
- Offline-first: Cached credentials, a 30-day offline PIN expiry window, and locally stored permissions allow field agents to work without connectivity.
- Real-time permission sync: SignalR pushes permission changes to connected mobile clients, with graceful revocation handling that respects unsaved form data.
Domain Summary Diagram
graph LR
subgraph "Mobile Client"
LOGIN((User Login))
REFRESH[Token Refresh Interceptor]
HUB[Dashboard Hub Router]
OFFLINE[Offline Auth Fallback]
PERMSYNC[Permission Sync Listener]
end
subgraph "Backend API"
AUTH_EP[AuthController]
TFA_EP[TwoFactorController]
PERM_HUB_EP[PermissionHub - SignalR]
OTP_SVC[OtpService]
end
subgraph "Storage"
SEC_STORE[[FlutterSecureStorage]]
SHARED_PREFS[[SharedPreferences]]
DB[[AlmafricaDbContext]]
end
LOGIN -->|POST /api/auth/login| AUTH_EP
AUTH_EP -->|JWT + roles + permissions| LOGIN
LOGIN -->|store tokens| SEC_STORE
LOGIN -->|store user info + permissions| SHARED_PREFS
LOGIN -->|fire-and-forget| AUTH_EP
LOGIN -.->|WF-AUTH-07| AUTH_EP
REFRESH -->|POST /api/auth/refresh-token| AUTH_EP
AUTH_EP -->|new JWT pair| REFRESH
LOGIN -->|role count check| HUB
HUB -->|single dashboard| OFFLINE
HUB -->|multi-dashboard| HUB
PERM_HUB_EP -->|PermissionsUpdated| PERMSYNC
PERMSYNC -->|update| SHARED_PREFS
TFA_EP -->|generate OTP| OTP_SVC
OTP_SVC -->|SMS via provider| OTP_SVC
AUTH_EP --> DB
OTP_SVC --> DB
Workflows
WF-AUTH-01: User Login Flow
Trigger: User submits credentials on the login screen Frequency: On-demand (each login attempt) Offline Support: No (requires network for initial login; see WF-AUTH-06 for offline fallback)
Workflow Diagram
graph TD
A((User taps Login)) --> B[Collect emailOrUsername + password + rememberMe]
B --> C[POST /api/auth/login via Dio]
C --> D{Backend: User exists?}
D -->|No| E[Throw UnauthorizedAccessException]
D -->|Yes| F{Backend: Password valid?}
F -->|No| G[Increment AccessFailedCount]
G --> H{Account locked out?}
H -->|Yes| I[Return 401 - Account locked]
H -->|No| J[Return 401 - Invalid credentials]
F -->|Yes| K[Reset AccessFailedCount]
K --> L[GenerateAuthResponseAsync]
L --> M[Build JWT with claims: sub, roles, AgentId, ClientId, collection_point_id]
M --> N[Generate RefreshToken - 64-byte random]
N --> O[Return AuthResponse: accessToken, refreshToken, roles, permissions, userId]
O --> P[Mobile: _storeAuthData]
P --> Q[Write tokens to FlutterSecureStorage]
Q --> R[Write UserInfo + roles to SharedPreferences]
R --> S[Store flat permissions via PermissionStorage]
S --> T[Fire-and-forget: _fetchAndStoreRolePermissions - WF-AUTH-07]
T --> U{Remember Me checked?}
U -->|Yes| V[Save credentials to FlutterSecureStorage]
U -->|No| W[Continue]
V --> W
W --> X{User change detected?}
X -->|Yes| Y[Wipe local database]
X -->|No| Z[Navigate to SplashScreen routing - WF-AUTH-04]
Y --> Z
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Manual Trigger | Login Screen UI | User submits email/username + password | Validation errors shown inline |
| 2 | HTTP Request | JwtAuthService.login() | POST /api/auth/login via Dio | DioException caught; rethrown for UI handling |
| 3 | Function | AuthService.LoginAsync() | Validate credentials via UserManager.CheckPasswordAsync | UnauthorizedAccessException for invalid creds |
| 4 | Function | AuthService.GenerateAuthResponseAsync() | Build JWT claims (sub, roles, AgentId, etc.) + refresh token | Config errors if JWT SecretKey missing |
| 5 | Function | JwtAuthService._storeAuthData() | Write tokens to FlutterSecureStorage, user info to SharedPreferences | Errors logged, non-fatal |
| 6 | Function | PermissionStorage.setPermissions() | Store flat permission list from AuthResponse.permissions | Errors rethrown |
| 7 | HTTP Request | JwtAuthService._fetchAndStoreRolePermissions() | GET /api/auth/my-permissions-by-role (fire-and-forget) | Caught silently; non-blocking |
| 8 | Function | JwtAuthService.isUserChange() | Compare currentAuthUserId with _lastLoggedInUserIdKey in SharedPreferences | False on error (safe default) |
Data Transformations
- Input:
LoginRequest { emailOrUsername, password, rememberMe } - Processing: Backend looks up
ApplicationUser, validates password, generates JWT with all role claims, buildsAuthResponse - Output:
JwtAuthResponse { accessToken, refreshToken, expiresIn, tokenType, authUserId, username, email, fullName, roles[], permissions[], userId }
Error Handling
- Invalid credentials: Backend returns 401; mobile shows localized error message
- Account lockout: After multiple failed attempts,
UserManager.IsLockedOutAsyncreturns true; 401 with lockout message - Network failure:
DioExceptionwithconnectionErrortype; login screen shows connectivity error - Token storage failure: Logged but non-fatal; user may need to re-login on next app launch
Cross-References
- Triggers: WF-AUTH-04 (Dashboard Hub routing after successful login)
- Triggers: WF-AUTH-07 (Per-role permission fetch, fire-and-forget)
- Triggered by: WF-AUTH-06 (offline fallback may redirect to online login when expired)
WF-AUTH-02: Token Refresh Flow
Trigger: Dio interceptor receives a 401 response on a non-auth endpoint Frequency: Event-driven (whenever access token expires mid-session) Offline Support: No (requires network to exchange refresh token)
Workflow Diagram
graph TD
A((Dio onError: 401 received)) --> B{Is auth endpoint?}
B -->|Yes /login, /register, /refresh-token| C[Pass through - handler.next]
B -->|No| D{skipAuthRetry flag?}
D -->|Yes| C
D -->|No| E{Already retried - authRetried flag?}
E -->|Yes| C
E -->|No| F[Call JwtAuthService.refreshToken]
F --> G[Read refreshToken from FlutterSecureStorage]
G --> H{Refresh token exists?}
H -->|No| I[Return false - keep current auth state]
H -->|Yes| J[POST /api/auth/refresh-token with skipAuthRetry=true]
J --> K{Backend: Token valid + not expired?}
K -->|No| L[Return false - log warning]
K -->|Yes| M[GenerateAuthResponseAsync - new JWT pair]
M --> N[_storeAuthData - update tokens + permissions]
N --> O[Set authRetried=true on original request]
O --> P[Retry original request with new Bearer token]
P --> Q[handler.resolve - return response to caller]
I --> R[handler.next - propagate 401]
L --> R
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Webhook Trigger | Dio InterceptorsWrapper.onError | Intercept 401 responses | Guards: skip auth endpoints, skip retried requests |
| 2 | IF | Guard checks | Check isAuthEndpoint, skipAuthRetry, alreadyRetried | Pass-through if any guard matches |
| 3 | HTTP Request | JwtAuthService.refreshToken() | POST /api/auth/refresh-token | Returns false on any error |
| 4 | Function | AuthService.RefreshTokenAsync() | Validate stored RefreshToken and RefreshTokenExpiryTime | UnauthorizedAccessException for invalid/expired |
| 5 | Function | JwtAuthService._storeAuthData() | Overwrite tokens and permissions in local storage | Errors logged |
| 6 | HTTP Request | _dio.fetch(requestOptions) | Retry the original failed request with new token | Errors propagate normally |
Data Transformations
- Input:
RefreshTokenRequest { refreshToken }(read fromFlutterSecureStorage) - Processing: Backend finds user by
RefreshToken, checksRefreshTokenExpiryTime > UtcNow, generates new JWT pair - Output:
AuthResponsewith freshaccessTokenandrefreshToken
Error Handling
- No refresh token stored: Returns false; original 401 propagates. Auth state is NOT cleared (transient failures tolerated).
- Expired refresh token: Backend returns 401; refresh returns false; original error propagates.
- Network failure during refresh: DioException caught; returns false; keeps current auth state.
- Critical design choice: A single 401 does NOT trigger automatic logout -- the comment in the interceptor says this can be transient during post-login background requests.
Cross-References
- Triggered by: Any authenticated API call that receives 401
- Triggers: None directly (but updated tokens flow into subsequent requests)
WF-AUTH-03: Two-Factor Authentication
Trigger: User initiates 2FA setup from account settings, or login requires 2FA verification Frequency: On-demand (setup once; verification on each login if enabled) Offline Support: No (requires network for OTP generation and verification)
Workflow Diagram
graph TD
A((User initiates 2FA setup)) --> B[POST /api/user-management/2fa/setup]
B --> C[TwoFactorService.GenerateSetupDataAsync]
C --> D[Return QR code URL + manual entry key]
D --> E[User scans QR or enters key in authenticator app]
E --> F[User enters TOTP code]
F --> G[POST /api/user-management/2fa/verify]
G --> H[TwoFactorService.EnableTwoFactorAsync]
H --> I{Code valid?}
I -->|No| J[Return 400 - Invalid code]
I -->|Yes| K[Enable 2FA + generate backup codes]
K --> L[Return TwoFactorVerifyResponse with backup codes]
M((OTP SMS Flow)) --> N[OtpService.SendOtpAsync]
N --> O{Development mode?}
O -->|Yes| P[Log OTP 000000 to console]
O -->|No| Q[Generate 6-digit random OTP]
Q --> R[Store OtpCode in database with 10-min expiry]
R --> S[Send SMS via provider - Africa's Talking planned]
S --> T[Return success]
P --> R
U((User enters OTP)) --> V[OtpService.VerifyOtpAsync]
V --> W{OTP record exists + not expired + not used?}
W -->|No| X[Return false]
W -->|Yes| Y{Attempt count < 5?}
Y -->|No| Z[Mark OTP as used - max attempts]
Y -->|Yes| AA{Code matches?}
AA -->|No| AB[Increment attempt count]
AA -->|Yes| AC[Mark OTP as used - verified]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | HTTP Request | TwoFactorController.Setup() | POST /api/user-management/2fa/setup | Returns 400 if setup fails |
| 2 | Function | TwoFactorService.GenerateSetupDataAsync() | Generate QR code + TOTP secret | Result.IsSuccess pattern |
| 3 | HTTP Request | TwoFactorController.Verify() | POST /api/user-management/2fa/verify | Returns 400 for invalid code |
| 4 | Function | OtpService.SendOtpAsync() | Generate 6-digit OTP, store in OtpCodes table, send via SMS | Returns false on failure; dev mode logs bypass code |
| 5 | Function | OtpService.VerifyOtpAsync() | Look up most recent valid OTP, check attempt count, verify code | Max 5 attempts per OTP; expired OTPs rejected |
| 6 | Scheduled | OtpService.CleanupExpiredOtpsAsync() | Remove OTPs older than 7 days | Errors logged |
Data Transformations
- OTP Generation: Phone number + purpose -> 6-digit code stored in
OtpCodestable withExpiresAt = UtcNow + 10 minutes - OTP Storage:
OtpCode { PhoneNumber, Code, Purpose, ExpiresAt, IsUsed, AttemptCount } - TOTP Setup: User ID -> QR code URI + manual key via
TwoFactorSetupResponse - Backup Codes: Generated on 2FA enable;
RegenerateBackupCodesResponsereturned once
Error Handling
- Invalid phone format: Rejected if not starting with
+ - Max attempts exceeded: OTP marked as used after 5 failed attempts
- Development mode: Bypass code
000000always accepted; real OTP not sent - SMS provider failure: Planned integration with Africa's Talking; currently dev-mode only
Cross-References
- Triggered by: WF-AUTH-01 (login may require 2FA step)
- Triggers: None (2FA verification gates login completion)
WF-AUTH-04: Multi-Role Dashboard Hub Routing
Trigger: Successful authentication completes and SplashScreen._navigateToNextScreen() runs
Frequency: On every app launch / login
Offline Support: Yes (uses cached roles from SharedPreferences)
Workflow Diagram
graph TD
A((SplashScreen._navigateToNextScreen)) --> B{Onboarding completed?}
B -->|No| C[Navigate to /onboarding]
B -->|Yes| D{Token exists in SecureStorage?}
D -->|No| E[Navigate to /login]
D -->|Yes| F{Initial sync completed?}
F -->|No| G[Navigate to /initial-sync]
F -->|Yes| H[ActiveRoleManager.initialize]
H --> I[RoleManager.getCurrentUserRoles -> List of UserRole]
I --> J{hasOnlyWebOnlyRoles - admin/manager only?}
J -->|Yes| K[Logout + Navigate to /login]
J -->|No| L{hasMultipleDashboards?}
L -->|Yes| M[Navigate to /role-selection - Dashboard Hub]
L -->|No| N{Match single role to route}
N -->|warehouseManager/Operator| O[setActiveRole + /warehouse-home]
N -->|fsa| P[setActiveRole + /fsa-home]
N -->|csa| Q[setActiveRole + /csa-home]
N -->|dataAgent| R[setActiveRole + /data-agent-home]
N -->|agent/superAgent fallback| M
N -->|other| S[Resolve fallback route from role list]
S --> T{Route found?}
T -->|Yes| U[setActiveRole + navigate to route]
T -->|No| V[Navigate to /login - preserve auth for offline]
M --> W((Dashboard Hub Screen))
W --> X[Show availableDashboards as cards]
X --> Y[User taps a dashboard card]
Y --> Z[ActiveRoleManager.setActiveRole]
Z --> AA[Navigator.pushReplacementNamed to dashboard.route]
AA --> AB[Fire-and-forget: SyncManager.requestSync]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Function | SplashScreen._navigateToNextScreen() | Determine next route based on auth state + roles | Falls back to /login on any error |
| 2 | Function | ActiveRoleManager.initialize() | Load roles from JwtAuthService.getUserRoles(), resolve persisted active role | Timeout after 3 seconds |
| 3 | IF | ActiveRoleManager.hasMultipleDashboards | Check if availableDashboards.length > 1 | N/A |
| 4 | Function | ActiveRoleManager.availableDashboards getter | Deduplicate dashboard routes; special handling for agent (FSA+CSA) and superAgent (FSA+CSA+DataAgent) | Web-only roles excluded |
| 5 | UI | RoleSelectionScreen | Display dashboard cards with icon, name, description; user taps to select | Loading overlay during navigation |
| 6 | Function | ActiveRoleManager.setActiveRole() | Persist selected role to SharedPreferences._activeRoleKey | Ignores unavailable roles |
| 7 | Function | SyncManager.requestSync() | Fire-and-forget background data sync after dashboard selection | Errors caught silently |
Data Transformations
- Input: User's
List<UserRole>from cached auth data - Processing:
ActiveRoleManager.availableDashboardsmaps roles toDashboardDestination { route, role, name, description, icon }with deduplication:agentexpands to/fsa-home+/csa-homesuperAgentexpands to/fsa-home+/csa-home+/data-agent-homewarehouseManagerandwarehouseOperatorstay distinct despite sharing/warehouse-homeadmin/managerexcluded (web-only)
- Output: Navigation to a specific dashboard route or the Dashboard Hub
Error Handling
- No roles resolved: Falls back to
/login - Web-only roles only: Logs out user, navigates to
/login - No mobile dashboard route resolved: Navigates to
/loginbut preserves auth data for offline access - ActiveRoleManager timeout: 3-second timeout in splash; continues to navigation regardless
Cross-References
- Triggered by: WF-AUTH-01 (after successful login)
- Triggered by: WF-AUTH-06 (offline auth completes, app needs routing)
- Triggers: Background data sync via
SyncManager
WF-AUTH-05: Permission Sync via SignalR
Trigger: Backend pushes PermissionsUpdated message via PermissionHub
Frequency: Event-driven (whenever admin changes a user's role or permissions)
Offline Support: No (requires active SignalR connection)
Workflow Diagram
graph TD
A((Backend: Admin changes user permissions)) --> B[Send PermissionsUpdated via PermissionHub]
B --> C[SignalR sends to user's connection]
D((Mobile: PermissionSyncService._handlePermissionsUpdated)) --> E{Valid message format?}
E -->|No| F[Log error and return]
E -->|Yes| G[Parse newPermissions from message]
G --> H[Read oldPermissions from PermissionStorage]
H --> I[PermissionStorage.setPermissions - store new list]
I --> J[Fire-and-forget: _refreshRolePermissions via GET /api/auth/my-permissions-by-role]
J --> K[Compute revokedPermissions = old - new]
K --> L{Any permissions revoked?}
L -->|No| M[Done - permissions silently updated]
L -->|Yes| N[Emit PermissionRevocationEvent to stream]
N --> O((PermissionRevokedHandler._handleRevocationEvent))
O --> P{User on form screen?}
P -->|Yes| Q[Set _logoutPending = true]
Q --> R[Wait for unregisterFormScreen]
R --> S{Still forms active?}
S -->|Yes| R
S -->|No| T[Execute graceful logout]
P -->|No| T
T --> U[Show SnackBar: permissions updated message]
U --> V[JwtAuthService.clearAuthData]
V --> W[Navigator.pushNamedAndRemoveUntil /login]
X((SignalR Connection Lifecycle))
X --> Y[_establishConnection with JWT bearer]
Y --> Z{Connected?}
Z -->|Yes| AA[Register PermissionsUpdated + DataChanged handlers]
Z -->|No| AB[Schedule reconnect with exponential backoff]
AB --> AC[Delay: 1s, 2s, 4s, 8s, ... max 30s]
AC --> Y
AD((Network restored)) --> AE[ConnectivityService stream fires]
AE --> AF{shouldReconnect + disconnected?}
AF -->|Yes| Y
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Webhook Trigger | PermissionHub (backend) | SignalR hub authenticated with [Authorize(AuthenticationSchemes = "Bearer")] | Logs connect/disconnect with userId |
| 2 | Function | PermissionSyncService._handlePermissionsUpdated() | Parse incoming message (Map or List format), compare old vs new permissions | Errors caught; logged |
| 3 | Function | PermissionStorage.setPermissions() | Overwrite user_permissions key in SharedPreferences | Errors rethrown |
| 4 | HTTP Request | PermissionSyncService._refreshRolePermissions() | GET /api/auth/my-permissions-by-role to refresh per-role map | Fire-and-forget; errors logged |
| 5 | Function | Revocation detection | Compute oldPermissions.where(!newPermissions.contains) | N/A |
| 6 | Event Emitter | _revocationController.add() | Broadcast PermissionRevocationEvent to listeners | Checks !_revocationController.isClosed |
| 7 | Function | PermissionRevokedHandler._handleRevocationEvent() | Check _activeFormScreens, defer or execute logout | Defers if unsaved forms active |
| 8 | Function | PermissionRevokedHandler._executeLogout() | Show SnackBar, clear auth, navigate to login | Falls back if no Navigator available |
Data Transformations
- Input (SignalR message):
PermissionUpdateMessage { UserId, Permissions[], Timestamp } - Processing: Compare with locally stored
List<String>permissions; detect revocations - Output: Updated local permission cache + optional
PermissionRevocationEvent { revokedPermissions, timestamp }
Error Handling
- Connection lost: Exponential backoff reconnect (1s, 2s, 4s, ... up to 30s max)
- No JWT token: Cannot connect; stays disconnected
- No network: Listens to
ConnectivityService.connectionStream; reconnects when network restored - Form screen active during revocation: Logout deferred via
_logoutPendingflag; executes whenunregisterFormScreen()empties the set - Graceful logout message: "Your permissions have been updated. Please log in again to continue."
Cross-References
- Triggered by: Backend admin permission changes
- Triggers: Graceful logout -> WF-AUTH-01 (user must re-login)
- Related: WF-AUTH-07 (per-role permissions refreshed alongside flat list)
WF-AUTH-06: Offline Authentication Fallback
Trigger: User opens the app without network connectivity Frequency: On-demand (whenever the app launches offline) Offline Support: Yes (this IS the offline mechanism)
Workflow Diagram
graph TD
A((App launch - no connectivity)) --> B[OfflineAuthService.canWorkOffline]
B --> C{Offline mode enabled?}
C -->|No| D[Return false - show login screen]
C -->|Yes| E{Offline session expired? - 30 days}
E -->|Yes| F[Return false - requires online login]
E -->|No| G{Access token exists in SecureStorage?}
G -->|No| H[Return false]
G -->|Yes| I{PIN hard-locked? - 10 failed attempts}
I -->|Yes| J[Return false - requires online re-auth]
I -->|No| K[Return true - offline mode available]
K --> L((Show offline PIN screen))
L --> M[User enters 4-6 digit PIN]
M --> N[OfflineAuthService.verifyOfflinePinWithStatus]
N --> O{Username matches stored offline username?}
O -->|No| P[Return failed - profile mismatch]
O -->|Yes| Q[Read stored hash + salt from FlutterSecureStorage]
Q --> R[Hash input PIN with SHA-256 + stored salt]
R --> S{Hash matches?}
S -->|Yes| T[Reset failed attempt state]
T --> U[Return success - proceed to dashboard routing WF-AUTH-04]
S -->|No| V[Register failed attempt]
V --> W{Attempts >= 10?}
W -->|Yes| X[Hard lockout - set _pinHardLockoutKey]
W -->|No| Y{Attempts >= 5?}
Y -->|Yes| Z[Soft lockout - escalating delays]
Z --> AA[30s -> 1m -> 5m -> 15m]
Y -->|No| AB[Return failed with remaining attempts count]
AC((Initial PIN Setup)) --> AD{User authenticated online?}
AD -->|No| AE[Reject - must be online first]
AD -->|Yes| AF[Validate PIN: 4-6 digits regex]
AF --> AG[Generate 16-byte random salt]
AG --> AH[SHA-256 hash: PIN + salt]
AH --> AI[Store hash + salt in FlutterSecureStorage]
AI --> AJ[Set offline_mode_enabled + last_online_login + username]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Function | OfflineAuthService.canWorkOffline() | Check enabled flag, 30-day expiry, token existence, lockout state | Returns false on any error (safe default) |
| 2 | Function | OfflineAuthService.isOfflineModeExpired() | Compare _lastOnlineLoginKey timestamp with 30-day window | Returns true (expired) on parse error |
| 3 | Function | OfflineAuthService.verifyOfflinePinWithStatus() | Full PIN verification with lockout awareness | Returns OfflinePinVerificationResult with status + message |
| 4 | Function | OfflineAuthService._hashPin() | SHA-256 of pin + salt via crypto package | N/A (pure function) |
| 5 | Function | OfflineAuthService._registerFailedAttempt() | Increment counter; apply soft or hard lockout | Writes to FlutterSecureStorage |
| 6 | Function | OfflineAuthService.setupOfflinePin() | Generate salt, hash PIN, store in FlutterSecureStorage | Requires online auth first |
| 7 | Function | OfflineAuthService.updateLastOnlineLogin() | Update timestamp + reset PIN attempts on successful online login | Errors logged |
Data Transformations
- PIN Setup:
pin (4-6 digits)+username-> SHA-256 hash stored inFlutterSecureStorage - PIN Verification: Input PIN + stored salt -> SHA-256 hash -> compare with stored hash
- Lockout State:
OfflinePinLockoutStatus { failedAttempts, retryAfter, requiresOnlineReauthentication } - Escalating lockouts: Attempt 5 = 30s, Attempt 6 = 1m, Attempt 7 = 5m, Attempt 8 = 15m, Attempt 10 = hard lock
Error Handling
- Expired offline session: After 30 days without online login, offline mode disabled; user must go online
- Soft lockout: After 5 failed PIN attempts, escalating delays (30s, 1m, 5m, 15m)
- Hard lockout: After 10 failed PIN attempts,
_pinHardLockoutKeyset; requires online re-authentication to reset - Username mismatch: If stored offline username differs from current, verification fails
- PIN not configured: Returns failed with informative message
Cross-References
- Triggered by: App launch without connectivity
- Triggers: WF-AUTH-04 (dashboard routing after successful PIN verification)
- Depends on: WF-AUTH-01 (initial online login to set up offline PIN)
WF-AUTH-07: Per-Role Permission Fetch
Trigger: Successful login (fire-and-forget from _storeAuthData) or SignalR reconnection
Frequency: On each login + on each SignalR PermissionsUpdated event
Offline Support: Partial (cached per-role permissions available offline; refresh requires network)
Workflow Diagram
graph TD
A((Login success: _storeAuthData completes)) --> B[_fetchAndStoreRolePermissions - fire-and-forget]
B --> C[GET /api/auth/my-permissions-by-role with Bearer token]
C --> D{Response 200?}
D -->|No| E[Log error - non-blocking]
D -->|Yes| F[Parse rolePermissions map from response]
F --> G[PermissionStorage.setRolePermissions]
G --> H[Store as JSON in SharedPreferences key: role_permissions_map]
I((SignalR PermissionsUpdated received)) --> J[PermissionSyncService._refreshRolePermissions]
J --> K[Create new Dio instance with current Bearer token]
K --> C
L((Backend: GetPermissionsByRoleAsync)) --> M[UserManager.GetRolesAsync - get user roles]
M --> N[Query ApplicationRolePermission table]
N --> O[Include ParentRoleId inherited permissions]
O --> P[Build Dictionary: roleName -> List of permission strings]
P --> Q[Return PermissionsByRoleResponse]
R((Mobile reads per-role cache)) --> S[PermissionStorage.getRolePermissions]
S --> T[Decode JSON map from SharedPreferences]
T --> U[Return Map of String to List of String]
V((Mobile reads single role)) --> W[PermissionStorage.getPermissionsForRole - roleName]
W --> X[Lookup by lowercase key then original key]
X --> Y[Return List of String or empty]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | HTTP Request | JwtAuthService._fetchAndStoreRolePermissions() | GET /api/auth/my-permissions-by-role | Errors caught + rethrown; non-blocking via .catchError |
| 2 | Function | AuthService.GetPermissionsByRoleAsync() | Query ApplicationRolePermission grouped by role, including parent role inheritance | UnauthorizedAccessException if user not found |
| 3 | Function | PermissionStorage.setRolePermissions() | JSON-encode Map<String, List<String>> to SharedPreferences | Errors rethrown |
| 4 | HTTP Request | PermissionSyncService._refreshRolePermissions() | Same endpoint, called from SignalR handler | Errors rethrown; caught by caller |
| 5 | Function | PermissionStorage.getPermissionsForRole() | Look up by role name (case-insensitive fallback) | Errors rethrown |
Data Transformations
- Input: Authenticated user ID (from JWT
subclaim) - Backend Processing:
UserManager.GetRolesAsync(user)-> role names- Query
ApplicationRolePermissionwhereRoleIdin user's roles + parent roles - Build
Dictionary<string, List<string>>with role name -> sorted permission list - Parent role permissions merged via
HashSet.UnionWith
- Output:
PermissionsByRoleResponse { rolePermissions: { "agent": ["CreateFarmers", "EditFarmers", ...], "warehouseOperator": [...] } } - Mobile Storage: JSON-serialized to
SharedPreferencesunder keyrole_permissions_map
Error Handling
- Login-time fetch failure: Caught silently by
_fetchAndStoreRolePermissions().catchError(); login succeeds regardless; flat permissions still available - SignalR refresh failure: Logged as warning; previous per-role cache remains valid
- No permissions for role: Returns empty list; RoleManager falls back to feature matrix
- Stale cache: Refreshed on every login + every SignalR permission update
Cross-References
- Triggered by: WF-AUTH-01 (fire-and-forget after login)
- Triggered by: WF-AUTH-05 (SignalR
PermissionsUpdatedhandler) - Used by:
RoleManagerpermission checks (storage-first, matrix fallback pattern)
File Reference Index
| File | Domain Role |
|---|---|
mobile/mon_jardin/lib/data/services/jwt_auth_service.dart | Login, token refresh, interceptor, credential storage, per-role permission fetch |
mobile/mon_jardin/lib/core/auth/role_manager.dart | Permission checks (storage-first + feature matrix fallback), role utilities |
mobile/mon_jardin/lib/core/auth/active_role_manager.dart | Dashboard navigation, multi-role dashboard deduplication |
mobile/mon_jardin/lib/core/auth/permission_storage.dart | Flat + per-role permission persistence in SharedPreferences |
mobile/mon_jardin/lib/core/auth/permission_revoked_handler.dart | Graceful logout on permission revocation with form-screen deferral |
mobile/mon_jardin/lib/data/services/permission_sync_service.dart | SignalR connection lifecycle, permission update handling |
mobile/mon_jardin/lib/data/services/offline_auth_service.dart | Offline PIN setup/verification, lockout management, 30-day expiry |
mobile/mon_jardin/lib/presentation/splash_screen/splash_screen.dart | App launch routing logic, multi-dashboard detection |
mobile/mon_jardin/lib/presentation/role_selection/role_selection_screen.dart | Dashboard Hub UI, dashboard card selection |
backend/Almafrica.API/Controllers/AuthController.cs | Auth endpoints: login, register, refresh, logout, my-permissions-by-role |
backend/Almafrica.Infrastructure/Services/AuthService.cs | Auth business logic: credential validation, JWT generation, permission queries |
backend/Almafrica.API/Hubs/PermissionHub.cs | SignalR hub for real-time permission broadcasting |
backend/Almafrica.API/Controllers/TwoFactorController.cs | 2FA management: setup, verify, disable, backup codes |
backend/Almafrica.Infrastructure/Services/OtpService.cs | OTP generation, SMS dispatch (dev mode + production), verification with attempt limits |