From 76c2aef2629dc591a170389f7275bc0603c5ba05 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Fri, 21 Nov 2025 21:14:44 -0600 Subject: [PATCH] fix: remove Authorization header for public clients in OAuth2 token exchange Public OAuth2 clients using PKCE should not send Authorization headers during token exchange per RFC 7636. This was causing token refresh failures with external OIDC providers like Authelia and Zitadel. Changes: - Made clientAuth nullable in TokenRequest and TokenRequestParams - Added conditional Authorization header in TokenRequestRemoteOperation - Added isTokenEndpointAuthMethodNone() helper in OIDCServerConfiguration - Updated LoginActivity and AccountAuthenticator for public client auth Related to #55 --- .../authentication/AccountAuthenticator.java | 16 ++++++++-- .../authentication/LoginActivity.kt | 29 ++++++++++++++----- .../oauth/TokenRequestRemoteOperation.kt | 4 ++- .../oauth/params/TokenRequestParams.kt | 6 ++-- .../oauth/model/OIDCServerConfiguration.kt | 3 ++ .../oauth/model/TokenRequest.kt | 6 ++-- 6 files changed, 46 insertions(+), 18 deletions(-) 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..e22f2cd7c 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 = null; 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 = null; + clientIdForRequest = clientId; + } else if (oidcServerConfigurationUseCaseResult.getDataOrNull() != null && + oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodSupportedClientSecretPost()) { + // For client_secret_post, credentials go in body, not Authorization header + clientAuth = null; 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 e926993f2..e89d2d8d7 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 @@ -604,28 +604,41 @@ 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) - - } else { - OAuthUtils.getClientAuth(getString(R.string.oauth2_client_secret), getString(R.string.oauth2_client_id)) - } - // 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 clientAuth: String? = null val serverInfo = authenticationViewModel.serverInfo.value?.peekContent()?.getStoredData() if (serverInfo is ServerInfo.OIDCServer) { tokenEndPoint = serverInfo.oidcServerConfiguration.tokenEndpoint - if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodSupportedClientSecretPost()) { + + // RFC 7636: Public clients (token_endpoint_auth_method: none) must not send Authorization header + if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodNone()) { + clientAuth = null + clientId = clientRegistrationInfo?.clientId ?: contextProvider.getString(R.string.oauth2_client_id) + } else if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodSupportedClientSecretPost()) { + // For client_secret_post, credentials go in body, not Authorization header + clientAuth = null clientId = clientRegistrationInfo?.clientId ?: contextProvider.getString(R.string.oauth2_client_id) clientSecret = clientRegistrationInfo?.clientSecret ?: contextProvider.getString(R.string.oauth2_client_secret) + } else { + // For other methods (e.g., client_secret_basic), use Basic auth header + clientAuth = if (clientRegistrationInfo?.clientId != null && clientRegistrationInfo.clientSecret != null) { + OAuthUtils.getClientAuth(clientRegistrationInfo.clientSecret as String, clientRegistrationInfo.clientId) + } else { + OAuthUtils.getClientAuth(getString(R.string.oauth2_client_secret), getString(R.string.oauth2_client_id)) + } } } else { tokenEndPoint = "$serverBaseUrl${File.separator}${contextProvider.getString(R.string.oauth2_url_endpoint_access)}" + clientAuth = if (clientRegistrationInfo?.clientId != null && clientRegistrationInfo.clientSecret != null) { + OAuthUtils.getClientAuth(clientRegistrationInfo.clientSecret as String, clientRegistrationInfo.clientId) + } else { + OAuthUtils.getClientAuth(getString(R.string.oauth2_client_secret), getString(R.string.oauth2_client_id)) + } } val scope = resources.getString(R.string.oauth2_openid_scope) 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..cf29a5263 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) + tokenRequestParams.clientAuth?.takeIf { it.isNotEmpty() }?.let { + postMethod.addRequestHeader(AUTHORIZATION_HEADER, it) + } val status = client.executeHttpMethod(postMethod) diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/params/TokenRequestParams.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/params/TokenRequestParams.kt index 0aac1ce04..6fd61a707 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/params/TokenRequestParams.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/params/TokenRequestParams.kt @@ -30,7 +30,7 @@ import okhttp3.RequestBody sealed class TokenRequestParams( val tokenEndpoint: String, - val clientAuth: String, + val clientAuth: String?, val grantType: String, val scope: String, val clientId: String?, @@ -40,7 +40,7 @@ sealed class TokenRequestParams( class Authorization( tokenEndpoint: String, - clientAuth: String, + clientAuth: String?, grantType: String, scope: String, clientId: String?, @@ -65,7 +65,7 @@ sealed class TokenRequestParams( class RefreshToken( tokenEndpoint: String, - clientAuth: String, + clientAuth: String?, grantType: String, scope: String, clientId: String?, 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/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 8b61ba59b..900a230db 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 @@ -24,7 +24,7 @@ package eu.opencloud.android.domain.authentication.oauth.model sealed class TokenRequest( val baseUrl: String, val tokenEndpoint: String, - val clientAuth: String, + val clientAuth: String?, val grantType: String, val scope: String, val clientId: String?, @@ -33,7 +33,7 @@ sealed class TokenRequest( class AccessToken( baseUrl: String, tokenEndpoint: String, - clientAuth: String, + clientAuth: String?, scope: String, clientId: String? = null, clientSecret: String? = null, @@ -45,7 +45,7 @@ sealed class TokenRequest( class RefreshToken( baseUrl: String, tokenEndpoint: String, - clientAuth: String, + clientAuth: String?, scope: String, clientId: String? = null, clientSecret: String? = null,