Skip to main content

Android Integration (Kotlin)

This guide integrates your node-auth backend in an Android app using Retrofit for HTTP, EncryptedSharedPreferences (via Jetpack Security) for safe token storage, and OkHttp interceptors for automatic token refresh.

Dependencies

Add to build.gradle.kts (app module):

dependencies {
// Networking
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

// Secure storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")

// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
}

Step 1 — Secure Token Storage

EncryptedSharedPreferences encrypts both keys and values using AES-256-GCM backed by the Android Keystore.

// auth/TokenStorage.kt
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

object TokenStorage {
private const val FILE_NAME = "auth_tokens"
private const val KEY_ACCESS = "access_token"
private const val KEY_REFRESH = "refresh_token"

private fun prefs(context: Context) = EncryptedSharedPreferences.create(
context,
FILE_NAME,
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)

fun saveTokens(context: Context, accessToken: String, refreshToken: String) {
prefs(context).edit()
.putString(KEY_ACCESS, accessToken)
.putString(KEY_REFRESH, refreshToken)
.apply()
}

fun getAccessToken(context: Context): String? = prefs(context).getString(KEY_ACCESS, null)
fun getRefreshToken(context: Context): String? = prefs(context).getString(KEY_REFRESH, null)

fun clearTokens(context: Context) {
prefs(context).edit().clear().apply()
}
}

Step 2 — Retrofit API Interface

// auth/AuthApi.kt
import retrofit2.http.*

data class LoginRequest(val email: String, val password: String)
data class AuthTokens(val accessToken: String, val refreshToken: String)
data class RefreshRequest(val refreshToken: String)
data class RefreshResponse(val accessToken: String)

data class JwtUser(
val sub: String,
val email: String,
val role: String,
val tenantId: String? = null,
)

interface AuthApi {
@POST("/auth/login")
suspend fun login(@Body body: LoginRequest): AuthTokens

@POST("/auth/logout")
suspend fun logout()

@POST("/auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): RefreshResponse

@GET("/auth/me")
suspend fun me(): JwtUser
}

Step 3 — OkHttp Auth Interceptor

Bearer mode required

Android is a native client and cannot use browser cookies. The X-Auth-Strategy: bearer header must be sent on all token-issuing requests (/auth/login, /auth/refresh, etc.) so the server returns tokens in the JSON body.

The interceptor attaches the ****** and retries once on 401:

// auth/AuthInterceptor.kt
import android.content.Context
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class AuthInterceptor(private val context: Context) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = TokenStorage.getAccessToken(context)
val request = chain.request().newBuilder()
.header("X-Auth-Strategy", "bearer") // opt into bearer-token delivery
.build()
.withBearer(accessToken)
val response = chain.proceed(request)

if (response.code == 401) {
response.close()
val newToken = tryRefresh() ?: return response
return chain.proceed(chain.request().withBearer(newToken))
}
return response
}

private fun tryRefresh(): String? {
val refreshToken = TokenStorage.getRefreshToken(context) ?: return null
return runCatching {
// Use a separate OkHttpClient (no interceptor) to avoid recursion
val bearerClient = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.header("X-Auth-Strategy", "bearer")
.build()
)
}
.build()
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(bearerClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val api = retrofit.create(AuthApi::class.java)

// runBlocking is acceptable here because OkHttp calls run on a background thread
val res = kotlinx.coroutines.runBlocking {
api.refresh(RefreshRequest(refreshToken))
}
TokenStorage.saveTokens(context, res.accessToken, refreshToken)
res.accessToken
}.getOrNull()
}
}

private fun Request.withBearer(token: String?): Request =
if (token != null) newBuilder().header("Authorization", "Bearer $token").build()
else this

const val BASE_URL = "https://your-api.example.com"

Step 4 — Retrofit Client Factory

// auth/AuthClient.kt
import android.content.Context
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object AuthClient {
fun create(context: Context): AuthApi {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY // remove in release
}
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(context))
.addInterceptor(logging)
.build()

return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AuthApi::class.java)
}
}

Step 5 — AuthViewModel

// auth/AuthViewModel.kt
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

sealed class AuthState {
object Idle : AuthState()
object Loading : AuthState()
data class Success(val user: JwtUser) : AuthState()
data class Error(val message: String) : AuthState()
}

class AuthViewModel(app: Application) : AndroidViewModel(app) {
private val api = AuthClient.create(app)
private val ctx get() = getApplication<Application>()

private val _state = MutableStateFlow<AuthState>(AuthState.Idle)
val state: StateFlow<AuthState> = _state

val isLoggedIn: Boolean
get() = TokenStorage.getAccessToken(ctx) != null

fun login(email: String, password: String) {
viewModelScope.launch {
_state.value = AuthState.Loading
_state.value = runCatching {
val tokens = api.login(LoginRequest(email, password))
TokenStorage.saveTokens(ctx, tokens.accessToken, tokens.refreshToken)
val user = api.me()
AuthState.Success(user)
}.getOrElse { AuthState.Error(it.message ?: "Login failed") }
}
}

fun logout() {
viewModelScope.launch {
runCatching { api.logout() }
TokenStorage.clearTokens(ctx)
_state.value = AuthState.Idle
}
}
}

Step 6 — Jetpack Compose Login Screen

// ui/LoginScreen.kt
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp

@Composable
fun LoginScreen(viewModel: AuthViewModel, onSuccess: () -> Unit) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val state by viewModel.state.collectAsState()

LaunchedEffect(state) {
if (state is AuthState.Success) onSuccess()
}

Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Sign In", style = MaterialTheme.typography.headlineLarge)
Spacer(Modifier.height(24.dp))

OutlinedTextField(
value = email, onValueChange = { email = it },
label = { Text("Email") }, modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))

OutlinedTextField(
value = password, onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))

if (state is AuthState.Error) {
Text((state as AuthState.Error).message, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}

Button(
onClick = { viewModel.login(email, password) },
enabled = state !is AuthState.Loading && email.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (state is AuthState.Loading) CircularProgressIndicator(Modifier.size(20.dp))
else Text("Sign In")
}
}
}

OAuth / Social Login

Use a Custom Chrome Tab to open the OAuth URL and capture the deep-link redirect:

// Redirect URI registered in AndroidManifest.xml:
// <data android:scheme="myapp" android:host="oauth-callback" />

val authUri = Uri.parse(
"$BASE_URL/auth/oauth/$provider?redirect_uri=${Uri.encode("myapp://oauth-callback")}"
)
CustomTabsIntent.Builder().build().launchUrl(context, authUri)

// In your Activity / NavController — handle the deep link:
intent?.data?.let { uri ->
val accessToken = uri.getQueryParameter("accessToken")
val refreshToken = uri.getQueryParameter("refreshToken")
if (accessToken != null && refreshToken != null) {
TokenStorage.saveTokens(context, accessToken, refreshToken)
}
}

Security Notes

  • ✅ Tokens stored in EncryptedSharedPreferences — backed by the Android Keystore (hardware-backed on modern devices)
  • ✅ AES-256-GCM encryption for values, AES-256-SIV for keys
  • ✅ Auto-refresh on 401 with a single retry (separate OkHttpClient to avoid recursion)
  • ✅ Add android:usesCleartextTraffic="false" to AndroidManifest.xml — enforces HTTPS
  • ✅ Use BuildConfig.DEBUG to disable the OkHttp logging interceptor in release builds