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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/9102.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Update OAuth-awareness to support the stable version of MSC3824.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package org.matrix.android.sdk.api.auth
/**
* See https://github.com/matrix-org/matrix-spec-proposals/pull/3824
*/
enum class SSOAction {
LOGIN,
REGISTER;
enum class SSOAction(val value: String) {
LOGIN("login"),
REGISTER("register");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright 2026 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.matrix.android.sdk.api.auth.data

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

/**
* This is a subset of the server metadata discovery for the OAuth 2.0 API
* https://spec.matrix.org/v1.16/client-server-api/#get_matrixclientv1auth_metadata
*
* Includes the values from MSC4191: https://github.com/matrix-org/matrix-spec-proposals/pull/4191
*
* <pre>
* {
* "issuer": "https://id.server.org",
* "account_management_uri": "https://id.server.org/my-account",
* "account_management_actions_supported": ["org.matrix.profile", "org.matrix.devices_list"],
* }
* </pre>
* .
*/

@JsonClass(generateAdapter = true)
data class AuthMetadata(
@Json(name = "issuer")
val issuer: String,

@Json(name = "account_management_uri")
val accountManagementUri: String?,

@Json(name = "account_management_actions_supported")
val accountManagementActionsSupported: List<String>?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,17 @@ data class HomeServerCapabilities(
var canRedactRelatedEvents: Boolean = false,

/**
* External account management url for use with MSC3824 delegated OIDC, provided in Wellknown.
* External account management url for use with OAuth API, provided by MSC4191 /auth_metadata discovery or in unstable Wellknown.
*/
val externalAccountManagementUrl: String? = null,

/**
* Authentication issuer for use with MSC3824 delegated OIDC, provided in Wellknown.
* External account management supported actions for use with OAuth API, provided by MSC4191 /auth_metadata discovery.
*/
val externalAccountManagementSupportedActions: List<String>? = null,

/**
* Authentication issuer for use with MSC3824 delegated OIDC, provided by /auth_metadata discovery or in unstable Wellknown.
*/
val authenticationIssuer: String? = null,

Expand Down Expand Up @@ -162,4 +167,26 @@ data class HomeServerCapabilities(
const val ROOM_CAP_KNOCK = "knock"
const val ROOM_CAP_RESTRICTED = "restricted"
}

fun getLogoutDeviceURL(deviceId: String): String? {
if (externalAccountManagementUrl == null) {
return null
}

// default to the stable value:
var action = "org.matrix.device_delete"
externalAccountManagementSupportedActions?.also { actions ->
if (actions.contains("org.matrix.device_delete")) {
// server supports stable version so use it
} else if (actions.contains("org.matrix.session_end")) {
// earlier version of MSC4191:
action = "org.matrix.session_end"
} else if (actions.contains("session_end")) {
// previous unspecified version
action = "session_end"
}
}

return externalAccountManagementUrl.removeSuffix("/") + "?action=${action}&device_id=${deviceId}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ internal class DefaultAuthenticationService @Inject constructor(
}

// unstable MSC3824 action param
appendParamToUrl("org.matrix.msc3824.action", action.toString())
// This can be removed once servers have been updated to support the stable one.
appendParamToUrl("org.matrix.msc3824.action", action.value)

// stable param:
appendParamToUrl("action", action.value)
}
}

Expand Down Expand Up @@ -297,8 +301,8 @@ internal class DefaultAuthenticationService @Inject constructor(
authAPI.getLoginFlows()
}

// If an m.login.sso flow is present that is flagged as being for MSC3824 OIDC compatibility then we only return that flow
val oidcCompatibilityFlow = loginFlowResponse.flows.orEmpty().firstOrNull { it.type == "m.login.sso" && it.delegatedOidcCompatibility == true }
// If an m.login.sso flow is present that is flagged as being for MSC3824 OAuth compatibility then we only return that flow
val oidcCompatibilityFlow = loginFlowResponse.flows.orEmpty().firstOrNull { it.type == "m.login.sso" && it.delegatedOidcCompatibility }
val flows = if (oidcCompatibilityFlow != null) listOf(oidcCompatibilityFlow) else loginFlowResponse.flows

val supportsGetLoginTokenFlow = loginFlowResponse.flows.orEmpty().firstOrNull { it.type == "m.login.token" && it.getLoginToken == true } != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.auth.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
import org.matrix.android.sdk.api.extensions.orFalse

@JsonClass(generateAdapter = true)
internal data class LoginFlowResponse(
Expand Down Expand Up @@ -46,12 +47,20 @@ internal data class LoginFlow(
val ssoIdentityProvider: List<SsoIdentityProvider>? = null,

/**
* Whether this login flow is preferred for OIDC-aware clients.
* Whether this login flow is preferred for OAuth 2.0-aware clients like we are.
*
* See [MSC3824](https://github.com/matrix-org/matrix-spec-proposals/pull/3824)
*/
@Json(name = "oauth_aware_preferred")
val oauthAwarePreferred: Boolean? = null,

/**
* Unstable name from MSC3824.
*
*/
@Deprecated("Use oauthAwarePreferred instead")
@Json(name = "org.matrix.msc3824.delegated_oidc_compatibility")
val delegatedOidcCompatibility: Boolean? = null,
val unstableDelegatedOidcCompatibility: Boolean? = null,

/**
* Whether a login flow of type m.login.token could accept a token issued using /login/get_token.
Expand All @@ -60,4 +69,7 @@ internal data class LoginFlow(
*/
@Json(name = "get_login_token")
val getLoginToken: Boolean? = null
)
) {
@Suppress("DEPRECATION") val delegatedOidcCompatibility: Boolean
get() = this.oauthAwarePreferred.orFalse() || this.unstableDelegatedOidcCompatibility.orFalse()
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ internal object HomeServerCapabilitiesMapper {
canRemotelyTogglePushNotificationsOfDevices = entity.canRemotelyTogglePushNotificationsOfDevices,
canRedactRelatedEvents = entity.canRedactEventWithRelations,
externalAccountManagementUrl = entity.externalAccountManagementUrl,
externalAccountManagementSupportedActions = entity.externalAccountManagementSupportedActions?.split(","),
authenticationIssuer = entity.authenticationIssuer,
disableNetworkConstraint = entity.disableNetworkConstraint,
canUseAuthenticatedMedia = entity.canUseAuthenticatedMedia,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ internal open class HomeServerCapabilitiesEntity(
var canRemotelyTogglePushNotificationsOfDevices: Boolean = false,
var canRedactEventWithRelations: Boolean = false,
var externalAccountManagementUrl: String? = null,
var externalAccountManagementSupportedActions: String? = null,
var authenticationIssuer: String? = null,
var disableNetworkConstraint: Boolean? = null,
var canUseAuthenticatedMedia: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright 2026 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.matrix.android.sdk.internal.session.homeserver

import org.matrix.android.sdk.api.auth.data.AuthMetadata
import org.matrix.android.sdk.internal.network.NetworkConstants
import retrofit2.http.GET

internal interface AuthMetadataAPI {
/**
* Request the homeserver OAuth 2.0 auth metadata.
*/
@GET(NetworkConstants.URI_API_PREFIX_PATH_V1 + "auth_metadata")
suspend fun getAuthMetadata(): AuthMetadata
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.homeserver

import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.data.AuthMetadata
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.wellknown.WellknownResult
import org.matrix.android.sdk.api.extensions.orFalse
Expand Down Expand Up @@ -66,7 +67,8 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
private val configExtractor: IntegrationManagerConfigExtractor,
private val homeServerConnectionConfig: HomeServerConnectionConfig,
@UserId
private val userId: String
private val userId: String,
private val authMetadataAPI: AuthMetadataAPI,
) : GetHomeServerCapabilitiesTask {

override suspend fun execute(params: GetHomeServerCapabilitiesTask.Params) {
Expand Down Expand Up @@ -104,6 +106,12 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
}
}.getOrNull()

val authMetadata = runCatching {
executeRequest(globalErrorReceiver = null) {
authMetadataAPI.getAuthMetadata()
}
}.getOrNull()

// Domain may include a port (eg, matrix.org:8080)
// Per https://spec.matrix.org/latest/client-server-api/#well-known-uri we should extract the hostname from the server name
// So we take everything before the last : as the domain for the well-known task.
Expand All @@ -117,14 +125,15 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
)
}.getOrNull()

insertInDb(capabilities, mediaConfig, versions, wellknownResult)
insertInDb(capabilities, mediaConfig, versions, wellknownResult, authMetadata)
}

private suspend fun insertInDb(
getCapabilitiesResult: GetCapabilitiesResult?,
getMediaConfigResult: GetMediaConfigResult?,
getVersionResult: Versions?,
getWellknownResult: WellknownResult?
getWellknownResult: WellknownResult?,
authMetadata: AuthMetadata?,
) {
monarchy.awaitTransaction { realm ->
val homeServerCapabilitiesEntity = HomeServerCapabilitiesEntity.getOrCreate(realm)
Expand Down Expand Up @@ -174,11 +183,19 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
Timber.v("Extracted integration config : $config")
realm.insertOrUpdate(config)
}
// Getting the OAuth 2.0 metadata from well-known was in unstable MSC:
homeServerCapabilitiesEntity.authenticationIssuer = getWellknownResult.wellKnown.unstableDelegatedAuthConfig?.issuer
homeServerCapabilitiesEntity.externalAccountManagementUrl = getWellknownResult.wellKnown.unstableDelegatedAuthConfig?.accountManagementUrl
homeServerCapabilitiesEntity.disableNetworkConstraint = getWellknownResult.wellKnown.disableNetworkConstraint
}

// If the server returns OAuth 2.0 metadata then prefer that over the well-known values:
if (authMetadata != null) {
homeServerCapabilitiesEntity.authenticationIssuer = authMetadata.issuer
homeServerCapabilitiesEntity.externalAccountManagementUrl = authMetadata.accountManagementUri
homeServerCapabilitiesEntity.externalAccountManagementSupportedActions = authMetadata.accountManagementActionsSupported?.joinToString(",")
}

homeServerCapabilitiesEntity.canLoginWithQrCode = canLoginWithQrCode(getCapabilitiesResult, getVersionResult)

homeServerCapabilitiesEntity.lastUpdatedTimestamp = Date().time
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ internal abstract class HomeServerCapabilitiesModule {
fun providesCapabilitiesAPI(retrofit: Retrofit): CapabilitiesAPI {
return retrofit.create(CapabilitiesAPI::class.java)
}

@Provides
@JvmStatic
@SessionScope
fun providesAuthMetadataAPI(retrofit: Retrofit): AuthMetadataAPI {
return retrofit.create(AuthMetadataAPI::class.java)
}
}

@Binds
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2026 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.matrix.android.sdk.api.session.homeserver

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class HomeServerCapabilitiesTest {

private val deviceId = "TEST_DEVICE_ID"

@Test
fun `given externalAccountManagementUrl is null, when getLogoutDeviceURL, then return null`() {
val capabilities = HomeServerCapabilities(
externalAccountManagementUrl = null
)
assertNull(capabilities.getLogoutDeviceURL(deviceId))
}

@Test
fun `given supported actions is null, when getLogoutDeviceURL, then uses default stable action`() {
val capabilities = HomeServerCapabilities(
externalAccountManagementUrl = "https://example.com",
externalAccountManagementSupportedActions = null
)
val expectedUrl = "https://example.com?action=org.matrix.device_delete&device_id=$deviceId"
assertEquals(expectedUrl, capabilities.getLogoutDeviceURL(deviceId))
}

@Test
fun `given supported actions contains stable action, when getLogoutDeviceURL, then uses stable action`() {
val capabilities = HomeServerCapabilities(
externalAccountManagementUrl = "https://example.com",
externalAccountManagementSupportedActions = listOf("org.matrix.device_delete", "org.matrix.session_end", "session_end")
)
val expectedUrl = "https://example.com?action=org.matrix.device_delete&device_id=$deviceId"
assertEquals(expectedUrl, capabilities.getLogoutDeviceURL(deviceId))
}

@Test
fun `given supported actions contains unstable action, when getLogoutDeviceURL, then uses unstable action`() {
val capabilities = HomeServerCapabilities(
externalAccountManagementUrl = "https://example.com",
externalAccountManagementSupportedActions = listOf("org.matrix.session_end", "session_end")
)
val expectedUrl = "https://example.com?action=org.matrix.session_end&device_id=$deviceId"
assertEquals(expectedUrl, capabilities.getLogoutDeviceURL(deviceId))
}

@Test
fun `given supported actions contains legacy action, when getLogoutDeviceURL, then uses legacy action`() {
val capabilities = HomeServerCapabilities(
externalAccountManagementUrl = "https://example.com",
externalAccountManagementSupportedActions = listOf("session_end")
)
val expectedUrl = "https://example.com?action=session_end&device_id=$deviceId"
assertEquals(expectedUrl, capabilities.getLogoutDeviceURL(deviceId))
}

@Test
fun `given url with trailing slash, when getLogoutDeviceURL, then slash is removed`() {
val capabilities = HomeServerCapabilities(
externalAccountManagementUrl = "https://example.com/account/"
)
val expectedUrl = "https://example.com/account?action=org.matrix.device_delete&device_id=$deviceId"
assertEquals(expectedUrl, capabilities.getLogoutDeviceURL(deviceId))
}
}
Loading
Loading