1

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!

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.