How Greenhouse Admin handles login, token storage, and session management across all platforms
Greenhouse Admin implements a secure authentication system using JWT tokens with role-based access control. The authentication flow works identically across all platforms thanks to the multiplatform architecture.
The AuthRepository sends credentials to the backend:
AuthRepositoryImpl.kt
override suspend fun login(username: String, password: String): Result<UserSession> { return runCatching { // Call authentication endpoint val response = authApiService.login(username, password) // Store tokens locally tokenStorage.saveAccessToken(response.token) tokenStorage.saveTokenType(response.type) tokenStorage.saveUsername(response.username) tokenStorage.saveRoles(response.roles) // Return user session response.toUserSession() }}
3
API returns JWT token
The backend responds with a JWT token and user information:
AuthModels.kt
@Serializabledata class JwtResponse( val token: String, val type: String = "Bearer", val username: String, val roles: List<String> = emptyList())
4
Token stored securely
Platform-specific storage saves the token:
Android: In-memory (production should use EncryptedSharedPreferences)
iOS: NSUserDefaults (production should use Keychain)
Web: localStorage
Desktop: Java Preferences API
5
Session created
A UserSession object represents the authenticated state:
AuthModels.kt
data class UserSession( val token: String, val tokenType: String, val username: String, val roles: List<String>) { val isAdmin: Boolean get() = roles.any { it.equals("ROLE_ADMIN", ignoreCase = true) }}
expect class TokenStorage() { fun getAccessToken(): String? fun saveAccessToken(token: String) fun getTokenType(): String? fun saveTokenType(type: String) fun getUsername(): String? fun saveUsername(username: String) fun getRoles(): List<String> fun saveRoles(roles: List<String>) fun clearTokens() fun isAuthenticated(): Boolean}
import kotlinx.browser.localStorageactual class TokenStorage actual constructor() { actual fun getAccessToken(): String? = localStorage.getItem(KEY_ACCESS_TOKEN) actual fun saveAccessToken(token: String) { localStorage.setItem(KEY_ACCESS_TOKEN, token) } actual fun getRoles(): List<String> { val rolesString = localStorage.getItem(KEY_ROLES) ?: return emptyList() return rolesString.split(",").filter { it.isNotBlank() } } actual fun saveRoles(roles: List<String>) { localStorage.setItem(KEY_ROLES, roles.joinToString(",")) } actual fun clearTokens() { localStorage.removeItem(KEY_ACCESS_TOKEN) localStorage.removeItem(KEY_TOKEN_TYPE) localStorage.removeItem(KEY_USERNAME) localStorage.removeItem(KEY_ROLES) } companion object { private const val KEY_ACCESS_TOKEN = "greenhouse_access_token" private const val KEY_TOKEN_TYPE = "greenhouse_token_type" private const val KEY_USERNAME = "greenhouse_username" private const val KEY_ROLES = "greenhouse_roles" }}
Uses NSUserDefaults (production should use Keychain):
iosMain/data/local/TokenStorage.ios.kt
import platform.Foundation.NSUserDefaultsactual class TokenStorage actual constructor() { private val defaults = NSUserDefaults.standardUserDefaults actual fun getAccessToken(): String? = defaults.stringForKey(KEY_ACCESS_TOKEN) actual fun saveAccessToken(token: String) { defaults.setObject(token, KEY_ACCESS_TOKEN) } actual fun getRoles(): List<String> { val rolesArray = defaults.arrayForKey(KEY_ROLES) ?: return emptyList() return rolesArray.mapNotNull { it as? String } } actual fun saveRoles(roles: List<String>) { defaults.setObject(roles, KEY_ROLES) }}
Uses in-memory storage (production should use EncryptedSharedPreferences):
androidMain/data/local/TokenStorage.android.kt
actual class TokenStorage actual constructor() { private var accessToken: String? = null private var tokenType: String? = null private var username: String? = null private var roles: List<String> = emptyList() actual fun getAccessToken(): String? = accessToken actual fun saveAccessToken(token: String) { accessToken = token } actual fun getRoles(): List<String> = roles actual fun saveRoles(roles: List<String>) { this.roles = roles }}
Current implementations are for development. Production apps should use:
Android: EncryptedSharedPreferences
iOS: Keychain Services
Web: Consider secure cookie storage or encrypted localStorage
Roles are stored as strings in the JWT token and persisted with the session:
AuthModels.kt
data class UserSession( val token: String, val tokenType: String, val username: String, val roles: List<String> // e.g., ["ROLE_ADMIN", "ROLE_OPERATOR"]) { val isAdmin: Boolean get() = roles.any { it.equals("ROLE_ADMIN", ignoreCase = true) }}
The app validates the session on startup and before critical operations:
AuthRepository.kt
interface AuthRepository { /** * Validates the current session with the backend. * Makes a lightweight API call to verify the token is still valid. */ suspend fun validateSession(): Boolean}
AuthRepositoryImpl.kt
override suspend fun validateSession(): Boolean { // If no token exists, session is invalid if (!tokenStorage.isAuthenticated()) return false return try { // Make a lightweight API call to validate the token authApiService.validateToken() true } catch (e: AuthenticationException) { // Token is expired or invalid - clear it tokenStorage.clearTokens() false } catch (e: Exception) { // Network error - assume valid to avoid logout on network problems true }}
The LoginViewModel validates the session on initialization:
LoginViewModel.kt
init { validateSession()}private fun validateSession() { viewModelScope.launch { if (!authRepository.isAuthenticated()) { return@launch // No session to validate } _uiState.update { it.copy(isValidatingSession = true) } val isValid = authRepository.validateSession() _uiState.update { it.copy( isValidatingSession = false, isLoginSuccessful = isValid ) } }}
The authentication state is managed in the LoginViewModel using MVI pattern:
LoginViewModel.kt
data class LoginUiState( val username: String = "", val password: String = "", val isPasswordVisible: Boolean = false, val isLoading: Boolean = false, val isValidatingSession: Boolean = false, val error: String? = null, val isLoginSuccessful: Boolean = false) { val isLoginEnabled: Boolean get() = username.isNotBlank() && password.isNotBlank() && !isLoading}sealed interface LoginEvent { data class OnUsernameChanged(val username: String) : LoginEvent data class OnPasswordChanged(val password: String) : LoginEvent data object OnLoginClicked : LoginEvent data object OnTogglePasswordVisibility : LoginEvent data object DismissError : LoginEvent data object OnNavigationHandled : LoginEvent}