Authentication, Security & Token Management
This document covers the complete authentication system in the Visla GPS Android app β from login flows through encrypted token storage, automatic refresh, OAuth providers, and two-factor authentication.
Architecture Overviewβ
Key source paths:
| Layer | Path |
|---|---|
| Network core | core/network/ β TokenManager, EncryptedTokenDataStore, AuthInterceptor, TokenAuthenticator |
| OAuth managers | data/auth/ β GoogleAuthManager, FacebookAuthManager |
| API interfaces | data/remote/api/ β AuthApi, AuthApiSync |
| DTOs | data/remote/dto/AuthDtos.kt |
| Use cases | domain/usecases/auth/ β 17 use case classes |
| Domain errors | domain/errors/DomainExceptions.kt |
| UI screens | ui/screens/ β Login, Register, Verification, TwoFactor, ForgotPassword, ResetPassword |
Token Lifecycleβ
The complete token lifecycle follows five phases:
1. Login β Acquire Tokensβ
A successful login (email/password, OAuth, or 2FA completion) returns a LoginResponseDto:
data class LoginResponseDto(
val user: UserDto? = null,
val accessToken: String? = null,
val refreshToken: String? = null,
val expiresIn: Int = 3600,
@SerializedName("require_2fa")
val require2fa: Boolean? = null,
@SerializedName("temp_token")
val tempToken: String? = null,
val type: String? = null
)
The ViewModel saves tokens immediately:
tokenManager.saveTokens(accessToken, refreshToken, userId)
2. Store β Encrypted Persistenceβ
TokenManager.saveTokens() writes to in-memory cache and persists via EncryptedTokenDataStore:
open fun saveTokens(
accessToken: String,
refreshToken: String?,
userId: Int,
email: String? = null,
name: String? = null
) {
cachedAccessToken = accessToken
cachedRefreshToken = refreshToken
cachedUserId = userId
email?.let { cachedUserEmail = it }
name?.let { cachedUserName = it }
scope.launch {
dataStore.saveAll(accessToken, refreshToken, userId, email, name)
}
// Sync tokens to widget for API access
WidgetDataStore.saveAccessToken(context, accessToken)
WidgetDataStore.saveRefreshToken(context, refreshToken)
}
Tokens are also synced to WidgetDataStore so home screen widgets can make authenticated API calls independently.
3. Intercept β Add Authorization Headerβ
AuthInterceptor reads from TokenManager's in-memory cache (synchronous, no I/O) and attaches the Bearer token:
class AuthInterceptor @Inject constructor(private val tokenManager: TokenManager) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val path = originalRequest.url.encodedPath
if (shouldSkipAuth(path)) {
return chain.proceed(originalRequest)
}
val token = tokenManager.accessToken ?: return chain.proceed(originalRequest)
val authenticatedRequest = originalRequest.newBuilder()
.header("Authorization", "Bearer $token")
.build()
return chain.proceed(authenticatedRequest)
}
}
Public paths (no auth header):
| Endpoint | Purpose |
|---|---|
/api/auth/login | Email/password login |
/api/auth/register | New user registration |
/api/auth/verify-code | Email verification |
/api/auth/verify | Token-based email verification |
/api/auth/resend-verification | Resend verification email |
/api/auth/refresh | Token refresh |
/api/auth/openid | OAuth login (Google/Facebook) |
/api/auth/password | Password reset flow |
/api/legal | Legal documents |
4. Refresh β Handle 401 Responsesβ
TokenAuthenticator intercepts 401 responses and performs a synchronous token refresh:
class TokenAuthenticator @Inject constructor(
private val tokenManager: TokenManager,
private val authApiSync: AuthApiSync
) : Authenticator {
@Volatile
private var isRefreshing = false
override fun authenticate(route: Route?, response: Response): Request? {
// Don't retry if we've already tried
if (response.request.header("Authorization-Retry") != null) {
return null
}
val refreshToken = tokenManager.refreshToken ?: return null
// Prevent multiple simultaneous refresh attempts
synchronized(this) {
if (isRefreshing) return null
isRefreshing = true
}
return try {
val call = authApiSync.refreshToken(RefreshTokenRequest(refreshToken))
val apiResponse = call.execute()
val newTokenResponse = if (apiResponse.isSuccessful) apiResponse.body() else null
val newAccessToken = newTokenResponse?.accessToken
val newUser = newTokenResponse?.user
if (!newAccessToken.isNullOrBlank() && newUser != null) {
tokenManager.saveTokens(
accessToken = newAccessToken,
refreshToken = newTokenResponse.refreshToken ?: refreshToken,
userId = newUser.id,
email = newUser.email,
name = newUser.name
)
response.request.newBuilder()
.header("Authorization", "Bearer $newAccessToken")
.header("Authorization-Retry", "true")
.build()
} else {
tokenManager.clearTokens()
null
}
} catch (e: Exception) {
Logger.error("Token refresh failed", mapOf("error" to (e.message ?: "unknown")))
tokenManager.clearTokens()
null
} finally {
isRefreshing = false
}
}
}
Key behaviors:
Authorization-Retryheader β Prevents infinite retry loops. If the retried request also returns 401, the authenticator returnsnull(gives up).synchronized(this)+@Volatile isRefreshingβ Only one thread refreshes at a time. Concurrent 401s getnull(the request fails), avoiding thundering herd on the refresh endpoint.- Failure clears all tokens β If refresh fails for any reason (network error, invalid refresh token, server error),
clearTokens()forces the user back to login.
5. Logout β Clear Everythingβ
open fun clearTokens() {
cachedAccessToken = null
cachedRefreshToken = null
cachedUserId = null
cachedUserEmail = null
cachedUserName = null
scope.launch { dataStore.clear() }
WidgetDataStore.clear(context)
}
Logout clears: in-memory cache, encrypted DataStore, and widget data.
Encrypted Token Storageβ
EncryptedTokenDataStoreβ
All tokens are encrypted at rest using Google Tink's AES256_GCM AEAD primitive, with the encryption key protected by the Android Keystore.
class EncryptedTokenDataStore @Inject constructor(private val context: Context) {
companion object {
private const val KEYSET_NAME = "visla_token_keyset"
private const val KEYSET_PREFS = "visla_token_prefs"
private const val MASTER_KEY_ALIAS = "visla_token_master_key"
private const val MASTER_KEY_URI = "android-keystore://$MASTER_KEY_ALIAS"
}
private val aead: Aead by lazy {
AeadConfig.register()
try {
buildAead()
} catch (e: java.security.InvalidKeyException) {
Log.w("EncryptedTokenDataStore", "Keystore key corrupted, resetting: ${e.message}")
resetKeystoreAndPrefs()
buildAead()
} catch (e: java.security.GeneralSecurityException) {
Log.w("EncryptedTokenDataStore", "Keystore error, resetting: ${e.message}")
resetKeystoreAndPrefs()
buildAead()
}
}
// ...
}
Encryption stack:
Stored fields:
| Key | Type | Encrypted |
|---|---|---|
access_token | String | β AES-256-GCM |
refresh_token | String | β AES-256-GCM |
user_id | Int | β (non-sensitive) |
user_email | String | β AES-256-GCM |
user_name | String | β AES-256-GCM |
Keystore Recoveryβ
If the Android Keystore becomes corrupted (factory reset restore, OS upgrade issue, hardware-backed key loss), the aead lazy initializer catches the exception and auto-recovers:
private fun resetKeystoreAndPrefs() {
try {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
keyStore.deleteEntry(MASTER_KEY_ALIAS)
} catch (e: Exception) {
Log.w("EncryptedTokenDataStore", "Failed to delete keystore entry: ${e.message}")
}
context.getSharedPreferences(KEYSET_PREFS, Context.MODE_PRIVATE).edit().clear().apply()
runBlocking { context.tokenDataStore.edit { it.clear() } }
}
This deletes the corrupted master key, clears the Tink keyset preferences, and clears all stored tokens. The user must re-login, but the app doesn't crash.
Sync vs Async Accessβ
The DataStore provides both access patterns:
// Async (coroutine) β used for writes and flows
val accessToken: Flow<String?> = context.tokenDataStore.data.map { prefs ->
prefs[Keys.ACCESS_TOKEN]?.let { decrypt(it) }
}
// Sync (runBlocking) β used to bootstrap in-memory cache at startup
fun getAccessTokenSync(): String? = runBlocking { accessToken.first() }
The sync accessors are only called once at TokenManager initialization to populate the in-memory cache. All runtime reads come from @Volatile cached fields.
TokenManagerβ
TokenManager is the central token authority. It maintains a @Volatile in-memory cache for lock-free synchronous reads (required by OkHttp interceptors running on background threads) and delegates persistence to EncryptedTokenDataStore.
@Singleton
open class TokenManager(
private val context: Context,
private val dataStore: EncryptedTokenDataStore
) {
@Volatile private var cachedAccessToken: String? = null
@Volatile private var cachedRefreshToken: String? = null
@Volatile private var cachedUserId: Int? = null
@Volatile private var cachedUserEmail: String? = null
@Volatile private var cachedUserName: String? = null
init {
migrateFromLegacyStorage(context)
loadCacheFromDataStore()
}
// ...
}
JWT Decodingβ
TokenManager can extract claims directly from JWT access tokens, avoiding an extra /profile API call:
open fun saveTokensFromJwt(accessToken: String, refreshToken: String? = null) {
val payload = decodeJwtPayload(accessToken)
val userId = payload?.optInt("userId", -1)?.takeIf { it > 0 }
val email = payload?.optString("email")?.takeIf { it.isNotBlank() }
if (userId != null) {
saveTokens(accessToken = accessToken, refreshToken = refreshToken, userId = userId, email = email)
} else {
this.accessToken = accessToken
}
}
Token Expirationβ
fun isTokenExpired(): Boolean {
val expirationTime = getTokenExpirationTime() ?: return true
return System.currentTimeMillis() >= expirationTime
}
fun isTokenExpiringSoon(withinSeconds: Long = 300): Boolean {
val expirationTime = getTokenExpirationTime() ?: return true
val timeRemaining = expirationTime - System.currentTimeMillis()
return timeRemaining < withinSeconds * MS_PER_SECOND
}
The isTokenExpiringSoon() method uses a 5-minute (300s) default window for proactive refresh hints.
Migration from Legacy Storageβ
On first launch after upgrade, TokenManager migrates tokens from two legacy storage mechanisms:
Priority 1: EncryptedSharedPreferences (visla_secure_prefs)
Priority 2: Plain SharedPreferences (visla_gps_prefs)
private fun migrateFromLegacyStorage(context: Context) {
val migrationPrefs = context.getSharedPreferences("visla_migration", Context.MODE_PRIVATE)
if (migrationPrefs.getBoolean(KEY_MIGRATED_TO_DATASTORE, false)) return
// Try legacy encrypted prefs first
try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedPrefs = EncryptedSharedPreferences.create(
context, LEGACY_ENCRYPTED_PREFS_NAME, masterKey, ...)
val token = encryptedPrefs.getString(KEY_ACCESS_TOKEN, null)
if (token != null) {
scope.launch { dataStore.saveAll(...) }
migrationPrefs.edit { putBoolean(KEY_MIGRATED_TO_DATASTORE, true) }
return
}
} catch (e: Exception) {
// EncryptedSharedPreferences may be corrupted β fall through
}
// Try plain legacy prefs
val legacyPrefs = context.getSharedPreferences(LEGACY_PREFS_NAME, Context.MODE_PRIVATE)
val legacyToken = legacyPrefs.getString(KEY_ACCESS_TOKEN, null)
if (legacyToken != null) {
scope.launch {
dataStore.saveAll(...)
legacyPrefs.edit { clear() } // Wipe old unencrypted data
}
}
migrationPrefs.edit { putBoolean(KEY_MIGRATED_TO_DATASTORE, true) }
}
The migration runs exactly once, tracked by a visla_migration SharedPreferences flag. After migration, the old SharedPreferences data is cleared.
OAuth Providersβ
Google Sign-Inβ
Uses the Android Credential Manager API (the modern replacement for deprecated Google Sign-In SDK):
class GoogleAuthManager(private val context: Context) {
companion object {
private const val WEB_CLIENT_ID =
"861438780212-us1il74aga5qt5op4b40urbmjs1j6pvj.apps.googleusercontent.com"
}
private val credentialManager = CredentialManager.create(context)
suspend fun signIn(activity: Activity): Result<String> = withContext(Dispatchers.Main) {
val result = credentialManager.getCredential(
request = buildCredentialRequest(),
context = activity
)
handleSignInResult(result)
}
private fun buildCredentialRequest(): GetCredentialRequest {
val googleIdOption = GetGoogleIdOption.Builder()
.setFilterByAuthorizedAccounts(false)
.setServerClientId(WEB_CLIENT_ID)
.setAutoSelectEnabled(false)
.build()
return GetCredentialRequest.Builder()
.addCredentialOption(googleIdOption)
.build()
}
}
Flow:
Error handling: Cancellation, no credentials available, and credential parsing errors are all handled with Result.failure().
Facebook Sign-Inβ
Uses the Facebook Login SDK with callback-based authentication:
class FacebookAuthManager private constructor(
private val activityResultRegistryOwner: ActivityResultRegistryOwner,
private val callbackManager: CallbackManager
) {
fun login(onSuccess: (accessToken: String) -> Unit, onError: (Exception) -> Unit) {
LoginManager.getInstance().logInWithReadPermissions(
activityResultRegistryOwner,
listOf("email", "public_profile")
)
}
}
Initialization: FacebookAuthManager must be initialized from MainActivity with the Activity's CallbackManager:
FacebookAuthManager.initWithCallbackManager(activity, callbackManager)
Flow:
Social Login Response Handlingβ
Both Google and Facebook use cases return a SocialLoginResponse with three possible outcomes:
sealed class SocialLoginResponse {
data class Success(val result: LoginResult) : SocialLoginResponse()
data class Requires2FA(val tempToken: String, val type: String) : SocialLoginResponse()
data class NeedsTermsAcceptance(val result: LoginResult) : SocialLoginResponse()
}
The NeedsTermsAcceptance path handles first-time OAuth users who haven't accepted terms of service yet.
Two-Factor Authentication (2FA)β
2FA Login Flowβ
When a user with 2FA enabled logs in, the server returns a temp token instead of access tokens:
data class LoginResponseDto(
// ... normal fields null ...
@SerializedName("require_2fa") val require2fa: Boolean? = null,
@SerializedName("temp_token") val tempToken: String? = null,
val type: String? = null // e.g. "totp"
)
The use case catches this as a TwoFactorRequiredException:
class LoginUseCase @Inject constructor(private val authRepository: AuthRepository) {
suspend operator fun invoke(email: String, password: String): LoginResponse {
// ... validation ...
return try {
val result = authRepository.login(normalizedEmail, password)
LoginResponse.Success(result)
} catch (e: TwoFactorRequiredException) {
LoginResponse.Requires2FA(e.tempToken, e.type)
}
}
}
Complete 2FA login sequence:
2FA Setupβ
Setting up 2FA for the first time:
class Setup2FAUseCase @Inject constructor(private val authRepository: AuthRepository) {
suspend operator fun invoke(): TwoFactorSetup = authRepository.setup2FA()
}
The server returns a TOTP secret and an otpauth:// URL for QR code generation:
data class TwoFactorSetupDto(
val secret: String,
@SerializedName("otpauth_url")
val otpauthUrl: String
)
Setup flow:
Backup Codesβ
data class BackupCodesDto(
@SerializedName("backup_codes")
val backupCodes: List<String>
)
Backup codes are generated server-side during 2FA verification and shown once. They can be used as one-time passwords if the user loses their authenticator device.
Disabling 2FAβ
class Disable2FAUseCase @Inject constructor(private val authRepository: AuthRepository) {
suspend operator fun invoke(code: String) {
authRepository.disable2FA(code)
}
}
Requires a valid TOTP code to disable β prevents unauthorized 2FA removal.
Complete Auth Flow Sequenceβ
Session Managementβ
What Triggers Logoutβ
| Trigger | Mechanism |
|---|---|
| User taps "Log out" | LogoutUseCase β DELETE /api/auth/logout β clearTokens() |
| Token refresh fails | TokenAuthenticator catches exception β clearTokens() |
| Refresh returns invalid response | TokenAuthenticator gets null body/token β clearTokens() |
| No refresh token available | TokenAuthenticator returns null, request fails with 401 |
| Account deleted | DeleteAccountUseCase β server invalidates all sessions |
Token Expiry Handlingβ
The app uses a reactive refresh strategy β it does not proactively refresh tokens before expiry. Instead:
- Normal request β
AuthInterceptorattaches current access token - Server returns 401 β
TokenAuthenticatorintercepts, calls refresh - Refresh succeeds β New token saved, original request retried with new token
- Refresh fails β Tokens cleared, user redirected to login
The isTokenExpiringSoon() method (5-minute window) is available for UI hints but is not used for preemptive refresh.
API Interfaceβ
AuthApi (Async β Retrofit Suspend)β
The primary API interface used by the repository layer. All methods are suspend functions.
interface AuthApi {
@POST("api/auth/login")
suspend fun login(@Body request: LoginRequest): LoginResponseDto
@POST("api/auth/2fa/login")
suspend fun login2FA(@Body request: Login2FARequest): LoginResponseDto
@POST("api/auth/openid/token")
suspend fun loginWithGoogle(@Body request: GoogleLoginRequest): LoginResponseDto
@POST("api/auth/openid/token")
suspend fun loginWithFacebook(@Body request: FacebookLoginRequest): LoginResponseDto
@HTTP(method = "DELETE", path = "api/auth/logout", hasBody = false)
suspend fun logout()
@POST("api/auth/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): LoginResponseDto
@POST("api/auth/register")
suspend fun register(@Body request: RegisterRequest): RegisterResponseDto
@POST("api/auth/verify-code")
suspend fun verifyCode(@Body request: VerifyCodeRequest): VerifyCodeResponseDto
@POST("api/auth/2fa/setup")
suspend fun setup2FA(): TwoFactorSetupDto
@POST("api/auth/2fa/verify")
suspend fun verify2FA(@Body request: Verify2FARequest): BackupCodesDto
@POST("api/auth/2fa/disable")
suspend fun disable2FA()
// ... password, profile, legal endpoints
}
AuthApiSync (Synchronous β for TokenAuthenticator)β
A minimal interface with only refreshToken, using Retrofit's Call<T> instead of suspend functions:
interface AuthApiSync {
@POST("api/auth/refresh")
fun refreshToken(@Body request: RefreshTokenRequest): Call<LoginResponseDto>
}
Domain Exceptionsβ
All auth errors are modeled as sealed subclasses of DomainException, enabling exhaustive when matching:
sealed class DomainException(override val message: String, override val cause: Throwable? = null) :
Exception(message, cause)
// Authentication
class AuthenticationException(message: String = "Authentication required", ...)
class InvalidCredentialsException(message: String = "Invalid email or password", ...)
class TwoFactorRequiredException(val tempToken: String, val type: String, ...)
class Invalid2FACodeException(message: String = "Invalid verification code", ...)
class EmailNotVerifiedException(val email: String, ...)
class TermsNotAcceptedException(message: String = "Terms of service must be accepted")
class SessionExpiredException(message: String = "Session expired, please login again")
// Validation (also used in auth flows)
class ValidationException(message: String, val field: String? = null, ...)
class InvalidEmailException(message: String = "Invalid email address")
class WeakPasswordException(message: String = "Password must be at least 8 characters")
Notable: TwoFactorRequiredException carries the tempToken needed to complete the 2FA flow. EmailNotVerifiedException carries the email needed to navigate to the verification screen.
Use Casesβ
All auth use cases follow the same pattern: validate inputs β delegate to AuthRepository β return typed result or throw domain exception.
| Use Case | Input | Output | Throws |
|---|---|---|---|
LoginUseCase | email, password | LoginResponse (Success | Requires2FA) | InvalidEmailException, ValidationException |
Login2FAUseCase | tempToken, code | LoginResult | ValidationException |
LoginWithGoogleUseCase | idToken | SocialLoginResponse | ValidationException |
LoginWithFacebookUseCase | accessToken | SocialLoginResponse | ValidationException |
RegisterUseCase | name, email, password, acceptTerms | RegisterResult | InvalidEmailException, WeakPasswordException, TermsNotAcceptedException, ValidationException |
VerifyEmailUseCase | email, code | VerifyEmailResult | InvalidEmailException, ValidationException |
LogoutUseCase | β | β | β |
RequestPasswordResetUseCase | β | β | |
ResetPasswordUseCase | email, code, newPassword | β | β |
VerifyResetCodeUseCase | email, code | β | β |
ChangePasswordUseCase | currentPassword, newPassword | β | β |
Setup2FAUseCase | β | TwoFactorSetup (secret + QR URL) | β |
Verify2FAUseCase | code | List<String> (backup codes) | β |
Disable2FAUseCase | code | β | β |
GetProfileUseCase | β | UserProfile | β |
UpdateProfileUseCase | name?, phone?, timezone?, language? | UserProfile | β |
DeleteAccountUseCase | β | β | β |
UI Screensβ
| Screen | ViewModel | Purpose |
|---|---|---|
LoginScreen | LoginViewModel | Email/password login, Google/Facebook login, 2FA inline |
RegisterScreen | RegisterViewModel | New user registration with validation |
VerificationCodeScreen | VerificationViewModel | 6-digit email verification with resend cooldown (30s) |
TwoFactorScreen | TwoFactorViewModel | 2FA code entry during login |
TwoFactorSetupScreen | TwoFactorSetupViewModel | QR code display + backup codes during 2FA enrollment |
ForgotPasswordScreen | ForgotPasswordViewModel | Initiate password reset |
ResetCodeScreen | β | Verify password reset code |
ResetPasswordScreen | ResetPasswordViewModel | Set new password with reset code |
Navigation pattern: All screens use event-driven navigation via LaunchedEffect collecting from StateFlow<Event?>. Events are cleared after consumption to prevent recomposition loops.
Design Decisionsβ
Why Tink over EncryptedSharedPreferencesβ
EncryptedSharedPreferences (from androidx.security.crypto) has well-documented reliability issues:
- Keystore corruption β After OS updates or backup/restore, the master key can become invalid, making all data permanently inaccessible.
EncryptedSharedPreferencesthrows aGeneralSecurityExceptionwith no recovery path. - No graceful degradation β Once corrupted, the file cannot be read or cleared through the
EncryptedSharedPreferencesAPI itself. - Threading issues β The initialization can throw on some devices due to keystore race conditions.
Tink + manual Keystore management solves all three:
- The
aeadlazy initializer catches corruption and auto-recovers by deleting the keystore entry and re-creating it. - Tink's key management is more robust and actively maintained by Google's security team.
- The DataStore backend provides proper coroutine-safe access compared to SharedPreferences' commit/apply semantics.
Why Synchronous Token Refreshβ
OkHttp's Authenticator.authenticate() runs on the calling thread (one of OkHttp's connection pool threads). Using runBlocking inside it can cause deadlocks:
Thread 1: Request A β 401 β authenticate() β runBlocking { refreshToken() }
Thread 2: Request B β 401 β authenticate() β runBlocking { refreshToken() }
...
Thread N: refreshToken() network call needs a thread β all threads blocked β DEADLOCK
The solution is AuthApiSync with Retrofit's Call<T>.execute(), which performs a truly synchronous HTTP call without requiring a coroutine dispatcher thread:
interface AuthApiSync {
@POST("api/auth/refresh")
fun refreshToken(@Body request: RefreshTokenRequest): Call<LoginResponseDto>
}
Why a Separate AuthApiSync Interfaceβ
AuthApi uses Retrofit suspend functions, which are routed through the OkHttp client that has AuthInterceptor and TokenAuthenticator installed. If TokenAuthenticator used AuthApi to refresh tokens, the refresh request would itself go through the interceptor chain, creating a circular dependency:
Request β 401 β TokenAuthenticator β AuthApi.refreshToken()
β AuthInterceptor adds (expired) Bearer token
β Server returns 401
β TokenAuthenticator tries to refresh again β infinite loop
AuthApiSync is configured on a separate, bare Retrofit instance without the auth interceptor/authenticator, breaking the cycle.
Why Domain Exceptions for Auth Errorsβ
Using a sealed DomainException hierarchy instead of generic Exception or error codes provides:
- Exhaustive
whenmatching β The compiler warns if a new exception type is added without handling it. - Rich error data β
TwoFactorRequiredExceptioncarries thetempToken,EmailNotVerifiedExceptioncarries theemailβ the ViewModel has everything it needs to navigate to the correct screen. - Clean separation β Network/HTTP errors are mapped to domain exceptions in the repository layer. ViewModels never see HTTP status codes or Retrofit exceptions.
Security Considerationsβ
-
Tokens at rest β All sensitive tokens are AES-256-GCM encrypted via Tink with Android Keystore-backed keys. Even with root access, tokens cannot be decrypted without the hardware-backed master key.
-
Tokens in memory β Cached in
@Volatilefields. Visible to a debugger or memory dump, but this is an accepted tradeoff for O(1) synchronous reads required by OkHttp interceptors. -
Token transmission β Always over HTTPS. The
Authorization: Bearerheader is only added to non-public endpoints. -
Refresh token rotation β The server may issue a new refresh token with each refresh response. The authenticator always saves the latest:
newTokenResponse.refreshToken ?: refreshToken(falls back to the existing one if the server doesn't rotate). -
No token logging β Tokens are never logged in full.
GoogleAuthManagerlogs onlyTOKEN_PREVIEW_LENGTHcharacters for debugging. -
Widget token isolation β
WidgetDataStorereceives a copy of tokens for widget API access but is stored separately from the main encrypted store. -
Retry prevention β The
Authorization-Retryheader prevents infinite 401 β refresh β 401 loops. -
Concurrent refresh protection β
synchronized(this)+@Volatile isRefreshingensures only one thread performs a token refresh at a time.