After replacing Firebase BOM version 33.1.2 we have started running into issues where authInstance.currentUser returns null although the user has logged in recently. In order to mitigate this, I've added a listener:
val authStateFlow: Flow<FirebaseUser?> = callbackFlow {
val l = FirebaseAuth.AuthStateListener { trySend(it.currentUser) }
authInstance.addAuthStateListener(l)
awaitClose { authInstance.removeAuthStateListener(l) }
}
We have also added something silly like allowing user to proceed into the app if there is a local user, and wait for this auth token for a minute to initialize itself, but this is now causing problems down the line when we're trying to retrieve a fresh token. Example:
val localUser = withTimeoutOrNull(250) {
userRepository.user.firstOrNull { it != null }
}
val timeout = localUser?.let {
holdSplash = false
60_000L
} ?: run {
1_000L
}
val firebaseUser = withTimeoutOrNull(timeout) {
userViewModel.initializedAuthState.first()
}
This is how we use a token provider that takes care of refreshing the token when we make server requests:
/**
* Class that centralizes token caching/refresh behind a small TokenProvider that:*
* * stores the last token + its expiration,
* * prevents refresh stampedes with a Mutex,
* * can be invalidated by an IdTokenListener/AuthStateListener.
*/
class FirebaseTokenProvider(
private val coPilotCrashlyticsHelper: CoPilotCrashlyticsHelper,
private val authSource: FirebaseAuthSource,
private val timeoutMs: Long = 60_000
) {
@Volatile private var cachedToken: String? = null
@Volatile private var expiryEpochMs: Long = 0L
private val mutex = Mutex()
init {
// Invalidate cache when Firebase rotates tokens or user changes.
authSource.addIdTokenListener {
synchronized(this) {
cachedToken = null
expiryEpochMs = 0L
}
}
authSource.addAuthStateListener {
synchronized(this) {
if (authSource.currentUser() == null) {
cachedToken = null
expiryEpochMs = 0L
}
}
}
}
suspend fun getValidTokenOrNull(forceRefresh: Boolean = false): String? {
val now = System.currentTimeMillis()
val token = cachedToken
if (!forceRefresh && token != null && now < expiryEpochMs - 5_000) {
return token
}
return mutex.withLock {
val stillNow = System.currentTimeMillis()
if (!forceRefresh && cachedToken != null && stillNow < expiryEpochMs - 5_000) {
return cachedToken!!
}
val user = authSource.currentUser() ?: run {
coPilotCrashlyticsHelper.log("FirebaseTokenProvider: No current user when requesting token.")
return null
}
val result: GetTokenResult = withTimeout(timeoutMs) {
user.getIdToken( forceRefresh /* forceRefresh = */).await()
}
val newToken = result.token ?: run {
coPilotCrashlyticsHelper.log("FirebaseTokenProvider: Got null token for user ${user.uid}.")
return null
}
val expSeconds = result.expirationTimestamp // seconds since epoch.
expiryEpochMs = expSeconds * 1000
cachedToken = newToken
newToken
}
}
fun clear() {
synchronized(this) {
cachedToken = null
expiryEpochMs = 0L
}
}
}
And then the authenticator/interceptor that we use for our OkHttp/Retrofit
class FirebaseAuthenticator(
private val firebaseCrashlyticsHelper: CoPilotCrashlyticsHelper,
private val tokenProvider: FirebaseTokenProvider) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
// Prevent infinite loops: only retry once per chain.
if (responseCount(response) >= 1) return null
// Only retry on 401; 403 usually means "permission" and a retry rarely helps.
if (response.code != 401 || response.code != 403) return null
firebaseCrashlyticsHelper.log("Refreshing token. Received response: $response")
val newToken = runCatching {
runBlocking { tokenProvider.getValidTokenOrNull(forceRefresh = true) }
}.getOrNull() ?: return null
// If the header already uses this token, don't loop.
val prior = response.request.header("Authorization")
val bearer = "Bearer $newToken"
if (prior == bearer) return null
return response.request.newBuilder()
.header("Authorization", bearer)
.build()
}
private fun responseCount(response: Response): Int {
var count = 1
var r: Response? = response.priorResponse
while (r != null) { count++; r = r.priorResponse }
return count
}
}
/** Interceptor for to provide Firebase token for HTTP requests to [CoPilotApiService]. */
class FirebaseAuthInterceptor(
private val tokenProvider: FirebaseTokenProvider
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
// If there is no user, proceed without header.
val token = runCatching { runBlocking { tokenProvider.getValidTokenOrNull(forceRefresh = false) } }
.getOrNull()
val request = if (token != null) {
original.newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
original
}
return chain.proceed(request)
}
}
...
okHttpClientBuilder.addInterceptor(FirebaseAuthInterceptor(firebaseTokenProvider))
okHttpClientBuilder.authenticator(firebaseAuthenticator)
Any idea what might be going on in the newer libraries of Firebase that we forgot to set up/upgrade and why the currentUser is being cleared from the keystore?
Thank you!