diff --git a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java index 5f45166d2eed88d26fc5afca64c62a3e11392e40..5e7b80e3e7bbcf1646ddf3edee1bd9f0c95a65f7 100644 --- a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java +++ b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java @@ -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); diff --git a/app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt b/app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt new file mode 100644 index 0000000000000000000000000000000000000000..3b180bb917b9945cb6bfaed076ead4eb69e44cc9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt @@ -0,0 +1,128 @@ +/* + * 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 . + * + */ + +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) + ) +}