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 40580ed59..b73a9a683 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 @@ -345,6 +345,7 @@ private String refreshToken( String clientIdForRequest = null; String clientSecretForRequest = null; + String clientAuth; if (clientId == null) { Timber.d("Client Id not stored. Let's use the hardcoded one"); @@ -362,20 +363,29 @@ private String refreshToken( // Use token endpoint retrieved from oidc discovery tokenEndpoint = oidcServerConfigurationUseCaseResult.getDataOrNull().getTokenEndpoint(); + // RFC 7636: Public clients (token_endpoint_auth_method: none) must not send Authorization header if (oidcServerConfigurationUseCaseResult.getDataOrNull() != null && - oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodSupportedClientSecretPost()) { + oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodNone()) { + clientAuth = ""; + clientIdForRequest = clientId; + } else if (oidcServerConfigurationUseCaseResult.getDataOrNull() != null && + oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodSupportedClientSecretPost()) { + // For client_secret_post, credentials go in body, not Authorization header + clientAuth = ""; clientIdForRequest = clientId; clientSecretForRequest = clientSecret; + } else { + // For other methods (e.g., client_secret_basic), use Basic auth header + clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId); } } else { Timber.d("OIDC Discovery failed. Server discovery info: [ %s ]", oidcServerConfigurationUseCaseResult.getThrowableOrNull().toString()); tokenEndpoint = baseUrl + File.separator + mContext.getString(R.string.oauth2_url_endpoint_access); + clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId); } - String clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId); - String scope = mContext.getResources().getString(R.string.oauth2_openid_scope); TokenRequest oauthTokenRequest = new TokenRequest.RefreshToken( diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 4e1819a0a..7fd2800a2 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -666,28 +666,40 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted val clientRegistrationInfo = authenticationViewModel.registerClient.value?.peekContent()?.getStoredData() - val clientAuth = if (clientRegistrationInfo?.clientId != null && clientRegistrationInfo.clientSecret != null) { - OAuthUtils.getClientAuth(clientRegistrationInfo.clientSecret as String, clientRegistrationInfo.clientId) + // Use oidc discovery one, or build an oauth endpoint using serverBaseUrl + Setup string. + val tokenEndPoint: String + // Determine clientId and clientSecret + val (clientId, clientSecret) = if (clientRegistrationInfo?.clientId != null && clientRegistrationInfo.clientSecret != null) { + Pair(clientRegistrationInfo.clientId, clientRegistrationInfo.clientSecret as String) } else { - OAuthUtils.getClientAuth(getString(R.string.oauth2_client_secret), getString(R.string.oauth2_client_id)) + Pair(getString(R.string.oauth2_client_id), getString(R.string.oauth2_client_secret)) } - // Use oidc discovery one, or build an oauth endpoint using serverBaseUrl + Setup string. - val tokenEndPoint: String - - var clientId: String? = null - var clientSecret: String? = null + var clientIdForRequest: String? = null + var clientSecretForRequest: String? = null + var clientAuth: String val serverInfo = authenticationViewModel.serverInfo.value?.peekContent()?.getStoredData() if (serverInfo is ServerInfo.OIDCServer) { tokenEndPoint = serverInfo.oidcServerConfiguration.tokenEndpoint - if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodSupportedClientSecretPost()) { - clientId = clientRegistrationInfo?.clientId ?: contextProvider.getString(R.string.oauth2_client_id) - clientSecret = clientRegistrationInfo?.clientSecret ?: contextProvider.getString(R.string.oauth2_client_secret) + + // RFC 7636: Public clients (token_endpoint_auth_method: none) must not send Authorization header + if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodNone()) { + clientAuth = "" + clientIdForRequest = clientId + } else if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodSupportedClientSecretPost()) { + // For client_secret_post, credentials go in body, not Authorization header + clientAuth = "" + clientIdForRequest = clientId + clientSecretForRequest = clientSecret + } else { + // For other methods (e.g., client_secret_basic), use Basic auth header + clientAuth = OAuthUtils.getClientAuth(clientSecret, clientId) } } else { tokenEndPoint = "$serverBaseUrl${File.separator}${contextProvider.getString(R.string.oauth2_url_endpoint_access)}" + clientAuth = OAuthUtils.getClientAuth(clientSecret, clientId) } val scope = resources.getString(R.string.oauth2_openid_scope) @@ -697,8 +709,8 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted tokenEndpoint = tokenEndPoint, clientAuth = clientAuth, scope = scope, - clientId = clientId, - clientSecret = clientSecret, + clientId = clientIdForRequest, + clientSecret = clientSecretForRequest, authorizationCode = authorizationCode, redirectUri = OAuthUtils.buildRedirectUri(applicationContext).toString(), codeVerifier = authenticationViewModel.codeVerifier 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 25064d141..a35b8bef0 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 @@ -54,7 +54,9 @@ class TokenRequestRemoteOperation( val postMethod = PostMethod(URL(tokenRequestParams.tokenEndpoint), requestBody) - postMethod.addRequestHeader(AUTHORIZATION_HEADER, tokenRequestParams.clientAuth) + if (tokenRequestParams.clientAuth.isNotEmpty()) { + postMethod.addRequestHeader(AUTHORIZATION_HEADER, tokenRequestParams.clientAuth) + } val status = client.executeHttpMethod(postMethod) diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSourceTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSourceTest.kt index a513e8282..8f0d899a3 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSourceTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSourceTest.kt @@ -34,6 +34,7 @@ import eu.opencloud.android.testutil.oauth.OC_CLIENT_REGISTRATION import eu.opencloud.android.testutil.oauth.OC_CLIENT_REGISTRATION_REQUEST import eu.opencloud.android.testutil.oauth.OC_OIDC_SERVER_CONFIGURATION import eu.opencloud.android.testutil.oauth.OC_TOKEN_REQUEST_ACCESS +import eu.opencloud.android.testutil.oauth.OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT import eu.opencloud.android.testutil.oauth.OC_TOKEN_RESPONSE import eu.opencloud.android.utils.createRemoteOperationResultMock import io.mockk.every @@ -41,6 +42,7 @@ import io.mockk.mockk import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -102,6 +104,37 @@ class OCRemoteOAuthDataSourceTest { } } + /** + * Test for public PKCE clients (RFC 7636). + * Public clients MUST NOT send Authorization header during token exchange. + * This test verifies that token requests work correctly with empty clientAuth. + */ + @Test + fun `performTokenRequest with public PKCE client returns a TokenResponse`() { + val tokenResponseResult: RemoteOperationResult = + createRemoteOperationResultMock(data = OC_REMOTE_TOKEN_RESPONSE, isSuccess = true) + + every { + oidcService.performTokenRequest(ocClientMocked, any()) + } returns tokenResponseResult + + // Verify the fixture has empty clientAuth for public clients + assertTrue( + "clientAuth should be empty for public clients", + OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT.clientAuth.isEmpty() + ) + + val tokenResponse = remoteOAuthDataSource.performTokenRequest(OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT) + + assertNotNull(tokenResponse) + assertEquals(OC_TOKEN_RESPONSE, tokenResponse) + + verify(exactly = 1) { + clientManager.getClientForAnonymousCredentials(OC_SECURE_BASE_URL, any()) + oidcService.performTokenRequest(ocClientMocked, any()) + } + } + @Test fun `registerClient returns a ClientRegistrationInfo`() { val clientRegistrationResponse: RemoteOperationResult = diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/OIDCServerConfiguration.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/OIDCServerConfiguration.kt index d871569b5..395083067 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/OIDCServerConfiguration.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/OIDCServerConfiguration.kt @@ -35,4 +35,7 @@ data class OIDCServerConfiguration( ) { fun isTokenEndpointAuthMethodSupportedClientSecretPost(): Boolean = tokenEndpointAuthMethodsSupported?.any { it == "client_secret_post" } ?: false + + fun isTokenEndpointAuthMethodNone(): Boolean = + tokenEndpointAuthMethodsSupported?.any { it == "none" } ?: false } diff --git a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/oauth/TokenRequest.kt b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/oauth/TokenRequest.kt index 7f69e5564..85c46bcc7 100644 --- a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/oauth/TokenRequest.kt +++ b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/oauth/TokenRequest.kt @@ -43,3 +43,25 @@ val OC_TOKEN_REQUEST_ACCESS = TokenRequest.AccessToken( redirectUri = OC_REDIRECT_URI, codeVerifier = "A high-entropy cryptographic random STRING using the unreserved characters" ) + +/** + * Test fixtures for public PKCE clients (RFC 7636). + * Public clients MUST NOT send Authorization header during token exchange. + */ +val OC_TOKEN_REQUEST_REFRESH_PUBLIC_CLIENT = TokenRequest.RefreshToken( + baseUrl = OC_SECURE_BASE_URL, + tokenEndpoint = OC_TOKEN_ENDPOINT, + clientAuth = "", // Empty for public clients per RFC 7636 + scope = OC_SCOPE, + refreshToken = OC_REFRESH_TOKEN +) + +val OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT = TokenRequest.AccessToken( + baseUrl = OC_SECURE_BASE_URL, + tokenEndpoint = OC_TOKEN_ENDPOINT, + clientAuth = "", // Empty for public clients per RFC 7636 + scope = OC_SCOPE, + authorizationCode = "4uth0r1z4t10nC0d3", + redirectUri = OC_REDIRECT_URI, + codeVerifier = "A high-entropy cryptographic random STRING using the unreserved characters" +)