Skip to content

Commit 25240d1

Browse files
committed
Webfinger: Return client_id and scope
To solve many of the issues our users have with getting OpenCloud to work with different IDPs we came up with a more generic way for how clients should discover their OIDC setttings (issuer, client_id and scopes). This is described here: https://github.com/opencloud-eu/opencloud/blob/main/docs/adr/0003-oidc-client-config-discovery.md This is for #96
1 parent 3c51c24 commit 25240d1

13 files changed

Lines changed: 282 additions & 67 deletions

File tree

opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AccountAuthenticator.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import static eu.opencloud.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_ID;
5555
import static eu.opencloud.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_SECRET;
5656
import static eu.opencloud.android.data.authentication.AuthenticationConstantsKt.KEY_OAUTH2_REFRESH_TOKEN;
57+
import static eu.opencloud.android.data.authentication.AuthenticationConstantsKt.KEY_OAUTH2_SCOPE;
5758
import static eu.opencloud.android.presentation.authentication.AuthenticatorConstants.KEY_AUTH_TOKEN_TYPE;
5859
import static org.koin.java.KoinJavaComponent.inject;
5960

@@ -386,7 +387,10 @@ private String refreshToken(
386387
clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId);
387388
}
388389

389-
String scope = mContext.getResources().getString(R.string.oauth2_openid_scope);
390+
String scope = accountManager.getUserData(account, KEY_OAUTH2_SCOPE);
391+
if (scope == null) {
392+
scope = mContext.getResources().getString(R.string.oauth2_openid_scope);
393+
}
390394

391395
TokenRequest oauthTokenRequest = new TokenRequest.RefreshToken(
392396
baseUrl,

opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import eu.opencloud.android.MainApp.Companion.accountType
5050
import eu.opencloud.android.R
5151
import eu.opencloud.android.data.authentication.KEY_USER_ID
5252
import eu.opencloud.android.databinding.AccountSetupBinding
53+
import eu.opencloud.android.domain.authentication.oauth.model.ClientRegistrationInfo
5354
import eu.opencloud.android.domain.authentication.oauth.model.ResponseType
5455
import eu.opencloud.android.domain.authentication.oauth.model.TokenRequest
5556
import eu.opencloud.android.domain.exceptions.NoNetworkConnectionException
@@ -447,14 +448,28 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
447448
showOrHideBasicAuthFields(shouldBeVisible = false)
448449
authTokenType = OAUTH_TOKEN_TYPE
449450
oidcSupported = true
450-
val registrationEndpoint = serverInfo.oidcServerConfiguration.registrationEndpoint
451-
if (registrationEndpoint != null) {
452-
registerClient(
451+
val webFingerClientId = serverInfo.webFingerClientId
452+
val webFingerScopes = serverInfo.webFingerScopes
453+
if (webFingerClientId != null) {
454+
performGetAuthorizationCodeRequest(
453455
authorizationEndpoint = serverInfo.oidcServerConfiguration.authorizationEndpoint.toUri(),
454-
registrationEndpoint = registrationEndpoint
456+
clientId = webFingerClientId,
457+
webFingerScopes = webFingerScopes,
455458
)
456459
} else {
457-
performGetAuthorizationCodeRequest(serverInfo.oidcServerConfiguration.authorizationEndpoint.toUri())
460+
val registrationEndpoint = serverInfo.oidcServerConfiguration.registrationEndpoint
461+
if (registrationEndpoint != null) {
462+
registerClient(
463+
authorizationEndpoint = serverInfo.oidcServerConfiguration.authorizationEndpoint.toUri(),
464+
registrationEndpoint = registrationEndpoint,
465+
webFingerScopes = webFingerScopes,
466+
)
467+
} else {
468+
performGetAuthorizationCodeRequest(
469+
authorizationEndpoint = serverInfo.oidcServerConfiguration.authorizationEndpoint.toUri(),
470+
webFingerScopes = webFingerScopes,
471+
)
472+
}
458473
}
459474
}
460475

@@ -559,7 +574,8 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
559574
*/
560575
private fun registerClient(
561576
authorizationEndpoint: Uri,
562-
registrationEndpoint: String
577+
registrationEndpoint: String,
578+
webFingerScopes: List<String>? = null,
563579
) {
564580
authenticationViewModel.registerClient(registrationEndpoint)
565581
authenticationViewModel.registerClient.observe(this) {
@@ -570,22 +586,24 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
570586
uiResult.data?.let { clientRegistrationInfo ->
571587
performGetAuthorizationCodeRequest(
572588
authorizationEndpoint = authorizationEndpoint,
573-
clientId = clientRegistrationInfo.clientId
589+
clientId = clientRegistrationInfo.clientId,
590+
webFingerScopes = webFingerScopes,
574591
)
575592
}
576593
}
577594

578595
is UIResult.Error -> {
579596
Timber.e(uiResult.error, "Client registration failed.")
580-
performGetAuthorizationCodeRequest(authorizationEndpoint)
597+
performGetAuthorizationCodeRequest(authorizationEndpoint, webFingerScopes = webFingerScopes)
581598
}
582599
}
583600
}
584601
}
585602

586603
private fun performGetAuthorizationCodeRequest(
587604
authorizationEndpoint: Uri,
588-
clientId: String = getString(R.string.oauth2_client_id)
605+
clientId: String = getString(R.string.oauth2_client_id),
606+
webFingerScopes: List<String>? = null,
589607
) {
590608
Timber.d("A browser should be opened now to authenticate this user.")
591609

@@ -597,19 +615,29 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
597615
// which helps Firefox properly handle the OAuth redirect back to the app
598616
customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
599617

618+
val scope = if (!oidcSupported) {
619+
""
620+
} else if (webFingerScopes != null) {
621+
webFingerScopes.joinToString(" ")
622+
} else {
623+
mdmProvider.getBrandingString(CONFIGURATION_OAUTH2_OPEN_ID_SCOPE, R.string.oauth2_openid_scope)
624+
}
625+
600626
val authorizationEndpointUri = OAuthUtils.buildAuthorizationRequest(
601627
authorizationEndpoint = authorizationEndpoint,
602628
redirectUri = OAuthUtils.buildRedirectUri(applicationContext).toString(),
603629
clientId = clientId,
604630
responseType = ResponseType.CODE.string,
605-
scope = if (oidcSupported) mdmProvider.getBrandingString(CONFIGURATION_OAUTH2_OPEN_ID_SCOPE, R.string.oauth2_openid_scope) else "",
631+
scope = scope,
606632
prompt = if (oidcSupported) mdmProvider.getBrandingString(CONFIGURATION_OAUTH2_OPEN_ID_PROMPT, R.string.oauth2_openid_prompt) else "",
607633
codeChallenge = authenticationViewModel.codeChallenge,
608634
state = authenticationViewModel.oidcState,
609635
username = username,
610636
sendLoginHintAndUser = mdmProvider.getBrandingBoolean(mdmKey = CONFIGURATION_SEND_LOGIN_HINT_AND_USER,
611637
booleanKey = R.bool.send_login_hint_and_user),
612638
)
639+
Timber.d("A browser should be opened now to authenticate this user is $authorizationEndpointUri ")
640+
613641

614642
try {
615643
saveAuthState()
@@ -673,6 +701,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
673701
val (clientId, clientSecret) = if (clientRegistrationInfo?.clientId != null && clientRegistrationInfo.clientSecret != null) {
674702
Pair(clientRegistrationInfo.clientId, clientRegistrationInfo.clientSecret as String)
675703
} else {
704+
// May be overridden below by serverInfo.webFingerClientId if available
676705
Pair(getString(R.string.oauth2_client_id), getString(R.string.oauth2_client_secret))
677706
}
678707

@@ -684,18 +713,21 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
684713
if (serverInfo is ServerInfo.OIDCServer) {
685714
tokenEndPoint = serverInfo.oidcServerConfiguration.tokenEndpoint
686715

716+
// Use webfinger-provided clientId if available, otherwise keep registration/default
717+
val effectiveClientId = serverInfo.webFingerClientId ?: clientId
718+
687719
// RFC 7636: Public clients (token_endpoint_auth_method: none) must not send Authorization header
688720
if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodNone()) {
689721
clientAuth = ""
690-
clientIdForRequest = clientId
722+
clientIdForRequest = effectiveClientId
691723
} else if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodSupportedClientSecretPost()) {
692724
// For client_secret_post, credentials go in body, not Authorization header
693725
clientAuth = ""
694-
clientIdForRequest = clientId
726+
clientIdForRequest = effectiveClientId
695727
clientSecretForRequest = clientSecret
696728
} else {
697729
// For other methods (e.g., client_secret_basic), use Basic auth header
698-
clientAuth = OAuthUtils.getClientAuth(clientSecret, clientId)
730+
clientAuth = OAuthUtils.getClientAuth(clientSecret, effectiveClientId)
699731
}
700732
} else {
701733
tokenEndPoint = "$serverBaseUrl${File.separator}${contextProvider.getString(R.string.oauth2_url_endpoint_access)}"
@@ -725,18 +757,41 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
725757
Timber.d("Tokens received ${uiResult.data}, trying to login, creating account and adding it to account manager")
726758
val tokenResponse = uiResult.data ?: return@observe
727759

760+
// When webfinger provides a client_id without dynamic registration,
761+
// store it so AccountAuthenticator can use it for token refresh
762+
val effectiveClientRegistrationInfo = clientRegistrationInfo
763+
?: (serverInfo as? ServerInfo.OIDCServer)?.webFingerClientId?.let { wfClientId ->
764+
ClientRegistrationInfo(
765+
clientId = wfClientId,
766+
clientSecret = null,
767+
clientIdIssuedAt = null,
768+
clientSecretExpiration = 0,
769+
)
770+
}
771+
772+
// Scope priority: webfinger scopes > MDM/string-resource > token response
773+
val webFingerScopes = if (serverInfo is ServerInfo.OIDCServer) {
774+
serverInfo.webFingerScopes
775+
} else {
776+
null
777+
}
778+
val effectiveScope = if (!oidcSupported) {
779+
tokenResponse.scope
780+
} else if (webFingerScopes != null) {
781+
webFingerScopes.joinToString(" ")
782+
} else {
783+
mdmProvider.getBrandingString(CONFIGURATION_OAUTH2_OPEN_ID_SCOPE, R.string.oauth2_openid_scope)
784+
}
785+
728786
authenticationViewModel.loginOAuth(
729787
serverBaseUrl = serverBaseUrl,
730788
username = tokenResponse.additionalParameters?.get(KEY_USER_ID).orEmpty(),
731789
authTokenType = OAUTH_TOKEN_TYPE,
732790
accessToken = tokenResponse.accessToken,
733791
refreshToken = tokenResponse.refreshToken.orEmpty(),
734-
scope = if (oidcSupported) mdmProvider.getBrandingString(
735-
CONFIGURATION_OAUTH2_OPEN_ID_SCOPE,
736-
R.string.oauth2_openid_scope,
737-
) else tokenResponse.scope,
792+
scope = effectiveScope,
738793
updateAccountWithUsername = if (loginAction != ACTION_CREATE) userAccount?.name else null,
739-
clientRegistrationInfo = clientRegistrationInfo
794+
clientRegistrationInfo = effectiveClientRegistrationInfo
740795
)
741796
}
742797

opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/GetInstancesViaWebFingerOperation.kt

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,15 @@ class GetInstancesViaWebFingerOperation(
4040
private val lockupServerDomain: String,
4141
private val rel: String,
4242
private val resource: String,
43-
) : RemoteOperation<List<String>>() {
43+
private val platform: String? = null,
44+
) : RemoteOperation<WebFingerResponse>() {
4445

4546
private fun buildRequestUri() =
4647
Uri.parse(lockupServerDomain).buildUpon()
4748
.path(ENDPOINT_WEBFINGER_PATH)
4849
.appendQueryParameter("rel", rel)
4950
.appendQueryParameter("resource", resource)
51+
.apply { platform?.let { appendQueryParameter("platform", it) } }
5052
.build()
5153

5254
private fun isSuccess(status: Int): Boolean = status == HttpConstants.HTTP_OK
@@ -61,34 +63,35 @@ class GetInstancesViaWebFingerOperation(
6163
method: HttpMethod,
6264
response: String?,
6365
status: Int
64-
): RemoteOperationResult<List<String>> {
66+
): RemoteOperationResult<WebFingerResponse> {
6567
Timber.e("Failed requesting WebFinger info")
6668
if (response != null) {
6769
Timber.e("*** status code: $status; response message: $response")
6870
} else {
6971
Timber.e("*** status code: $status")
7072
}
71-
return RemoteOperationResult<List<String>>(method)
73+
return RemoteOperationResult<WebFingerResponse>(method)
7274
}
7375

74-
private fun onRequestSuccessful(rawResponse: String): RemoteOperationResult<List<String>> {
76+
private fun onRequestSuccessful(rawResponse: String): RemoteOperationResult<WebFingerResponse> {
7577
val response = parseResponse(rawResponse)
7678
Timber.d("Successful WebFinger request: $response")
77-
val operationResult = RemoteOperationResult<List<String>>(RemoteOperationResult.ResultCode.OK)
78-
operationResult.data = response.links?.map { it.href } ?: listOf()
79+
val operationResult = RemoteOperationResult<WebFingerResponse>(RemoteOperationResult.ResultCode.OK)
80+
operationResult.data = response
7981
return operationResult
8082
}
8183

82-
override fun run(client: OpenCloudClient): RemoteOperationResult<List<String>> {
84+
override fun run(client: OpenCloudClient): RemoteOperationResult<WebFingerResponse> {
8385
val requestUri = buildRequestUri()
86+
Timber.d("Doing WebFinger request: $requestUri")
8487
val getMethod = GetMethod(URL(requestUri.toString()))
8588

8689
// First iteration won't follow redirections.
8790
getMethod.followRedirects = false
8891

8992
return try {
9093
val status = client.executeHttpMethod(getMethod)
91-
val response = getMethod.getResponseBodyAsString()!!
94+
val response = getMethod.getResponseBodyAsString()
9295

9396
if (isSuccess(status)) {
9497
onRequestSuccessful(response)
@@ -97,7 +100,7 @@ class GetInstancesViaWebFingerOperation(
97100
}
98101
} catch (e: Exception) {
99102
Timber.e(e, "Requesting WebFinger info failed")
100-
RemoteOperationResult<List<String>>(e)
103+
RemoteOperationResult<WebFingerResponse>(e)
101104
}
102105
}
103106

opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/responses/WebFingerResponse.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,20 @@
2424

2525
package eu.opencloud.android.lib.resources.webfinger.responses
2626

27+
import com.squareup.moshi.Json
2728
import com.squareup.moshi.JsonClass
2829

30+
@JsonClass(generateAdapter = true)
31+
data class WebFingerProperties(
32+
@Json(name = "http://opencloud.eu/ns/oidc/client_id") val clientId: String?,
33+
@Json(name = "http://opencloud.eu/ns/oidc/scopes") val scopes: List<String>?,
34+
)
35+
2936
@JsonClass(generateAdapter = true)
3037
data class WebFingerResponse(
3138
val subject: String,
32-
val links: List<LinkItem>?
39+
val links: List<LinkItem>?,
40+
val properties: WebFingerProperties? = null,
3341
)
3442

3543
@JsonClass(generateAdapter = true)

opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/services/WebFingerService.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package eu.opencloud.android.lib.resources.webfinger.services
1919

2020
import eu.opencloud.android.lib.common.OpenCloudClient
2121
import eu.opencloud.android.lib.common.operations.RemoteOperationResult
22+
import eu.opencloud.android.lib.resources.webfinger.responses.WebFingerResponse
2223

2324
interface WebFingerService {
2425
fun getInstancesFromWebFinger(
@@ -27,4 +28,12 @@ interface WebFingerService {
2728
rel: String,
2829
client: OpenCloudClient,
2930
): RemoteOperationResult<List<String>>
31+
32+
fun getOidcDiscoveryFromWebFinger(
33+
lookupServer: String,
34+
resource: String,
35+
rel: String,
36+
platform: String,
37+
client: OpenCloudClient,
38+
): RemoteOperationResult<WebFingerResponse>
3039
}

opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/services/implementation/OCWebFingerService.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package eu.opencloud.android.lib.resources.webfinger.services.implementation
2020
import eu.opencloud.android.lib.common.OpenCloudClient
2121
import eu.opencloud.android.lib.common.operations.RemoteOperationResult
2222
import eu.opencloud.android.lib.resources.webfinger.GetInstancesViaWebFingerOperation
23+
import eu.opencloud.android.lib.resources.webfinger.responses.WebFingerResponse
2324
import eu.opencloud.android.lib.resources.webfinger.services.WebFingerService
2425

2526
class OCWebFingerService : WebFingerService {
@@ -29,6 +30,23 @@ class OCWebFingerService : WebFingerService {
2930
resource: String,
3031
rel: String,
3132
client: OpenCloudClient,
32-
): RemoteOperationResult<List<String>> =
33-
GetInstancesViaWebFingerOperation(lookupServer, rel, resource).execute(client)
33+
): RemoteOperationResult<List<String>> {
34+
val result = GetInstancesViaWebFingerOperation(lockupServerDomain = lookupServer, rel = rel, resource = resource).execute(client)
35+
if (!result.isSuccess) {
36+
@Suppress("UNCHECKED_CAST")
37+
return result as RemoteOperationResult<List<String>>
38+
}
39+
val listResult = RemoteOperationResult<List<String>>(RemoteOperationResult.ResultCode.OK)
40+
listResult.data = result.data.links?.map { it.href } ?: listOf()
41+
return listResult
42+
}
43+
44+
override fun getOidcDiscoveryFromWebFinger(
45+
lookupServer: String,
46+
resource: String,
47+
rel: String,
48+
platform: String,
49+
client: OpenCloudClient,
50+
): RemoteOperationResult<WebFingerResponse> =
51+
GetInstancesViaWebFingerOperation(lockupServerDomain = lookupServer, rel = rel, resource = resource, platform = platform).execute(client)
3452
}

0 commit comments

Comments
 (0)