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

Commit e4de98c0 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

Merge branch '4085-fix_notes_app_401_for_oidc_login' into 'main'

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

See merge request !155
parents f979e8a3 a75607ca
Loading
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)
    )
}