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

Commit a75607ca authored by Fahim Salam Chowdhury's avatar Fahim Salam Chowdhury 👽 Committed by Fahim M. Choudhury
Browse files

fix: resolve Notes app's synchronization fail issue with SSO

If a user is logged in using OIDC for Murena account, the access token is only refreshed on CalDAV & CardDAV. The sync happens there either forcefully by user, or periodically in every \~15 minutes. The Notes app reuses the same token; it doesn't check or update the token.

In a corner case, when DAV syncs are not happening in the next 'X' minutes, but the token's Time-to-Live (TTL) is expired, and the user opens the Notes app at that time, then the API will return 401. To resolve this, we need to refresh the token before the notes API request is made.

The AppAuth library in AccountManager uses a callback function to notify when the token refresh is done. But we want the request to be synchronous because the Notes app's request is synchronous. Hence, we had to rely on Kotlin's runBlocking() method to make the refresh() call blocking and synchronous.
parent f979e8a3
Loading
Loading
Loading
Loading
+10 −1
Original line number Diff line number Diff line
@@ -80,7 +80,6 @@ import java.util.List;
import java.util.Map;
import java.util.logging.Level;

import at.bitfire.davdroid.BuildConfig;
import at.bitfire.davdroid.log.Logger;

public class InputStreamBinder extends IInputStreamService.Stub {
@@ -335,6 +334,11 @@ public class InputStreamBinder extends IInputStreamService.Stub {
                    new IllegalStateException("URL need to start with a /"));
        }

        if (AccountManagerUtils.isOidcAccount(context, account)) {
            // Blocking call
            OidcTokenRefresher.refresh(context, account);
        }

        final OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton();
        final OwnCloudAccount ownCloudAccount = new OwnCloudAccount(account, context);
        final OwnCloudClient client = ownCloudClientManager.getClientFor(ownCloudAccount, context, OwnCloudClient.DONT_USE_COOKIES);
@@ -424,6 +428,11 @@ public class InputStreamBinder extends IInputStreamService.Stub {
                    new IllegalStateException("URL need to start with a /"));
        }

        if (AccountManagerUtils.isOidcAccount(context, account)) {
            // Blocking call
            OidcTokenRefresher.refresh(context, account);
        }

        final OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton();
        final OwnCloudAccount ownCloudAccount = new OwnCloudAccount(account, context);
        final OwnCloudClient client = ownCloudClientManager.getClientFor(ownCloudAccount, context, OwnCloudClient.DONT_USE_COOKIES);
+128 −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 com.nextcloud.android.sso

import android.accounts.Account
import android.content.Context
import at.bitfire.davdroid.OpenIdUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient.HttpClientEntryPoint
import at.bitfire.davdroid.settings.AccountSettings
import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.runBlocking
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
import net.openid.appauth.ClientAuthentication
import org.jetbrains.annotations.Blocking
import java.util.logging.Level
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

/**
 * Utility for refreshing OpenID Connect (OIDC) tokens in the Android AccountManager.
 *
 * This object exposes a synchronous, blocking entry point for token refresh requests
 * and internally uses coroutines to perform the refresh operation with proper
 * callback-to-suspension conversion.
 */
object OidcTokenRefresher {

    /**
     * Refreshes the OIDC token for the given [Account].
     *
     * It will:
     * 1. Invoke the authorization service to refresh tokens.
     * 2. Update AccountManager on successful refresh or log failures.
     *
     * **Threading:** This method uses [runBlocking] and therefore must **not** be
     * called from the Main/UI thread. It is annotated with `@Blocking` to signal
     * blocking behavior.
     */
    @JvmStatic
    @Blocking
    fun refresh(context: Context, account: Account) {
        runBlocking {
            val accountSettings = AccountSettings(context, account)
            val credentials = accountSettings.credentials()
            val authState = credentials.authState

            if (authState == null) {
                Logger.log.log(Level.FINE, "Account: $account has null AuthState, refresh isn't possible.")
                return@runBlocking
            }

            val authorizationService =
                EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java)
                    .authorizationService()
            val clientAuth = OpenIdUtils.getClientAuthentication(credentials.clientSecret)

            val updatedAuthState = runCatching {
                refreshAuthState(authorizationService, authState, clientAuth)
            }.getOrNull()

            if (updatedAuthState != null) {
                updateAndroidAccountManagerAuthState(accountSettings, updatedAuthState)
            } else {
                Logger.log.warning("Couldn't update AuthState for account: $account")
            }
        }
    }

    /**
     * Suspends until the authState has fresh tokens from AuthorizationService.
     *
     * Internally it bridges the callback-based `performActionWithFreshTokens`
     * API into a coroutine suspension using [suspendCoroutine]. On success, it
     * resumes with the same [AuthState] instance containing updated tokens. On
     * failure, it throws the encountered [Throwable].
     *
     * @param authService The [AuthorizationService] to use for token refresh.
     * @param authState   The current [AuthState] containing existing tokens.
     * @param clientAuth  [ClientAuthentication] mechanism (e.g., client secret).
     * @return The same [AuthState] instance with refreshed tokens.
     * @throws Exception if the refresh operation fails.
     */
    private suspend fun refreshAuthState(
        authService: AuthorizationService, authState: AuthState, clientAuth: ClientAuthentication
    ): AuthState {
        return suspendCoroutine { continuation ->
            authState.performActionWithFreshTokens(
                authService,
                clientAuth
            ) { accessToken, _, authorizationException ->
                when {
                    accessToken != null -> continuation.resume(authState)
                    authorizationException != null -> continuation.resumeWithException(
                        authorizationException
                    )
                }
            }
        }
    }

    /**
     * Persists an updated [AuthState] back into the Android AccountManager.
     */
    private fun updateAndroidAccountManagerAuthState(
        accountSettings: AccountSettings, updatedAuthState: AuthState
    ) = accountSettings.credentials(
        accountSettings.credentials().copy(authState = updatedAuthState)
    )
}