Loading app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java +10 −1 Original line number Diff line number Diff line Loading @@ -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 { Loading Loading @@ -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); Loading Loading @@ -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); Loading app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt 0 → 100644 +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) ) } Loading
app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java +10 −1 Original line number Diff line number Diff line Loading @@ -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 { Loading Loading @@ -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); Loading Loading @@ -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); Loading
app/src/main/java/com/nextcloud/android/sso/OidcTokenRefresher.kt 0 → 100644 +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) ) }