Browse Source

Add OIDC support

feature/fga/small_timeline_improvements
Benoit Marty 1 year ago committed by Benoit Marty
parent
commit
f1d2f566bc
  1. 45
      docs/oidc.md
  2. 14
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
  3. 23
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcEvents.kt
  4. 58
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcNode.kt
  5. 97
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcPresenter.kt
  6. 26
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcState.kt
  7. 39
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcStateProvider.kt
  8. 47
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcUrlParser.kt
  9. 121
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcView.kt
  10. 42
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcWebViewClient.kt
  11. 29
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/WebViewEventListener.kt
  12. 11
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootNode.kt
  13. 28
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt
  14. 9
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt
  15. 8
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt
  16. 65
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt
  17. 3
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt
  18. 1
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt
  19. 19
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt
  20. 2
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixHomeServerDetails.kt
  21. 21
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcConfig.kt
  22. 25
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcDetails.kt
  23. 7
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt
  24. 2
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetails.kt
  25. 29
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfig.kt
  26. 62
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt

45
docs/oidc.md

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
[ios implementation](https://github.com/vector-im/element-x-ios/compare/develop...doug/oidc-temp)
Rust sdk branch: https://github.com/matrix-org/matrix-rust-sdk/tree/oidc-ffi
Figma https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?node-id=133-5426&t=yQXKeANatk6keoZF-0
Server list: https://github.com/vector-im/oidc-playground
Metadata iOS: (from https://github.com/vector-im/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28)
clientName: InfoPlistReader.main.bundleDisplayName,
redirectUri: "io.element:/callback",
clientUri: "https://element.io",
tosUri: "https://element.io/user-terms-of-service",
policyUri: "https://element.io/privacy"
Android:
clientName = "Element",
redirectUri = "io.element:/callback",
clientUri = "https://element.io",
tosUri = "https://element.io/user-terms-of-service",
policyUri = "https://element.io/privacy"
Example of OidcData (from presentUrl callback):
url: https://auth-oidc.lab.element.dev/authorize?response_type=code&client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&redirect_uri=io.element%3A%2Fcallback&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&state=ex6mNJVFZ5jn9wL8&nonce=NZ93DOyIGQd9exPQ&code_challenge_method=S256&code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&prompt=consent
Formatted url:
https://auth-oidc.lab.element.dev/authorize?
response_type=code&
client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&
redirect_uri=io.element%3A%2Fcallback&
scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&
state=ex6mNJVFZ5jn9wL8&
nonce=NZ93DOyIGQd9exPQ&
code_challenge_method=S256&
code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&
prompt=consent
state: ex6mNJVFZ5jn9wL8
Oidc client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs
Oidc sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs

14
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt

@ -29,11 +29,13 @@ import dagger.assisted.Assisted @@ -29,11 +29,13 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.changeserver.ChangeServerNode
import io.element.android.features.login.impl.oidc.OidcNode
import io.element.android.features.login.impl.root.LoginRootNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@ -55,6 +57,9 @@ class LoginFlowNode @AssistedInject constructor( @@ -55,6 +57,9 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize
object ChangeServer : NavTarget
@Parcelize
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -64,10 +69,19 @@ class LoginFlowNode @AssistedInject constructor( @@ -64,10 +69,19 @@ class LoginFlowNode @AssistedInject constructor(
override fun onChangeHomeServer() {
backstack.push(NavTarget.ChangeServer)
}
override fun onOidcDetails(oidcDetails: OidcDetails) {
backstack.push(NavTarget.OidcView(oidcDetails))
}
}
createNode<LoginRootNode>(buildContext, plugins = listOf(callback))
}
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
is NavTarget.OidcView -> {
val input = OidcNode.Inputs(navTarget.oidcDetails)
createNode<OidcNode>(buildContext, plugins = listOf(input))
}
}
}

23
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcEvents.kt

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.features.login.impl.oidc
sealed interface OidcEvents {
object Cancel : OidcEvents
data class OidcActionEvent(val oidcAction: OidcAction): OidcEvents
object ClearError : OidcEvents
}

58
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcNode.kt

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.features.login.impl.oidc
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
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
/**
* TODO Transmit back press to the webview
*/
@ContributesNode(AppScope::class)
class OidcNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: OidcPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val oidcDetails: OidcDetails,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.oidcDetails)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
OidcView(
state = state,
modifier = modifier,
onNavigateBack = ::navigateUp,
)
}
}

97
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcPresenter.kt

@ -0,0 +1,97 @@ @@ -0,0 +1,97 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.features.login.impl.oidc
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OidcDetails
import kotlinx.coroutines.launch
class OidcPresenter @AssistedInject constructor(
@Assisted private val oidcDetails: OidcDetails,
private val authenticationService: MatrixAuthenticationService,
) : Presenter<OidcState> {
@AssistedFactory
interface Factory {
fun create(oidcDetails: OidcDetails): OidcPresenter
}
@Composable
override fun present(): OidcState {
var requestState: Async<Unit> by remember {
mutableStateOf(Async.Uninitialized)
}
val localCoroutineScope = rememberCoroutineScope()
fun handleCancel() {
requestState = Async.Loading()
localCoroutineScope.launch {
requestState = try {
authenticationService.cancelOidcLogin()
// Then go back
Async.Success(Unit)
} catch (throwable: Throwable) {
Async.Failure(throwable)
}
}
}
fun handleSuccess(url: String) {
requestState = Async.Loading()
localCoroutineScope.launch {
try {
authenticationService.loginWithOidc(url)
// Then the node tree will be updated, there is nothing to do
} catch (throwable: Throwable) {
requestState = Async.Failure(throwable)
}
}
}
fun handleAction(action: OidcAction) {
when (action) {
OidcAction.GoBack -> handleCancel()
is OidcAction.Success -> handleSuccess(action.url)
}
}
fun handleEvents(event: OidcEvents) {
when (event) {
OidcEvents.Cancel -> handleCancel()
is OidcEvents.OidcActionEvent -> handleAction(event.oidcAction)
OidcEvents.ClearError -> requestState = Async.Uninitialized
}
}
return OidcState(
oidcDetails = oidcDetails,
requestState = requestState,
eventSink = ::handleEvents
)
}
}

26
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcState.kt

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.features.login.impl.oidc
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.OidcDetails
data class OidcState(
val oidcDetails: OidcDetails,
val requestState: Async<Unit>,
val eventSink: (OidcEvents) -> Unit
)

39
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcStateProvider.kt

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.features.login.impl.oidc
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.OidcDetails
open class OidcStateProvider : PreviewParameterProvider<OidcState> {
override val values: Sequence<OidcState>
get() = sequenceOf(
aOidcState(),
aOidcState().copy(requestState = Async.Loading()),
)
}
fun aOidcState() = OidcState(
oidcDetails = aOidcDetails(),
requestState = Async.Uninitialized,
eventSink = {}
)
fun aOidcDetails() = OidcDetails(
url = "aUrl",
)

47
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcUrlParser.kt

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.features.login.impl.oidc
import io.element.android.libraries.matrix.api.auth.OidcConfig
/**
* Simple parser for oidc url interception.
* TODO Find documentation about the format.
*/
class OidcUrlParser {
// When user press button "Cancel", we get the url:
// `io.element:/callback?error=access_denied&state=IFF1UETGye2ZA8pO`
// On success, we get:
// `io.element:/callback?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB`
/**
* Return a OidcAction, or null if the url is not a OidcUrl
*/
fun parse(url: String): OidcAction? {
if (!url.startsWith(OidcConfig.redirectUri)) return null
if (url.contains("error=access_denied")) return OidcAction.GoBack
if (url.contains("code=")) return OidcAction.Success(url)
// Other case not supported, let's crash the app for now
error("Not supported: $url")
}
}
sealed interface OidcAction {
object GoBack : OidcAction
data class Success(val url: String) : OidcAction
}

121
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcView.kt

@ -0,0 +1,121 @@ @@ -0,0 +1,121 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.features.login.impl.oidc
import android.webkit.WebView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
@Composable
fun OidcView(
state: OidcState,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val oidcUrlParser = remember { OidcUrlParser() }
var webView: WebView? = null
fun shouldOverrideUrl(url: String): Boolean {
val action = oidcUrlParser.parse(url)
if (action != null) {
state.eventSink.invoke(OidcEvents.OidcActionEvent(action))
return true
}
return false
}
val oidcWebViewClient = remember {
OidcWebViewClient(eventListener = object : WebViewEventListener {
override fun shouldOverrideUrlLoading(url: String): Boolean {
return shouldOverrideUrl(url)
}
})
}
BackHandler {
if (webView?.canGoBack().orFalse()) {
webView?.goBack()
} else {
// To properly cancel Oidc login
state.eventSink.invoke(OidcEvents.Cancel)
}
}
Box(modifier = modifier.statusBarsPadding()) {
AndroidView(
modifier = modifier
.statusBarsPadding(),
factory = { context ->
WebView(context).apply {
webViewClient = oidcWebViewClient
loadUrl(state.oidcDetails.url)
}.also {
webView = it
}
}
)
when (state.requestState) {
Async.Uninitialized -> Unit
is Async.Failure -> {
ErrorDialog(
content = state.requestState.error.toString(),
onDismiss = { state.eventSink(OidcEvents.ClearError) }
)
}
is Async.Loading -> {
// Indeterminate indicator, to avoid the freeze effect if the connection takes time to initialize.
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
is Async.Success -> onNavigateBack()
}
}
}
@Preview
@Composable
fun OidcViewLightPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun OidcViewDarkPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: OidcState) {
OidcView(
state = state,
onNavigateBack = { },
)
}

42
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcWebViewClient.kt

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.features.login.impl.oidc
import android.annotation.TargetApi
import android.os.Build
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import timber.log.Timber
// TODO Move to a dedicated module
class OidcWebViewClient(private val eventListener: WebViewEventListener) : WebViewClient() {
@TargetApi(Build.VERSION_CODES.N)
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return shouldOverrideUrl(request.url.toString())
}
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
return shouldOverrideUrl(url)
}
private fun shouldOverrideUrl(url: String): Boolean {
Timber.d("shouldOverrideUrl: $url")
return eventListener.shouldOverrideUrlLoading(url)
}
}

29
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/WebViewEventListener.kt

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.features.login.impl.oidc
interface WebViewEventListener {
/**
* Triggered when a Webview loads an url.
*
* @param url The url about to be rendered.
* @return true if the method needs to manage some custom handling
*/
fun shouldOverrideUrlLoading(url: String): Boolean {
return false
}
}

11
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootNode.kt

@ -26,6 +26,7 @@ import dagger.assisted.Assisted @@ -26,6 +26,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
class LoginRootNode @AssistedInject constructor(
@ -36,20 +37,26 @@ class LoginRootNode @AssistedInject constructor( @@ -36,20 +37,26 @@ class LoginRootNode @AssistedInject constructor(
interface Callback : Plugin {
fun onChangeHomeServer()
fun onOidcDetails(oidcDetails: OidcDetails)
}
private fun onChangeHomeServer() {
plugins<Callback>().forEach { it.onChangeHomeServer() }
}
private fun onOidcDetails(oidcDetails: OidcDetails) {
plugins<Callback>().forEach { it.onOidcDetails(oidcDetails) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LoginRootView(
state = state,
modifier = modifier,
onChangeServer = this::onChangeHomeServer,
onBackPressed = this::navigateUp
onChangeServer = ::onChangeHomeServer,
onOidcDetails = ::onOidcDetails,
onBackPressed = ::navigateUp
)
}
}

28
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt

@ -33,7 +33,11 @@ import javax.inject.Inject @@ -33,7 +33,11 @@ import javax.inject.Inject
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
private val defaultHomeserver = MatrixHomeServerDetails(LoginConstants.DEFAULT_HOMESERVER_URL, true, null)
private val defaultHomeserver = MatrixHomeServerDetails(
url = LoginConstants.DEFAULT_HOMESERVER_URL,
supportsPasswordLogin = true,
supportsOidc = false,
)
@Composable
override fun present(): LoginRootState {
@ -54,7 +58,12 @@ class LoginRootPresenter @Inject constructor(private val authenticationService: @@ -54,7 +58,12 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
is LoginRootEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.url, formState.value, loggedInState)
LoginRootEvents.Submit -> {
when {
homeserver.supportsOidc -> localCoroutineScope.submitOidc(homeserver.url, loggedInState)
homeserver.supportsPasswordLogin -> localCoroutineScope.submit(homeserver.url, formState.value, loggedInState)
}
}
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
}
}
@ -67,9 +76,22 @@ class LoginRootPresenter @Inject constructor(private val authenticationService: @@ -67,9 +76,22 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
)
}
private fun CoroutineScope.submitOidc(homeserver: String, loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn
// TODO rework the setHomeserver flow
authenticationService.setHomeserver(homeserver)
authenticationService.getOidcUrl()
.onSuccess {
loggedInState.value = LoggedInState.OidcStarted(it)
}
.onFailure { failure ->
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
}
}
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn
//TODO rework the setHomeserver flow
// TODO rework the setHomeserver flow
authenticationService.setHomeserver(homeserver)
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->

9
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt

@ -18,6 +18,7 @@ package io.element.android.features.login.impl.root @@ -18,6 +18,7 @@ package io.element.android.features.login.impl.root
import android.os.Parcelable
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize
@ -27,13 +28,17 @@ data class LoginRootState( @@ -27,13 +28,17 @@ data class LoginRootState(
val formState: LoginFormState,
val eventSink: (LoginRootEvents) -> Unit
) {
val submitEnabled: Boolean get() =
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState !is LoggedInState.ErrorLoggingIn
val supportPasswordLogin = homeserverDetails.supportsPasswordLogin
val supportOidcLogin = homeserverDetails.supportsOidc
val submitEnabled: Boolean
get() = loggedInState !is LoggedInState.ErrorLoggingIn &&
((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin)
}
sealed interface LoggedInState {
object NotLoggedIn : LoggedInState
object LoggingIn : LoggedInState
data class OidcStarted(val oidcDetail: OidcDetails) : LoggedInState
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
data class LoggedIn(val sessionId: SessionId) : LoggedInState
}

8
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt

@ -24,16 +24,20 @@ open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> { @@ -24,16 +24,20 @@ open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
override val values: Sequence<LoginRootState>
get() = sequenceOf(
aLoginRootState(),
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("some-custom-server.com", true, null)),
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("some-custom-server.com", supportsPasswordLogin = true, supportsOidc = false)),
aLoginRootState().copy(formState = LoginFormState("user", "pass")),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
// Oidc
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("server-with-oidc.org", supportsPasswordLogin = false, supportsOidc = true)),
// No password, no oidc support
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("wrong.org", supportsPasswordLogin = false, supportsOidc = false)),
)
}
fun aLoginRootState() = LoginRootState(
homeserverDetails = MatrixHomeServerDetails("matrix.org", true, null),
homeserverDetails = MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidc = false),
loggedInState = LoggedInState.NotLoggedIn,
formState = LoginFormState.Default,
eventSink = {}

65
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt

@ -83,7 +83,7 @@ import io.element.android.libraries.designsystem.theme.components.TextField @@ -83,7 +83,7 @@ import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
@ -94,7 +94,7 @@ fun LoginRootView( @@ -94,7 +94,7 @@ fun LoginRootView(
state: LoginRootState,
modifier: Modifier = Modifier,
onChangeServer: () -> Unit = {},
onLoginWithSuccess: (SessionId) -> Unit = {},
onOidcDetails: (OidcDetails) -> Unit = {},
onBackPressed: () -> Unit,
) {
val isLoading by remember(state.loggedInState) {
@ -102,6 +102,15 @@ fun LoginRootView( @@ -102,6 +102,15 @@ fun LoginRootView(
state.loggedInState == LoggedInState.LoggingIn
}
}
val focusManager = LocalFocusManager.current
fun submit() {
// Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true)
state.eventSink(LoginRootEvents.Submit)
}
Scaffold(
topBar = {
TopAppBar(
@ -143,13 +152,37 @@ fun LoginRootView( @@ -143,13 +152,37 @@ fun LoginRootView(
Spacer(Modifier.height(32.dp))
LoginForm(state = state, isLoading = isLoading)
when {
state.supportOidcLogin -> {
// Oidc, in this case, just display a Spacer and the submit button
Spacer(Modifier.height(28.dp))
}
state.supportPasswordLogin -> {
LoginForm(state = state, isLoading = isLoading, onSubmit = ::submit)
}
else -> {
Text(text = "No supported login flow")
}
}
Spacer(modifier = Modifier.height(32.dp))
Spacer(Modifier.height(28.dp))
if (state.supportOidcLogin || state.supportPasswordLogin) {
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = ::submit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
Spacer(modifier = Modifier.height(32.dp))
}
}
when (val loggedInState = state.loggedInState) {
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
is LoggedInState.OidcStarted -> onOidcDetails(loggedInState.oidcDetail)
else -> Unit
}
}
@ -217,6 +250,7 @@ internal fun ChangeServerSection( @@ -217,6 +250,7 @@ internal fun ChangeServerSection(
internal fun LoginForm(
state: LoginRootState,
isLoading: Boolean,
onSubmit: () -> Unit,
modifier: Modifier = Modifier
) {
var loginFieldState by textFieldState(stateValue = state.formState.login)
@ -225,13 +259,6 @@ internal fun LoginForm( @@ -225,13 +259,6 @@ internal fun LoginForm(
val focusManager = LocalFocusManager.current
val eventSink = state.eventSink
fun submit() {
// Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true)
eventSink(LoginRootEvents.Submit)
}
Column(modifier) {
Text(
text = stringResource(R.string.screen_login_form_header),
@ -318,23 +345,11 @@ internal fun LoginForm( @@ -318,23 +345,11 @@ internal fun LoginForm(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { submit() }
onDone = { onSubmit() }
),
singleLine = true,
maxLines = 1,
)
Spacer(Modifier.height(28.dp))
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = ::submit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
}
}

3
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt

@ -18,7 +18,6 @@ package io.element.android.features.login.impl.util @@ -18,7 +18,6 @@ package io.element.android.features.login.impl.util
object LoginConstants {
const val DEFAULT_HOMESERVER_URL = "matrix.org"
const val DEFAULT_HOMESERVER_URL = "synapse-oidc.lab.element.dev" // "matrix.org"
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
}

1
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt

@ -22,4 +22,5 @@ sealed class AuthenticationException(message: String) : Exception(message) { @@ -22,4 +22,5 @@ sealed class AuthenticationException(message: String) : Exception(message) {
class SlidingSyncNotAvailable(message: String) : AuthenticationException(message)
class SessionMissing(message: String) : AuthenticationException(message)
class Generic(message: String) : AuthenticationException(message)
class OidcError(type: String, message: String) : AuthenticationException(message)
}

19
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt

@ -28,4 +28,23 @@ interface MatrixAuthenticationService { @@ -28,4 +28,23 @@ interface MatrixAuthenticationService {
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
suspend fun setHomeserver(homeserver: String): Result<Unit>
suspend fun login(username: String, password: String): Result<SessionId>
/*
* OIDC part.
*/
/**
* Get the Oidc url to display to the user.
*/
suspend fun getOidcUrl(): Result<OidcDetails>
/**
* Cancel Oidc login sequence.
*/
suspend fun cancelOidcLogin(): Result<Unit>
/**
* Attempt to login using the [callbackUrl] provided by the Oidc page.
*/
suspend fun loginWithOidc(callbackUrl: String): Result<SessionId>
}

2
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixHomeServerDetails.kt

@ -23,5 +23,5 @@ import kotlinx.parcelize.Parcelize @@ -23,5 +23,5 @@ import kotlinx.parcelize.Parcelize
data class MatrixHomeServerDetails(
val url: String,
val supportsPasswordLogin: Boolean,
val authenticationIssuer: String?
val supportsOidc: Boolean,
): Parcelable

21
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcConfig.kt

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.libraries.matrix.api.auth
object OidcConfig {
const val redirectUri = "io.element:/callback"
}

25
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcDetails.kt

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.libraries.matrix.api.auth
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class OidcDetails(
val url: String,
) : Parcelable

7
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt

@ -26,6 +26,13 @@ fun Throwable.mapAuthenticationException(): Throwable { @@ -26,6 +26,13 @@ fun Throwable.mapAuthenticationException(): Throwable {
is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!)
is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!)
is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(this.message!!)
is RustAuthenticationException.OidcException -> AuthenticationException.OidcError("OidcException", message!!)
is RustAuthenticationException.OidcMetadataInvalid -> AuthenticationException.OidcError("OidcMetadataInvalid", message!!)
is RustAuthenticationException.OidcMetadataMissing -> AuthenticationException.OidcError("OidcMetadataMissing", message!!)
is RustAuthenticationException.OidcNotStarted -> AuthenticationException.OidcError("OidcNotStarted", message!!)
is RustAuthenticationException.OidcNotSupported -> AuthenticationException.OidcError("OidcNotSupported", message!!)
else -> this
}
}

2
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetails.kt

@ -23,6 +23,6 @@ fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use { @@ -23,6 +23,6 @@ fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use {
MatrixHomeServerDetails(
url = url(),
supportsPasswordLogin = supportsPasswordLogin(),
authenticationIssuer = authenticationIssuer()
supportsOidc = supportsOidcLogin(),
)
}

29
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfig.kt

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 io.element.android.libraries.matrix.impl.auth
import io.element.android.libraries.matrix.api.auth.OidcConfig
import org.matrix.rustcomponents.sdk.OidcClientMetadata
val oidcClientMetadata: OidcClientMetadata = OidcClientMetadata(
clientName = "Element",
redirectUri = OidcConfig.redirectUri,
clientUri = "https://element.io",
tosUri = "https://element.io/user-terms-of-service",
policyUri = "https://element.io/privacy"
)

62
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt

@ -24,8 +24,8 @@ import io.element.android.libraries.di.SingleIn @@ -24,8 +24,8 @@ import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.impl.RustMatrixClient
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore
@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.StateFlow @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.OidcAuthenticationUrl
import org.matrix.rustcomponents.sdk.Session
import org.matrix.rustcomponents.sdk.use
import java.io.File
@ -51,7 +52,12 @@ class RustMatrixAuthenticationService @Inject constructor( @@ -51,7 +52,12 @@ class RustMatrixAuthenticationService @Inject constructor(
private val sessionStore: SessionStore,
) : MatrixAuthenticationService {
private val authService: RustAuthenticationService = RustAuthenticationService(baseDirectory.absolutePath, null, null)
private val authService: RustAuthenticationService = RustAuthenticationService(
basePath = baseDirectory.absolutePath,
passphrase = null,
oidcClientMetadata = oidcClientMetadata,
customSlidingSyncProxy = null
)
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
override fun isLoggedIn(): Flow<Boolean> {
@ -91,9 +97,9 @@ class RustMatrixAuthenticationService @Inject constructor( @@ -91,9 +97,9 @@ class RustMatrixAuthenticationService @Inject constructor(
if (homeServerDetails != null) {
currentHomeserver.value = homeServerDetails.copy(url = homeserver)
}
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
override suspend fun login(username: String, password: String): Result<SessionId> =
@ -103,10 +109,54 @@ class RustMatrixAuthenticationService @Inject constructor( @@ -103,10 +109,54 @@ class RustMatrixAuthenticationService @Inject constructor(
val sessionData = client.use { it.session().toSessionData() }
sessionStore.storeData(sessionData)
SessionId(sessionData.userId)
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}
private var pendingUrlForOidcLogin: OidcAuthenticationUrl? = null
override suspend fun getOidcUrl(): Result<OidcDetails> {
return withContext(coroutineDispatchers.io) {
runCatching {
val urlForOidcLogin = authService.urlForOidcLogin()
val url = urlForOidcLogin.loginUrl()
pendingUrlForOidcLogin = urlForOidcLogin
OidcDetails(url)
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}
override suspend fun cancelOidcLogin(): Result<Unit> {
return withContext(coroutineDispatchers.io) {
runCatching {
pendingUrlForOidcLogin?.close()
pendingUrlForOidcLogin = null
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}
}
/**
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters)
*/
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> {
return withContext(coroutineDispatchers.io) {
runCatching {
val urlForOidcLogin = pendingUrlForOidcLogin ?: error("You need to call `getOidcUrl()` first")
val client = authService.loginWithOidcCallback(urlForOidcLogin, callbackUrl)
val sessionData = client.use { it.session().toSessionData() }
pendingUrlForOidcLogin = null
sessionStore.storeData(sessionData)
SessionId(sessionData.userId)
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}
}
private fun createMatrixClient(client: Client): MatrixClient {
return RustMatrixClient(

Loading…
Cancel
Save