From cead9c5931f67d98d761061a22f20bb4a44495d9 Mon Sep 17 00:00:00 2001 From: Paolo Stivanin Date: Sun, 11 Oct 2026 10:59:52 +0200 Subject: [PATCH] Token refresh: Do not report transient failures as expired token Any failed silent token refresh (network error, timeout, IdP 5xx, failed OIDC discovery) ended in an empty bearer token, a second 401 and the "token expired, sign in again" prompt. Background uploads were marked as permanently failed. - AccountAuthenticator now tells a refresh token rejected by the IdP (invalid_grant and similar) from a transient failure. Only the former asks for a new login. The latter is reported as a connection problem and the stored tokens are kept. - ConnectionValidator no longer counts a failed refresh as a success and does not retry with stale credentials. - mTLS: the validation probe and the OIDC discovery and refresh requests now present the account's client certificate. - Serialize refreshes with a lock and store the rotated refresh token before the access token. - Stop logging tokens. --- .../authentication/AccountAuthenticator.java | 82 +++++++-- .../ui/activity/FileDisplayActivity.kt | 28 +-- .../AccountAuthenticatorTest.kt | 61 ++++++ .../android/lib/common/ConnectionValidator.kt | 120 ++++++++---- .../android/lib/common/OpenCloudAccount.java | 8 +- .../lib/common/accounts/AccountUtils.java | 16 +- .../accounts/TokenRefreshFailedException.kt | 37 ++++ .../android/lib/common/http/HttpClient.java | 7 + .../oauth/TokenRequestRemoteOperation.kt | 21 ++- .../lib/common/ConnectionValidatorTest.kt | 174 ++++++++++++++++++ .../oauth/OAuthRemoteOperationTest.kt | 73 ++++++++ .../opencloud/android/data/ClientManager.kt | 10 +- .../datasources/RemoteOAuthDataSource.kt | 2 +- .../implementation/OCRemoteOAuthDataSource.kt | 11 +- .../oauth/repository/OCOAuthRepository.kt | 4 +- .../authentication/oauth/OAuthRepository.kt | 2 +- .../oauth/OIDCDiscoveryUseCase.kt | 5 +- .../oauth/model/TokenRequest.kt | 4 +- 18 files changed, 574 insertions(+), 91 deletions(-) create mode 100644 opencloudApp/src/test/java/eu/opencloud/android/presentation/authentication/AccountAuthenticatorTest.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/accounts/TokenRefreshFailedException.kt create mode 100644 opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/ConnectionValidatorTest.kt diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AccountAuthenticator.java b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AccountAuthenticator.java index 8f408e6ea6..2bf1f317be 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AccountAuthenticator.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AccountAuthenticator.java @@ -42,6 +42,9 @@ import eu.opencloud.android.domain.authentication.oauth.model.OIDCServerConfiguration; import eu.opencloud.android.domain.authentication.oauth.model.TokenRequest; import eu.opencloud.android.domain.authentication.oauth.model.TokenResponse; +import eu.opencloud.android.domain.exceptions.OAuth2ErrorAccessDeniedException; +import eu.opencloud.android.domain.exceptions.OAuth2ErrorException; +import eu.opencloud.android.domain.exceptions.UnauthorizedException; import eu.opencloud.android.lib.common.accounts.AccountTypeUtils; import eu.opencloud.android.lib.common.accounts.AccountUtils; import kotlin.Lazy; @@ -74,6 +77,13 @@ public class AccountAuthenticator extends AbstractAccountAuthenticator { private static final String KEY_REQUIRED_FEATURES = "requiredFeatures"; public static final String KEY_ACCOUNT = "account"; + /** + * Serializes the refresh of access tokens. Refresh tokens can be single-use (rotation), so two refreshes + * running at once with the same refresh token make the second one fail with invalid_grant and may even + * revoke the whole session at the IdP. + */ + private static final Object REFRESH_TOKEN_LOCK = new Object(); + private Context mContext; AccountAuthenticator(Context context) { @@ -152,7 +162,7 @@ public Bundle editProperties(AccountAuthenticatorResponse response, */ @Override public Bundle getAuthToken(AccountAuthenticatorResponse accountAuthenticatorResponse, - Account account, String authTokenType, Bundle options) { + Account account, String authTokenType, Bundle options) throws NetworkErrorException { /// validate parameters try { validateAccountType(account.type); @@ -174,7 +184,15 @@ public Bundle getAuthToken(AccountAuthenticatorResponse accountAuthenticatorResp // this account, null will be returned accessToken = accountManager.peekAuthToken(account, authTokenType); if (accessToken == null && canBeRefreshed(authTokenType) && clientSecretIsValid(accountManager, account)) { - accessToken = refreshToken(account, authTokenType, accountManager); + synchronized (REFRESH_TOKEN_LOCK) { + // Another thread may have refreshed the token while this one was waiting for the lock + accessToken = accountManager.peekAuthToken(account, authTokenType); + if (accessToken == null) { + // Throws NetworkErrorException if the failure is transient, so the caller does not mistake + // it for an expired session + accessToken = refreshToken(account, authTokenType, accountManager); + } + } } } @@ -315,11 +333,19 @@ private boolean canBeRefreshed(String authTokenType) { getAccountType()))); } + /** + * Exchanges the stored refresh token for a new access token. + * + * @return the new access token, or null if the session is definitely over (no refresh token stored, or the IdP + * rejected it) and the user needs to log in again. + * @throws NetworkErrorException if the refresh failed for a transient reason (no network, timeout, server + * error...). The stored tokens are untouched and a later attempt may succeed. + */ private String refreshToken( Account account, String authTokenType, AccountManager accountManager - ) { + ) throws NetworkErrorException { // Prepare everything to perform the token request String refreshToken = accountManager.getUserData(account, KEY_OAUTH2_REFRESH_TOKEN); @@ -329,18 +355,21 @@ private String refreshToken( return null; } - Timber.d("Ready to exchange for new tokens. Account: [ %s ], Refresh token: [ %s ]", account.name, - refreshToken); + Timber.d("Ready to exchange for new tokens. Account: [ %s ]", account.name); String baseUrl = accountManager.getUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL); + // Servers requiring mTLS reject discovery and token requests that do not present the account's certificate + String clientCertAlias = AccountUtils.getClientCertAliasForAccount(mContext, account); + // OIDC Discovery: prefer the stored webfinger issuer (points to the real IDP), // fall back to baseUrl for accounts created before webfinger support. String oidcIssuer = accountManager.getUserData(account, KEY_OIDC_ISSUER); String discoveryUrl = (oidcIssuer != null) ? oidcIssuer : baseUrl; @NotNull Lazy oidcDiscoveryUseCase = inject(OIDCDiscoveryUseCase.class); - OIDCDiscoveryUseCase.Params oidcDiscoveryUseCaseParams = new OIDCDiscoveryUseCase.Params(discoveryUrl); + OIDCDiscoveryUseCase.Params oidcDiscoveryUseCaseParams = + new OIDCDiscoveryUseCase.Params(discoveryUrl, clientCertAlias); UseCaseResult oidcServerConfigurationUseCaseResult = oidcDiscoveryUseCase.getValue().invoke(oidcDiscoveryUseCaseParams); @@ -385,8 +414,14 @@ private String refreshToken( clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId); } } else { - Timber.d("OIDC Discovery failed. Server discovery info: [ %s ]", - oidcServerConfigurationUseCaseResult.getThrowableOrNull().toString()); + Throwable discoveryError = oidcServerConfigurationUseCaseResult.getThrowableOrNull(); + Timber.d("OIDC Discovery failed. Server discovery info: [ %s ]", String.valueOf(discoveryError)); + + if (oidcIssuer != null) { + // The IdP is known, so the legacy ownCloud endpoint below can not be the right one: the failure is + // just the IdP being unreachable for now + throw new NetworkErrorException("OIDC discovery failed for " + oidcIssuer, discoveryError); + } tokenEndpoint = baseUrl + File.separator + mContext.getString(R.string.oauth2_url_endpoint_access); clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId); @@ -404,7 +439,8 @@ private String refreshToken( scope, clientIdForRequest, clientSecretForRequest, - refreshToken + refreshToken, + clientCertAlias ); // Token exchange @@ -416,10 +452,26 @@ private String refreshToken( if (safeTokenResponse != null) { return handleSuccessfulRefreshToken(safeTokenResponse, account, authTokenType, accountManager, refreshToken); - } else { - Timber.e(tokenResponseResult.getThrowableOrNull(), "OAuth request to refresh access token failed. Preparing to access Login Activity"); + } + + Throwable error = tokenResponseResult.getThrowableOrNull(); + if (isRefreshTokenRejected(error)) { + Timber.e(error, "OAuth refresh token was rejected. Preparing to access Login Activity"); return null; } + + Timber.w(error, "OAuth request to refresh access token failed, but the refresh token was not rejected"); + throw new NetworkErrorException("Could not refresh the access token for " + account.name, error); + } + + /** + * Tells whether a failed token request means the refresh token (or the client) is not valid anymore, as opposed + * to a transient problem like no connection, a timeout or a server error. + */ + static boolean isRefreshTokenRejected(Throwable error) { + return error instanceof OAuth2ErrorException + || error instanceof OAuth2ErrorAccessDeniedException + || error instanceof UnauthorizedException; } private String handleSuccessfulRefreshToken( @@ -430,7 +482,6 @@ private String handleSuccessfulRefreshToken( String oldRefreshToken ) { String newAccessToken = tokenResponse.getAccessToken(); - accountManager.setAuthToken(account, authTokenType, newAccessToken); String refreshTokenToUseFromNowOn; if (tokenResponse.getRefreshToken() != null) { @@ -438,10 +489,13 @@ private String handleSuccessfulRefreshToken( } else { refreshTokenToUseFromNowOn = oldRefreshToken; } + + // The refresh token goes first: it may have been rotated, which makes the old one useless. If the process + // dies before the access token is stored, the next call finds no access token and refreshes with it. accountManager.setUserData(account, KEY_OAUTH2_REFRESH_TOKEN, refreshTokenToUseFromNowOn); + accountManager.setAuthToken(account, authTokenType, newAccessToken); - Timber.d("Token refreshed successfully. New access token: [ %s ]. New refresh token: [ %s ]", - newAccessToken, refreshTokenToUseFromNowOn); + Timber.d("Token refreshed successfully for account [ %s ]", account.name); return newAccessToken; } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt index a4c9c95f16..3a3d2f3595 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt @@ -89,7 +89,6 @@ import eu.opencloud.android.extensions.SNACKBAR_DURATION_ERROR_MS import eu.opencloud.android.extensions.showErrorInSnackbar import eu.opencloud.android.extensions.showMessageInSnackbar import eu.opencloud.android.lib.common.accounts.AccountUtils -import eu.opencloud.android.lib.common.authentication.OpenCloudBearerCredentials import eu.opencloud.android.lib.common.network.CertificateCombinedException import eu.opencloud.android.lib.common.operations.RemoteOperationResult import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode @@ -1413,16 +1412,10 @@ class FileDisplayActivity : FileActivity(), } is UnauthorizedException -> { - launch(Dispatchers.IO) { - val credentials = AccountUtils.getCredentialsForAccount(MainApp.appContext, account) - - launch(Dispatchers.Main) { - if (credentials is OpenCloudBearerCredentials) { // OAuth - showRequestRegainAccess() - } else { - showRequestAccountChangeNotice(getString(R.string.auth_failure_snackbar), false) - } - } + if (AccountUtils.isOAuth2Account(MainApp.appContext, account)) { + showRequestRegainAccess() + } else { + showRequestAccountChangeNotice(getString(R.string.auth_failure_snackbar), false) } } @@ -1522,15 +1515,10 @@ class FileDisplayActivity : FileActivity(), is UIResult.Error -> { when (uiResult.error) { is UnauthorizedException -> { - launch(Dispatchers.IO) { - val credentials = AccountUtils.getCredentialsForAccount(MainApp.appContext, account) - launch(Dispatchers.Main) { - if (credentials is OpenCloudBearerCredentials) { // OAuth - showRequestRegainAccess() - } else { - showRequestAccountChangeNotice(getString(R.string.auth_failure_snackbar), false) - } - } + if (AccountUtils.isOAuth2Account(MainApp.appContext, account)) { + showRequestRegainAccess() + } else { + showRequestAccountChangeNotice(getString(R.string.auth_failure_snackbar), false) } } diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/authentication/AccountAuthenticatorTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/authentication/AccountAuthenticatorTest.kt new file mode 100644 index 0000000000..cf4ec8ebbd --- /dev/null +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/authentication/AccountAuthenticatorTest.kt @@ -0,0 +1,61 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * 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 eu.opencloud.android.presentation.authentication + +import eu.opencloud.android.domain.exceptions.NoConnectionWithServerException +import eu.opencloud.android.domain.exceptions.OAuth2ErrorAccessDeniedException +import eu.opencloud.android.domain.exceptions.OAuth2ErrorException +import eu.opencloud.android.domain.exceptions.ServerConnectionTimeoutException +import eu.opencloud.android.domain.exceptions.ServerNotReachableException +import eu.opencloud.android.domain.exceptions.ServiceUnavailableException +import eu.opencloud.android.domain.exceptions.UnauthorizedException +import eu.opencloud.android.domain.exceptions.UnhandledHttpCodeException +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Whether a failed token refresh is "the session is over" or "try again later" decides if the user is sent to the + * login screen. Only a refusal by the IdP may do that. + */ +class AccountAuthenticatorTest { + + @Test + fun `refresh token rejected by the IdP is a permanent failure`() { + assertTrue(AccountAuthenticator.isRefreshTokenRejected(OAuth2ErrorException())) + assertTrue(AccountAuthenticator.isRefreshTokenRejected(OAuth2ErrorAccessDeniedException())) + assertTrue(AccountAuthenticator.isRefreshTokenRejected(UnauthorizedException())) + } + + @Test + fun `connection problems and server errors are transient failures`() { + assertFalse(AccountAuthenticator.isRefreshTokenRejected(NoConnectionWithServerException())) + assertFalse(AccountAuthenticator.isRefreshTokenRejected(ServerConnectionTimeoutException())) + assertFalse(AccountAuthenticator.isRefreshTokenRejected(ServerNotReachableException())) + assertFalse(AccountAuthenticator.isRefreshTokenRejected(ServiceUnavailableException())) + assertFalse(AccountAuthenticator.isRefreshTokenRejected(UnhandledHttpCodeException(500))) + assertFalse(AccountAuthenticator.isRefreshTokenRejected(UnhandledHttpCodeException(400))) + } + + @Test + fun `unknown failure or no failure at all is not a rejection`() { + assertFalse(AccountAuthenticator.isRefreshTokenRejected(RuntimeException("boom"))) + assertFalse(AccountAuthenticator.isRefreshTokenRejected(null)) + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/ConnectionValidator.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/ConnectionValidator.kt index 1448b8afd9..6611be116b 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/ConnectionValidator.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/ConnectionValidator.kt @@ -27,6 +27,8 @@ package eu.opencloud.android.lib.common import android.accounts.AccountManager import android.accounts.AccountsException import android.content.Context +import eu.opencloud.android.lib.common.accounts.TokenRefreshFailedException +import eu.opencloud.android.lib.common.authentication.OpenCloudBearerCredentials import eu.opencloud.android.lib.common.authentication.OpenCloudCredentials import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory.OpenCloudAnonymousCredentials import eu.opencloud.android.lib.common.http.HttpConstants @@ -59,6 +61,9 @@ class ConnectionValidator( client.account = baseClient.account client.credentials = baseClient.credentials + // The probes must present the same client certificate as the client they validate, otherwise a server + // requiring mTLS rejects them and the expired token is never detected nor refreshed + client.clientCertAlias = baseClient.clientCertAlias while (validationRetryCount < VALIDATION_RETRY_COUNT) { Timber.d("validationRetryCount %d", validationRetryCount) var successCounter = 0 @@ -83,8 +88,12 @@ class ConnectionValidator( } } else { failCounter++ - if (contentReply.httpCode == HttpConstants.HTTP_UNAUTHORIZED) { - checkUnauthorizedAccess(client, singleSessionManager, contentReply.httpCode) + if (contentReply.httpCode == HttpConstants.HTTP_UNAUTHORIZED && + checkUnauthorizedAccess(client, singleSessionManager, contentReply.httpCode) == RefreshResult.FAILED + ) { + // The token was rejected and could not be renewed. Do not retry the request with stale + // credentials, let the 401 surface. + return false } } } @@ -96,6 +105,9 @@ class ConnectionValidator( validationRetryCount++ } Timber.d("Could not authenticate or get valid data from opencloud") + } catch (e: TokenRefreshFailedException) { + // Transient failure renewing the token: not a validation result, the caller must report a connection problem + throw e } catch (e: Exception) { Timber.d(ExceptionUtils.getStackTrace(e)) } @@ -161,7 +173,11 @@ class ConnectionValidator( account.savedAccount.type, credentials.authToken ) - am.clearPassword(account.savedAccount) // being strict, only needed for Basic Auth credentials + if (credentials !is OpenCloudBearerCredentials) { + // Only needed for Basic Auth credentials. For OAuth it would also wipe the access token that a + // concurrent request may have just refreshed, forcing a pointless second refresh. + am.clearPassword(account.savedAccount) + } } /** @@ -173,49 +189,73 @@ class ConnectionValidator( * * Refresh current credentials if possible, and marks a retry. * - * @return + * @return [RefreshResult.REFRESHED] if fresh credentials were set in the client, + * [RefreshResult.FAILED] if the token was rejected and the authenticator could not renew it (the user has to + * log in again), or [RefreshResult.NOT_ATTEMPTED] if there was nothing to refresh. + * @throws TokenRefreshFailedException if the refresh failed for a transient reason (no network, server error...) + * and the session is therefore not known to be expired. */ - private fun checkUnauthorizedAccess(client: OpenCloudClient, singleSessionManager: SingleSessionManager, status: Int): Boolean { - var credentialsWereRefreshed = false + private fun checkUnauthorizedAccess(client: OpenCloudClient, singleSessionManager: SingleSessionManager, status: Int): RefreshResult { // During initial OAuth login the client has bearer credentials but no Android Account yet // (the Account is persisted only after this call succeeds). Skip refresh — there's nothing // to refresh against — otherwise we'd NPE on account.credentials / savedAccount below. - val account: OpenCloudAccount = client.account ?: return false - val credentials: OpenCloudCredentials = account.credentials ?: return false - if (shouldInvalidateAccountCredentials(credentials, account, status)) { - invalidateAccountCredentials(account, credentials) - - if (credentials.authTokenCanBeRefreshed()) { - try { - // This command does the actual refresh - Timber.i("Trying to refresh auth token for account $account") - account.loadCredentials(context) - // if mAccount.getCredentials().length() == 0 --> refresh failed - client.credentials = account.credentials - credentialsWereRefreshed = true - } catch (e: AccountsException) { - Timber.e( - e, "Error while trying to refresh auth token for %s\ntrace: %s", - account.savedAccount.name, - ExceptionUtils.getStackTrace(e) - ) - } catch (e: IOException) { - Timber.e( - e, "Error while trying to refresh auth token for %s\ntrace: %s", - account.savedAccount.name, - ExceptionUtils.getStackTrace(e) - ) - } - if (!credentialsWereRefreshed) { - // if credentials are not refreshed, client must be removed - // from the OpenCloudClientManager to prevent it is reused once and again - Timber.w("Credentials were not refreshed, client will be removed from the Session Manager to prevent using it over and over") - singleSessionManager.removeClientFor(account) - } + val account: OpenCloudAccount = client.account ?: return RefreshResult.NOT_ATTEMPTED + val credentials: OpenCloudCredentials = account.credentials ?: return RefreshResult.NOT_ATTEMPTED + if (!shouldInvalidateAccountCredentials(credentials, account, status)) { + return RefreshResult.NOT_ATTEMPTED + } + invalidateAccountCredentials(account, credentials) + + if (!credentials.authTokenCanBeRefreshed()) { + // onExecute will finish with status 401 + return RefreshResult.NOT_ATTEMPTED + } + + var credentialsWereRefreshed = false + var transientFailure: TokenRefreshFailedException? = null + try { + // This command does the actual refresh + Timber.i("Trying to refresh auth token for account $account") + account.loadCredentials(context) + // An empty token means the refresh was refused: the authenticator then only offers the login screen + credentialsWereRefreshed = !account.credentials?.authToken.isNullOrEmpty() + if (credentialsWereRefreshed) { + client.credentials = account.credentials } - // else: onExecute will finish with status 401 + } catch (e: TokenRefreshFailedException) { + Timber.w(e, "Transient error while trying to refresh auth token for %s", account.savedAccount.name) + transientFailure = e + } catch (e: AccountsException) { + Timber.e( + e, "Error while trying to refresh auth token for %s\ntrace: %s", + account.savedAccount.name, + ExceptionUtils.getStackTrace(e) + ) + } catch (e: IOException) { + Timber.e( + e, "Error while trying to refresh auth token for %s\ntrace: %s", + account.savedAccount.name, + ExceptionUtils.getStackTrace(e) + ) + } + + if (credentialsWereRefreshed) { + return RefreshResult.REFRESHED } - return credentialsWereRefreshed + + // if credentials are not refreshed, client must be removed + // from the OpenCloudClientManager to prevent it is reused once and again + Timber.w("Credentials were not refreshed, client will be removed from the Session Manager to prevent using it over and over") + singleSessionManager.removeClientFor(account) + + transientFailure?.let { throw it } + return RefreshResult.FAILED + } + + private enum class RefreshResult { + NOT_ATTEMPTED, + REFRESHED, + FAILED } companion object { diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudAccount.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudAccount.java index 7a17a088f9..6b4f5f2a2e 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudAccount.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/OpenCloudAccount.java @@ -33,6 +33,7 @@ import eu.opencloud.android.lib.common.accounts.AccountUtils; import eu.opencloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException; +import eu.opencloud.android.lib.common.accounts.TokenRefreshFailedException; import eu.opencloud.android.lib.common.authentication.OpenCloudCredentials; import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory; @@ -118,7 +119,12 @@ public void loadCredentials(Context context) throws AuthenticatorException, IOEx } if (mSavedAccount != null) { - mCredentials = AccountUtils.getCredentialsForAccount(context, mSavedAccount); + try { + mCredentials = AccountUtils.getCredentialsForAccount(context, mSavedAccount); + } catch (IOException e) { + // The authenticator reports a transient failure refreshing the token as an IOException + throw new TokenRefreshFailedException("Could not obtain a token for " + mSavedAccount.name, e); + } } } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/accounts/AccountUtils.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/accounts/AccountUtils.java index 957dd59f01..41b1980f38 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/accounts/AccountUtils.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/accounts/AccountUtils.java @@ -106,8 +106,7 @@ public static OpenCloudCredentials getCredentialsForAccount(Context context, Acc OpenCloudCredentials credentials; AccountManager am = AccountManager.get(context); - String supportsOAuth2 = am.getUserData(account, AccountUtils.Constants.KEY_SUPPORTS_OAUTH2); - boolean isOauth2 = supportsOAuth2 != null && supportsOAuth2.equals(Constants.OAUTH_SUPPORTED_TRUE); + boolean isOauth2 = isOAuth2Account(context, account); String username = AccountUtils.getUsernameForAccount(account); @@ -134,6 +133,19 @@ public static OpenCloudCredentials getCredentialsForAccount(Context context, Acc return credentials; } + /** + * Tells whether the account authenticates with OAuth2, without touching its tokens. Unlike + * {@link #getCredentialsForAccount(Context, Account)} it never triggers a token refresh. + * + * @param context Valid Android {@link Context}, needed to access the {@link AccountManager} + * @param account A stored openCloud {@link Account} + * @return true if the account uses OAuth2 + */ + public static boolean isOAuth2Account(Context context, Account account) { + String supportsOAuth2 = AccountManager.get(context).getUserData(account, Constants.KEY_SUPPORTS_OAUTH2); + return Constants.OAUTH_SUPPORTED_TRUE.equals(supportsOAuth2); + } + /** * Get the user id corresponding to an OC account. * diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/accounts/TokenRefreshFailedException.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/accounts/TokenRefreshFailedException.kt new file mode 100644 index 0000000000..13c2f3782c --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/accounts/TokenRefreshFailedException.kt @@ -0,0 +1,37 @@ +/* openCloud Android Library is available under MIT license + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ +package eu.opencloud.android.lib.common.accounts + +import java.net.SocketException + +/** + * The silent refresh of an access token could not be completed for a transient reason (no network, timeout, + * server error...). The stored refresh token is still considered valid, so this must NOT be treated as an + * expired session. It extends [SocketException] so it is reported like any other connection problem, which + * callers already know how to retry. + */ +class TokenRefreshFailedException(message: String?, cause: Throwable?) : SocketException(message) { + init { + cause?.let { initCause(it) } + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpClient.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpClient.java index 702e3b0654..c593858def 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpClient.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/HttpClient.java @@ -111,6 +111,13 @@ public synchronized void invalidate() { mOkHttpClient = null; } + /** + * @return the alias (from the Android KeyChain) of the client certificate presented for mTLS, or null when none. + */ + public synchronized String getClientCertAlias() { + return mClientCertAlias; + } + /** * Sets the alias (from the Android KeyChain) of the client certificate to present for mTLS. * Invalidates the cached client when the alias changes so it is rebuilt with the new certificate. diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/TokenRequestRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/TokenRequestRemoteOperation.kt index a35b8bef0a..48723f43ce 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/TokenRequestRemoteOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/TokenRequestRemoteOperation.kt @@ -77,7 +77,12 @@ class TokenRequestRemoteOperation( } else { Timber.e("Failed response while getting tokens from the server status code: $status; response message: $responseBody") - RemoteOperationResult(postMethod) + if (tokenRequestParams is TokenRequestParams.RefreshToken && isRefreshTokenRejected(responseBody)) { + // The IdP says the refresh token (or the client) is not valid anymore: it will never work again + RemoteOperationResult(RemoteOperationResult.ResultCode.OAUTH2_ERROR) + } else { + RemoteOperationResult(postMethod) + } } } catch (e: Exception) { @@ -86,4 +91,18 @@ class TokenRequestRemoteOperation( } } + + companion object { + private val OAUTH_ERROR_REGEX = Regex("\"error\"\\s*:\\s*\"([^\"]+)\"") + + /** Error codes of RFC 6749 section 5.2 that no retry can fix, the user has to authorize again. */ + private val REJECTED_REFRESH_TOKEN_ERRORS = setOf("invalid_grant", "invalid_client", "unauthorized_client", "access_denied") + + /** + * @return true if the body of a failed token request carries an OAuth error telling that the refresh token + * was rejected for good. Transient failures (5xx, proxies, timeouts...) do not match. + */ + internal fun isRefreshTokenRejected(responseBody: String?): Boolean = + responseBody?.let { OAUTH_ERROR_REGEX.find(it)?.groupValues?.get(1) } in REJECTED_REFRESH_TOKEN_ERRORS + } } diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/ConnectionValidatorTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/ConnectionValidatorTest.kt new file mode 100644 index 0000000000..822eb4f0de --- /dev/null +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/ConnectionValidatorTest.kt @@ -0,0 +1,174 @@ +/* openCloud Android Library is available under MIT license + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package eu.opencloud.android.lib.common + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.test.core.app.ApplicationProvider +import eu.opencloud.android.lib.common.accounts.AccountUtils +import eu.opencloud.android.lib.common.accounts.TokenRefreshFailedException +import eu.opencloud.android.lib.common.authentication.OpenCloudCredentials +import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Collections + +/** + * When a request gets a 401 the validator renews the token. Its outcome decides what the user sees, so a failed + * renewal must be told apart: a transient problem (no network, IdP down) must not look like an expired session. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE) +class ConnectionValidatorTest { + + private val context: Context by lazy { ApplicationProvider.getApplicationContext() } + private lateinit var server: MockWebServer + private val propfindAuthHeaders: MutableList = Collections.synchronizedList(mutableListOf()) + + private val validator by lazy { ConnectionValidator(context, false) } + + @Before + fun setUp() { + server = MockWebServer() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = when { + request.path == "/status.php" -> { + MockResponse().setResponseCode(200).setBody(STATUS_JSON) + } + request.method == "PROPFIND" -> { + propfindAuthHeaders.add(request.getHeader("Authorization")) + if (request.getHeader("Authorization") == "Bearer $NEW_TOKEN") { + MockResponse().setResponseCode(207).setBody(MULTISTATUS) + } else { + MockResponse().setResponseCode(401) + } + } + else -> { + MockResponse().setResponseCode(404) + } + } + } + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `transient failure renewing the token is not swallowed`() { + val client = clientWithAccount { throw TokenRefreshFailedException("IdP is down", null) } + + assertThrows(TokenRefreshFailedException::class.java) { + validator.validate(client, SingleSessionManager.getDefaultSingleton(), context) + } + assertEquals("the failed renewal must not trigger a retry of the probe", 1, propfindAuthHeaders.size) + } + + @Test + fun `rejected token renewal fails the validation without retrying with stale credentials`() { + // The authenticator only offers the login screen, so the account ends up with an empty token + val client = clientWithAccount { bearer("") } + + val valid = validator.validate(client, SingleSessionManager.getDefaultSingleton(), context) + + assertFalse(valid) + assertEquals(1, propfindAuthHeaders.size) + } + + @Test + fun `successful token renewal validates and hands the new token to the client`() { + val client = clientWithAccount { bearer(NEW_TOKEN) } + + val valid = validator.validate(client, SingleSessionManager.getDefaultSingleton(), context) + + assertTrue(valid) + assertEquals(NEW_TOKEN, client.credentials.authToken) + } + + private fun clientWithAccount(onLoadCredentials: () -> OpenCloudCredentials): OpenCloudClient { + val androidAccount = Account("user@localhost", ACCOUNT_TYPE) + AccountManager.get(context).addAccountExplicitly( + androidAccount, + null, + Bundle().apply { + putString(AccountUtils.Constants.KEY_OC_BASE_URL, baseUrl()) + putString(AccountUtils.Constants.KEY_ID, "user") + } + ) + val account = ReloadableAccount(androidAccount, context, bearer(OLD_TOKEN), onLoadCredentials) + return OpenCloudClient(Uri.parse(baseUrl()), validator, true, SingleSessionManager.getDefaultSingleton(), context).apply { + this.account = account + credentials = account.credentials + } + } + + private fun baseUrl() = server.url("/").toString().removeSuffix("/") + + private fun bearer(token: String) = OpenCloudCredentialsFactory.newBearerCredentials("user", token) + + /** Account whose "reload from the AccountManager" is scripted by the test. */ + private class ReloadableAccount( + savedAccount: Account, + context: Context, + private var currentCredentials: OpenCloudCredentials, + private val onLoadCredentials: () -> OpenCloudCredentials + ) : OpenCloudAccount(savedAccount, context) { + override fun getCredentials(): OpenCloudCredentials = currentCredentials + + override fun loadCredentials(context: Context) { + currentCredentials = onLoadCredentials() + } + } + + companion object { + private const val ACCOUNT_TYPE = "eu.opencloud.test" + private const val OLD_TOKEN = "old-token" + private const val NEW_TOKEN = "new-token" + private const val STATUS_JSON = """{"installed":true,"version":"10.0.0.0","productversion":"1.0.0"}""" + private const val MULTISTATUS = """ + + + /remote.php/dav/files/user/ + HTTP/1.1 200 OK + +""" + } +} diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/oauth/OAuthRemoteOperationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/oauth/OAuthRemoteOperationTest.kt index f007bfe464..3a33238695 100644 --- a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/oauth/OAuthRemoteOperationTest.kt +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/oauth/OAuthRemoteOperationTest.kt @@ -28,6 +28,7 @@ import android.net.Uri import androidx.test.core.app.ApplicationProvider import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.http.HttpConstants.AUTHORIZATION_HEADER +import eu.opencloud.android.lib.common.operations.RemoteOperationResult import eu.opencloud.android.lib.resources.oauth.params.ClientRegistrationParams import eu.opencloud.android.lib.resources.oauth.params.TokenRequestParams import okhttp3.mockwebserver.MockResponse @@ -36,6 +37,7 @@ import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -143,6 +145,67 @@ class OAuthRemoteOperationTest { assertTrue(result.exception != null) } + @Test + fun refreshTokenRequest_invalidGrant_isReportedAsOAuth2Error() { + server.enqueue(MockResponse().setResponseCode(400).setBody("""{"error":"invalid_grant","error_description":"Token is not active"}""")) + + val result = TokenRequestRemoteOperation(refreshParams()).execute(newClient()) + + assertFalse(result.isSuccess) + assertEquals(RemoteOperationResult.ResultCode.OAUTH2_ERROR, result.code) + } + + @Test + fun refreshTokenRequest_invalidClientWithUnauthorizedStatus_isReportedAsOAuth2Error() { + server.enqueue(MockResponse().setResponseCode(401).setBody("""{ "error" : "invalid_client" }""")) + + val result = TokenRequestRemoteOperation(refreshParams()).execute(newClient()) + + assertEquals(RemoteOperationResult.ResultCode.OAUTH2_ERROR, result.code) + } + + @Test + fun refreshTokenRequest_serverError_isNotReportedAsRejectedToken() { + server.enqueue(MockResponse().setResponseCode(500).setBody("""{"error":"server_error"}""")) + + val result = TokenRequestRemoteOperation(refreshParams()).execute(newClient()) + + assertFalse(result.isSuccess) + assertEquals(RemoteOperationResult.ResultCode.UNHANDLED_HTTP_CODE, result.code) + } + + @Test + fun refreshTokenRequest_badRequestFromProxyWithoutOAuthError_isNotReportedAsRejectedToken() { + server.enqueue(MockResponse().setResponseCode(400).setBody("400 No required SSL certificate was sent")) + + val result = TokenRequestRemoteOperation(refreshParams()).execute(newClient()) + + assertFalse(result.isSuccess) + assertNotEquals(RemoteOperationResult.ResultCode.OAUTH2_ERROR, result.code) + } + + @Test + fun authorizationTokenRequest_invalidGrant_keepsGenericHttpErrorMapping() { + server.enqueue(MockResponse().setResponseCode(400).setBody("""{"error":"invalid_grant"}""")) + + val result = TokenRequestRemoteOperation( + TokenRequestParams.Authorization( + tokenEndpoint = server.url("/token").toString(), + clientAuth = "", + grantType = "authorization_code", + scope = "openid", + clientId = "public-client", + clientSecret = null, + authorizationCode = "auth-code", + redirectUri = "opencloud://oauth", + codeVerifier = "verifier" + ) + ).execute(newClient()) + + assertFalse(result.isSuccess) + assertNotEquals(RemoteOperationResult.ResultCode.OAUTH2_ERROR, result.code) + } + @Test fun registerClient_sendsJsonRequestAndParsesCreatedResponse() { server.enqueue(MockResponse().setResponseCode(201).setBody(clientRegistrationResponseJson())) @@ -190,6 +253,16 @@ class OAuthRemoteOperationTest { assertEquals(400, result.httpCode) } + private fun refreshParams() = TokenRequestParams.RefreshToken( + tokenEndpoint = server.url("/token").toString(), + clientAuth = "", + grantType = "refresh_token", + scope = "openid", + clientId = "public-client", + clientSecret = null, + refreshToken = "refresh-token" + ) + private fun newClient(): OpenCloudClient = OpenCloudClient( Uri.parse(server.url("/").toString().removeSuffix("/")), diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt b/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt index 46fe016b13..e39c735fa6 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt @@ -76,10 +76,14 @@ class ClientManager( fun getClientForAnonymousCredentials( path: String, requiresNewClient: Boolean, - openCloudCredentials: OpenCloudCredentials? = getAnonymousCredentials() + openCloudCredentials: OpenCloudCredentials? = getAnonymousCredentials(), + clientCertAlias: String? = null ): OpenCloudClient { val safeClient = openCloudClient val pathUri = path.toUri() + // An explicit alias (e.g. the one of an existing account whose token is being refreshed) wins over the one + // chosen in the login screen + val effectiveClientCertAlias = clientCertAlias ?: loginClientCertAlias return if (requiresNewClient || safeClient == null || safeClient.baseUri != pathUri) { Timber.d("Creating new client for path: $pathUri. Old client path: ${safeClient?.baseUri}, requiresNewClient: $requiresNewClient") @@ -91,7 +95,7 @@ class ClientManager( context ).apply { credentials = openCloudCredentials - setClientCertAlias(loginClientCertAlias) + setClientCertAlias(effectiveClientCertAlias) }.also { openCloudClient = it } @@ -99,7 +103,7 @@ class ClientManager( Timber.d("Reusing anonymous client for ${safeClient.baseUri}") safeClient.apply { credentials = openCloudCredentials - setClientCertAlias(loginClientCertAlias) + setClientCertAlias(effectiveClientCertAlias) } } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/RemoteOAuthDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/RemoteOAuthDataSource.kt index b1db8c587b..982efb9a55 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/RemoteOAuthDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/RemoteOAuthDataSource.kt @@ -25,7 +25,7 @@ import eu.opencloud.android.domain.authentication.oauth.model.TokenRequest import eu.opencloud.android.domain.authentication.oauth.model.TokenResponse interface RemoteOAuthDataSource { - fun performOIDCDiscovery(baseUrl: String): OIDCServerConfiguration + fun performOIDCDiscovery(baseUrl: String, clientCertAlias: String? = null): OIDCServerConfiguration fun performTokenRequest(tokenRequest: TokenRequest): TokenResponse fun registerClient(clientRegistrationRequest: ClientRegistrationRequest): ClientRegistrationInfo diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSource.kt index d7cf5f7ca1..625ab2bdd4 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSource.kt @@ -41,8 +41,8 @@ class OCRemoteOAuthDataSource( private val oidcService: OIDCService, ) : RemoteOAuthDataSource { - override fun performOIDCDiscovery(baseUrl: String): OIDCServerConfiguration { - val openCloudClient = clientManager.getClientForAnonymousCredentials(baseUrl, false) + override fun performOIDCDiscovery(baseUrl: String, clientCertAlias: String?): OIDCServerConfiguration { + val openCloudClient = clientManager.getClientForAnonymousCredentials(baseUrl, false, clientCertAlias = clientCertAlias) val serverConfiguration = executeRemoteOperation { oidcService.getOIDCServerDiscovery(openCloudClient) @@ -54,7 +54,12 @@ class OCRemoteOAuthDataSource( override fun performTokenRequest(tokenRequest: TokenRequest): TokenResponse { // For token refreshments, a new client is required, otherwise it could keep outdated credentials or data. val requiresNewClient = tokenRequest is TokenRequest.RefreshToken - val openCloudClient = clientManager.getClientForAnonymousCredentials(path = tokenRequest.baseUrl, requiresNewClient = requiresNewClient) + val clientCertAlias = (tokenRequest as? TokenRequest.RefreshToken)?.clientCertAlias + val openCloudClient = clientManager.getClientForAnonymousCredentials( + path = tokenRequest.baseUrl, + requiresNewClient = requiresNewClient, + clientCertAlias = clientCertAlias + ) val tokenResponse = executeRemoteOperation { oidcService.performTokenRequest( diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/oauth/repository/OCOAuthRepository.kt b/opencloudData/src/main/java/eu/opencloud/android/data/oauth/repository/OCOAuthRepository.kt index c58212c358..2b936f3362 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/oauth/repository/OCOAuthRepository.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/oauth/repository/OCOAuthRepository.kt @@ -30,8 +30,8 @@ class OCOAuthRepository( private val oidcRemoteOAuthDataSource: RemoteOAuthDataSource, ) : OAuthRepository { - override fun performOIDCDiscovery(baseUrl: String): OIDCServerConfiguration = - oidcRemoteOAuthDataSource.performOIDCDiscovery(baseUrl) + override fun performOIDCDiscovery(baseUrl: String, clientCertAlias: String?): OIDCServerConfiguration = + oidcRemoteOAuthDataSource.performOIDCDiscovery(baseUrl, clientCertAlias) override fun performTokenRequest(tokenRequest: TokenRequest): TokenResponse = oidcRemoteOAuthDataSource.performTokenRequest(tokenRequest) diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/OAuthRepository.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/OAuthRepository.kt index 5038394b4a..65a3db9155 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/OAuthRepository.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/OAuthRepository.kt @@ -25,7 +25,7 @@ import eu.opencloud.android.domain.authentication.oauth.model.TokenRequest import eu.opencloud.android.domain.authentication.oauth.model.TokenResponse interface OAuthRepository { - fun performOIDCDiscovery(baseUrl: String): OIDCServerConfiguration + fun performOIDCDiscovery(baseUrl: String, clientCertAlias: String? = null): OIDCServerConfiguration fun performTokenRequest(tokenRequest: TokenRequest): TokenResponse fun registerClient(clientRegistrationRequest: ClientRegistrationRequest): ClientRegistrationInfo diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/OIDCDiscoveryUseCase.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/OIDCDiscoveryUseCase.kt index 5fa315ae91..315fb40d8f 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/OIDCDiscoveryUseCase.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/OIDCDiscoveryUseCase.kt @@ -27,10 +27,11 @@ class OIDCDiscoveryUseCase( override fun run(params: Params): OIDCServerConfiguration { require(params.baseUrl.isNotEmpty()) { "Invalid URL" } - return oAuthRepository.performOIDCDiscovery(params.baseUrl) + return oAuthRepository.performOIDCDiscovery(params.baseUrl, params.clientCertAlias) } data class Params( - val baseUrl: String + val baseUrl: String, + val clientCertAlias: String? = null ) } diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenRequest.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenRequest.kt index 8b61ba59b5..3b7fe25a21 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenRequest.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenRequest.kt @@ -49,7 +49,9 @@ sealed class TokenRequest( scope: String, clientId: String? = null, clientSecret: String? = null, - val refreshToken: String? = null + val refreshToken: String? = null, + /** Alias of the client certificate to present for mTLS, if the account uses one. */ + val clientCertAlias: String? = null ) : TokenRequest(baseUrl, tokenEndpoint, clientAuth, GrantType.REFRESH_TOKEN.string, scope, clientId, clientSecret) enum class GrantType(val string: String) {