Most tutorials on OpenID Connect for mobile applications stop the moment an ID token is acquired. In a real-world project, this is where the real work begins. The initial login is the easy part. The difficult, failure-prone part is managing the token lifecycle: securely storing tokens, handling expiration, implementing a robust refresh mechanism that survives network failures and app restarts, and ensuring the user isn’t abruptly logged out during a critical operation. We recently faced this exact challenge: building an authentication flow for an Android application written in Jetpack Compose, backed by a Scala microservice acting as a Backend-for-Frontend (BFF).
The initial concept was simple: use the OIDC Authorization Code Flow with Proof Key for Code Exchange (PKCE). This is the standard for native applications. The complexity arose in the implementation details. Simply using a library like AppAuth on Android handles the browser interaction but leaves the token exchange, storage, and refresh logic largely up to the developer. A common mistake is to perform the token exchange directly from the mobile app, exposing the client secret. Our architecture mandated that the mobile client should never possess the OIDC client secret. All direct communication with the Identity Provider’s (IdP) token endpoint would be brokered by our Scala BFF.
This decision introduced its own set of trade-offs. It secured the client secret but coupled the client’s authentication state to the BFF. It also meant we had to build two tightly-cooperating components: a stateful authentication manager on the Android client and a stateless, secure token exchange endpoint in the Scala service.
The entire flow can be visualized as follows. The key is the separation of concerns: the client handles user interaction and proof key generation, while the BFF handles the secret-laden communication with the IdP.
sequenceDiagram participant Client as Jetpack Compose Client participant BFF as Scala (Akka HTTP) BFF participant IdP as OpenID Connect Provider Client->>Client: 1. Generate code_verifier & code_challenge Client->>IdP: 2. Auth Request w/ code_challenge (via Browser) IdP-->>Client: 3. User authenticates & consents IdP->>Client: 4. Redirect with Authorization Code (to custom URI) Client->>BFF: 5. Send Authorization Code & code_verifier BFF->>IdP: 6. Token Request w/ Auth Code, code_verifier, client_secret IdP-->>BFF: 7. ID, Access, Refresh Tokens BFF-->>Client: 8. Return Access & Refresh Tokens (ID token is opaque to client) Note over Client,BFF: Later, when Access Token expires... Client->>BFF: 9. API Request with expired Access Token BFF-->>Client: 10. HTTP 401 Unauthorized Client->>BFF: 11. Refresh Request w/ Refresh Token BFF->>IdP: 12. Token Request w/ Refresh Token, client_secret IdP-->>BFF: 13. New Access & Refresh Tokens BFF-->>Client: 14. Return new tokens Client->>Client: 15. Store new tokens Client->>BFF: 16. Retry original API Request with new Access Token
This sequence looks straightforward on a diagram, but each arrow represents a potential point of failure that must be handled gracefully.
The Scala BFF: A Secure Token Broker
The BFF’s role is to be the secure intermediary. We chose Akka HTTP for its robust, asynchronous handling of HTTP requests, which is essential for a service that primarily makes calls to other services. The core of the BFF is a single route that handles the callback from the Android client.
First, let’s define the configuration in application.conf
. Never hardcode URIs or credentials.
oidc {
provider {
# The OIDC provider's discovery document URL is often preferred
# but for clarity, we define endpoints directly.
authorization-endpoint = "https://idp.example.com/auth"
token-endpoint = "https://idp.example.com/oauth2/token"
jwks-uri = "https://idp.example.com/oauth2/jwks"
}
client {
id = "our-mobile-client-id"
# This secret MUST NOT be known by the mobile client.
# In production, this should be loaded from a secure vault.
secret = "very-secret-value"
redirect-uri = "com.example.myapp://auth/callback"
}
}
We use Circe for JSON decoding and encoding. The case classes below model the data we expect from the client and the IdP.
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
import io.circe.{Decoder, Encoder}
// Data received from the Android client in the callback
case class AuthCallbackRequest(code: String, codeVerifier: String, state: String)
object AuthCallbackRequest {
implicit val decoder: Decoder[AuthCallbackRequest] = deriveDecoder[AuthCallbackRequest]
}
// Data sent back to the Android client
case class AppTokenResponse(
accessToken: String,
refreshToken: String,
expiresIn: Long
)
object AppTokenResponse {
implicit val encoder: Encoder[AppTokenResponse] = deriveEncoder[AppTokenResponse]
}
// Full response from the OIDC Provider's token endpoint
// We only forward a subset of this to the client.
case class IdpTokenResponse(
accessToken: String,
expiresIn: Long,
idToken: String,
refreshToken: Option[String], // Refresh token is optional
scope: String,
tokenType: String
)
object IdpTokenResponse {
// OIDC providers use snake_case, so we need a custom decoder config
import io.circe.generic.extras.Configuration
import io.circe.generic.extras.semiauto.deriveConfiguredDecoder
private implicit val config: Configuration = Configuration.default.withSnakeCaseMemberNames
implicit val decoder: Decoder[IdpTokenResponse] = deriveConfiguredDecoder[IdpTokenResponse]
}
The core of the service is the Akka HTTP route. It must perform several steps:
- Receive the authorization code and code verifier from the client.
- Construct a form-urlencoded request to the IdP’s token endpoint.
- Include the
client_id
,client_secret
,code
,code_verifier
,redirect_uri
, andgrant_type
. - Handle success and failure responses from the IdP.
- Return a simplified token response to the mobile client.
import akka.actor.typed.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.model._
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import com.typesafe.config.Config
import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._
import org.slf4j.LoggerFactory
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
class AuthService(config: Config)(implicit system: ActorSystem[_], ec: ExecutionContext) {
private val logger = LoggerFactory.getLogger(getClass)
private val oidcConfig = config.getConfig("oidc")
private val tokenEndpoint = oidcConfig.getString("provider.token-endpoint")
private val clientId = oidcConfig.getString("client.id")
private val clientSecret = oidcConfig.getString("client.secret")
private val redirectUri = oidcConfig.getString("client.redirect-uri")
// The endpoint that the Android client calls after receiving the auth code.
val tokenExchangeRoute: Route =
path("auth" / "token-exchange") {
post {
entity(as[AuthCallbackRequest]) { req =>
logger.info(s"Received token exchange request for state: ${req.state}")
// A real implementation should validate the state parameter against
// a value stored in a temporary cache to prevent CSRF.
// For simplicity, this is omitted here.
val tokenRequest = createTokenRequest(req.code, req.codeVerifier)
// Perform the POST request to the IdP
val responseFuture: Future[HttpResponse] = Http().singleRequest(tokenRequest)
onComplete(responseFuture) {
case Success(response) => handleIdpResponse(response)
case Failure(ex) =>
logger.error("Failed to connect to IdP token endpoint", ex)
complete(StatusCodes.InternalServerError, "Error connecting to identity provider")
}
}
}
}
private def createTokenRequest(code: String, codeVerifier: String): HttpRequest = {
HttpRequest(
method = HttpMethods.POST,
uri = tokenEndpoint,
entity = FormData(
"grant_type" -> "authorization_code",
"client_id" -> clientId,
"client_secret" -> clientSecret,
"code" -> code,
"code_verifier" -> codeVerifier,
"redirect_uri" -> redirectUri
).toEntity
)
}
// A separate route for handling token refresh
// The client will send its current refresh_token here.
val tokenRefreshRoute: Route =
path("auth" / "token-refresh") {
post {
entity(as[Map[String, String]]) { payload =>
payload.get("refresh_token") match {
case Some(token) =>
logger.info("Received token refresh request.")
val refreshRequest = createRefreshRequest(token)
val responseFuture = Http().singleRequest(refreshRequest)
onComplete(responseFuture) {
case Success(response) => handleIdpResponse(response)
case Failure(ex) =>
logger.error("Failed to connect to IdP for refresh", ex)
complete(StatusCodes.InternalServerError, "Error during token refresh")
}
case None =>
complete(StatusCodes.BadRequest, "Missing refresh_token")
}
}
}
}
private def createRefreshRequest(refreshToken: String): HttpRequest = {
HttpRequest(
method = HttpMethods.POST,
uri = tokenEndpoint,
entity = FormData(
"grant_type" -> "refresh_token",
"client_id" -> clientId,
"client_secret" -> clientSecret,
"refresh_token" -> refreshToken
).toEntity
)
}
private def handleIdpResponse(response: HttpResponse): Route = {
response.status match {
case StatusCodes.OK =>
import io.circe.parser._
// Unmarshal the response entity to a string first, then parse
val entityToString = response.entity.toStrict(3.seconds).map(_.data.utf8String)
onComplete(entityToString) {
case Success(jsonString) =>
decode[IdpTokenResponse](jsonString) match {
case Right(idpToken) =>
idpToken.refreshToken match {
case Some(rt) =>
// We only forward what the client needs. The id_token is processed
// and validated here on the BFF if necessary (e.g., creating a session).
val appResponse = AppTokenResponse(idpToken.accessToken, rt, idpToken.expiresIn)
complete(appResponse)
case None =>
logger.warn("IdP did not return a refresh token. This may be a configuration issue.")
complete(StatusCodes.InternalServerError, "Identity provider misconfiguration")
}
case Left(error) =>
logger.error(s"Failed to decode IdP token response: $error. Body: $jsonString")
complete(StatusCodes.InternalServerError, "Invalid response from identity provider")
}
case Failure(ex) =>
logger.error("Failed to read IdP response entity", ex)
complete(StatusCodes.InternalServerError)
}
case _ =>
// The IdP returned an error (e.g., invalid code). We must propagate this.
val errorBodyFuture = response.entity.toStrict(3.seconds).map(_.data.utf8String)
onComplete(errorBodyFuture) { body =>
logger.error(s"IdP returned non-OK status: ${response.status}. Body: ${body.getOrElse("N/A")}")
complete(StatusCodes.Unauthorized, "Failed to authenticate with identity provider")
}
}
}
}
The Jetpack Compose Client: State Management and Resilience
On the Android side, the challenges are different. We need to manage UI state reactively, handle system-level components like Chrome Custom Tabs and Intent Filters, and securely store cryptographic material.
1. State and ViewModel
First, we define the authentication state. A sealed interface is perfect for representing these mutually exclusive states in a type-safe way.
// AuthState.kt
sealed interface AuthState {
object LoggedOut : AuthState
object InProgress : AuthState
data class LoggedIn(val accessToken: String) : AuthState
data class Failed(val error: String) : AuthState
}
// AuthViewModel.kt
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authService: AuthService, // Retrofit service for calling our BFF
private val tokenManager: TokenManager // Secure storage wrapper
) : ViewModel() {
private val _authState = MutableStateFlow<AuthState>(AuthState.LoggedOut)
val authState: StateFlow<AuthState> = _authState.asStateFlow()
init {
// On startup, check if we already have valid tokens
viewModelScope.launch {
if (tokenManager.getAccessToken() != null) {
_authState.value = AuthState.LoggedIn(tokenManager.getAccessToken()!!)
}
}
}
// This is called by the UI to start the login flow
fun startLoginFlow(context: Context) {
_authState.value = AuthState.InProgress
// PKCE code generation
val (codeVerifier, codeChallenge) = PkceUtil.generatePkcePair()
// Store the verifier to be sent to the BFF later
// A real implementation might use a more robust mechanism than a simple property
// in case the ViewModel process is killed.
this.pendingCodeVerifier = codeVerifier
val authRequest = authService.buildAuthorizationUrl(codeChallenge)
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(context, Uri.parse(authRequest))
}
// This is called by the RedirectHandlerActivity with the authorization code
fun handleAuthCallback(code: String, state: String) {
val verifier = pendingCodeVerifier ?: run {
_authState.value = AuthState.Failed("Login session expired. Please try again.")
return
}
viewModelScope.launch {
try {
val response = authService.exchangeToken(code, verifier, state)
tokenManager.saveTokens(response.accessToken, response.refreshToken)
_authState.value = AuthState.LoggedIn(response.accessToken)
} catch (e: Exception) {
// Handle network errors, BFF errors, etc.
Log.e("AuthViewModel", "Token exchange failed", e)
_authState.value = AuthState.Failed("Login failed: ${e.message}")
}
}
}
}
2. Handling the Redirect URI
A common pitfall is trying to handle the OIDC redirect back into the main Activity
. This is fragile. The correct approach is a dedicated, transparent Activity
whose only job is to capture the intent and pass the data to the ViewModel.
First, in AndroidManifest.xml
:
<activity
android:name=".auth.RedirectHandlerActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.example.myapp" android:host="auth" android:path="/callback" />
</intent-filter>
</activity>
And the Activity
itself is minimal:
// RedirectHandlerActivity.kt
@AndroidEntryPoint
class RedirectHandlerActivity : ComponentActivity() {
private val authViewModel: AuthViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data: Uri? = intent.data
val code = data?.getQueryParameter("code")
val state = data?.getQueryParameter("state")
if (code != null && state != null) {
authViewModel.handleAuthCallback(code, state)
} else {
// Handle error case, maybe redirect to a login failed screen
}
// Redirect to the main activity and finish this one
val mainActivityIntent = Intent(this, MainActivity::class.java)
mainActivityIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
startActivity(mainActivityIntent)
finish()
}
}
3. Secure Token Storage
Never store tokens in plain SharedPreferences
. Android’s Jetpack Security library provides EncryptedSharedPreferences
, which is a reasonable choice for storing tokens.
// TokenManager.kt
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
// This should be injected via Hilt/Dagger
class TokenManager(context: Context) {
private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
private val prefs = EncryptedSharedPreferences.create(
"auth_tokens",
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveTokens(accessToken: String, refreshToken: String) {
prefs.edit()
.putString("access_token", accessToken)
.putString("refresh_token", refreshToken)
.apply()
}
fun getAccessToken(): String? = prefs.getString("access_token", null)
fun getRefreshToken(): String? = prefs.getString("refresh_token", null)
fun clearTokens() {
prefs.edit().clear().apply()
}
}
4. The Resilient Part: Automatic Token Refresh
This is the most critical component for a seamless user experience. We use an Authenticator
with OkHttp, the underlying HTTP client for Retrofit. The Authenticator
intercepts 401 Unauthorized
responses and attempts to refresh the token.
// AuthAuthenticator.kt
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject
class AuthAuthenticator @Inject constructor(
private val tokenManager: TokenManager,
// Note: Injecting the service lazily to avoid circular dependency
private val authServiceProvider: Provider<AuthService>
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val currentToken = tokenManager.getAccessToken()
// If the request already used a token and failed, or if we have no token, don't retry.
if (currentToken == null || response.request.header("Authorization") == "Bearer $currentToken") {
return null
}
// Use a synchronized block to prevent multiple threads from trying to refresh the token simultaneously
// if multiple API calls fail at once.
synchronized(this) {
// Double-check if another thread already refreshed the token while we were waiting.
val newAccessToken = tokenManager.getAccessToken()
if (newAccessToken != currentToken) {
return response.request.newBuilder()
.header("Authorization", "Bearer $newAccessToken")
.build()
}
// Time to perform the actual refresh.
val currentRefreshToken = tokenManager.getRefreshToken() ?: return null
// We must block here as Authenticator is a synchronous API.
val refreshResponse = runBlocking {
try {
authServiceProvider.get().refreshToken(RefreshRequest(currentRefreshToken))
} catch (e: Exception) {
null // Network error, etc.
}
}
return if (refreshResponse != null && refreshResponse.isSuccessful) {
val newTokens = refreshResponse.body()!!
tokenManager.saveTokens(newTokens.accessToken, newTokens.refreshToken)
response.request.newBuilder()
.header("Authorization", "Bearer ${newTokens.accessToken}")
.build()
} else {
// The refresh token is invalid or expired. We must log the user out.
// A real app should have a global mechanism to observe this and navigate to the login screen.
runBlocking {
tokenManager.clearTokens()
// Post an event on a shared flow to trigger logout navigation
}
null // Give up. The request will fail with the original 401.
}
}
}
}
This AuthAuthenticator
needs to be added to the OkHttpClient
instance used by Retrofit. This setup ensures that API calls are automatically and transparently retried with a new token, providing a smooth experience. The synchronized block is crucial in a production environment to prevent a storm of refresh requests.
This architecture, while involving several moving parts, provides a secure and resilient authentication system. The BFF protects the client secret, the Android client uses modern, state-driven UI and secure storage, and the refresh mechanism handles the inevitable token expiry with grace. The clear separation of responsibilities makes each component easier to reason about, test, and maintain.
The current implementation still has boundaries. It relies on a client-side refresh token, which, while stored encrypted, remains on a potentially compromised device. An alternative, more complex architecture could involve the BFF managing the OIDC tokens entirely and issuing its own short-lived, encrypted session cookie to the client. This would move the refresh logic and token storage completely to the server, enhancing security at the cost of increased BFF statefulness and complexity. Furthermore, this design does not account for server-initiated logout flows, such as OIDC’s Back-Channel Logout, which would require a persistent connection or push notification mechanism to invalidate the client’s session in real-time.