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

Commit 42a0a717 authored by Mohammed Althaf T's avatar Mohammed Althaf T 😊
Browse files

Refresh token before it expires

parent d9264a68
Loading
Loading
Loading
Loading
Loading
+230 −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 foundation.e.accountmanager.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.network.OAuthModule
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import foundation.e.accountmanager.utils.AccountHelper
import foundation.e.accountmanager.utils.SystemUtils
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 java.util.logging.Logger
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

    val earlyTriggerTime = 2.minutes.inWholeMilliseconds

    val logger: Logger = Logger.getLogger(this.javaClass.name)

    // 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.info("Token refresh alarm cancelled")
        } else {
            logger.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 = getOrSaveCredentials(context) ?: run {
            logger.warning("No account credentials found, cannot schedule refresh.")
            return
        }

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

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

        if (SystemUtils.isNetworkAvailable(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(earlyTriggerTime)
            if (refreshAt <= System.currentTimeMillis()) {
                logger.info("Token expired or near expiry, refreshing immediately.")
                refreshAuthToken(context, onComplete)
                return
            } else {
                setTokenRefreshAlarm(context, refreshAt)
            }
        } else {
            logger.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.warning("could not schedule token refresh alarm.")
            return
        }

        alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timeInMillis, pendingIntent)
        logger.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 authService = OAuthModule.authorizationService(context)
            val credentials = getOrSaveCredentials(context) ?: run {
                logger.warning("No account credentials found during token refresh.")
                return
            }

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

            if (isInvalidGrant(authState.authorizationException)) {
                logger.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)
                        getOrSaveCredentials(context, credentials.copy(authState = authState))
                        logger.info("Token refreshed for ${credentials.username}")

                        onComplete?.invoke(authState)
                    }

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

                    else -> {
                        logger.log(Level.SEVERE, "Token refresh failed: unknown error, retrying in 5 minutes.")
                        setTokenRefreshAlarm(context, System.currentTimeMillis() + 5.minutes.inWholeMilliseconds)
                    }
                }
            }
        } catch (e: Exception) {
            logger.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 getOrSaveCredentials(context: Context, newCredentials: Credentials? = null): Credentials? {
        val accountType = context.getString(R.string.eelo_account_type)
        val account = AccountManager.get(context)
            .getAccountsByType(accountType)
            .firstOrNull() ?: return null.also { logger.info("No Murena account found.") }

        // Save new credentials if provided
        newCredentials?.authState?.let { authState ->
            AccountManager.get(context).setAndVerifyUserData(
                account,
                AccountSettings.KEY_AUTH_STATE,
                authState.jsonSerializeString()
            )
            logger.info("Saved new credentials for account: ${account.name}")
            AccountHelper.notifyAccountAdded(context, account.name)
        }

        // Return current credentials
        val authState = AccountManager.get(context)
            .getUserData(account, AccountSettings.KEY_AUTH_STATE)
            ?.let { AuthState.jsonDeserialize(it) }

        return Credentials(
            username = account.name,
            password = null,
            authState = authState,
            certificateAlias = null
        )
    }

    private fun Long.asDateString(): String =
        SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(this))
}
+38 −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 foundation.e.accountmanager.token

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.util.logging.Logger

class TokenRefreshReceiver : BroadcastReceiver() {
    val logger: Logger = Logger.getLogger(this.javaClass.name)

    override fun onReceive(context: Context, intent: Intent) {
        when (intent.action) {
            Intent.ACTION_BOOT_COMPLETED,
            MurenaTokenManager.ACTION_REFRESH -> {
                MurenaTokenManager.handleTokenRefresh(context.applicationContext)
            }
            else -> logger.warning("Received unknown action: ${intent.action}")
        }
    }
}
+102 −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 foundation.e.accountmanager.token

import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.BuildConfig
import java.util.logging.Logger
import kotlin.time.Duration.Companion.seconds

class TokenUpdaterService : Service() {
    val logger: Logger = Logger.getLogger(this.javaClass.name)

    companion object {
        const val ACTION_STOP_TOKEN_SERVICE = "${BuildConfig.APPLICATION_ID}.action.STOP_TOKEN_SERVICE"
    }

    private val context by lazy { applicationContext }
    private val connectivityManager by lazy {
        getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
    }

    private val refreshDelay = 10.seconds.inWholeMilliseconds

    private val handler = Handler(Looper.getMainLooper())

    private val stopReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            when (intent.action) {
                ACTION_STOP_TOKEN_SERVICE -> stopSelf()
                else -> logger.warning("Received unknown action: ${intent.action}")
            }
        }
    }

    private val networkCallback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) {
            logger.info("Network became available.")
            // Send broadcast 10 seconds delayed, let's wait till internet is stable.
            handler.postDelayed({
                val intent = Intent(context.applicationContext, TokenRefreshReceiver::class.java).apply {
                    action = MurenaTokenManager.ACTION_REFRESH
                }
                sendBroadcast(intent)
            }, refreshDelay)
        }

        override fun onLost(network: Network) {
            logger.warning("Network lost, waiting to reconnect.")
        }
    }

    override fun onCreate() {
        super.onCreate()

        ContextCompat.registerReceiver(this, stopReceiver, IntentFilter(ACTION_STOP_TOKEN_SERVICE),
            ContextCompat.RECEIVER_NOT_EXPORTED
        )

        connectivityManager.registerNetworkCallback(NetworkRequest.Builder().build(), networkCallback)
    }

    override fun onDestroy() {
        super.onDestroy()
        handler.removeCallbacksAndMessages(null)
        try {
            connectivityManager.unregisterNetworkCallback(networkCallback)
            unregisterReceiver(stopReceiver)
        } catch (e: Exception) {
            logger.warning("Error during service cleanup: ${e.message}")
        }
        logger.info("Service stopped and cleaned up.")
    }

    override fun onBind(intent: Intent?): IBinder? = null
}
+3 −0
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.settings.AccountSettings
import foundation.e.accountmanager.AccountTypes
import foundation.e.accountmanager.sync.SyncBroadcastReceiver
import foundation.e.accountmanager.token.MurenaTokenManager
import java.util.concurrent.TimeUnit

object AccountHelper {
@@ -79,6 +80,7 @@ object AccountHelper {
        intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, accountName)
        intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountTypes.Murena.accountType)
        context.sendBroadcast(intent, ACCOUNT_EVENTS_PERMISSION)
        MurenaTokenManager.handleTokenRefresh(context.applicationContext)
    }

    fun notifyAccountRemoved(context: Context, intent: Intent) {
@@ -86,6 +88,7 @@ object AccountHelper {
        intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
        intent.putExtras(intent)
        context.sendBroadcast(intent, ACCOUNT_EVENTS_PERMISSION)
        MurenaTokenManager.cancelTokenRefreshAlarm(context.applicationContext)
    }

    fun scheduleSyncWithDelay(context: Context) {
+18 −0
Original line number Diff line number Diff line
@@ -67,6 +67,24 @@
            </intent-filter>
        </receiver>

        <receiver
                android:name="foundation.e.accountmanager.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="foundation.e.accountmanager.token.TokenUpdaterService"
                android:foregroundServiceType="specialUse">
            <property
                    android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
                    android:value="updater" />
        </service>

        <service
                android:name=".sync.adapter.MurenaCalendarsSyncAdapterService"
                android:exported="true"