Add OIDC support
This commit is contained in:
parent
534bf67883
commit
a7eae1cda5
26 changed files with 789 additions and 44 deletions
45
docs/oidc.md
Normal file
45
docs/oidc.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -29,11 +29,13 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.login.impl.changeserver.ChangeServerNode
|
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.features.login.impl.root.LoginRootNode
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
|
|
@ -55,6 +57,9 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object ChangeServer : NavTarget
|
object ChangeServer : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -64,10 +69,19 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
override fun onChangeHomeServer() {
|
override fun onChangeHomeServer() {
|
||||||
backstack.push(NavTarget.ChangeServer)
|
backstack.push(NavTarget.ChangeServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||||
|
backstack.push(NavTarget.OidcView(oidcDetails))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<LoginRootNode>(buildContext, plugins = listOf(callback))
|
createNode<LoginRootNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
|
|
||||||
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
|
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
|
||||||
|
is NavTarget.OidcView -> {
|
||||||
|
val input = OidcNode.Inputs(navTarget.oidcDetails)
|
||||||
|
createNode<OidcNode>(buildContext, plugins = listOf(input))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
class LoginRootNode @AssistedInject constructor(
|
class LoginRootNode @AssistedInject constructor(
|
||||||
|
|
@ -36,20 +37,26 @@ class LoginRootNode @AssistedInject constructor(
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onChangeHomeServer()
|
fun onChangeHomeServer()
|
||||||
|
fun onOidcDetails(oidcDetails: OidcDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onChangeHomeServer() {
|
private fun onChangeHomeServer() {
|
||||||
plugins<Callback>().forEach { it.onChangeHomeServer() }
|
plugins<Callback>().forEach { it.onChangeHomeServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||||
|
plugins<Callback>().forEach { it.onOidcDetails(oidcDetails) }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
LoginRootView(
|
LoginRootView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onChangeServer = this::onChangeHomeServer,
|
onChangeServer = ::onChangeHomeServer,
|
||||||
onBackPressed = this::navigateUp
|
onOidcDetails = ::onOidcDetails,
|
||||||
|
onBackPressed = ::navigateUp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,11 @@ import javax.inject.Inject
|
||||||
|
|
||||||
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
|
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
|
@Composable
|
||||||
override fun present(): LoginRootState {
|
override fun present(): LoginRootState {
|
||||||
|
|
@ -54,7 +58,12 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
|
||||||
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
||||||
copy(password = event.password)
|
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
|
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 {
|
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
|
||||||
loggedInState.value = LoggedInState.LoggingIn
|
loggedInState.value = LoggedInState.LoggingIn
|
||||||
//TODO rework the setHomeserver flow
|
// TODO rework the setHomeserver flow
|
||||||
authenticationService.setHomeserver(homeserver)
|
authenticationService.setHomeserver(homeserver)
|
||||||
authenticationService.login(formState.login.trim(), formState.password)
|
authenticationService.login(formState.login.trim(), formState.password)
|
||||||
.onSuccess { sessionId ->
|
.onSuccess { sessionId ->
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.login.impl.root
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
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.SessionId
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
|
@ -27,13 +28,17 @@ data class LoginRootState(
|
||||||
val formState: LoginFormState,
|
val formState: LoginFormState,
|
||||||
val eventSink: (LoginRootEvents) -> Unit
|
val eventSink: (LoginRootEvents) -> Unit
|
||||||
) {
|
) {
|
||||||
val submitEnabled: Boolean get() =
|
val supportPasswordLogin = homeserverDetails.supportsPasswordLogin
|
||||||
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState !is LoggedInState.ErrorLoggingIn
|
val supportOidcLogin = homeserverDetails.supportsOidc
|
||||||
|
val submitEnabled: Boolean
|
||||||
|
get() = loggedInState !is LoggedInState.ErrorLoggingIn &&
|
||||||
|
((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface LoggedInState {
|
sealed interface LoggedInState {
|
||||||
object NotLoggedIn : LoggedInState
|
object NotLoggedIn : LoggedInState
|
||||||
object LoggingIn : LoggedInState
|
object LoggingIn : LoggedInState
|
||||||
|
data class OidcStarted(val oidcDetail: OidcDetails) : LoggedInState
|
||||||
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
|
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
|
||||||
data class LoggedIn(val sessionId: SessionId) : LoggedInState
|
data class LoggedIn(val sessionId: SessionId) : LoggedInState
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,20 @@ open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
|
||||||
override val values: Sequence<LoginRootState>
|
override val values: Sequence<LoginRootState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aLoginRootState(),
|
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")),
|
||||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
|
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.ErrorLoggingIn(Throwable())),
|
||||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
|
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(
|
fun aLoginRootState() = LoginRootState(
|
||||||
homeserverDetails = MatrixHomeServerDetails("matrix.org", true, null),
|
homeserverDetails = MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidc = false),
|
||||||
loggedInState = LoggedInState.NotLoggedIn,
|
loggedInState = LoggedInState.NotLoggedIn,
|
||||||
formState = LoginFormState.Default,
|
formState = LoginFormState.Default,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
|
|
|
||||||
|
|
@ -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.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.autofill
|
import io.element.android.libraries.designsystem.theme.components.autofill
|
||||||
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
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.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
@ -94,7 +94,7 @@ fun LoginRootView(
|
||||||
state: LoginRootState,
|
state: LoginRootState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onChangeServer: () -> Unit = {},
|
onChangeServer: () -> Unit = {},
|
||||||
onLoginWithSuccess: (SessionId) -> Unit = {},
|
onOidcDetails: (OidcDetails) -> Unit = {},
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isLoading by remember(state.loggedInState) {
|
val isLoading by remember(state.loggedInState) {
|
||||||
|
|
@ -102,6 +102,15 @@ fun LoginRootView(
|
||||||
state.loggedInState == LoggedInState.LoggingIn
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -143,13 +152,37 @@ fun LoginRootView(
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
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) {
|
when (val loggedInState = state.loggedInState) {
|
||||||
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
|
is LoggedInState.OidcStarted -> onOidcDetails(loggedInState.oidcDetail)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +250,7 @@ internal fun ChangeServerSection(
|
||||||
internal fun LoginForm(
|
internal fun LoginForm(
|
||||||
state: LoginRootState,
|
state: LoginRootState,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
|
onSubmit: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
||||||
|
|
@ -225,13 +259,6 @@ internal fun LoginForm(
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val eventSink = state.eventSink
|
val eventSink = state.eventSink
|
||||||
|
|
||||||
fun submit() {
|
|
||||||
// Clear focus to prevent keyboard issues with textfields
|
|
||||||
focusManager.clearFocus(force = true)
|
|
||||||
|
|
||||||
eventSink(LoginRootEvents.Submit)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.screen_login_form_header),
|
text = stringResource(R.string.screen_login_form_header),
|
||||||
|
|
@ -318,23 +345,11 @@ internal fun LoginForm(
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onDone = { submit() }
|
onDone = { onSubmit() }
|
||||||
),
|
),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
maxLines = 1,
|
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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ package io.element.android.features.login.impl.util
|
||||||
|
|
||||||
object LoginConstants {
|
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"
|
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,5 @@ sealed class AuthenticationException(message: String) : Exception(message) {
|
||||||
class SlidingSyncNotAvailable(message: String) : AuthenticationException(message)
|
class SlidingSyncNotAvailable(message: String) : AuthenticationException(message)
|
||||||
class SessionMissing(message: String) : AuthenticationException(message)
|
class SessionMissing(message: String) : AuthenticationException(message)
|
||||||
class Generic(message: String) : AuthenticationException(message)
|
class Generic(message: String) : AuthenticationException(message)
|
||||||
|
class OidcError(type: String, message: String) : AuthenticationException(message)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,23 @@ interface MatrixAuthenticationService {
|
||||||
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
|
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
|
||||||
suspend fun setHomeserver(homeserver: String): Result<Unit>
|
suspend fun setHomeserver(homeserver: String): Result<Unit>
|
||||||
suspend fun login(username: String, password: String): Result<SessionId>
|
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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,5 @@ import kotlinx.parcelize.Parcelize
|
||||||
data class MatrixHomeServerDetails(
|
data class MatrixHomeServerDetails(
|
||||||
val url: String,
|
val url: String,
|
||||||
val supportsPasswordLogin: Boolean,
|
val supportsPasswordLogin: Boolean,
|
||||||
val authenticationIssuer: String?
|
val supportsOidc: Boolean,
|
||||||
): Parcelable
|
): Parcelable
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -26,6 +26,13 @@ fun Throwable.mapAuthenticationException(): Throwable {
|
||||||
is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!)
|
is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!)
|
||||||
is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!)
|
is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!)
|
||||||
is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(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
|
else -> this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,6 @@ fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use {
|
||||||
MatrixHomeServerDetails(
|
MatrixHomeServerDetails(
|
||||||
url = url(),
|
url = url(),
|
||||||
supportsPasswordLogin = supportsPasswordLogin(),
|
supportsPasswordLogin = supportsPasswordLogin(),
|
||||||
authenticationIssuer = authenticationIssuer()
|
supportsOidc = supportsOidcLogin(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
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.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.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
|
||||||
import io.element.android.libraries.matrix.impl.RustMatrixClient
|
import io.element.android.libraries.matrix.impl.RustMatrixClient
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
|
@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||||
|
import org.matrix.rustcomponents.sdk.OidcAuthenticationUrl
|
||||||
import org.matrix.rustcomponents.sdk.Session
|
import org.matrix.rustcomponents.sdk.Session
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -51,7 +52,12 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
) : MatrixAuthenticationService {
|
) : 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)
|
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||||
|
|
||||||
override fun isLoggedIn(): Flow<Boolean> {
|
override fun isLoggedIn(): Flow<Boolean> {
|
||||||
|
|
@ -91,9 +97,9 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
if (homeServerDetails != null) {
|
if (homeServerDetails != null) {
|
||||||
currentHomeserver.value = homeServerDetails.copy(url = homeserver)
|
currentHomeserver.value = homeServerDetails.copy(url = homeserver)
|
||||||
}
|
}
|
||||||
|
}.mapFailure { failure ->
|
||||||
|
failure.mapAuthenticationException()
|
||||||
}
|
}
|
||||||
}.mapFailure { failure ->
|
|
||||||
failure.mapAuthenticationException()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun login(username: String, password: String): Result<SessionId> =
|
override suspend fun login(username: String, password: String): Result<SessionId> =
|
||||||
|
|
@ -103,11 +109,55 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
val sessionData = client.use { it.session().toSessionData() }
|
val sessionData = client.use { it.session().toSessionData() }
|
||||||
sessionStore.storeData(sessionData)
|
sessionStore.storeData(sessionData)
|
||||||
SessionId(sessionData.userId)
|
SessionId(sessionData.userId)
|
||||||
|
}.mapFailure { failure ->
|
||||||
|
failure.mapAuthenticationException()
|
||||||
}
|
}
|
||||||
}.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
private fun createMatrixClient(client: Client): MatrixClient {
|
||||||
return RustMatrixClient(
|
return RustMatrixClient(
|
||||||
client = client,
|
client = client,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue