Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 91afad0c authored by Jonathan Klee's avatar Jonathan Klee
Browse files

refactor: replace proactive token alarms with reactive OIDC refresh and authenticator error bundles

parent aad8ec08
Loading
Loading
Loading
Loading
+0 −19
Original line number Diff line number Diff line
@@ -15,7 +15,6 @@
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
    <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
    <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>

    <!-- account management permissions not required for own accounts since API level 22 -->
    <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
@@ -753,24 +752,6 @@
            </intent-filter>
        </receiver>

        <receiver
            android:name=".token.TokenRefreshReceiver"
            android:exported="true"
            android:permission="${applicationId}.permission.ACCOUNT_EVENTS">
            <intent-filter>
                <action android:name="${applicationId}.action.REFRESH_TOKEN" />
                <action android:name="android.intent.action.BOOT_COMPLETED" />
            </intent-filter>
        </receiver>

        <service
            android:name=".token.TokenUpdaterService"
            android:foregroundServiceType="specialUse">
            <property
                android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
                android:value="updater" />
        </service>

        <!-- provider to share debug info/logs -->
        <provider
            android:name="androidx.core.content.FileProvider"
+3 −4
Original line number Diff line number Diff line
@@ -20,7 +20,7 @@ package at.bitfire.davdroid
import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import at.bitfire.davdroid.token.MurenaTokenManager
import at.bitfire.davdroid.token.OidcAccountTokenRefresher

object AccountSyncHelper {

@@ -37,7 +37,7 @@ object AccountSyncHelper {
            putExtra(AccountManager.KEY_ACCOUNT_TYPE, accountType)
        }
        context.sendBroadcast(intent, ACCOUNT_EVENTS_PERMISSION)
        MurenaTokenManager.handleTokenRefresh(context)
        OidcAccountTokenRefresher.refreshMurenaAccountIfNeeded(context, accountName)
    }

    fun notifyAccountRemoved(context: Context, accountRemovedIntent: Intent) {
@@ -48,7 +48,6 @@ object AccountSyncHelper {
            },
            ACCOUNT_EVENTS_PERMISSION
        )
        MurenaTokenManager.cancelTokenRefreshAlarm(context)
    }

}
+20 −34
Original line number Diff line number Diff line
@@ -26,11 +26,10 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.annotation.AnyThread
import at.bitfire.davdroid.token.MurenaTokenManager
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.token.OidcAccountTokenRefresher
import at.bitfire.davdroid.ui.account.SettingsActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import dagger.hilt.EntryPoint
@@ -40,8 +39,6 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
import java.util.logging.Level

abstract class DefaultAccountAuthenticatorService : Service(), OnAccountsUpdateListener {
@@ -50,7 +47,6 @@ abstract class DefaultAccountAuthenticatorService : Service(), OnAccountsUpdateL
    @InstallIn(SingletonComponent::class)
    interface DefaultAccountAuthenticatorServiceEntryPoint {
        fun appDatabase(): AppDatabase
        fun authorizationService(): AuthorizationService
    }

    companion object {
@@ -191,46 +187,36 @@ abstract class DefaultAccountAuthenticatorService : Service(), OnAccountsUpdateL
            authTokenType: String?,
            options: Bundle?
        ): Bundle? {
            val accountManager = AccountManager.get(context)
            val authStateString = accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE) ?: return null
            if (authStateString.isBlank()) {
                return null
            if (account == null) {
                return errorResult("Account is missing")
            }

            val authState = AuthState.jsonDeserialize(authStateString)
            val updatedAuthState = OidcAccountTokenRefresher.refreshAccountIfNeeded(context, account)
                ?: return errorResult("Unable to refresh account token")

            if (authState == null) {
                val result = Bundle()
                result.putInt(
                    AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
                    AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION
                )
                return result
            val accessToken = updatedAuthState.accessToken
            if (accessToken.isNullOrBlank()) {
                return errorResult("Refreshed token is empty")
            }

            if (!authState.needsTokenRefresh) {
            return successResult(account, accessToken)
        }

        private fun successResult(account: Account, accessToken: String): Bundle {
            val result = Bundle()
                result.putString(AccountManager.KEY_ACCOUNT_NAME, account!!.name)
            result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
            result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
                result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken)
            result.putString(AccountManager.KEY_AUTHTOKEN, accessToken)
            return result
        }

            MurenaTokenManager.handleTokenRefresh(context, onComplete = { authState ->
                authState ?: return@handleTokenRefresh

                val result = Bundle()
                result.putString(AccountManager.KEY_ACCOUNT_NAME, account?.name)
                result.putString(AccountManager.KEY_ACCOUNT_TYPE, account?.type)
                result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken)
                response?.onResult(result)
            })

        private fun errorResult(message: String): Bundle {
            val result = Bundle()
            result.putInt(
                AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
                AccountManager.KEY_ERROR_CODE,
                AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION
            )
            result.putString(AccountManager.KEY_ERROR_MESSAGE, message)
            return result
        }

+0 −8
Original line number Diff line number Diff line
@@ -34,7 +34,6 @@ import at.bitfire.dav4jvm.property.GetCTag
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.ScheduleTag
import at.bitfire.dav4jvm.property.SyncToken
import at.bitfire.davdroid.token.MurenaTokenManager
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
@@ -204,13 +203,6 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
     * Call performSync with default retry values
     */
    fun performSync() {
        val authState = accountSettings.credentials().authState

        if (authState != null && authState.needsTokenRefresh) {
            Logger.log.info("Token refresh needed for: ${account.name} used by authority: $authority.")
            MurenaTokenManager.handleTokenRefresh(context)
        }

        performSync(DEFAULT_RETRY_AFTER, DEFAULT_SECOND_RETRY_AFTER, DEFAULT_MAX_RETRY_TIME)
    }

+0 −216
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package at.bitfire.davdroid.token

import android.accounts.AccountManager
import android.annotation.SuppressLint
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.NetworkUtils
import dagger.hilt.android.EntryPointAccessors
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.logging.Level
import kotlin.time.Duration.Companion.minutes

object MurenaTokenManager {

    const val ACTION_REFRESH = "${BuildConfig.APPLICATION_ID}.action.REFRESH_TOKEN"
    private const val PENDING_INTENT_REQUEST_CODE = 1001

    // Returns a PendingIntent for scheduling or triggering token refresh.
    fun getPendingIntent(context: Context, noCreate: Boolean = false): PendingIntent? {
        val flags = if (noCreate) {
            PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
        } else {
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        }

        val intent = Intent(context, TokenRefreshReceiver::class.java).apply {
            action = ACTION_REFRESH
        }

        return PendingIntent.getBroadcast(context, PENDING_INTENT_REQUEST_CODE, intent, flags)
    }

    // Cancels any scheduled refresh alarm for the active murena account.
    fun cancelTokenRefreshAlarm(context: Context) {
        val alarmManager = context.getSystemService(AlarmManager::class.java)
        val pendingIntent = getPendingIntent(context, noCreate = true)

        if (pendingIntent != null) {
            alarmManager?.cancel(pendingIntent)
            pendingIntent.cancel()
            Logger.log.info("Token refresh alarm cancelled")
        } else {
            Logger.log.info("No existing token refresh alarm to cancel")
        }
    }

    // Schedules a refresh before the token expires, or refreshes immediately if already expired.
    fun handleTokenRefresh(context: Context, onComplete: ((AuthState?) -> Unit)? = null) {
        val credentials = getAccountSettings(context)?.credentials() ?: run {
            Logger.log.warning("No account credentials found, cannot schedule refresh.")
            return
        }

        val authState = credentials.authState ?: run {
            Logger.log.warning("Missing AuthState, cannot schedule refresh.")
            return
        }

        val expiration = authState.accessTokenExpirationTime ?: run {
            Logger.log.warning("Missing token expiration, forcing immediate refresh.")
            refreshAuthToken(context, onComplete)
            return
        }

        if (NetworkUtils.isConnectedToNetwork(context)) {
            // Stop token service if its running
            val intent = Intent(TokenUpdaterService.ACTION_STOP_TOKEN_SERVICE).apply {
                setPackage(context.packageName)
            }
            context.sendBroadcast(intent)

            // Request at least 2 minutes early.
            val refreshAt = expiration.minus(2.minutes.inWholeMilliseconds)
            if (refreshAt <= System.currentTimeMillis()) {
                Logger.log.info("Token expired or near expiry, refreshing immediately.")
                refreshAuthToken(context, onComplete)
                return
            } else {
                setTokenRefreshAlarm(context, refreshAt)
            }
        } else {
            Logger.log.warning("No internet connection, starting service")
            val intent = Intent(context, TokenUpdaterService::class.java)
            context.startService(intent)
        }

        onComplete?.invoke(authState)
    }

    // Schedules an exact alarm to refresh the auth token at the specified time.
    @SuppressLint("ScheduleExactAlarm")
    private fun setTokenRefreshAlarm(context: Context, timeInMillis: Long) {
        val alarmManager = context.getSystemService(AlarmManager::class.java)
        val pendingIntent = getPendingIntent(context)

        if (alarmManager == null || pendingIntent == null) {
            Logger.log.warning("could not schedule token refresh alarm.")
            return
        }

        alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timeInMillis, pendingIntent)
        Logger.log.info("Next token refresh scheduled at ${timeInMillis.asDateString()}")
    }

   // Refreshes the authentication token and updates stored credentials if successful.
    private fun refreshAuthToken(context: Context, onComplete: ((AuthState?) -> Unit)? = null) {
        try {
            val httpEntryPoint = EntryPointAccessors.fromApplication(
                context, HttpClient.HttpClientEntryPoint::class.java
            )

            val authService = httpEntryPoint.authorizationService()
            val accountSettings = getAccountSettings(context) ?: run {
                Logger.log.warning("No account settings found during token refresh.")
                return
            }

            val credentials = accountSettings.credentials()
            val authState = credentials.authState ?: run {
                Logger.log.warning("Missing AuthState during token refresh.")
                return
            }

            if (isInvalidGrant(authState.authorizationException)) {
                Logger.log.warning("Invalid grant detected, user re-auth required.")
                cancelTokenRefreshAlarm(context)
                return
            }

            val tokenRequest = authState.createTokenRefreshRequest()
            authService.performTokenRequest(tokenRequest) { response, exception ->
                when {
                    response != null && exception == null -> {
                        authState.update(response, null)
                        accountSettings.credentials(credentials.copy(authState = authState))
                        Logger.log.info("Token refreshed for ${accountSettings.account.name}")

                        // Schedule at least 2 minutes early for the new token.
                        val refreshAt = authState.accessTokenExpirationTime?.minus(2.minutes.inWholeMilliseconds)
                        if (refreshAt != null) {
                            setTokenRefreshAlarm(context, refreshAt)
                        }

                        onComplete?.invoke(authState)
                    }

                    isInvalidGrant(exception) -> {
                        Logger.log.log(Level.SEVERE, "Invalid grant: refresh cancelled, User must re-authenticate.", exception)
                        cancelTokenRefreshAlarm(context)
                    }

                    else -> {
                        Logger.log.log(Level.SEVERE, "Token refresh failed: unknown error, retrying in 5 minutes.")
                        setTokenRefreshAlarm(context, System.currentTimeMillis() + 5.minutes.inWholeMilliseconds)
                    }
                }
            }
        } catch (e: Exception) {
            Logger.log.log(Level.SEVERE, "Token refresh failed due to unexpected exception.", e)
        } finally {
            onComplete?.invoke(null)
        }
    }

   // Checks whether the given AuthorizationException indicates an invalid grant (requires re-login).
    private fun isInvalidGrant(ex: AuthorizationException?): Boolean {
        val invalidGrant = AuthorizationException.TokenRequestErrors.INVALID_GRANT
        return ex?.code == invalidGrant.code && ex.error == invalidGrant.error
    }

    // Retrieves the Murena account settings for the currently active account, if available.
    // We only allow one murena account.
    fun getAccountSettings(context: Context): AccountSettings? {
        val accountType = context.getString(R.string.eelo_account_type)
        val account = AccountManager.get(context)
            .getAccountsByType(accountType)
            .firstOrNull()

        return account?.let { AccountSettings(context, it) } ?: run {
            Logger.log.info("No Murena account found.")
            null
        }
    }

    private fun Long.asDateString(): String =
        SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(this))
}
Loading