Improve the callback uri format and customization. (#4664)

* Remove unused SUPPORT_EMAIL_ADDRESS

* Improve the callback uri format and customization.

Use io.element.android for the scheme of Oidc redirection for Element X.
For nightly the scheme will be io.element.android.nightly
For debug the scheme will be  io.element.android.debug

Element Pro is using `io.element`
This commit is contained in:
Benoit Marty
2025-05-05 17:46:17 +02:00
committed by GitHub
parent 024aa49e60
commit 9ea4853e88
20 changed files with 166 additions and 49 deletions

View File

@@ -106,14 +106,25 @@ android {
logger.warnInBox("Building ${defaultConfig.applicationId} ($baseAppName)")
buildTypes {
val oidcRedirectSchemeBase = BuildTimeConfig.METADATA_HOST_REVERSED ?: "io.element.android"
getByName("debug") {
resValue("string", "app_name", "$baseAppName dbg")
resValue(
"string",
"login_redirect_scheme",
"$oidcRedirectSchemeBase.debug",
)
applicationIdSuffix = ".debug"
signingConfig = signingConfigs.getByName("debug")
}
getByName("release") {
resValue("string", "app_name", baseAppName)
resValue(
"string",
"login_redirect_scheme",
oidcRedirectSchemeBase,
)
signingConfig = signingConfigs.getByName("debug")
postprocessing {
@@ -131,6 +142,11 @@ android {
applicationIdSuffix = ".nightly"
versionNameSuffix = "-nightly"
resValue("string", "app_name", "$baseAppName nightly")
resValue(
"string",
"login_redirect_scheme",
"$oidcRedirectSchemeBase.nightly",
)
matchingFallbacks += listOf("release")
signingConfig = signingConfigs.getByName("nightly")
@@ -284,6 +300,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.toolbox.test)
koverDependencies()
}

View File

@@ -60,8 +60,7 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Note: the scheme must match the scheme of the value of OidcConfig.REDIRECT_URI -->
<data android:scheme="io.element" />
<data android:scheme="@string/login_redirect_scheme" />
</intent-filter>
<!--
Element web links

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.x.oidc
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.x.R
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultOidcRedirectUrlProvider @Inject constructor(
private val stringProvider: StringProvider,
) : OidcRedirectUrlProvider {
override fun provide() = buildString {
append(stringProvider.getString(R.string.login_redirect_scheme))
append(":/")
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.x.oidc
import com.google.common.truth.Truth.assertThat
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.x.R
import org.junit.Test
class DefaultOidcRedirectUrlProviderTest {
@Test
fun `test provide`() {
val stringProvider = FakeStringProvider(
defaultResult = "str"
)
val sut = DefaultOidcRedirectUrlProvider(
stringProvider = stringProvider,
)
val result = sut.provide()
assertThat(result).isEqualTo("str:/")
assertThat(stringProvider.lastResIdParam).isEqualTo(R.string.login_redirect_scheme)
}
}

View File

@@ -20,10 +20,11 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.impl.DefaultOidcIntentResolver
import io.element.android.libraries.oidc.impl.OidcUrlParser
import io.element.android.libraries.oidc.impl.DefaultOidcUrlParser
import io.element.android.tests.testutils.lambda.lambdaError
import org.junit.Assert.assertThrows
import org.junit.Test
@@ -119,7 +120,7 @@ class IntentResolverTest {
val sut = createIntentResolver()
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "io.element:/callback?error=access_denied&state=IFF1UETGye2ZA8pO".toUri()
data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
@@ -134,13 +135,13 @@ class IntentResolverTest {
val sut = createIntentResolver()
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "io.element:/callback?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB".toUri()
data = "io.element.android:/?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Oidc(
oidcAction = OidcAction.Success(
url = "io.element:/callback?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
url = "io.element.android:/?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
)
)
)
@@ -151,7 +152,7 @@ class IntentResolverTest {
val sut = createIntentResolver()
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "io.element:/callback/invalid".toUri()
data = "io.element.android:/invalid".toUri()
}
assertThrows(IllegalStateException::class.java) {
sut.resolve(intent)
@@ -246,7 +247,9 @@ class IntentResolverTest {
return IntentResolver(
deeplinkParser = DeeplinkParser(),
oidcIntentResolver = DefaultOidcIntentResolver(
oidcUrlParser = OidcUrlParser()
oidcUrlParser = DefaultOidcUrlParser(
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),
)
),
permalinkParser = FakePermalinkParser(
result = permalinkParserResult

View File

@@ -11,7 +11,7 @@ Server list: https://github.com/element-hq/oidc-playground
Metadata iOS: (from https://github.com/element-hq/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28)
clientName: InfoPlistReader.main.bundleDisplayName,
redirectUri: "io.element:/callback",
redirectUri: "io.element.android:/",
clientUri: "https://element.io",
tosUri: "https://element.io/user-terms-of-service",
policyUri: "https://element.io/privacy"
@@ -19,7 +19,7 @@ policyUri: "https://element.io/privacy"
Android:
clientName = "Element",
redirectUri = "io.element:/callback",
redirectUri = "io.element.android:/",
clientUri = "https://element.io",
tosUri = "https://element.io/user-terms-of-service",
policyUri = "https://element.io/privacy"

View File

@@ -27,13 +27,6 @@ android {
name = "CLIENT_URI",
value = BuildTimeConfig.URL_WEBSITE ?: "https://element.io"
)
buildConfigFieldStr(
name = "REDIRECT_URI",
value = buildString {
append(BuildTimeConfig.METADATA_HOST_REVERSED ?: "io.element")
append(":/callback")
}
)
buildConfigFieldStr(
name = "LOGO_URI",
value = BuildTimeConfig.URL_LOGO ?: "https://element.io/mobile-icon.png"

View File

@@ -12,11 +12,6 @@ import io.element.android.libraries.matrix.api.BuildConfig
object OidcConfig {
const val CLIENT_URI = BuildConfig.CLIENT_URI
// Notes:
// 1. the scheme must match the value declared in the AndroidManifest.xml
// 2. the scheme must be the reverse of the host of CLIENT_URI
const val REDIRECT_URI = BuildConfig.REDIRECT_URI
// Note: host must match with the host of CLIENT_URI
const val LOGO_URI = BuildConfig.LOGO_URI

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.auth
interface OidcRedirectUrlProvider {
fun provide(): String
}

View File

@@ -9,15 +9,17 @@ package io.element.android.libraries.matrix.impl.auth
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.OidcConfig
import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
import org.matrix.rustcomponents.sdk.OidcConfiguration
import javax.inject.Inject
class OidcConfigurationProvider @Inject constructor(
private val buildMeta: BuildMeta,
private val oidcRedirectUrlProvider: OidcRedirectUrlProvider,
) {
fun get(): OidcConfiguration = OidcConfiguration(
clientName = buildMeta.applicationName,
redirectUri = OidcConfig.REDIRECT_URI,
redirectUri = oidcRedirectUrlProvider.provide(),
clientUri = OidcConfig.CLIENT_URI,
logoUri = OidcConfig.LOGO_URI,
tosUri = OidcConfig.TOS_URI,

View File

@@ -8,7 +8,8 @@
package io.element.android.libraries.matrix.impl.auth
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.auth.OidcConfig
import io.element.android.libraries.matrix.test.auth.FAKE_REDIRECT_URL
import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider
import io.element.android.libraries.matrix.test.core.aBuildMeta
import org.junit.Test
@@ -16,11 +17,12 @@ class OidcConfigurationProviderTest {
@Test
fun get() {
val result = OidcConfigurationProvider(
aBuildMeta(
buildMeta = aBuildMeta(
applicationName = "myName",
)
),
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),
).get()
assertThat(result.clientName).isEqualTo("myName")
assertThat(result.redirectUri).isEqualTo(OidcConfig.REDIRECT_URI)
assertThat(result.redirectUri).isEqualTo(FAKE_REDIRECT_URL)
}
}

View File

@@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.impl.createRustMatrixClientFactory
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
@@ -49,7 +50,10 @@ class RustMatrixAuthenticationServiceTest {
sessionStore = sessionStore,
rustMatrixClientFactory = rustMatrixClientFactory,
passphraseGenerator = FakePassphraseGenerator(),
oidcConfigurationProvider = OidcConfigurationProvider(aBuildMeta()),
oidcConfigurationProvider = OidcConfigurationProvider(
buildMeta = aBuildMeta(),
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),
),
)
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
const val FAKE_REDIRECT_URL = "io.element.android:/"
class FakeOidcRedirectUrlProvider(
private val provideResult: String = FAKE_REDIRECT_URL,
) : OidcRedirectUrlProvider {
override fun provide() = provideResult
}

View File

@@ -39,5 +39,5 @@ fun aBuildMeta(
gitRevision = gitRevision,
gitBranchName = gitBranchName,
flavorDescription = flavorDescription,
flavorShortDescription = flavorShortDescription
flavorShortDescription = flavorShortDescription,
)

View File

@@ -7,25 +7,34 @@
package io.element.android.libraries.oidc.impl
import io.element.android.libraries.matrix.api.auth.OidcConfig
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
import io.element.android.libraries.oidc.api.OidcAction
import javax.inject.Inject
fun interface OidcUrlParser {
fun parse(url: String): OidcAction?
}
/**
* Simple parser for oidc url interception.
* TODO Find documentation about the format.
*/
class OidcUrlParser @Inject constructor() {
@ContributesBinding(AppScope::class)
class DefaultOidcUrlParser @Inject constructor(
private val oidcRedirectUrlProvider: OidcRedirectUrlProvider,
) : OidcUrlParser {
/**
* Return a OidcAction, or null if the url is not a OidcUrl.
* Note:
* When user press button "Cancel", we get the url:
* `io.element:/callback?error=access_denied&state=IFF1UETGye2ZA8pO`
* `io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO`
* On success, we get:
* `io.element:/callback?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB`
* `io.element.android:/?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB`
*/
fun parse(url: String): OidcAction? {
if (url.startsWith(OidcConfig.REDIRECT_URI).not()) return null
override fun parse(url: String): OidcAction? {
if (url.startsWith(oidcRedirectUrlProvider.provide()).not()) return null
if (url.contains("error=access_denied")) return OidcAction.GoBack
if (url.contains("code=")) return OidcAction.Success(url)

View File

@@ -19,12 +19,14 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.oidc.impl.OidcUrlParser
@ContributesNode(AppScope::class)
class OidcNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: OidcPresenter.Factory,
private val oidcUrlParser: OidcUrlParser,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val oidcDetails: OidcDetails,
@@ -38,6 +40,7 @@ class OidcNode @AssistedInject constructor(
val state = presenter.present()
OidcView(
state = state,
oidcUrlParser = oidcUrlParser,
modifier = modifier,
onNavigateBack = ::navigateUp,
)

View File

@@ -34,11 +34,11 @@ import io.element.android.libraries.oidc.impl.OidcUrlParser
@Composable
fun OidcView(
state: OidcState,
oidcUrlParser: OidcUrlParser,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val isPreview = LocalInspectionMode.current
val oidcUrlParser = remember { OidcUrlParser() }
var webView by remember { mutableStateOf<WebView?>(null) }
fun shouldOverrideUrl(url: String): Boolean {
val action = oidcUrlParser.parse(url)
@@ -111,6 +111,7 @@ fun OidcView(
internal fun OidcViewPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = ElementPreview {
OidcView(
state = state,
oidcUrlParser = { null },
onNavigateBack = {},
)
}

View File

@@ -8,44 +8,51 @@
package io.element.android.libraries.oidc.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.auth.OidcConfig
import io.element.android.libraries.matrix.test.auth.FAKE_REDIRECT_URL
import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider
import io.element.android.libraries.oidc.api.OidcAction
import org.junit.Assert
import org.junit.Test
class OidcUrlParserTest {
class DefaultOidcUrlParserTest {
@Test
fun `test empty url`() {
val sut = OidcUrlParser()
val sut = createDefaultOidcUrlParser()
assertThat(sut.parse("")).isNull()
}
@Test
fun `test regular url`() {
val sut = OidcUrlParser()
val sut = createDefaultOidcUrlParser()
assertThat(sut.parse("https://matrix.org")).isNull()
}
@Test
fun `test cancel url`() {
val sut = OidcUrlParser()
val aCancelUrl = OidcConfig.REDIRECT_URI + "?error=access_denied&state=IFF1UETGye2ZA8pO"
val sut = createDefaultOidcUrlParser()
val aCancelUrl = "$FAKE_REDIRECT_URL?error=access_denied&state=IFF1UETGye2ZA8pO"
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack)
}
@Test
fun `test success url`() {
val sut = OidcUrlParser()
val aSuccessUrl = OidcConfig.REDIRECT_URI + "?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
val sut = createDefaultOidcUrlParser()
val aSuccessUrl = "$FAKE_REDIRECT_URL?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
assertThat(sut.parse(aSuccessUrl)).isEqualTo(OidcAction.Success(aSuccessUrl))
}
@Test
fun `test unknown url`() {
val sut = OidcUrlParser()
val anUnknownUrl = OidcConfig.REDIRECT_URI + "?state=IFF1UETGye2ZA8pO&goat=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
val sut = createDefaultOidcUrlParser()
val anUnknownUrl = "$FAKE_REDIRECT_URL?state=IFF1UETGye2ZA8pO&goat=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
Assert.assertThrows(IllegalStateException::class.java) {
assertThat(sut.parse(anUnknownUrl))
}
}
private fun createDefaultOidcUrlParser(): DefaultOidcUrlParser {
return DefaultOidcUrlParser(
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),
)
}
}

View File

@@ -21,7 +21,6 @@ object BuildTimeConfig {
val URL_ACCEPTABLE_USE: String? = null
val URL_PRIVACY: String? = null
val URL_POLICY: String? = null
val SUPPORT_EMAIL_ADDRESS: String? = null
val SERVICES_MAPTILER_BASE_URL: String? = null
val SERVICES_MAPTILER_APIKEY: String? = null
val SERVICES_MAPTILER_LIGHT_MAPID: String? = null

View File

@@ -8,7 +8,7 @@
# Format is:
# Error
# adb shell am start -a android.intent.action.VIEW -d "io.element:/callback?error=access_denied\\&state=IFF1UETGye2ZA8pO"
# adb shell am start -a android.intent.action.VIEW -d "io.element.android:/?error=access_denied\\&state=IFF1UETGye2ZA8pO"
# Success
adb shell am start -a android.intent.action.VIEW -d "io.element:/callback?state=IFF1UETGye2ZA8pO\\&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
adb shell am start -a android.intent.action.VIEW -d "io.element.android:/?state=IFF1UETGye2ZA8pO\\&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"