Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ 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
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

Expand Down Expand Up @@ -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<TokenResponse> =
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<ClientRegistrationResponse> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Loading