From b8d12e49e472e44c62b7a76f4304fb860d0c3fdf Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:28:33 +0200 Subject: [PATCH 1/2] feat(crypto): migrate to Core Crypto v10 --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 4483514868..a1552ff4ba 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 448351486818f7cfe2719145bfbd4c27929d9c7b +Subproject commit a1552ff4ba856589bb3662b6e3ca6ca28997a55e From 114dd2296ca9cc95f6281439f07a70cea5addd14 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:30:10 +0200 Subject: [PATCH 2/2] fix(e2ei): adapt enrollment to continuous flow --- .../wire/android/ui/debug/DebugDataOptions.kt | 4 +- .../ui/debug/DebugDataOptionsViewModel.kt | 18 +-- .../ui/e2eiEnrollment/E2EIEnrollmentScreen.kt | 4 +- .../e2eiEnrollment/E2EIEnrollmentViewModel.kt | 8 +- .../ui/e2eiEnrollment/GetE2EICertificateUI.kt | 41 ++++++- .../GetE2EICertificateViewModel.kt | 106 ++++++++--------- .../sync/FeatureFlagNotificationViewModel.kt | 8 +- .../settings/devices/DeviceDetailsScreen.kt | 4 +- .../devices/DeviceDetailsViewModel.kt | 8 +- .../e2eiEnrollment/GetE2EICertificateTest.kt | 110 ++++++++++++++++++ 10 files changed, 222 insertions(+), 89 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateTest.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index 39478c47a2..7e26c235d9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -60,7 +60,7 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.feature.debug.MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS -import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult +import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIResult @Composable fun DebugDataOptions( @@ -108,7 +108,7 @@ fun DebugDataOptionsContent( onForceUpdateApiVersions: () -> Unit, enrollE2EICertificate: () -> Unit, e2eiCertificateExpirationInputState: TextFieldState, - handleE2EIEnrollmentResult: (FinalizeEnrollmentResult) -> Unit, + handleE2EIEnrollmentResult: (EnrollE2EIResult) -> Unit, dismissCertificateDialog: () -> Unit, checkCrlRevocationList: () -> Unit, forceCRLExpirationAfterOneMinute: Boolean, diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt index f71e064559..2e90cb5043 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -47,7 +47,7 @@ import com.wire.kalium.logic.feature.debug.SetDebugCRLExpirationAfterOneMinuteUs import com.wire.kalium.logic.feature.debug.SetDebugE2EICertificateExpirationUseCase import com.wire.kalium.logic.feature.debug.TargetedRepairParam import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase -import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult +import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIResult import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase import com.wire.kalium.logic.feature.notificationToken.SendFCMTokenError @@ -81,7 +81,7 @@ interface DebugDataOptionsViewModel { fun enrollE2EICertificate() {} fun updateE2EICertificateExpiration(seconds: Long) {} fun updateE2EICertificateExpirationInput(minutes: String) {} - fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) {} + fun handleE2EIEnrollmentResult(result: EnrollE2EIResult) {} fun dismissCertificateDialog() {} fun forceUpdateApiVersions() {} fun disableEventProcessing(disabled: Boolean) {} @@ -227,17 +227,9 @@ class DebugDataOptionsViewModelImpl @Inject constructor( } } - override fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) { + override fun handleE2EIEnrollmentResult(result: EnrollE2EIResult) { state = when (result) { - is FinalizeEnrollmentResult.Failure.OAuthError -> { - state.copy( - certificate = result.reason, - showCertificate = true, - startGettingE2EICertificate = false - ) - } - - is FinalizeEnrollmentResult.Failure -> { + is EnrollE2EIResult.Failure -> { state.copy( certificate = result.toString(), showCertificate = true, @@ -245,7 +237,7 @@ class DebugDataOptionsViewModelImpl @Inject constructor( ) } - is FinalizeEnrollmentResult.Success -> { + is EnrollE2EIResult.Success -> { state.copy( certificate = result.certificate, showCertificate = true, diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt index 05b90e5779..df1d6b95ab 100644 --- a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt @@ -52,7 +52,7 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.SupportPage import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes -import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult +import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIResult @Composable internal fun E2EIEnrollmentRouteScreen( @@ -91,7 +91,7 @@ private fun E2EIEnrollmentScreenContent( dismissSuccess: () -> Unit, dismissErrorDialog: () -> Unit, enrollE2EICertificate: () -> Unit, - handleE2EIEnrollmentResult: (FinalizeEnrollmentResult) -> Unit, + handleE2EIEnrollmentResult: (EnrollE2EIResult) -> Unit, openCertificateDetails: () -> Unit, onBackButtonClicked: () -> Unit, onCancelEnrollmentClicked: () -> Unit, diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentViewModel.kt index 4b4c78e091..94e56df389 100644 --- a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentViewModel.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.kalium.logic.feature.client.FinalizeMLSClientAfterE2EIEnrollmentUseCase -import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult +import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIResult import kotlinx.coroutines.launch data class E2EIEnrollmentState( @@ -56,16 +56,16 @@ class E2EIEnrollmentViewModel @Inject constructor( state = state.copy(isLoading = true, startGettingE2EICertificate = true) } - fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) { + fun handleE2EIEnrollmentResult(result: EnrollE2EIResult) { state = when (result) { - is FinalizeEnrollmentResult.Failure -> { + is EnrollE2EIResult.Failure -> { state.copy( isLoading = false, isCertificateEnrollError = true, startGettingE2EICertificate = false ) } - is FinalizeEnrollmentResult.Success -> { + is EnrollE2EIResult.Success -> { state.copy( certificate = result.certificate, isCertificateEnrollSuccess = true, diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateUI.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateUI.kt index bcbb849c88..2b5237cdad 100644 --- a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateUI.kt +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateUI.kt @@ -24,13 +24,16 @@ import androidx.compose.ui.platform.LocalContext import com.wire.android.feature.e2ei.OAuthUseCase import com.wire.android.ui.getE2EICertificateViewModel import com.wire.android.util.extension.getActivity -import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult +import com.wire.kalium.logic.data.e2ei.E2EIAuthenticationRequest +import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIResult import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive @Composable fun GetE2EICertificateUI( - enrollmentResultHandler: (FinalizeEnrollmentResult) -> Unit, + enrollmentResultHandler: (EnrollE2EIResult) -> Unit, isNewClient: Boolean, viewModel: GetE2EICertificateViewModel = getE2EICertificateViewModel() ) { @@ -38,11 +41,15 @@ fun GetE2EICertificateUI( val context = LocalContext.current LaunchedEffect(Unit) { - viewModel.requestOAuthFlow.onEach { - OAuthUseCase(context, it.target, it.oAuthClaims).launch( + viewModel.requestOAuthFlow.onEach { request -> + OAuthUseCase( + context, + request.authenticationRequest.target, + request.authenticationRequest.toOAuthClaims(), + ).launch( context.getActivity()!!.activityResultRegistry, forceLoginFlow = true - ) { result -> viewModel.handleOAuthResult(result, it) } + ) { result -> viewModel.handleOAuthResult(request.id, result) } }.launchIn(coroutineScope) } @@ -53,3 +60,27 @@ fun GetE2EICertificateUI( viewModel.getCertificate(isNewClient) } } + +internal fun E2EIAuthenticationRequest.toOAuthClaims() = JsonObject( + mapOf( + ID_TOKEN to JsonObject( + mapOf( + KEY_AUTH to requiredClaim(keyAuth), + ACME_AUDIENCE to requiredClaim(acmeAudience), + ) + ) + ) +) + +private fun requiredClaim(value: String) = JsonObject( + mapOf( + ESSENTIAL to JsonPrimitive(true), + VALUE to JsonPrimitive(value), + ) +) + +private const val ID_TOKEN = "id_token" +private const val KEY_AUTH = "keyauth" +private const val ACME_AUDIENCE = "acme_aud" +private const val ESSENTIAL = "essential" +private const val VALUE = "value" diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateViewModel.kt index d74f7c2cde..4a58257f9a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateViewModel.kt @@ -21,18 +21,55 @@ import com.wire.android.di.KaliumCoreLogic import dev.zacsweers.metro.Inject import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.wire.android.feature.e2ei.OAuthUseCase import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.CoreLogic -import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult -import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult -import com.wire.kalium.logic.feature.e2ei.usecase.InitialEnrollmentResult +import com.wire.kalium.logic.data.e2ei.E2EIAuthenticationRequest +import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIResult import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.CurrentSessionUseCase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +internal data class E2EIOAuthRequest( + val id: Long, + val authenticationRequest: E2EIAuthenticationRequest, +) + +internal class E2EIOAuthCoordinator { + private val nextRequestId = AtomicLong() + private val pendingRequests = ConcurrentHashMap>() + private val requestChannel = Channel(Channel.BUFFERED) + + val requestFlow = requestChannel.receiveAsFlow() + + suspend fun authenticate(request: E2EIAuthenticationRequest): String { + val requestId = nextRequestId.incrementAndGet() + val result = CompletableDeferred() + pendingRequests[requestId] = result + return try { + requestChannel.send(E2EIOAuthRequest(requestId, request)) + when (val oAuthResult = result.await()) { + is OAuthUseCase.OAuthResult.Success -> oAuthResult.idToken + is OAuthUseCase.OAuthResult.Failed -> throw E2EIAuthenticationException(oAuthResult.reason) + } + } finally { + pendingRequests.remove(requestId)?.cancel() + } + } + + fun handleResult(requestId: Long, oAuthResult: OAuthUseCase.OAuthResult) { + pendingRequests[requestId]?.complete(oAuthResult) + } +} + +internal class E2EIAuthenticationException(reason: String) : Exception(reason) class GetE2EICertificateViewModel @Inject constructor( @KaliumCoreLogic private val coreLogic: CoreLogic, @@ -40,65 +77,28 @@ class GetE2EICertificateViewModel @Inject constructor( val dispatcherProvider: DispatcherProvider ) : ViewModel() { - private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.default()) + private val oAuthCoordinator = E2EIOAuthCoordinator() - val requestOAuthFlow = MutableSharedFlow() - val enrollmentResultFlow = MutableSharedFlow() + internal val requestOAuthFlow = oAuthCoordinator.requestFlow + val enrollmentResultFlow = MutableSharedFlow() - fun handleOAuthResult(oAuthResult: OAuthUseCase.OAuthResult, initialEnrollmentResult: E2EIEnrollmentResult.Initialized) { - scope.launch { - when (oAuthResult) { - is OAuthUseCase.OAuthResult.Success -> finalizeEnrollment(oAuthResult, initialEnrollmentResult) - - is OAuthUseCase.OAuthResult.Failed -> enrollmentResultFlow.emit( - FinalizeEnrollmentResult.Failure.OAuthError(oAuthResult.reason) - ) - } - } + internal fun handleOAuthResult(requestId: Long, oAuthResult: OAuthUseCase.OAuthResult) { + oAuthCoordinator.handleResult(requestId, oAuthResult) } fun getCertificate(isNewClient: Boolean) { - scope.launch { + viewModelScope.launch(dispatcherProvider.default()) { val currentSessionResult = currentSession() if (currentSessionResult is CurrentSessionResult.Success && currentSessionResult.accountInfo.isValid()) { val result = coreLogic.getSessionScope(currentSessionResult.accountInfo.userId) .users .enrollE2EI - .initialEnrollment(isNewClientRegistration = isNewClient) - when (result) { - is InitialEnrollmentResult.Failure -> { - enrollmentResultFlow.emit(FinalizeEnrollmentResult.Failure.Generic(result.toE2EIFailure())) - } - - is InitialEnrollmentResult.Success -> { - requestOAuthFlow.emit(result.initializationResult) - } - } + .invoke( + isNewClientRegistration = isNewClient, + authenticate = oAuthCoordinator::authenticate, + ) + enrollmentResultFlow.emit(result) } } } - - private suspend fun finalizeEnrollment( - oAuthResult: OAuthUseCase.OAuthResult.Success, - initialEnrollmentResult: E2EIEnrollmentResult.Initialized - ) { - val currentSessionResult = currentSession() - - if (currentSessionResult is CurrentSessionResult.Success && currentSessionResult.accountInfo.isValid()) { - val enrollmentResult = coreLogic.getSessionScope(currentSessionResult.accountInfo.userId) - .users - .enrollE2EI.finalizeEnrollment( - oAuthResult.idToken, - oAuthResult.authState, - initialEnrollmentResult - ) - enrollmentResultFlow.emit(enrollmentResult) - } - } - - private fun InitialEnrollmentResult.Failure.toE2EIFailure() = when (this) { - is InitialEnrollmentResult.Failure.E2EIDisabled -> com.wire.kalium.common.error.E2EIFailure.Disabled - is InitialEnrollmentResult.Failure.MissingTeamSettings -> com.wire.kalium.common.error.E2EIFailure.MissingTeamSettings - is InitialEnrollmentResult.Failure.Generic -> this.e2EIFailure - } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt index 7a66a03d7d..856a81868f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt @@ -36,7 +36,7 @@ import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.message.TeamSelfDeleteTimer import com.wire.kalium.logic.data.sync.SyncState import com.wire.kalium.logic.data.user.UserId -import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult +import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIResult import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.E2EIRequiredResult @@ -286,10 +286,10 @@ class FeatureFlagNotificationViewModel @Inject constructor( featureFlagState = featureFlagState.copy(isE2EILoading = true, startGettingE2EICertificate = true) } - fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) { + fun handleE2EIEnrollmentResult(result: EnrollE2EIResult) { val e2eiRequired = featureFlagState.e2EIRequired featureFlagState = when (result) { - is FinalizeEnrollmentResult.Failure -> { + is EnrollE2EIResult.Failure -> { featureFlagState.copy( isE2EILoading = false, startGettingE2EICertificate = false, @@ -298,7 +298,7 @@ class FeatureFlagNotificationViewModel @Inject constructor( ) } - is FinalizeEnrollmentResult.Success -> { + is EnrollE2EIResult.Success -> { featureFlagState.copy( isE2EILoading = false, e2EIRequired = null, diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt index dcebbb4ee2..19e2f4bf1a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt @@ -93,7 +93,7 @@ import com.wire.kalium.logic.feature.e2ei.MLSClientE2EIStatus import com.wire.kalium.logic.feature.e2ei.MLSClientIdentity import com.wire.kalium.logic.feature.e2ei.MLSCredentialsType import com.wire.kalium.logic.feature.e2ei.X509Identity -import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult +import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIResult import kotlinx.datetime.Instant @Composable @@ -132,7 +132,7 @@ internal fun DeviceDetailsRouteScreen( fun DeviceDetailsContent( state: DeviceDetailsState, passwordTextState: TextFieldState, - handleE2EIEnrollmentResult: (FinalizeEnrollmentResult) -> Unit, + handleE2EIEnrollmentResult: (EnrollE2EIResult) -> Unit, modifier: Modifier = Modifier, onDeleteDevice: () -> Unit = {}, onNavigateBack: () -> Unit = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt index 4295a8e513..8c05c17a65 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt @@ -43,7 +43,7 @@ import com.wire.kalium.logic.feature.client.ObserveClientDetailsUseCase import com.wire.kalium.logic.feature.client.Result import com.wire.kalium.logic.feature.client.UpdateClientVerificationStatusUseCase import com.wire.kalium.logic.feature.debug.BreakSessionUseCase -import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult +import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIResult import com.wire.kalium.logic.feature.e2ei.usecase.GetMLSClientIdentityResult import com.wire.kalium.logic.feature.e2ei.usecase.GetMLSClientIdentityUseCase import com.wire.kalium.logic.feature.user.GetUserInfoResult @@ -159,16 +159,16 @@ class DeviceDetailsViewModel @AssistedInject constructor( fun enrollE2EICertificate() { state = state.copy(isLoadingCertificate = true, startGettingE2EICertificate = true) } - fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) { + fun handleE2EIEnrollmentResult(result: EnrollE2EIResult) { state = when (result) { - is FinalizeEnrollmentResult.Failure -> { + is EnrollE2EIResult.Failure -> { state.copy( isLoadingCertificate = false, startGettingE2EICertificate = false, isE2EICertificateEnrollError = true, ) } - is FinalizeEnrollmentResult.Success -> { + is EnrollE2EIResult.Success -> { getE2eiCertificate() state.copy( isE2EICertificateEnrollSuccess = true, diff --git a/app/src/test/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateTest.kt b/app/src/test/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateTest.kt new file mode 100644 index 0000000000..bbad4b2f06 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateTest.kt @@ -0,0 +1,110 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.e2eiEnrollment + +import com.wire.android.feature.e2ei.OAuthUseCase +import com.wire.kalium.logic.data.e2ei.E2EIAuthenticationRequest +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GetE2EICertificateTest { + + @Test + fun givenEnrollmentStartsBeforeOAuthCollector_whenCollectorStarts_thenRequestIsDelivered() = runTest { + val coordinator = E2EIOAuthCoordinator() + val authentication = async(start = CoroutineStart.UNDISPATCHED) { + coordinator.authenticate(AUTHENTICATION_REQUEST) + } + + val request = coordinator.requestFlow.first() + coordinator.handleResult(request.id, OAuthUseCase.OAuthResult.Success(FIRST_ID_TOKEN, AUTH_STATE)) + + assertEquals(FIRST_ID_TOKEN, authentication.await()) + } + + @Test + fun givenConcurrentOAuthRequests_whenResultsArriveOutOfOrder_thenEachEnrollmentReceivesItsOwnToken() = runTest { + val coordinator = E2EIOAuthCoordinator() + val firstRequest = async(start = CoroutineStart.UNDISPATCHED) { coordinator.requestFlow.first() } + val firstAuthentication = async(start = CoroutineStart.UNDISPATCHED) { + coordinator.authenticate(AUTHENTICATION_REQUEST) + } + val emittedFirstRequest = firstRequest.await() + + val secondRequest = async(start = CoroutineStart.UNDISPATCHED) { coordinator.requestFlow.first() } + val secondAuthentication = async(start = CoroutineStart.UNDISPATCHED) { + coordinator.authenticate(AUTHENTICATION_REQUEST) + } + val emittedSecondRequest = secondRequest.await() + + coordinator.handleResult( + emittedSecondRequest.id, + OAuthUseCase.OAuthResult.Success(SECOND_ID_TOKEN, AUTH_STATE), + ) + coordinator.handleResult( + emittedFirstRequest.id, + OAuthUseCase.OAuthResult.Success(FIRST_ID_TOKEN, AUTH_STATE), + ) + + assertEquals(FIRST_ID_TOKEN, firstAuthentication.await()) + assertEquals(SECOND_ID_TOKEN, secondAuthentication.await()) + } + + @Test + fun givenOAuthFailure_whenAuthenticating_thenEnrollmentReceivesTheFailure() = runTest { + val coordinator = E2EIOAuthCoordinator() + val request = async(start = CoroutineStart.UNDISPATCHED) { coordinator.requestFlow.first() } + val authentication = async(start = CoroutineStart.UNDISPATCHED) { + runCatching { coordinator.authenticate(AUTHENTICATION_REQUEST) } + } + + coordinator.handleResult(request.await().id, OAuthUseCase.OAuthResult.Failed(FAILURE_REASON)) + + val exception = authentication.await().exceptionOrNull() + assertTrue(exception is E2EIAuthenticationException) + assertEquals(FAILURE_REASON, exception?.message) + } + + @Test + fun givenAuthenticationRequest_whenCreatingOAuthClaims_thenRequiredClaimsArePreserved() { + val expected = Json.parseToJsonElement( + """{"id_token":{"keyauth":{"essential":true,"value":"key-auth"},"acme_aud":{"essential":true,"value":"audience"}}}""" + ) + + assertEquals(expected, AUTHENTICATION_REQUEST.toOAuthClaims()) + } + + private companion object { + val AUTHENTICATION_REQUEST = E2EIAuthenticationRequest( + target = "https://example.com/authorize", + keyAuth = "key-auth", + acmeAudience = "audience", + ) + const val FIRST_ID_TOKEN = "first-id-token" + const val SECOND_ID_TOKEN = "second-id-token" + const val AUTH_STATE = "auth-state" + const val FAILURE_REASON = "OAuth failed" + } +}