From 2688d260cf54dedc7e5ad6657e1035932e468adf Mon Sep 17 00:00:00 2001 From: Markus Goetz Date: Tue, 24 Feb 2026 16:29:33 +0100 Subject: [PATCH 1/3] 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 https://github.com/opencloud-eu/android/issues/96 --- .../authentication/AccountAuthenticator.java | 6 +- .../authentication/LoginActivity.kt | 91 +++++++++++++++---- .../GetInstancesViaWebFingerOperation.kt | 21 +++-- .../webfinger/responses/WebFingerResponse.kt | 10 +- .../webfinger/services/WebFingerService.kt | 9 ++ .../implementation/OCWebFingerService.kt | 22 ++++- .../repository/OCServerInfoRepository.kt | 51 +++++------ .../datasources/RemoteWebFingerDataSource.kt | 11 +++ .../OCRemoteWebFingerDataSource.kt | 24 +++++ .../repository/OCServerInfoRepositoryTest.kt | 65 +++++++++++-- .../android/domain/server/model/ServerInfo.kt | 2 + .../webfinger/model/WebFingerOidcInfo.kt | 26 ++++++ .../android/testutil/OCServerInfo.kt | 11 +++ 13 files changed, 282 insertions(+), 67 deletions(-) create mode 100644 opencloudDomain/src/main/java/eu/opencloud/android/domain/webfinger/model/WebFingerOidcInfo.kt 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 b73a9a683..213ab40b1 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 @@ -54,6 +54,7 @@ import static eu.opencloud.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_ID; import static eu.opencloud.android.data.authentication.AuthenticationConstantsKt.KEY_CLIENT_REGISTRATION_CLIENT_SECRET; import static eu.opencloud.android.data.authentication.AuthenticationConstantsKt.KEY_OAUTH2_REFRESH_TOKEN; +import static eu.opencloud.android.data.authentication.AuthenticationConstantsKt.KEY_OAUTH2_SCOPE; import static eu.opencloud.android.presentation.authentication.AuthenticatorConstants.KEY_AUTH_TOKEN_TYPE; import static org.koin.java.KoinJavaComponent.inject; @@ -386,7 +387,10 @@ private String refreshToken( clientAuth = OAuthUtils.Companion.getClientAuth(clientSecret, clientId); } - String scope = mContext.getResources().getString(R.string.oauth2_openid_scope); + String scope = accountManager.getUserData(account, KEY_OAUTH2_SCOPE); + if (scope == null) { + scope = mContext.getResources().getString(R.string.oauth2_openid_scope); + } TokenRequest oauthTokenRequest = new TokenRequest.RefreshToken( baseUrl, 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 7fd2800a2..8f5f45a73 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 @@ -50,6 +50,7 @@ import eu.opencloud.android.MainApp.Companion.accountType import eu.opencloud.android.R import eu.opencloud.android.data.authentication.KEY_USER_ID import eu.opencloud.android.databinding.AccountSetupBinding +import eu.opencloud.android.domain.authentication.oauth.model.ClientRegistrationInfo import eu.opencloud.android.domain.authentication.oauth.model.ResponseType import eu.opencloud.android.domain.authentication.oauth.model.TokenRequest import eu.opencloud.android.domain.exceptions.NoNetworkConnectionException @@ -447,14 +448,28 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted showOrHideBasicAuthFields(shouldBeVisible = false) authTokenType = OAUTH_TOKEN_TYPE oidcSupported = true - val registrationEndpoint = serverInfo.oidcServerConfiguration.registrationEndpoint - if (registrationEndpoint != null) { - registerClient( + val webFingerClientId = serverInfo.webFingerClientId + val webFingerScopes = serverInfo.webFingerScopes + if (webFingerClientId != null) { + performGetAuthorizationCodeRequest( authorizationEndpoint = serverInfo.oidcServerConfiguration.authorizationEndpoint.toUri(), - registrationEndpoint = registrationEndpoint + clientId = webFingerClientId, + webFingerScopes = webFingerScopes, ) } else { - performGetAuthorizationCodeRequest(serverInfo.oidcServerConfiguration.authorizationEndpoint.toUri()) + val registrationEndpoint = serverInfo.oidcServerConfiguration.registrationEndpoint + if (registrationEndpoint != null) { + registerClient( + authorizationEndpoint = serverInfo.oidcServerConfiguration.authorizationEndpoint.toUri(), + registrationEndpoint = registrationEndpoint, + webFingerScopes = webFingerScopes, + ) + } else { + performGetAuthorizationCodeRequest( + authorizationEndpoint = serverInfo.oidcServerConfiguration.authorizationEndpoint.toUri(), + webFingerScopes = webFingerScopes, + ) + } } } @@ -559,7 +574,8 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted */ private fun registerClient( authorizationEndpoint: Uri, - registrationEndpoint: String + registrationEndpoint: String, + webFingerScopes: List? = null, ) { authenticationViewModel.registerClient(registrationEndpoint) authenticationViewModel.registerClient.observe(this) { @@ -570,14 +586,15 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted uiResult.data?.let { clientRegistrationInfo -> performGetAuthorizationCodeRequest( authorizationEndpoint = authorizationEndpoint, - clientId = clientRegistrationInfo.clientId + clientId = clientRegistrationInfo.clientId, + webFingerScopes = webFingerScopes, ) } } is UIResult.Error -> { Timber.e(uiResult.error, "Client registration failed.") - performGetAuthorizationCodeRequest(authorizationEndpoint) + performGetAuthorizationCodeRequest(authorizationEndpoint, webFingerScopes = webFingerScopes) } } } @@ -585,7 +602,8 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted private fun performGetAuthorizationCodeRequest( authorizationEndpoint: Uri, - clientId: String = getString(R.string.oauth2_client_id) + clientId: String = getString(R.string.oauth2_client_id), + webFingerScopes: List? = null, ) { Timber.d("A browser should be opened now to authenticate this user.") @@ -597,12 +615,20 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted // which helps Firefox properly handle the OAuth redirect back to the app customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val scope = if (!oidcSupported) { + "" + } else if (webFingerScopes != null) { + webFingerScopes.joinToString(" ") + } else { + mdmProvider.getBrandingString(CONFIGURATION_OAUTH2_OPEN_ID_SCOPE, R.string.oauth2_openid_scope) + } + val authorizationEndpointUri = OAuthUtils.buildAuthorizationRequest( authorizationEndpoint = authorizationEndpoint, redirectUri = OAuthUtils.buildRedirectUri(applicationContext).toString(), clientId = clientId, responseType = ResponseType.CODE.string, - scope = if (oidcSupported) mdmProvider.getBrandingString(CONFIGURATION_OAUTH2_OPEN_ID_SCOPE, R.string.oauth2_openid_scope) else "", + scope = scope, prompt = if (oidcSupported) mdmProvider.getBrandingString(CONFIGURATION_OAUTH2_OPEN_ID_PROMPT, R.string.oauth2_openid_prompt) else "", codeChallenge = authenticationViewModel.codeChallenge, state = authenticationViewModel.oidcState, @@ -610,6 +636,8 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted sendLoginHintAndUser = mdmProvider.getBrandingBoolean(mdmKey = CONFIGURATION_SEND_LOGIN_HINT_AND_USER, booleanKey = R.bool.send_login_hint_and_user), ) + Timber.d("A browser should be opened now to authenticate this user is $authorizationEndpointUri ") + try { saveAuthState() @@ -673,6 +701,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted val (clientId, clientSecret) = if (clientRegistrationInfo?.clientId != null && clientRegistrationInfo.clientSecret != null) { Pair(clientRegistrationInfo.clientId, clientRegistrationInfo.clientSecret as String) } else { + // May be overridden below by serverInfo.webFingerClientId if available Pair(getString(R.string.oauth2_client_id), getString(R.string.oauth2_client_secret)) } @@ -684,18 +713,21 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted if (serverInfo is ServerInfo.OIDCServer) { tokenEndPoint = serverInfo.oidcServerConfiguration.tokenEndpoint + // Use webfinger-provided clientId if available, otherwise keep registration/default + val effectiveClientId = serverInfo.webFingerClientId ?: clientId + // RFC 7636: Public clients (token_endpoint_auth_method: none) must not send Authorization header if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodNone()) { clientAuth = "" - clientIdForRequest = clientId + clientIdForRequest = effectiveClientId } else if (serverInfo.oidcServerConfiguration.isTokenEndpointAuthMethodSupportedClientSecretPost()) { // For client_secret_post, credentials go in body, not Authorization header clientAuth = "" - clientIdForRequest = clientId + clientIdForRequest = effectiveClientId clientSecretForRequest = clientSecret } else { // For other methods (e.g., client_secret_basic), use Basic auth header - clientAuth = OAuthUtils.getClientAuth(clientSecret, clientId) + clientAuth = OAuthUtils.getClientAuth(clientSecret, effectiveClientId) } } else { tokenEndPoint = "$serverBaseUrl${File.separator}${contextProvider.getString(R.string.oauth2_url_endpoint_access)}" @@ -725,18 +757,41 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted Timber.d("Tokens received ${uiResult.data}, trying to login, creating account and adding it to account manager") val tokenResponse = uiResult.data ?: return@observe + // When webfinger provides a client_id without dynamic registration, + // store it so AccountAuthenticator can use it for token refresh + val effectiveClientRegistrationInfo = clientRegistrationInfo + ?: (serverInfo as? ServerInfo.OIDCServer)?.webFingerClientId?.let { wfClientId -> + ClientRegistrationInfo( + clientId = wfClientId, + clientSecret = null, + clientIdIssuedAt = null, + clientSecretExpiration = 0, + ) + } + + // Scope priority: webfinger scopes > MDM/string-resource > token response + val webFingerScopes = if (serverInfo is ServerInfo.OIDCServer) { + serverInfo.webFingerScopes + } else { + null + } + val effectiveScope = if (!oidcSupported) { + tokenResponse.scope + } else if (webFingerScopes != null) { + webFingerScopes.joinToString(" ") + } else { + mdmProvider.getBrandingString(CONFIGURATION_OAUTH2_OPEN_ID_SCOPE, R.string.oauth2_openid_scope) + } + authenticationViewModel.loginOAuth( serverBaseUrl = serverBaseUrl, username = tokenResponse.additionalParameters?.get(KEY_USER_ID).orEmpty(), authTokenType = OAUTH_TOKEN_TYPE, accessToken = tokenResponse.accessToken, refreshToken = tokenResponse.refreshToken.orEmpty(), - scope = if (oidcSupported) mdmProvider.getBrandingString( - CONFIGURATION_OAUTH2_OPEN_ID_SCOPE, - R.string.oauth2_openid_scope, - ) else tokenResponse.scope, + scope = effectiveScope, updateAccountWithUsername = if (loginAction != ACTION_CREATE) userAccount?.name else null, - clientRegistrationInfo = clientRegistrationInfo + clientRegistrationInfo = effectiveClientRegistrationInfo ) } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/GetInstancesViaWebFingerOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/GetInstancesViaWebFingerOperation.kt index ba69ded9a..1db52524a 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/GetInstancesViaWebFingerOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/GetInstancesViaWebFingerOperation.kt @@ -40,13 +40,15 @@ class GetInstancesViaWebFingerOperation( private val lockupServerDomain: String, private val rel: String, private val resource: String, -) : RemoteOperation>() { + private val platform: String? = null, +) : RemoteOperation() { private fun buildRequestUri() = Uri.parse(lockupServerDomain).buildUpon() .path(ENDPOINT_WEBFINGER_PATH) .appendQueryParameter("rel", rel) .appendQueryParameter("resource", resource) + .apply { platform?.let { appendQueryParameter("platform", it) } } .build() private fun isSuccess(status: Int): Boolean = status == HttpConstants.HTTP_OK @@ -61,26 +63,27 @@ class GetInstancesViaWebFingerOperation( method: HttpMethod, response: String?, status: Int - ): RemoteOperationResult> { + ): RemoteOperationResult { Timber.e("Failed requesting WebFinger info") if (response != null) { Timber.e("*** status code: $status; response message: $response") } else { Timber.e("*** status code: $status") } - return RemoteOperationResult>(method) + return RemoteOperationResult(method) } - private fun onRequestSuccessful(rawResponse: String): RemoteOperationResult> { + private fun onRequestSuccessful(rawResponse: String): RemoteOperationResult { val response = parseResponse(rawResponse) Timber.d("Successful WebFinger request: $response") - val operationResult = RemoteOperationResult>(RemoteOperationResult.ResultCode.OK) - operationResult.data = response.links?.map { it.href } ?: listOf() + val operationResult = RemoteOperationResult(RemoteOperationResult.ResultCode.OK) + operationResult.data = response return operationResult } - override fun run(client: OpenCloudClient): RemoteOperationResult> { + override fun run(client: OpenCloudClient): RemoteOperationResult { val requestUri = buildRequestUri() + Timber.d("Doing WebFinger request: $requestUri") val getMethod = GetMethod(URL(requestUri.toString())) // First iteration won't follow redirections. @@ -88,7 +91,7 @@ class GetInstancesViaWebFingerOperation( return try { val status = client.executeHttpMethod(getMethod) - val response = getMethod.getResponseBodyAsString()!! + val response = getMethod.getResponseBodyAsString() if (isSuccess(status)) { onRequestSuccessful(response) @@ -97,7 +100,7 @@ class GetInstancesViaWebFingerOperation( } } catch (e: Exception) { Timber.e(e, "Requesting WebFinger info failed") - RemoteOperationResult>(e) + RemoteOperationResult(e) } } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/responses/WebFingerResponse.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/responses/WebFingerResponse.kt index b344bf0a7..1fb4efb11 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/responses/WebFingerResponse.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/responses/WebFingerResponse.kt @@ -24,12 +24,20 @@ package eu.opencloud.android.lib.resources.webfinger.responses +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) +data class WebFingerProperties( + @Json(name = "http://opencloud.eu/ns/oidc/client_id") val clientId: String?, + @Json(name = "http://opencloud.eu/ns/oidc/scopes") val scopes: List?, +) + @JsonClass(generateAdapter = true) data class WebFingerResponse( val subject: String, - val links: List? + val links: List?, + val properties: WebFingerProperties? = null, ) @JsonClass(generateAdapter = true) diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/services/WebFingerService.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/services/WebFingerService.kt index fe1639754..0af520b81 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/services/WebFingerService.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/services/WebFingerService.kt @@ -19,6 +19,7 @@ package eu.opencloud.android.lib.resources.webfinger.services import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.resources.webfinger.responses.WebFingerResponse interface WebFingerService { fun getInstancesFromWebFinger( @@ -27,4 +28,12 @@ interface WebFingerService { rel: String, client: OpenCloudClient, ): RemoteOperationResult> + + fun getOidcDiscoveryFromWebFinger( + lookupServer: String, + resource: String, + rel: String, + platform: String, + client: OpenCloudClient, + ): RemoteOperationResult } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/services/implementation/OCWebFingerService.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/services/implementation/OCWebFingerService.kt index 6bf422787..fcb9e54e1 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/services/implementation/OCWebFingerService.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/webfinger/services/implementation/OCWebFingerService.kt @@ -20,6 +20,7 @@ package eu.opencloud.android.lib.resources.webfinger.services.implementation import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.operations.RemoteOperationResult import eu.opencloud.android.lib.resources.webfinger.GetInstancesViaWebFingerOperation +import eu.opencloud.android.lib.resources.webfinger.responses.WebFingerResponse import eu.opencloud.android.lib.resources.webfinger.services.WebFingerService class OCWebFingerService : WebFingerService { @@ -29,6 +30,23 @@ class OCWebFingerService : WebFingerService { resource: String, rel: String, client: OpenCloudClient, - ): RemoteOperationResult> = - GetInstancesViaWebFingerOperation(lookupServer, rel, resource).execute(client) + ): RemoteOperationResult> { + val result = GetInstancesViaWebFingerOperation(lockupServerDomain = lookupServer, rel = rel, resource = resource).execute(client) + if (!result.isSuccess) { + @Suppress("UNCHECKED_CAST") + return result as RemoteOperationResult> + } + val listResult = RemoteOperationResult>(RemoteOperationResult.ResultCode.OK) + listResult.data = result.data.links?.map { it.href } ?: listOf() + return listResult + } + + override fun getOidcDiscoveryFromWebFinger( + lookupServer: String, + resource: String, + rel: String, + platform: String, + client: OpenCloudClient, + ): RemoteOperationResult = + GetInstancesViaWebFingerOperation(lockupServerDomain = lookupServer, rel = rel, resource = resource, platform = platform).execute(client) } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/server/repository/OCServerInfoRepository.kt b/opencloudData/src/main/java/eu/opencloud/android/data/server/repository/OCServerInfoRepository.kt index f2e267769..46d48f441 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/server/repository/OCServerInfoRepository.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/server/repository/OCServerInfoRepository.kt @@ -26,6 +26,7 @@ import eu.opencloud.android.data.server.datasources.RemoteServerInfoDataSource import eu.opencloud.android.data.webfinger.datasources.RemoteWebFingerDataSource import eu.opencloud.android.domain.server.ServerInfoRepository import eu.opencloud.android.domain.server.model.ServerInfo +import eu.opencloud.android.domain.webfinger.model.WebFingerOidcInfo import eu.opencloud.android.domain.webfinger.model.WebFingerRel import timber.log.Timber @@ -36,25 +37,23 @@ class OCServerInfoRepository( ) : ServerInfoRepository { override fun getServerInfo(path: String, creatingAccount: Boolean, enforceOIDC: Boolean): ServerInfo { - val oidcIssuerFromWebFinger: String? = if (creatingAccount) retrieveOIDCIssuerFromWebFinger(serverUrl = path) else null - - if (oidcIssuerFromWebFinger != null) { - val openIDConnectServerConfiguration = oidcRemoteOAuthDataSource.performOIDCDiscovery(oidcIssuerFromWebFinger) - return ServerInfo.OIDCServer( - openCloudVersion = "10.12", - baseUrl = path, - oidcServerConfiguration = openIDConnectServerConfiguration - ) - } + // Try webfinger first to get OIDC client config (client_id, scopes) and issuer. + // This is a lightweight call that returns null if webfinger is not available. + val oidcInfoFromWebFinger: WebFingerOidcInfo? = retrieveOidcInfoFromWebFinger(serverUrl = path) + // Always check server status to get the proper baseUrl and version. + // We must not skip this, because the server may normalize/redirect the URL, + // and the account name is built from baseUrl + username. val serverInfo = remoteServerInfoDataSource.getServerInfo(path, enforceOIDC) return if (serverInfo is ServerInfo.BasicServer) { serverInfo } else { - // Could be OAuth or OpenID Connect + // OIDC discovery: prefer the webfinger issuer if available (it may differ from baseUrl), + // otherwise discover from the server's baseUrl. + val oidcDiscoveryUrl = oidcInfoFromWebFinger?.issuer ?: serverInfo.baseUrl val openIDConnectServerConfiguration = try { - oidcRemoteOAuthDataSource.performOIDCDiscovery(serverInfo.baseUrl) + oidcRemoteOAuthDataSource.performOIDCDiscovery(oidcDiscoveryUrl) } catch (exception: Exception) { Timber.d(exception, "OIDC discovery not found") null @@ -64,7 +63,9 @@ class OCServerInfoRepository( ServerInfo.OIDCServer( openCloudVersion = serverInfo.openCloudVersion, baseUrl = serverInfo.baseUrl, - oidcServerConfiguration = openIDConnectServerConfiguration + oidcServerConfiguration = openIDConnectServerConfiguration, + webFingerClientId = oidcInfoFromWebFinger?.clientId, + webFingerScopes = oidcInfoFromWebFinger?.scopes, ) } else { ServerInfo.OAuth2Server( @@ -75,20 +76,16 @@ class OCServerInfoRepository( } } - private fun retrieveOIDCIssuerFromWebFinger( + private fun retrieveOidcInfoFromWebFinger( serverUrl: String, - ): String? { - val oidcIssuer = try { - webFingerDatasource.getInstancesFromWebFinger( - lookupServer = serverUrl, - rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, - resource = serverUrl, - ).firstOrNull() - } catch (exception: Exception) { - Timber.d(exception, "Cant retrieve the oidc issuer from webfinger.") - null - } - - return oidcIssuer + ): WebFingerOidcInfo? = try { + webFingerDatasource.getOidcInfoFromWebFinger( + lookupServer = serverUrl, + rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, + resource = serverUrl, + ) + } catch (exception: Exception) { + Timber.d(exception, "Cant retrieve the oidc info from webfinger.") + null } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/webfinger/datasources/RemoteWebFingerDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/webfinger/datasources/RemoteWebFingerDataSource.kt index 75ab7d435..fabfb0687 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/webfinger/datasources/RemoteWebFingerDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/webfinger/datasources/RemoteWebFingerDataSource.kt @@ -17,9 +17,12 @@ */ package eu.opencloud.android.data.webfinger.datasources +import eu.opencloud.android.domain.webfinger.model.WebFingerOidcInfo import eu.opencloud.android.domain.webfinger.model.WebFingerRel interface RemoteWebFingerDataSource { + // Used with OPENCLOUD_INSTANCE rel: routes a user to the correct openCloud server instance + // when the user knows their username but not which server they are on (MDM-configured lookup server). fun getInstancesFromWebFinger( lookupServer: String, rel: WebFingerRel, @@ -33,4 +36,12 @@ interface RemoteWebFingerDataSource { username: String, accessToken: String, ): List + + // Used with OIDC_ISSUER_DISCOVERY rel: fetches the OIDC issuer URL and any server-advertised + // OIDC client config (client_id, scopes) so the app does not need hardcoded or MDM-configured values. + fun getOidcInfoFromWebFinger( + lookupServer: String, + rel: WebFingerRel, + resource: String, + ): WebFingerOidcInfo? } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/webfinger/datasources/implementation/OCRemoteWebFingerDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/webfinger/datasources/implementation/OCRemoteWebFingerDataSource.kt index d9466b778..67d7f5c84 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/webfinger/datasources/implementation/OCRemoteWebFingerDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/webfinger/datasources/implementation/OCRemoteWebFingerDataSource.kt @@ -20,6 +20,7 @@ package eu.opencloud.android.data.webfinger.datasources.implementation import eu.opencloud.android.data.ClientManager import eu.opencloud.android.data.executeRemoteOperation import eu.opencloud.android.data.webfinger.datasources.RemoteWebFingerDataSource +import eu.opencloud.android.domain.webfinger.model.WebFingerOidcInfo import eu.opencloud.android.domain.webfinger.model.WebFingerRel import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory @@ -71,4 +72,27 @@ class OCRemoteWebFingerDataSource( ) } } + + override fun getOidcInfoFromWebFinger( + lookupServer: String, + rel: WebFingerRel, + resource: String, + ): WebFingerOidcInfo? { + val openCloudClient = clientManager.getClientForAnonymousCredentials(lookupServer, false) + val result = webFingerService.getOidcDiscoveryFromWebFinger( + lookupServer = lookupServer, + resource = resource, + rel = rel.uri, + platform = "android", + client = openCloudClient + ) + if (!result.isSuccess) return null + val response = result.data ?: return null + val issuer = response.links?.firstOrNull { it.rel == rel.uri }?.href ?: return null + return WebFingerOidcInfo( + issuer = issuer, + clientId = response.properties?.clientId, + scopes = response.properties?.scopes, + ) + } } diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/server/repository/OCServerInfoRepositoryTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/server/repository/OCServerInfoRepositoryTest.kt index 349f261ca..8f765bb42 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/server/repository/OCServerInfoRepositoryTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/server/repository/OCServerInfoRepositoryTest.kt @@ -25,12 +25,17 @@ package eu.opencloud.android.data.server.repository import eu.opencloud.android.data.oauth.datasources.RemoteOAuthDataSource import eu.opencloud.android.data.server.datasources.RemoteServerInfoDataSource import eu.opencloud.android.data.webfinger.datasources.RemoteWebFingerDataSource +import eu.opencloud.android.domain.server.model.ServerInfo +import eu.opencloud.android.domain.webfinger.model.WebFingerOidcInfo import eu.opencloud.android.domain.webfinger.model.WebFingerRel import eu.opencloud.android.testutil.OC_SECURE_SERVER_INFO_BASIC_AUTH import eu.opencloud.android.testutil.OC_SECURE_SERVER_INFO_BEARER_AUTH import eu.opencloud.android.testutil.OC_SECURE_SERVER_INFO_OIDC_AUTH import eu.opencloud.android.testutil.OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE +import eu.opencloud.android.testutil.OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE_WITH_CLIENT +import eu.opencloud.android.testutil.OC_WEBFINGER_CLIENT_ID import eu.opencloud.android.testutil.OC_WEBFINGER_INSTANCE_URL +import eu.opencloud.android.testutil.OC_WEBFINGER_SCOPES import eu.opencloud.android.testutil.oauth.OC_OIDC_SERVER_CONFIGURATION import io.mockk.every import io.mockk.mockk @@ -66,7 +71,7 @@ class OCServerInfoRepositoryTest { @Test fun `getServerInfo returns a BasicServer when creatingAccount parameter is true and webfinger datasource throws an exception`() { every { - remoteWebFingerDataSource.getInstancesFromWebFinger( + remoteWebFingerDataSource.getOidcInfoFromWebFinger( lookupServer = OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl, rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, resource = OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl @@ -85,7 +90,7 @@ class OCServerInfoRepositoryTest { assertEquals(OC_SECURE_SERVER_INFO_BASIC_AUTH, basicServer) verify(exactly = 1) { - remoteWebFingerDataSource.getInstancesFromWebFinger( + remoteWebFingerDataSource.getOidcInfoFromWebFinger( lookupServer = OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl, rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, resource = OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl @@ -120,7 +125,7 @@ class OCServerInfoRepositoryTest { @Test fun `getServerInfo returns an OAuth2Server when creatingAccount parameter is true and webfinger datasource throws an exception`() { every { - remoteWebFingerDataSource.getInstancesFromWebFinger( + remoteWebFingerDataSource.getOidcInfoFromWebFinger( lookupServer = OC_SECURE_SERVER_INFO_BEARER_AUTH.baseUrl, rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, resource = OC_SECURE_SERVER_INFO_BEARER_AUTH.baseUrl @@ -143,7 +148,7 @@ class OCServerInfoRepositoryTest { assertEquals(OC_SECURE_SERVER_INFO_BEARER_AUTH, oAuthServer) verify(exactly = 1) { - remoteWebFingerDataSource.getInstancesFromWebFinger( + remoteWebFingerDataSource.getOidcInfoFromWebFinger( lookupServer = OC_SECURE_SERVER_INFO_BEARER_AUTH.baseUrl, rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, resource = OC_SECURE_SERVER_INFO_BEARER_AUTH.baseUrl @@ -179,7 +184,7 @@ class OCServerInfoRepositoryTest { @Test fun `getServerInfo returns an OIDCServer when creatingAccount parameter is true and webfinger datasource throws an exception`() { every { - remoteWebFingerDataSource.getInstancesFromWebFinger( + remoteWebFingerDataSource.getOidcInfoFromWebFinger( lookupServer = OC_SECURE_SERVER_INFO_OIDC_AUTH.baseUrl, rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, resource = OC_SECURE_SERVER_INFO_OIDC_AUTH.baseUrl @@ -202,7 +207,7 @@ class OCServerInfoRepositoryTest { assertEquals(OC_SECURE_SERVER_INFO_OIDC_AUTH, oIDCServer) verify(exactly = 1) { - remoteWebFingerDataSource.getInstancesFromWebFinger( + remoteWebFingerDataSource.getOidcInfoFromWebFinger( lookupServer = OC_SECURE_SERVER_INFO_OIDC_AUTH.baseUrl, rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, resource = OC_SECURE_SERVER_INFO_OIDC_AUTH.baseUrl @@ -215,12 +220,16 @@ class OCServerInfoRepositoryTest { @Test fun `getServerInfo returns an OIDCServer when creatingAccount is true and webfinger datasource returns an OIDC issuer`() { every { - remoteWebFingerDataSource.getInstancesFromWebFinger( + remoteWebFingerDataSource.getOidcInfoFromWebFinger( lookupServer = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE.baseUrl, rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, resource = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE.baseUrl ) - } returns listOf(OC_WEBFINGER_INSTANCE_URL) + } returns WebFingerOidcInfo(issuer = OC_WEBFINGER_INSTANCE_URL, clientId = null, scopes = null) + + every { + remoteServerInfoDataSource.getServerInfo(OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE.baseUrl, false) + } returns ServerInfo.OAuth2Server(baseUrl = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE.baseUrl, openCloudVersion = "10.12") every { remoteOAuthDataSource.performOIDCDiscovery(OC_WEBFINGER_INSTANCE_URL) @@ -234,7 +243,7 @@ class OCServerInfoRepositoryTest { assertEquals(OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE, oIDCServerWebfinger) verify(exactly = 1) { - remoteWebFingerDataSource.getInstancesFromWebFinger( + remoteWebFingerDataSource.getOidcInfoFromWebFinger( lookupServer = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE.baseUrl, rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, resource = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE.baseUrl @@ -242,4 +251,42 @@ class OCServerInfoRepositoryTest { remoteOAuthDataSource.performOIDCDiscovery(OC_WEBFINGER_INSTANCE_URL) } } + + @Test + fun `getServerInfo returns an OIDCServer with webfinger client id and scopes when webfinger provides them`() { + every { + remoteWebFingerDataSource.getOidcInfoFromWebFinger( + lookupServer = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE_WITH_CLIENT.baseUrl, + rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, + resource = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE_WITH_CLIENT.baseUrl + ) + } returns WebFingerOidcInfo(issuer = OC_WEBFINGER_INSTANCE_URL, clientId = OC_WEBFINGER_CLIENT_ID, scopes = OC_WEBFINGER_SCOPES) + + every { + remoteServerInfoDataSource.getServerInfo(OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE_WITH_CLIENT.baseUrl, false) + } returns ServerInfo.OAuth2Server( + baseUrl = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE_WITH_CLIENT.baseUrl, + openCloudVersion = "10.12", + ) + + every { + remoteOAuthDataSource.performOIDCDiscovery(OC_WEBFINGER_INSTANCE_URL) + } returns OC_OIDC_SERVER_CONFIGURATION + + val result = ocServerInfoRepository.getServerInfo( + path = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE_WITH_CLIENT.baseUrl, + creatingAccount = true, + enforceOIDC = false + ) + assertEquals(OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE_WITH_CLIENT, result) + + verify(exactly = 1) { + remoteWebFingerDataSource.getOidcInfoFromWebFinger( + lookupServer = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE_WITH_CLIENT.baseUrl, + rel = WebFingerRel.OIDC_ISSUER_DISCOVERY, + resource = OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE_WITH_CLIENT.baseUrl + ) + remoteOAuthDataSource.performOIDCDiscovery(OC_WEBFINGER_INSTANCE_URL) + } + } } diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/server/model/ServerInfo.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/server/model/ServerInfo.kt index 90189498e..ea0f20160 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/server/model/ServerInfo.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/server/model/ServerInfo.kt @@ -43,6 +43,8 @@ sealed class ServerInfo( openCloudVersion: String, baseUrl: String, val oidcServerConfiguration: OIDCServerConfiguration, + val webFingerClientId: String? = null, + val webFingerScopes: List? = null, ) : ServerInfo(openCloudVersion = openCloudVersion, baseUrl = baseUrl) { override fun equals(other: Any?): Boolean { if (other !is OIDCServer) return false diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/webfinger/model/WebFingerOidcInfo.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/webfinger/model/WebFingerOidcInfo.kt new file mode 100644 index 000000000..f4baae574 --- /dev/null +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/webfinger/model/WebFingerOidcInfo.kt @@ -0,0 +1,26 @@ +/** + * openCloud Android client application + * + * @author Markus Goetz + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package eu.opencloud.android.domain.webfinger.model + +data class WebFingerOidcInfo( + val issuer: String, + val clientId: String?, + val scopes: List?, +) diff --git a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCServerInfo.kt b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCServerInfo.kt index d3b2455f4..47940eca3 100644 --- a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCServerInfo.kt +++ b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCServerInfo.kt @@ -66,3 +66,14 @@ val OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE = ServerInfo.OIDCServer( openCloudVersion = "10.12", oidcServerConfiguration = OC_OIDC_SERVER_CONFIGURATION ) + +const val OC_WEBFINGER_CLIENT_ID = "OpenCloudAndroid" +val OC_WEBFINGER_SCOPES = listOf("openid", "profile", "email", "offline_access") + +val OC_SECURE_SERVER_INFO_OIDC_AUTH_WEBFINGER_INSTANCE_WITH_CLIENT = ServerInfo.OIDCServer( + baseUrl = OC_SECURE_BASE_URL, + openCloudVersion = "10.12", + oidcServerConfiguration = OC_OIDC_SERVER_CONFIGURATION, + webFingerClientId = OC_WEBFINGER_CLIENT_ID, + webFingerScopes = OC_WEBFINGER_SCOPES, +) From 68ea5aa04e4cc1a541255246846e8283a80ed778 Mon Sep 17 00:00:00 2001 From: Markus Goetz Date: Sat, 28 Feb 2026 17:12:45 +0100 Subject: [PATCH 2/3] Login: Fix OAuth crash and UX after process death When Android kills the app process while the user is in the browser for OAuth login, the OAuth redirect creates a fresh LoginActivity with no ViewModel state. This caused a MalformedURLException because the token endpoint URL was built from an empty serverBaseUrl. Unlike activity death (where onSaveInstanceState Bundle survives), process death requires SharedPreferences to persist state across the browser redirect. The existing commit feba750ea46 saved PKCE state to SharedPreferences but missed serverBaseUrl. Fix by: - Saving serverBaseUrl and oidcSupported to SharedPreferences alongside the existing PKCE state - On OAuth redirect after process death, re-running OIDC discovery (getServerInfo) to repopulate the ViewModel before processing the authorization code - Always launching FileDisplayActivity after successful account discovery, so the user lands in the app instead of Chrome coming back to foreground (cherry picked from commit a7b0863c7a3a60fc6794286e101e97802d26eabe) --- .../authentication/LoginActivity.kt | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) 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 8f5f45a73..728d50a87 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 @@ -99,6 +99,10 @@ private const val KEY_OIDC_SUPPORTED = "KEY_OIDC_SUPPORTED" private const val KEY_CODE_VERIFIER = "KEY_CODE_VERIFIER" private const val KEY_CODE_CHALLENGE = "KEY_CODE_CHALLENGE" private const val KEY_OIDC_STATE = "KEY_OIDC_STATE" +private const val KEY_AUTH_SERVER_BASE_URL = "KEY_AUTH_SERVER_BASE_URL" +private const val KEY_AUTH_OIDC_SUPPORTED = "KEY_AUTH_OIDC_SUPPORTED" +private const val KEY_AUTH_LOGIN_ACTION = "KEY_AUTH_LOGIN_ACTION" +private const val KEY_AUTH_USER_ACCOUNT = "KEY_AUTH_USER_ACCOUNT" class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrustedCertListener, SecurityEnforced { @@ -237,14 +241,19 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted if (savedInstanceState == null) { restoreAuthState() } - handleGetAuthorizationCodeResponse(intent) + if (authenticationViewModel.serverInfo.value?.peekContent()?.getStoredData() == null + && ::serverBaseUrl.isInitialized && serverBaseUrl.isNotEmpty()) { + // Process death: serverInfo is gone. Re-fetch it before processing the OAuth response. + // Store the intent as pending — getServerInfoIsSuccess will process it via checkServerType bypass. + pendingAuthorizationIntent = intent + authenticationViewModel.getServerInfo(serverBaseUrl) + } else { + handleGetAuthorizationCodeResponse(intent) + } } - // Process any pending intent that arrived before binding was ready - pendingAuthorizationIntent?.let { - handleGetAuthorizationCodeResponse(it) - pendingAuthorizationIntent = null - } + // Note: pendingAuthorizationIntent is processed in checkServerType() after + // getServerInfo() completes (process death recovery flow). } @@ -262,7 +271,10 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted private fun launchFileDisplayActivity() { val newIntent = Intent(this, FileDisplayActivity::class.java) - newIntent.data = intent.data + if (authenticationViewModel.launchedFromDeepLink) { + newIntent.data = intent.data + } + newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(newIntent) finish() } @@ -296,11 +308,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted authenticationViewModel.accountDiscovery.observe(this) { if (it.peekContent() is UIResult.Success) { notifyDocumentsProviderRoots(applicationContext) - if (authenticationViewModel.launchedFromDeepLink) { - launchFileDisplayActivity() - } else { - finish() - } + launchFileDisplayActivity() } else { binding.authStatusText.run { text = context.getString(R.string.login_account_preparing) @@ -425,6 +433,18 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted } private fun checkServerType(serverInfo: ServerInfo) { + // If we have a pending OAuth response (process death recovery), process it now + // that serverInfo has been re-fetched, instead of starting a new auth flow. + pendingAuthorizationIntent?.let { pendingIntent -> + pendingAuthorizationIntent = null + authTokenType = OAUTH_TOKEN_TYPE + if (serverInfo is ServerInfo.OIDCServer) { + oidcSupported = true + } + handleGetAuthorizationCodeResponse(pendingIntent) + return + } + when (serverInfo) { is ServerInfo.BasicServer -> { authTokenType = BASIC_TOKEN_TYPE @@ -1016,6 +1036,15 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier) putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge) putString(KEY_OIDC_STATE, authenticationViewModel.oidcState) + if (::serverBaseUrl.isInitialized) { + putString(KEY_AUTH_SERVER_BASE_URL, serverBaseUrl) + } + putBoolean(KEY_AUTH_OIDC_SUPPORTED, oidcSupported) + // The browser is also opened for re-login (ACTION_UPDATE_TOKEN / ACTION_UPDATE_EXPIRED_TOKEN), + // not just for new account creation. Persist these so the redirect back knows + // whether to update an existing account or create a new one. + putInt(KEY_AUTH_LOGIN_ACTION, loginAction.toInt()) + userAccount?.name?.let { putString(KEY_AUTH_USER_ACCOUNT, it) } apply() } } @@ -1025,6 +1054,17 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted prefs.getString(KEY_CODE_VERIFIER, null)?.let { authenticationViewModel.codeVerifier = it } prefs.getString(KEY_CODE_CHALLENGE, null)?.let { authenticationViewModel.codeChallenge = it } prefs.getString(KEY_OIDC_STATE, null)?.let { authenticationViewModel.oidcState = it } + prefs.getString(KEY_AUTH_SERVER_BASE_URL, null)?.let { serverBaseUrl = it } + oidcSupported = prefs.getBoolean(KEY_AUTH_OIDC_SUPPORTED, false) + val savedLoginAction = prefs.getInt(KEY_AUTH_LOGIN_ACTION, -1) + if (savedLoginAction != -1) { + loginAction = savedLoginAction.toByte() + } + if (userAccount == null) { + prefs.getString(KEY_AUTH_USER_ACCOUNT, null)?.let { accountName -> + userAccount = Account(accountName, getString(R.string.account_type)) + } + } } private fun clearAuthState() { From 8a0d4b18c2f1d331dee4a3ea4f23fb4b99fe7be1 Mon Sep 17 00:00:00 2001 From: Markus Goetz Date: Fri, 6 Mar 2026 14:58:19 +0100 Subject: [PATCH 3/3] LoginActivity: Extract preferred_username This is to show a nice username "username" instead of "long-uuid@server" in the web form. --- .../authentication/LoginActivity.kt | 40 +++++++++++++++++-- .../oauth/responses/TokenResponse.kt | 2 + .../authentication/AuthenticationConstants.kt | 5 +++ .../implementation/OCRemoteOAuthDataSource.kt | 1 + .../oauth/model/TokenResponse.kt | 1 + 5 files changed, 46 insertions(+), 3 deletions(-) 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 728d50a87..da7c8cebe 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 @@ -48,6 +48,7 @@ import androidx.core.widget.doAfterTextChanged import eu.opencloud.android.BuildConfig import eu.opencloud.android.MainApp.Companion.accountType import eu.opencloud.android.R +import eu.opencloud.android.data.authentication.KEY_PREFERRED_USERNAME import eu.opencloud.android.data.authentication.KEY_USER_ID import eu.opencloud.android.databinding.AccountSetupBinding import eu.opencloud.android.domain.authentication.oauth.model.ClientRegistrationInfo @@ -117,6 +118,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted private lateinit var serverBaseUrl: String private var oidcSupported = false + private var preferredUsername: String? = null private lateinit var binding: AccountSetupBinding @@ -177,10 +179,12 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted if (loginAction != ACTION_CREATE) { binding.accountUsername.isEnabled = false binding.accountUsername.isFocusable = false - userAccount?.name?.let { - username = getUsernameOfAccount(it) + userAccount?.let { account -> + // Prefer preferred_username from id_token (stored in AccountManager) for login_hint, + // fall back to the account name part (which may be a UUID) + username = AccountManager.get(this).getUserData(account, KEY_PREFERRED_USERNAME) + ?: getUsernameOfAccount(account.name) } - } if (savedInstanceState == null) { @@ -555,6 +559,12 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted resultBundle = intent.extras setResult(Activity.RESULT_OK, intent) + // Store preferred_username from id_token for login_hint on re-login + preferredUsername?.let { prefUsername -> + val account = Account(accountName, contextProvider.getString(R.string.account_type)) + AccountManager.get(this).setUserData(account, KEY_PREFERRED_USERNAME, prefUsername) + } + authenticationViewModel.discoverAccount(accountName = accountName, discoveryNeeded = loginAction == ACTION_CREATE) clearAuthState() } @@ -777,6 +787,10 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted Timber.d("Tokens received ${uiResult.data}, trying to login, creating account and adding it to account manager") val tokenResponse = uiResult.data ?: return@observe + // Extract preferred_username from id_token for login_hint on re-login + preferredUsername = extractPreferredUsernameFromIdToken(tokenResponse.idToken) + Timber.d("Preferred username from id_token: $preferredUsername") + // When webfinger provides a client_id without dynamic registration, // store it so AccountAuthenticator can use it for token refresh val effectiveClientRegistrationInfo = clientRegistrationInfo @@ -1071,4 +1085,24 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted val prefs = getSharedPreferences("auth_state", android.content.Context.MODE_PRIVATE) prefs.edit().clear().apply() } + + /** + * Extract preferred_username from an OIDC id_token JWT. + * Fallback chain: preferred_username -> email -> sub + */ + private fun extractPreferredUsernameFromIdToken(idToken: String?): String? { + if (idToken == null) return null + return try { + val parts = idToken.split(".") + if (parts.size != 3) return null + val payload = String(android.util.Base64.decode(parts[1], android.util.Base64.URL_SAFE)) + val json = org.json.JSONObject(payload) + json.optString("preferred_username").ifBlank { null } + ?: json.optString("email").ifBlank { null } + ?: json.optString("sub").ifBlank { null } + } catch (e: Exception) { + Timber.e(e, "Failed to extract preferred_username from id_token") + null + } + } } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/responses/TokenResponse.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/responses/TokenResponse.kt index 2dd479896..313d0c064 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/responses/TokenResponse.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/responses/TokenResponse.kt @@ -40,6 +40,8 @@ data class TokenResponse( @Json(name = "user_id") val userId: String?, val scope: String?, + @Json(name = "id_token") + val idToken: String? = null, @Json(name = "additional_parameters") val additionalParameters: Map? ) diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/authentication/AuthenticationConstants.kt b/opencloudData/src/main/java/eu/opencloud/android/data/authentication/AuthenticationConstants.kt index 40e27ad8d..1700dc134 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/authentication/AuthenticationConstants.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/authentication/AuthenticationConstants.kt @@ -47,6 +47,11 @@ const val KEY_FEATURE_SPACES = "KEY_FEATURE_SPACES" /** * OIDC Client Registration */ +/** + * Preferred username from OIDC id_token, used for login_hint + */ +const val KEY_PREFERRED_USERNAME = "oc_preferred_username" + const val KEY_CLIENT_REGISTRATION_CLIENT_ID = "client_id" const val KEY_CLIENT_REGISTRATION_CLIENT_SECRET = "client_secret" const val KEY_CLIENT_REGISTRATION_CLIENT_EXPIRATION_DATE = "client_secret_expires_at" diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSource.kt index f51614acb..d7cf5f7ca 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/oauth/datasources/implementation/OCRemoteOAuthDataSource.kt @@ -131,6 +131,7 @@ class OCRemoteOAuthDataSource( tokenType = this.tokenType, userId = this.userId, scope = this.scope, + idToken = this.idToken, additionalParameters = this.additionalParameters ) diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenResponse.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenResponse.kt index 2103aa001..44dcad9fb 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenResponse.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenResponse.kt @@ -26,5 +26,6 @@ data class TokenResponse( val tokenType: String, val userId: String?, val scope: String?, + val idToken: String? = null, val additionalParameters: Map? )