Skip to content

Commit e25a7ea

Browse files
committed
fix: persist the registered OAuth client authentication method
Before dynamic registration, the app selects one method from discovery: client_secret_basic when that method is advertised or when the list is omitted, and client_secret_post only when basic is absent. A list that contains neither method stops registration with an error and does not send a request. The selected method is carried on the registration result, saved on the account as token_endpoint_auth_method, and reused for the authorization-code exchange and for refresh. A previously registered client with no stored method stays on Basic, which is how it was registered. Clients that were never dynamically registered still use client_secret_post when discovery advertises it. Fixes #4891 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
1 parent 275850d commit e25a7ea

8 files changed

Lines changed: 124 additions & 43 deletions

File tree

‎owncloudApp/src/main/java/com/owncloud/android/presentation/authentication/AccountAuthenticator.java‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import static com.owncloud.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_EXPIRATION_DATE;
5555
import static com.owncloud.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_ID;
5656
import static com.owncloud.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_SECRET;
57+
import static com.owncloud.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_TOKEN_ENDPOINT_AUTH_METHOD;
5758
import static com.owncloud.android.data.authentication.AuthenticationConstantsKt.KEY_OAUTH2_REFRESH_TOKEN;
5859
import static com.owncloud.android.presentation.authentication.AuthenticatorConstants.KEY_AUTH_TOKEN_TYPE;
5960
import static org.koin.java.KoinJavaComponent.inject;
@@ -339,10 +340,14 @@ private String refreshToken(
339340
UseCaseResult<OIDCServerConfiguration> oidcServerConfigurationUseCaseResult =
340341
oidcDiscoveryUseCase.getValue().invoke(oidcDiscoveryUseCaseParams);
341342

342-
String tokenEndpoint;
343+
// Capture the dynamic client before resource defaults replace a missing id or secret.
344+
String registeredClientId = accountManager.getUserData(account, KEY_CLIENT_REGISTRATION_CLIENT_ID);
345+
String registeredClientSecret = accountManager.getUserData(account, KEY_CLIENT_REGISTRATION_CLIENT_SECRET);
346+
String storedAuthMethod = accountManager.getUserData(account, KEY_CLIENT_REGISTRATION_TOKEN_ENDPOINT_AUTH_METHOD);
347+
boolean hasDynamicClient = registeredClientId != null;
343348

344-
String clientId = accountManager.getUserData(account, KEY_CLIENT_REGISTRATION_CLIENT_ID);
345-
String clientSecret = accountManager.getUserData(account, KEY_CLIENT_REGISTRATION_CLIENT_SECRET);
349+
String clientId = registeredClientId;
350+
String clientSecret = registeredClientSecret;
346351

347352
String clientIdForRequest = null;
348353
String clientSecretForRequest = null;
@@ -367,26 +372,38 @@ private String refreshToken(
367372
}
368373
}
369374

375+
String tokenEndpoint;
370376
if (oidcServerConfigurationUseCaseResult.isSuccess()) {
371377
Timber.d("OIDC Discovery success. Server discovery info: [ %s ]",
372378
oidcServerConfigurationUseCaseResult.getDataOrNull());
373379

374380
// Use token endpoint retrieved from oidc discovery
375381
tokenEndpoint = oidcServerConfigurationUseCaseResult.getDataOrNull().getTokenEndpoint();
376-
377-
if (oidcServerConfigurationUseCaseResult.getDataOrNull() != null &&
378-
oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodSupportedClientSecretPost()) {
379-
clientIdForRequest = clientId;
380-
clientSecretForRequest = clientSecret;
381-
useAuthorizationHeader = false;
382-
}
383382
} else {
384383
Timber.d("OIDC Discovery failed. Server discovery info: [ %s ]",
385384
oidcServerConfigurationUseCaseResult.getThrowableOrNull().toString());
386385

387386
tokenEndpoint = baseUrl + File.separator + mContext.getString(R.string.oauth2_url_endpoint_access);
388387
}
389388

389+
if (hasDynamicClient) {
390+
// Pin the method stored at registration. Legacy dynamic clients have no method and stay on Basic.
391+
String authMethod = storedAuthMethod != null
392+
? storedAuthMethod
393+
: OIDCServerConfiguration.CLIENT_SECRET_BASIC;
394+
if (OIDCServerConfiguration.CLIENT_SECRET_POST.equals(authMethod)) {
395+
clientIdForRequest = registeredClientId;
396+
clientSecretForRequest = registeredClientSecret;
397+
useAuthorizationHeader = false;
398+
}
399+
} else if (oidcServerConfigurationUseCaseResult.isSuccess()
400+
&& oidcServerConfigurationUseCaseResult.getDataOrNull() != null
401+
&& oidcServerConfigurationUseCaseResult.getDataOrNull().isTokenEndpointAuthMethodSupportedClientSecretPost()) {
402+
clientIdForRequest = clientId;
403+
clientSecretForRequest = clientSecret;
404+
useAuthorizationHeader = false;
405+
}
406+
390407
String clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId);
391408

392409
String scope;

‎owncloudApp/src/main/java/com/owncloud/android/presentation/authentication/AuthenticationViewModel.kt‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ import androidx.lifecycle.LiveData
2626
import androidx.lifecycle.MediatorLiveData
2727
import androidx.lifecycle.ViewModel
2828
import androidx.lifecycle.viewModelScope
29-
import com.owncloud.android.MainApp
3029
import com.owncloud.android.R
3130
import com.owncloud.android.domain.authentication.oauth.RegisterClientUseCase
3231
import com.owncloud.android.domain.authentication.oauth.RequestTokenUseCase
3332
import com.owncloud.android.domain.authentication.oauth.model.ClientRegistrationInfo
33+
import com.owncloud.android.domain.authentication.oauth.model.OIDCServerConfiguration
3434
import com.owncloud.android.domain.authentication.oauth.model.TokenRequest
3535
import com.owncloud.android.domain.authentication.oauth.model.TokenResponse
3636
import com.owncloud.android.domain.authentication.usecases.GetBaseUrlUseCase
@@ -39,6 +39,7 @@ import com.owncloud.android.domain.authentication.usecases.LoginOAuthAsyncUseCas
3939
import com.owncloud.android.domain.authentication.usecases.SupportsOAuth2UseCase
4040
import com.owncloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
4141
import com.owncloud.android.domain.capabilities.usecases.RefreshCapabilitiesFromServerAsyncUseCase
42+
import com.owncloud.android.domain.exceptions.OAuth2ErrorException
4243
import com.owncloud.android.domain.server.model.ServerInfo
4344
import com.owncloud.android.domain.server.usecases.GetServerInfoAsyncUseCase
4445
import com.owncloud.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase
@@ -231,12 +232,23 @@ class AuthenticationViewModel(
231232
)
232233

233234
fun registerClient(
234-
registrationEndpoint: String
235+
registrationEndpoint: String,
236+
oidcServerConfiguration: OIDCServerConfiguration,
235237
) {
238+
val tokenEndpointAuthMethod = try {
239+
oidcServerConfiguration.selectRegistrationAuthMethod()
240+
} catch (exception: OAuth2ErrorException) {
241+
_registerClient.value = Event(UIResult.Error(error = exception))
242+
return
243+
}
244+
245+
// Replace any previous registration before dispatching so a failure cannot reuse its method.
246+
_registerClient.value = Event(UIResult.Loading())
247+
236248
val registrationRequest = OAuthUtils.buildClientRegistrationRequest(
237249
registrationEndpoint = registrationEndpoint,
238-
MainApp.appContext
239-
)
250+
contextProvider.getContext()
251+
).copy(tokenEndpointAuthMethod = tokenEndpointAuthMethod)
240252

241253
runUseCaseWithResult(
242254
coroutineDispatcher = coroutinesDispatcherProvider.io,

‎owncloudApp/src/main/java/com/owncloud/android/presentation/authentication/LoginActivity.kt‎

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import com.owncloud.android.MainApp.Companion.accountType
5353
import com.owncloud.android.R
5454
import com.owncloud.android.data.authentication.KEY_USER_ID
5555
import com.owncloud.android.databinding.AccountSetupBinding
56+
import com.owncloud.android.domain.authentication.oauth.model.OIDCServerConfiguration
5657
import com.owncloud.android.domain.authentication.oauth.model.ResponseType
5758
import com.owncloud.android.domain.authentication.oauth.model.TokenRequest
5859
import com.owncloud.android.domain.authentication.oauth.model.TokenResponse
@@ -406,7 +407,8 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
406407
if (registrationEndpoint != null) {
407408
registerClient(
408409
authorizationEndpoint = serverInfo.oidcServerConfiguration.authorizationEndpoint.toUri(),
409-
registrationEndpoint = registrationEndpoint
410+
registrationEndpoint = registrationEndpoint,
411+
oidcServerConfiguration = serverInfo.oidcServerConfiguration,
410412
)
411413
} else {
412414
performGetAuthorizationCodeRequest(serverInfo.oidcServerConfiguration.authorizationEndpoint.toUri())
@@ -520,9 +522,13 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
520522
*/
521523
private fun registerClient(
522524
authorizationEndpoint: Uri,
523-
registrationEndpoint: String
525+
registrationEndpoint: String,
526+
oidcServerConfiguration: OIDCServerConfiguration,
524527
) {
525-
authenticationViewModel.registerClient(registrationEndpoint)
528+
authenticationViewModel.registerClient(
529+
registrationEndpoint = registrationEndpoint,
530+
oidcServerConfiguration = oidcServerConfiguration,
531+
)
526532
authenticationViewModel.registerClient.observe(this) {
527533
when (val uiResult = it.peekContent()) {
528534
is UIResult.Loading -> {}
@@ -645,39 +651,53 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
645651
private fun exchangeAuthorizationCodeForTokens(authorizationCode: String) {
646652
binding.serverStatusText.text = getString(R.string.auth_getting_authorization)
647653

648-
val clientRegistrationInfo = authenticationViewModel.registerClient.value?.peekContent()?.getStoredData()
649-
650-
val clientAuth = if (clientRegistrationInfo?.clientId != null && clientRegistrationInfo.clientSecret != null) {
651-
OAuthUtils.getClientAuth(clientRegistrationInfo.clientSecret as String, clientRegistrationInfo.clientId)
654+
// Only a successful registration carries a method. Failures fall back to preconfigured credentials.
655+
val clientRegistrationInfo =
656+
(authenticationViewModel.registerClient.value?.peekContent() as? UIResult.Success)?.data
657+
val registeredSecret = clientRegistrationInfo?.clientSecret
652658

659+
val clientAuth = if (clientRegistrationInfo != null && registeredSecret != null) {
660+
OAuthUtils.getClientAuth(registeredSecret, clientRegistrationInfo.clientId)
661+
} else if (isKiteworksServer) {
662+
OAuthUtils.getClientAuth(getString(R.string.kiteworks_client_secret), getString(R.string.kiteworks_client_id))
653663
} else {
654-
if (isKiteworksServer) {
655-
OAuthUtils.getClientAuth(getString(R.string.kiteworks_client_secret), getString(R.string.kiteworks_client_id))
656-
} else {
657-
OAuthUtils.getClientAuth(getString(R.string.oauth2_client_secret), getString(R.string.oauth2_client_id))
658-
}
664+
OAuthUtils.getClientAuth(getString(R.string.oauth2_client_secret), getString(R.string.oauth2_client_id))
659665
}
660666

661667
// Use oidc discovery one, or build an oauth endpoint using serverBaseUrl + Setup string.
662-
val tokenEndPoint: String
668+
// The registered auth method is resolved separately so later discovery cannot change it.
669+
val serverInfo = authenticationViewModel.serverInfo.value?.peekContent()?.getStoredData()
670+
val tokenEndPoint = if (serverInfo is ServerInfo.OIDCServer) {
671+
serverInfo.oidcServerConfiguration.tokenEndpoint
672+
} else {
673+
"$serverBaseUrl${File.separator}${contextProvider.getString(R.string.oauth2_url_endpoint_access)}"
674+
}
663675

664-
var clientId: String? = null
665-
var clientSecret: String? = null
666-
var useAuthorizationHeader = true
676+
val useClientSecretPost = if (clientRegistrationInfo == null) {
677+
serverInfo is ServerInfo.OIDCServer &&
678+
serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodSupportedClientSecretPost()
679+
} else {
680+
clientRegistrationInfo.tokenEndpointAuthMethod == OIDCServerConfiguration.CLIENT_SECRET_POST
681+
}
667682

668-
val serverInfo = authenticationViewModel.serverInfo.value?.peekContent()?.getStoredData()
669-
if (serverInfo is ServerInfo.OIDCServer) {
670-
tokenEndPoint = serverInfo.oidcServerConfiguration.tokenEndpoint
671-
if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodSupportedClientSecretPost()) {
683+
val clientId: String?
684+
val clientSecret: String?
685+
val useAuthorizationHeader: Boolean
686+
if (useClientSecretPost) {
687+
if (clientRegistrationInfo != null) {
688+
clientId = clientRegistrationInfo.clientId
689+
clientSecret = clientRegistrationInfo.clientSecret
690+
} else {
672691
val defaultClientId = if (isKiteworksServer) R.string.kiteworks_client_id else R.string.oauth2_client_id
673692
val defaultClientSecret = if (isKiteworksServer) R.string.kiteworks_client_secret else R.string.oauth2_client_secret
674-
675-
clientId = clientRegistrationInfo?.clientId ?: contextProvider.getString(defaultClientId)
676-
clientSecret = clientRegistrationInfo?.clientSecret ?: contextProvider.getString(defaultClientSecret)
677-
useAuthorizationHeader = false
693+
clientId = contextProvider.getString(defaultClientId)
694+
clientSecret = contextProvider.getString(defaultClientSecret)
678695
}
696+
useAuthorizationHeader = false
679697
} else {
680-
tokenEndPoint = "$serverBaseUrl${File.separator}${contextProvider.getString(R.string.oauth2_url_endpoint_access)}"
698+
clientId = null
699+
clientSecret = null
700+
useAuthorizationHeader = true
681701
}
682702

683703
val requestToken = TokenRequest.AccessToken(

‎owncloudData/src/main/java/com/owncloud/android/data/authentication/AuthenticationConstants.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const val KEY_FEATURE_SPACES = "KEY_FEATURE_SPACES"
5151
const val KEY_CLIENT_REGISTRATION_CLIENT_ID = "client_id"
5252
const val KEY_CLIENT_REGISTRATION_CLIENT_SECRET = "client_secret"
5353
const val KEY_CLIENT_REGISTRATION_CLIENT_EXPIRATION_DATE = "client_secret_expires_at"
54+
const val KEY_CLIENT_REGISTRATION_TOKEN_ENDPOINT_AUTH_METHOD = "token_endpoint_auth_method"
5455

5556
const val KEY_IS_KITEWORKS_SERVER = "is_kiteworks_server"
5657

‎owncloudData/src/main/java/com/owncloud/android/data/authentication/datasources/implementation/OCLocalAuthenticationDataSource.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import android.net.Uri
2828
import com.owncloud.android.data.authentication.KEY_CLIENT_REGISTRATION_CLIENT_EXPIRATION_DATE
2929
import com.owncloud.android.data.authentication.KEY_CLIENT_REGISTRATION_CLIENT_ID
3030
import com.owncloud.android.data.authentication.KEY_CLIENT_REGISTRATION_CLIENT_SECRET
31+
import com.owncloud.android.data.authentication.KEY_CLIENT_REGISTRATION_TOKEN_ENDPOINT_AUTH_METHOD
3132
import com.owncloud.android.data.authentication.KEY_FEATURE_ALLOWED
3233
import com.owncloud.android.data.authentication.KEY_FEATURE_SPACES
3334
import com.owncloud.android.data.authentication.KEY_IS_KITEWORKS_SERVER
@@ -121,6 +122,11 @@ class OCLocalAuthenticationDataSource(
121122
setUserData(it, KEY_CLIENT_REGISTRATION_CLIENT_ID, clientRegistrationInfo.clientId)
122123
setUserData(it, KEY_CLIENT_REGISTRATION_CLIENT_SECRET, clientRegistrationInfo.clientSecret)
123124
setUserData(it, KEY_CLIENT_REGISTRATION_CLIENT_EXPIRATION_DATE, clientRegistrationInfo.clientSecretExpiration.toString())
125+
setUserData(
126+
it,
127+
KEY_CLIENT_REGISTRATION_TOKEN_ENDPOINT_AUTH_METHOD,
128+
clientRegistrationInfo.tokenEndpointAuthMethod
129+
)
124130
}
125131
}
126132

‎owncloudData/src/main/java/com/owncloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSource.kt‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ class OCRemoteOAuthDataSource(
8080
)
8181
}
8282

83-
return remoteClientRegistrationInfo.toModel()
83+
return remoteClientRegistrationInfo.toModel(clientRegistrationRequest.tokenEndpointAuthMethod)
8484
}
8585

8686
/**************************************************************************************************************
@@ -148,11 +148,12 @@ class OCRemoteOAuthDataSource(
148148
applicationType = this.applicationType
149149
)
150150

151-
private fun ClientRegistrationResponse.toModel(): ClientRegistrationInfo =
151+
private fun ClientRegistrationResponse.toModel(tokenEndpointAuthMethod: String): ClientRegistrationInfo =
152152
ClientRegistrationInfo(
153153
clientId = this.clientId,
154154
clientSecret = this.clientSecret,
155155
clientIdIssuedAt = this.clientIdIssuedAt,
156-
clientSecretExpiration = this.clientSecretExpiration
156+
clientSecretExpiration = this.clientSecretExpiration,
157+
tokenEndpointAuthMethod = tokenEndpointAuthMethod,
157158
)
158159
}

‎owncloudDomain/src/main/java/com/owncloud/android/domain/authentication/oauth/model/ClientRegistrationInfo.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ data class ClientRegistrationInfo(
2424
val clientSecret: String?,
2525
val clientIdIssuedAt: Int?,
2626
val clientSecretExpiration: Int,
27+
val tokenEndpointAuthMethod: String = OIDCServerConfiguration.CLIENT_SECRET_BASIC,
2728
)

‎owncloudDomain/src/main/java/com/owncloud/android/domain/authentication/oauth/model/OIDCServerConfiguration.kt‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
package com.owncloud.android.domain.authentication.oauth.model
2424

25+
import com.owncloud.android.domain.exceptions.OAuth2ErrorException
26+
2527
data class OIDCServerConfiguration(
2628
val authorizationEndpoint: String,
2729
val checkSessionIframe: String?,
@@ -36,5 +38,26 @@ data class OIDCServerConfiguration(
3638
val isKiteworksServer: Boolean = false,
3739
) {
3840
fun isTokenEndpointAuthMethodSupportedClientSecretPost(): Boolean =
39-
tokenEndpointAuthMethodsSupported?.any { it == "client_secret_post" } ?: false
41+
tokenEndpointAuthMethodsSupported?.any { it == CLIENT_SECRET_POST } ?: false
42+
43+
/**
44+
* Auth method submitted with dynamic client registration.
45+
* Basic wins when both supported methods are advertised. Omitted discovery metadata defaults to Basic.
46+
* An explicit list that contains neither supported method cannot be registered.
47+
*/
48+
fun selectRegistrationAuthMethod(): String {
49+
val advertisedMethods = tokenEndpointAuthMethodsSupported ?: return CLIENT_SECRET_BASIC
50+
if (advertisedMethods.any { it == CLIENT_SECRET_BASIC }) {
51+
return CLIENT_SECRET_BASIC
52+
}
53+
if (advertisedMethods.any { it == CLIENT_SECRET_POST }) {
54+
return CLIENT_SECRET_POST
55+
}
56+
throw OAuth2ErrorException()
57+
}
58+
59+
companion object {
60+
const val CLIENT_SECRET_BASIC = "client_secret_basic"
61+
const val CLIENT_SECRET_POST = "client_secret_post"
62+
}
4063
}

0 commit comments

Comments
 (0)