Skip to main content

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

ComponentFileResponsibility
AuthSessionServiceauth_session_service.dartRead/write the session in flutter_secure_storage, validate expiry, orchestrate auto-refresh via ensureValidAccessToken()
AuthApiServiceauth_api_service.dartHTTP client to backend: login/OTP, refreshToken()
SplashPagesplash_page.dartInitial routing: check session and trigger auto-refresh before going to Home/Login
Backend refresh endpointPOST /auth/refresh-token (auth_serviceinternal/transport/http/mobile_auth.go)Validate the refresh token + issue a new access+refresh pair

Storage

All values live in flutter_secure_storage:

KeyContents
auth.access_tokenLatest access JWT
auth.refresh_tokenLatest refresh token
auth.token_typeUsually Bearer
auth.phoneUser phone number (re-saved on refresh for consistency)
auth.expires_in_secsRaw TTL seconds from the backend response
auth.expires_atISO-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:

  1. Read auth.access_token. None → return null.
  2. Check isAccessTokenExpired(). Still valid → return the access token as is.
  3. Read auth.refresh_token. None → clearSession() + return null.
  4. Call authApi.refreshToken(refreshToken: ...):
    • SuccesssaveSession() (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.

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

  1. Splash → ensureValidAccessToken() → returns access immediately
  2. → HomePage

2. User opens the app after 1 hour (access expired, refresh OK)

  1. Splash → ensureValidAccessToken() → access expired → calls /auth/refresh-token
  2. Backend validates the refresh token → issues a new access + refresh → 200 OK
  3. The client saveSession()s the new pair
  4. 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

  1. User taps "Take Photo" → _uploadCapturedFile()ensureValidAccessToken()
  2. Access has expired → auto-refresh → new access stored
  3. uploadRegistrationPhoto(accessToken: token, ...) → backend accepts the valid token → 200 OK
  4. Upload succeeds, the user moves to step 4

4. Refresh token revoked server-side (admin force logout)

  1. ensureValidAccessToken() → calls /auth/refresh-token
  2. Backend returns 401 "refresh token invalid"
  3. The client throws AuthApiExceptionclearSession() → returns null
  4. Upload/splash shows "Login session not found" → user logs in again

5. Device offline during refresh

  1. ensureValidAccessToken()refreshToken() throws a network error (not AuthApiException)
  2. The client does not clearSession() → returns null
  3. The current attempt fails (the upload shows a snackbar), but the refresh token is still there
  4. Once the device is online again → scenario 2 → auto-recovery

6. Reinstall / new device

  1. Empty storage → ensureValidAccessToken() → access null → returns null
  2. Splash → LoginPage → OTP flow → new session

7. User logs in with a different phone number

  1. OTP login for number B succeeds → saveSession() overwrites access/refresh/phone
  2. All previous tokens (number A) on this device are replaced
  3. 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:

  1. hasActiveSession() is no longer destructive (stop the auto-clearSession on expiry)
  2. Add ensureValidAccessToken() that auto-refreshes using the refresh token
  3. Splash + merchant_business_photo_page + merchant_owner_identity_page migrated to ensureValidAccessToken()

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.

  • 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 use readAccessToken(). Not urgent because the behavior is no longer destructive, but UX becomes smoother when everything is migrated.