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..986614ede 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 @@ -60,7 +60,8 @@ /** * Authenticator for openCloud accounts. * - * Controller class accessed from the system AccountManager, providing integration of openCloud accounts with the + * Controller class accessed from the system AccountManager, providing + * integration of openCloud accounts with the * Android system. */ public class AccountAuthenticator extends AbstractAccountAuthenticator { @@ -84,8 +85,8 @@ public class AccountAuthenticator extends AbstractAccountAuthenticator { */ @Override public Bundle addAccount(AccountAuthenticatorResponse response, - String accountType, String authTokenType, - String[] requiredFeatures, Bundle options) { + String accountType, String authTokenType, + String[] requiredFeatures, Bundle options) { Timber.i("Adding account with type " + accountType + " and auth token " + authTokenType); final Bundle bundle = new Bundle(); @@ -120,7 +121,7 @@ public Bundle addAccount(AccountAuthenticatorResponse response, */ @Override public Bundle confirmCredentials(AccountAuthenticatorResponse response, - Account account, Bundle options) { + Account account, Bundle options) { try { validateAccountType(account.type); } catch (AuthenticatorException e) { @@ -141,7 +142,7 @@ public Bundle confirmCredentials(AccountAuthenticatorResponse response, @Override public Bundle editProperties(AccountAuthenticatorResponse response, - String accountType) { + String accountType) { return null; } @@ -150,7 +151,7 @@ public Bundle editProperties(AccountAuthenticatorResponse response, */ @Override public Bundle getAuthToken(AccountAuthenticatorResponse accountAuthenticatorResponse, - Account account, String authTokenType, Bundle options) { + Account account, String authTokenType, Bundle options) { /// validate parameters try { validateAccountType(account.type); @@ -168,7 +169,8 @@ public Bundle getAuthToken(AccountAuthenticatorResponse accountAuthenticatorResp // Basic accessToken = accountManager.getPassword(account); } else { - // OAuth, gets an auth token from the AccountManager's cache. If no auth token is cached for + // OAuth, gets an auth token from the AccountManager's cache. If no auth token + // is cached for // this account, null will be returned accessToken = accountManager.peekAuthToken(account, authTokenType); if (accessToken == null && canBeRefreshed(authTokenType) && clientSecretIsValid(accountManager, account)) { @@ -184,13 +186,15 @@ public Bundle getAuthToken(AccountAuthenticatorResponse accountAuthenticatorResp return result; } - /// if not stored, return Intent to access the LoginActivity and UPDATE the token for the account + /// if not stored, return Intent to access the LoginActivity and UPDATE the + /// token for the account return prepareBundleToAccessLoginActivity(accountAuthenticatorResponse, account, authTokenType, options); } /** * Check if the client has expired or not. - * If the client has expired, we can not refresh the token and user needs to re-authenticate. + * If the client has expired, we can not refresh the token and user needs to + * re-authenticate. * * @return true if the client is still valid */ @@ -220,7 +224,7 @@ public String getAuthTokenLabel(String authTokenType) { @Override public Bundle hasFeatures(AccountAuthenticatorResponse response, - Account account, String[] features) { + Account account, String[] features) { final Bundle result = new Bundle(); result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true); return result; @@ -228,7 +232,7 @@ public Bundle hasFeatures(AccountAuthenticatorResponse response, @Override public Bundle updateCredentials(AccountAuthenticatorResponse response, - Account account, String authTokenType, Bundle options) { + Account account, String authTokenType, Bundle options) { final Intent intent = new Intent(mContext, LoginActivity.class); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); @@ -265,9 +269,10 @@ private void validateAuthTokenType(String authTokenType) throws UnsupportedAuthTokenTypeException { if (!authTokenType.equals(MainApp.Companion.getAuthTokenType()) && !authTokenType.equals(AccountTypeUtils.getAuthTokenTypePass(MainApp.Companion.getAccountType())) && - !authTokenType.equals(AccountTypeUtils.getAuthTokenTypeAccessToken(MainApp.Companion.getAccountType())) && - !authTokenType.equals(AccountTypeUtils.getAuthTokenTypeRefreshToken(MainApp.Companion.getAccountType())) - ) { + !authTokenType.equals(AccountTypeUtils.getAuthTokenTypeAccessToken(MainApp.Companion.getAccountType())) + && + !authTokenType + .equals(AccountTypeUtils.getAuthTokenTypeRefreshToken(MainApp.Companion.getAccountType()))) { throw new UnsupportedAuthTokenTypeException(); } } @@ -309,15 +314,13 @@ public static class UnsupportedAuthTokenTypeException extends } private boolean canBeRefreshed(String authTokenType) { - return (authTokenType.equals(AccountTypeUtils.getAuthTokenTypeAccessToken(MainApp.Companion. - getAccountType()))); + return (authTokenType.equals(AccountTypeUtils.getAuthTokenTypeAccessToken(MainApp.Companion.getAccountType()))); } private String refreshToken( Account account, String authTokenType, - AccountManager accountManager - ) { + AccountManager accountManager) { // Prepare everything to perform the token request String refreshToken = accountManager.getUserData(account, KEY_OAUTH2_REFRESH_TOKEN); @@ -333,10 +336,11 @@ private String refreshToken( String baseUrl = accountManager.getUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL); // OIDC Discovery - @NotNull Lazy oidcDiscoveryUseCase = inject(OIDCDiscoveryUseCase.class); + @NotNull + Lazy oidcDiscoveryUseCase = inject(OIDCDiscoveryUseCase.class); OIDCDiscoveryUseCase.Params oidcDiscoveryUseCaseParams = new OIDCDiscoveryUseCase.Params(baseUrl); - UseCaseResult oidcServerConfigurationUseCaseResult = - oidcDiscoveryUseCase.getValue().invoke(oidcDiscoveryUseCaseParams); + UseCaseResult oidcServerConfigurationUseCaseResult = oidcDiscoveryUseCase.getValue() + .invoke(oidcDiscoveryUseCaseParams); String tokenEndpoint; @@ -363,7 +367,8 @@ private String refreshToken( tokenEndpoint = oidcServerConfigurationUseCaseResult.getDataOrNull().getTokenEndpoint(); if (oidcServerConfigurationUseCaseResult.getDataOrNull() != null && - oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodSupportedClientSecretPost()) { + oidcServerConfigurationUseCaseResult.getDataOrNull() + .isTokenEndpointAuthMethodSupportedClientSecretPost()) { clientIdForRequest = clientId; clientSecretForRequest = clientSecret; } @@ -374,7 +379,12 @@ private String refreshToken( tokenEndpoint = baseUrl + File.separator + mContext.getString(R.string.oauth2_url_endpoint_access); } - String clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId); + String clientAuth; + if (clientSecret.isEmpty()) { + clientAuth = ""; + } else { + clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId); + } String scope = mContext.getResources().getString(R.string.oauth2_openid_scope); @@ -385,11 +395,11 @@ private String refreshToken( scope, clientIdForRequest, clientSecretForRequest, - refreshToken - ); + refreshToken); // Token exchange - @NotNull Lazy requestTokenUseCase = inject(RequestTokenUseCase.class); + @NotNull + Lazy requestTokenUseCase = inject(RequestTokenUseCase.class); RequestTokenUseCase.Params requestTokenParams = new RequestTokenUseCase.Params(oauthTokenRequest); UseCaseResult tokenResponseResult = requestTokenUseCase.getValue().invoke(requestTokenParams); @@ -398,7 +408,8 @@ private String refreshToken( return handleSuccessfulRefreshToken(safeTokenResponse, account, authTokenType, accountManager, refreshToken); } else { - Timber.e(tokenResponseResult.getThrowableOrNull(), "OAuth request to refresh access token failed. Preparing to access Login Activity"); + Timber.e(tokenResponseResult.getThrowableOrNull(), + "OAuth request to refresh access token failed. Preparing to access Login Activity"); return null; } } @@ -408,34 +419,33 @@ private String handleSuccessfulRefreshToken( Account account, String authTokenType, AccountManager accountManager, - String oldRefreshToken - ) { - String newAccessToken = tokenResponse.getAccessToken(); - accountManager.setAuthToken(account, authTokenType, newAccessToken); - - String refreshTokenToUseFromNowOn; - if (tokenResponse.getRefreshToken() != null) { - refreshTokenToUseFromNowOn = tokenResponse.getRefreshToken(); - } else { - refreshTokenToUseFromNowOn = oldRefreshToken; - } - accountManager.setUserData(account, KEY_OAUTH2_REFRESH_TOKEN, refreshTokenToUseFromNowOn); - - Timber.d("Token refreshed successfully. New access token: [ %s ]. New refresh token: [ %s ]", - newAccessToken, refreshTokenToUseFromNowOn); + String oldRefreshToken) { + String newAccessToken = tokenResponse.getAccessToken(); + accountManager.setAuthToken(account, authTokenType, newAccessToken); - return newAccessToken; + String refreshTokenToUseFromNowOn; + if (tokenResponse.getRefreshToken() != null) { + refreshTokenToUseFromNowOn = tokenResponse.getRefreshToken(); + } else { + refreshTokenToUseFromNowOn = oldRefreshToken; } + accountManager.setUserData(account, KEY_OAUTH2_REFRESH_TOKEN, refreshTokenToUseFromNowOn); + + Timber.d("Token refreshed successfully. New access token: [ %s ]. New refresh token: [ %s ]", + newAccessToken, refreshTokenToUseFromNowOn); + + return newAccessToken; + } /** - * Return bundle with intent to access LoginActivity and UPDATE the token for the account + * Return bundle with intent to access LoginActivity and UPDATE the token for + * the account */ private Bundle prepareBundleToAccessLoginActivity( AccountAuthenticatorResponse accountAuthenticatorResponse, Account account, String authTokenType, - Bundle options - ) { + Bundle options) { final Intent intent = new Intent(mContext, LoginActivity.class); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, accountAuthenticatorResponse); @@ -443,8 +453,7 @@ private Bundle prepareBundleToAccessLoginActivity( intent.putExtra(AuthenticatorConstants.EXTRA_ACCOUNT, account); intent.putExtra( AuthenticatorConstants.EXTRA_ACTION, - AuthenticatorConstants.ACTION_UPDATE_EXPIRED_TOKEN - ); + AuthenticatorConstants.ACTION_UPDATE_EXPIRED_TOKEN); final Bundle bundle = new Bundle(); bundle.putParcelable(AccountManager.KEY_INTENT, intent); 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..bd4d2d86d 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,25 +666,31 @@ 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) + // Determine clientId and clientSecret + val (clientId, clientSecret) = if (clientRegistrationInfo?.clientId != null && clientRegistrationInfo.clientSecret != null) { + Pair(clientRegistrationInfo.clientId, clientRegistrationInfo.clientSecret as String) + } else { + Pair(getString(R.string.oauth2_client_id), getString(R.string.oauth2_client_secret)) + } + val clientAuth = if (clientSecret.isEmpty()) { + "" } else { - OAuthUtils.getClientAuth(getString(R.string.oauth2_client_secret), getString(R.string.oauth2_client_id)) + OAuthUtils.getClientAuth(clientSecret, clientId) } // 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 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) + clientIdForRequest = clientRegistrationInfo?.clientId ?: contextProvider.getString(R.string.oauth2_client_id) + clientSecretForRequest = clientRegistrationInfo?.clientSecret ?: contextProvider.getString(R.string.oauth2_client_secret) } } else { tokenEndPoint = "$serverBaseUrl${File.separator}${contextProvider.getString(R.string.oauth2_url_endpoint_access)}" @@ -697,8 +703,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/RemoteOAuthUtils.kt b/opencloudData/src/test/java/eu/opencloud/android/data/oauth/RemoteOAuthUtils.kt index 7c513e4bb..22d86962e 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/oauth/RemoteOAuthUtils.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/oauth/RemoteOAuthUtils.kt @@ -27,7 +27,9 @@ 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_REQUEST_REFRESH +import eu.opencloud.android.testutil.oauth.OC_TOKEN_REQUEST_REFRESH_PUBLIC_CLIENT import eu.opencloud.android.testutil.oauth.OC_TOKEN_RESPONSE val OC_REMOTE_OIDC_DISCOVERY_RESPONSE = OIDCDiscoveryResponse( @@ -65,6 +67,32 @@ val OC_REMOTE_TOKEN_REQUEST_PARAMS_REFRESH = TokenRequestParams.RefreshToken( refreshToken = OC_TOKEN_REQUEST_REFRESH.refreshToken ) +/** + * Test fixtures for public PKCE clients (RFC 7636). + * Public clients MUST NOT send Authorization header during token exchange. + */ +val OC_REMOTE_TOKEN_REQUEST_PARAMS_ACCESS_PUBLIC_CLIENT = TokenRequestParams.Authorization( + tokenEndpoint = OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT.tokenEndpoint, + clientAuth = OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT.clientAuth, // Empty string + grantType = OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT.grantType, + scope = OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT.scope, + clientId = null, + clientSecret = null, + authorizationCode = OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT.authorizationCode, + redirectUri = OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT.redirectUri, + codeVerifier = OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT.codeVerifier +) + +val OC_REMOTE_TOKEN_REQUEST_PARAMS_REFRESH_PUBLIC_CLIENT = TokenRequestParams.RefreshToken( + tokenEndpoint = OC_TOKEN_REQUEST_REFRESH_PUBLIC_CLIENT.tokenEndpoint, + clientAuth = OC_TOKEN_REQUEST_REFRESH_PUBLIC_CLIENT.clientAuth, // Empty string + grantType = OC_TOKEN_REQUEST_REFRESH_PUBLIC_CLIENT.grantType, + scope = OC_TOKEN_REQUEST_REFRESH_PUBLIC_CLIENT.scope, + clientId = null, + clientSecret = null, + refreshToken = OC_TOKEN_REQUEST_REFRESH_PUBLIC_CLIENT.refreshToken +) + val OC_REMOTE_TOKEN_RESPONSE = TokenResponse( accessToken = OC_TOKEN_RESPONSE.accessToken, expiresIn = OC_TOKEN_RESPONSE.expiresIn, 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..8c0a89fc1 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,8 @@ 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_REQUEST_REFRESH_PUBLIC_CLIENT import eu.opencloud.android.testutil.oauth.OC_TOKEN_RESPONSE import eu.opencloud.android.utils.createRemoteOperationResultMock import io.mockk.every @@ -41,6 +43,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 +105,98 @@ 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 + + // Use the public client fixture which has empty clientAuth + 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 for public PKCE clients (RFC 7636) using refresh token. + * Public clients MUST NOT send Authorization header during token refresh. + * This test verifies that refresh token requests work correctly with empty clientAuth. + */ + @Test + fun `performTokenRequest with public PKCE client refresh token returns a TokenResponse`() { + val tokenResponseResult: RemoteOperationResult = + createRemoteOperationResultMock(data = OC_REMOTE_TOKEN_RESPONSE, isSuccess = true) + + every { + oidcService.performTokenRequest(ocClientMocked, any()) + } returns tokenResponseResult + + // Verify the refresh token fixture has empty clientAuth + assertTrue( + "clientAuth should be empty for public clients", + OC_TOKEN_REQUEST_REFRESH_PUBLIC_CLIENT.clientAuth.isEmpty() + ) + + val tokenResponse = remoteOAuthDataSource.performTokenRequest(OC_TOKEN_REQUEST_REFRESH_PUBLIC_CLIENT) + + assertNotNull(tokenResponse) + assertEquals(OC_TOKEN_RESPONSE, tokenResponse) + + verify(exactly = 1) { + clientManager.getClientForAnonymousCredentials(OC_SECURE_BASE_URL, any()) + oidcService.performTokenRequest(ocClientMocked, any()) + } + } + + /** + * RFC 7636 compliance verification: + * This test ensures that our public client test fixtures correctly have empty clientAuth, + * which means TokenRequestRemoteOperation will NOT add an Authorization header. + * The actual header logic is in TokenRequestRemoteOperation: + * if (tokenRequestParams.clientAuth.isNotEmpty()) { + * postMethod.addRequestHeader(AUTHORIZATION_HEADER, tokenRequestParams.clientAuth) + * } + */ + @Test + fun `public PKCE client fixtures have empty clientAuth preventing Authorization header`() { + // Verify access token fixture + assertTrue( + "Access token public client fixture should have empty clientAuth", + OC_TOKEN_REQUEST_ACCESS_PUBLIC_CLIENT.clientAuth.isEmpty() + ) + + // Verify refresh token fixture + assertTrue( + "Refresh token public client fixture should have empty clientAuth", + OC_TOKEN_REQUEST_REFRESH_PUBLIC_CLIENT.clientAuth.isEmpty() + ) + + // Verify confidential client fixtures have non-empty clientAuth (for comparison) + assertTrue( + "Confidential client fixture should have non-empty clientAuth", + OC_TOKEN_REQUEST_ACCESS.clientAuth.isNotEmpty() + ) + } + @Test fun `registerClient returns a ClientRegistrationInfo`() { val clientRegistrationResponse: RemoteOperationResult = 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" +)