Mobile Auth Session Lifecycle
Documentation of how apps/mobile_user manages access token + refresh token from login, through usage, until expiry and re-login. Explains why a user can keep going through onboarding even after the access token has expired, without being forced to repeat OTP.
Target reader: engineers who touch login flow, splash, photo upload, or any other authenticated API in the mobile app.
TL;DR
- Access token lives 15 minutes. Used in the
Authorization: Bearer ...header for every backend request. - Refresh token lives much longer (server-side). Stored locally in secure storage, used only to exchange for a new access token via
POST /auth/refresh-token. - The client automatically refreshes the access token before the request when it has expired, as long as the refresh token is still valid on the backend.
- The client does not delete the refresh token just because the access token expired — the refresh token is only deleted on explicit logout or when the backend rejects that refresh token.
Components
| Component | File | Responsibility |
|---|---|---|
AuthSessionService | auth_session_service.dart | Read/write the session in flutter_secure_storage, validate expiry, orchestrate auto-refresh via ensureValidAccessToken() |
AuthApiService | auth_api_service.dart | HTTP client to backend: login/OTP, refreshToken() |
SplashPage | splash_page.dart | Initial routing: check session and trigger auto-refresh before going to Home/Login |
| Backend refresh endpoint | POST /auth/refresh-token (auth_service — internal/transport/http/mobile_auth.go) | Validate the refresh token + issue a new access+refresh pair |
Storage
All values live in flutter_secure_storage:
| Key | Contents |
|---|---|
auth.access_token | Latest access JWT |
auth.refresh_token | Latest refresh token |
auth.token_type | Usually Bearer |
auth.phone | User phone number (re-saved on refresh for consistency) |
auth.expires_in_secs | Raw TTL seconds from the backend response |
auth.expires_at | ISO-8601 UTC timestamp when the access token expires |
profile.* | Profile + merchant state data (only cleared by clearSession) |
Uninstalling the app on Android/iOS wipes the keychain/keystore → empty storage → automatically goes to LoginPage.
Session state machine
┌──────────────┐
│ no session │ ← first install / post-logout / refresh rejected
└──────┬───────┘
│ login OTP success → saveSession(access, refresh, expires_at)
▼
┌──────────────┐
│ fresh access │ (< 15 minutes since issue)
└──────┬───────┘
│ DateTime.now().toUtc() ≥ expires_at - 30s
▼
┌──────────────┐ refresh success
│ access stale │ ──────────────────────► fresh access
│ refresh OK │
└──────┬───────┘
│ refresh rejected by backend (4xx) → clearSession
▼
┌──────────────┐
│ no session │
└──────────────┘
A 30-second grace period is used in the expired check — a request fired at T-5s can reach the backend after the token has actually expired; the grace forces a slightly earlier refresh to avoid the race.
AuthSessionService API contract
Future<String?> ensureValidAccessToken({AuthApiService authApi = const AuthApiService()})
The single entry point that authenticated callers should use. Flow:
- Read
auth.access_token. None →return null. - Check
isAccessTokenExpired(). Still valid → return the access token as is. - Read
auth.refresh_token. None →clearSession()+return null. - Call
authApi.refreshToken(refreshToken: ...):- Success →
saveSession()(write the new access + refresh, reset expires_at) → return the new access. - Rejected by backend (AuthApiException) →
clearSession()+return null. - Network error (timeout, DNS, etc.) → does not
clearSession()→return null. The caller aborts this attempt; the refresh token can still be used on the next boot when online.
- Success →
Future<bool> hasActiveSession()
Read-only (no side effect). Returns true if the access token exists and has not expired. Does not call clearSession() — the refresh token stays safe.
Future<String?> readAccessToken()
Legacy wrapper on top of hasActiveSession(). Returns access if valid, null if expired/empty. Does not trigger a refresh. Suitable for read-only paths that may silently fail (banner, home widget). For upload/submit, use ensureValidAccessToken().
Future<bool> isAccessTokenExpired()
Pure check based on expires_at. No side effects.
Future<String?> readRefreshToken() / readRawAccessToken()
Bypass the expiry check — used only by ensureValidAccessToken() itself / tests.
Future<void> clearSession()
Full wipe: access, refresh, profile, merchant state, PIN, etc. Called on:
- Explicit user logout (Profile → Sign out)
- Refresh token rejected by the backend
- Total registration failure
Runtime scenarios
1. User opens the app, access still valid
- Splash →
ensureValidAccessToken()→ returns access immediately - → HomePage
2. User opens the app after 1 hour (access expired, refresh OK)
- Splash →
ensureValidAccessToken()→ access expired → calls/auth/refresh-token - Backend validates the refresh token → issues a new access + refresh → 200 OK
- The client
saveSession()s the new pair - Returns the new access → HomePage
The user does not see LoginPage at all. This is the main goal of the fix.
3. User pauses mid-onboarding step 3 (business photo) for 20 minutes
- User taps "Take Photo" →
_uploadCapturedFile()→ensureValidAccessToken() - Access has expired → auto-refresh → new access stored
uploadRegistrationPhoto(accessToken: token, ...)→ backend accepts the valid token → 200 OK- Upload succeeds, the user moves to step 4
4. Refresh token revoked server-side (admin force logout)
ensureValidAccessToken()→ calls/auth/refresh-token- Backend returns 401 "refresh token invalid"
- The client throws
AuthApiException→clearSession()→ returns null - Upload/splash shows "Login session not found" → user logs in again
5. Device offline during refresh
ensureValidAccessToken()→refreshToken()throws a network error (notAuthApiException)- The client does not
clearSession()→ returns null - The current attempt fails (the upload shows a snackbar), but the refresh token is still there
- Once the device is online again → scenario 2 → auto-recovery
6. Reinstall / new device
- Empty storage →
ensureValidAccessToken()→ access null → returns null - Splash → LoginPage → OTP flow → new session
7. User logs in with a different phone number
- OTP login for number B succeeds →
saveSession()overwrites access/refresh/phone - All previous tokens (number A) on this device are replaced
- If number A then tries to sync via refresh → backend sees that the refresh token is bound to a different user → reject → clearSession → log in again
Call-site guideline
Must use ensureValidAccessToken():
- Photo upload (KTP, face, business) in onboarding steps
- Submit merchant registration
- Submit any form that must not fail due to an expired token
- Initial splash routing
May use readAccessToken():
- Read-only widgets that can render a fallback state without error
- Non-critical background polling
Recommended pattern:
final String? token = await _sessionService.ensureValidAccessToken();
if (token == null || token.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Login session not found. Please log in again.'),
),
);
return;
}
// Safe to call the backend with `token`
Fix history (2026-04-22)
Previously hasActiveSession() called clearSession() every time the access token expired — that also wiped refresh_token + the entire profile. The result:
- A user who paused onboarding > 15 minutes silently lost the session
- On retry upload: access token null → "Login session not found"
- The user had to OTP again even though the refresh token was actually still valid on the backend
The fix consists of:
hasActiveSession()is no longer destructive (stop the auto-clearSessionon expiry)- Add
ensureValidAccessToken()that auto-refreshes using the refresh token - Splash +
merchant_business_photo_page+merchant_owner_identity_pagemigrated toensureValidAccessToken()
Migration is now essentially complete: the large majority of call sites use ensureValidAccessToken() (including splash, onboarding, kasir, profile, and home pages). Only a handful of read-only paths still use readAccessToken() (e.g. home_dashboard_page, profile_page, pos_riwayat_page, notification routing/push services). Those remaining sites are safe — worst case they get null, but the refresh token is not lost, so the next flow that uses ensureValidAccessToken() immediately recovers.
Backend contract
Endpoint: POST /auth/refresh-token
Request:
{ "refresh_token": "<jwt>" }
Success response (200):
{
"access_token": "<new jwt>",
"refresh_token": "<new jwt>",
"token_type": "Bearer",
"expires_in_secs": 900
}
Failure response (401/403):
{ "error": "refresh token invalid" }
The client cares about: access_token, refresh_token, token_type, expires_in_secs. Extra fields are ignored.
If the backend wants to rotate refresh_token on every refresh (rotating refresh tokens): return the new value in the refresh_token field. The client will replace it. If the backend wants the refresh_token to be stable: return refresh_token: "" or the same refresh_token — the client auto-falls back to the old refresh_token if the field is empty.
Recommended test matrix
- Login → wait for access to expire → reopen app → must reach HomePage (Case 2)
- Login → pause at step 3 > 15 minutes → upload photo → must succeed without error (Case 3)
- Login → force-revoke refresh on backend → reopen app → must reach LoginPage (Case 4)
- Login → airplane mode → reopen app → must reach LoginPage temporarily → disable airplane → reopen → HomePage (Case 5)
- Uninstall + reinstall → must reach LoginPage (Case 6)
What is not yet handled
- Refresh endpoint rate limit — the client does not throttle. If refresh repeatedly fails with 429, the current impl will keep retrying on every upload. Add exponential backoff in
ensureValidAccessToken()if abuse becomes visible. - Concurrent refresh — two parallel uploads can trigger two simultaneous refreshes. The backend must be idempotent about this. If it becomes a problem, add a mutex in
AuthSessionService. - Full migration of the last read-only call sites to
ensureValidAccessToken(). Most call sites are already migrated; a few read-only paths still usereadAccessToken(). Not urgent because the behavior is no longer destructive, but UX becomes smoother when everything is migrated.