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

Commit 621d098f authored by Mohammed Althaf T's avatar Mohammed Althaf T 😊
Browse files

Merge branch '3743-main-token_expiry' into 'main'

Refresh token before it expires

See merge request !179
parents c62d2c6a 54253982
Loading
Loading
Loading
Loading
Loading
+21 −2
Original line number Diff line number Diff line
@@ -15,6 +15,7 @@
    <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"/>
@@ -31,9 +32,9 @@

    <!-- permission for broadcast related to account changes event (added/removed) -->
    <permission
        android:name="foundation.e.accountmanager.permission.ACCOUNT_EVENTS"
        android:name="${applicationId}.permission.ACCOUNT_EVENTS"
        android:protectionLevel="signature"/>
    <uses-permission android:name="foundation.e.accountmanager.permission.ACCOUNT_EVENTS"/>
    <uses-permission android:name="${applicationId}.permission.ACCOUNT_EVENTS"/>

    <!-- android.permission-group.LOCATION -->
    <!-- getting the WiFi name (for "sync in Wifi only") requires
@@ -740,6 +741,24 @@
            </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"
+5 −2
Original line number Diff line number Diff line
@@ -20,11 +20,12 @@ package at.bitfire.davdroid
import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import at.bitfire.davdroid.token.MurenaTokenManager

object AccountSyncHelper {

    private const val ACTION_PREFIX = "foundation.e.accountmanager.action"
    const val ACCOUNT_EVENTS_PERMISSION = "foundation.e.accountmanager.permission.ACCOUNT_EVENTS"
    private const val ACTION_PREFIX = "${BuildConfig.APPLICATION_ID}.action"
    const val ACCOUNT_EVENTS_PERMISSION = "${BuildConfig.APPLICATION_ID}.permission.ACCOUNT_EVENTS"
    const val ACTION_ACCOUNT_REMOVED = "$ACTION_PREFIX.ACCOUNT_REMOVED"
    const val ACTION_ACCOUNT_ADDED = "$ACTION_PREFIX.ACCOUNT_ADDED"

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

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

}
 No newline at end of file
+6 −36
Original line number Diff line number Diff line
@@ -26,14 +26,13 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.annotation.AnyThread
import at.bitfire.davdroid.OpenIdUtils
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.ui.account.SettingsActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import at.bitfire.davdroid.util.AuthStatePrefUtils
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -217,44 +216,15 @@ abstract class DefaultAccountAuthenticatorService : Service(), OnAccountsUpdateL
                return result
            }

            val tokenRequest = authState.createTokenRefreshRequest()
            val clientSecretString = accountManager.getUserData(account, AccountSettings.KEY_CLIENT_SECRET)
            val clientSecret = OpenIdUtils.getClientAuthentication(clientSecretString)

            val authorizationService =
                EntryPointAccessors.fromApplication(context, DefaultAccountAuthenticatorServiceEntryPoint::class.java)
                    .authorizationService()

            authorizationService.performTokenRequest(
                tokenRequest,
                clientSecret
            ) { tokenResponse, ex ->
                authState.update(tokenResponse, ex)
                accountManager.setUserData(
                    account,
                    AccountSettings.KEY_AUTH_STATE,
                    authState.jsonSerializeString()
                )
                accountManager.setUserData(
                    account,
                    AccountSettings.KEY_CLIENT_SECRET,
                    clientSecretString
                )

                AuthStatePrefUtils.saveAuthState(context, account!!, authState.jsonSerializeString())
            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_ACCOUNT_NAME, account?.name)
                result.putString(AccountManager.KEY_ACCOUNT_TYPE, account?.type)
                result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken)
                response?.onResult(result)

                try {
                    authorizationService.dispose()
                } catch (e: Exception) {
                    Logger.log.log(Level.WARNING, "Failed to dispose AuthorizationService", e)
                }
            }
            })

            val result = Bundle()
            result.putInt(
+5 −40
Original line number Diff line number Diff line
@@ -34,12 +34,11 @@ 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.OpenIdUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.db.SyncStats
import at.bitfire.davdroid.log.Logger
@@ -70,7 +69,6 @@ import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
import okhttp3.HttpUrl
import okhttp3.RequestBody
@@ -208,47 +206,14 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
    fun performSync() {
        val authState = accountSettings.credentials().authState

        if (authState == null || !authState.needsTokenRefresh) {
            performSync(DEFAULT_RETRY_AFTER, DEFAULT_SECOND_RETRY_AFTER, DEFAULT_MAX_RETRY_TIME)
            return
        }

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

    private fun refreshAuthTokenAndSync(authState: AuthState) {
        val tokenRequest = authState.createTokenRefreshRequest()
        val clientSecretString = accountSettings.credentials().clientSecret
        val clientSecret = OpenIdUtils.getClientAuthentication(clientSecretString)

        val authorizationService =
            EntryPointAccessors.fromApplication(context, SyncManagerEntryPoint::class.java)
                .authorizationService()

        authorizationService.performTokenRequest(tokenRequest, clientSecret) { tokenResponse, ex ->
            authState.update(tokenResponse, ex)
            accountSettings.credentials(
                Credentials(
                    account.name,
                    null,
                    authState,
                    null,
                    clientSecret = clientSecretString
                )
            )

            executor.execute {
        performSync(DEFAULT_RETRY_AFTER, DEFAULT_SECOND_RETRY_AFTER, DEFAULT_MAX_RETRY_TIME)
    }

            try {
                authorizationService.dispose()
            } catch (e: Exception) {
                Logger.log.log(Level.INFO, "failed to dispose oidc authorizationService", e)
            }
        }
    }

    /**
     * Perform sync operation.
     * On unhandled exceptions, retry following fibonnacci sequence (if user pass valid retry times.
+216 −0
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