Skip to content

Commit cb25434

Browse files
SanderKondratjevNortalaarmam
authored andcommitted
NFC-57 Fix and improve authentication flow
1 parent 56befef commit cb25434

File tree

26 files changed

+833
-910
lines changed

26 files changed

+833
-910
lines changed

app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt

Lines changed: 151 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,27 @@
22

33
package ee.ria.DigiDoc.viewmodel
44

5-
import android.app.Activity
65
import android.net.Uri
6+
import android.util.Base64.URL_SAFE
7+
import android.util.Base64.decode
78
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
89
import ee.ria.DigiDoc.webEid.WebEidAuthService
9-
import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest
10-
import kotlinx.coroutines.flow.MutableStateFlow
10+
import kotlinx.coroutines.ExperimentalCoroutinesApi
11+
import kotlinx.coroutines.async
12+
import kotlinx.coroutines.flow.first
13+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
14+
import kotlinx.coroutines.test.runTest
1115
import org.json.JSONObject
16+
import org.junit.Assert.assertEquals
1217
import org.junit.Before
1318
import org.junit.Rule
1419
import org.junit.Test
1520
import org.junit.runner.RunWith
1621
import org.mockito.Mock
17-
import org.mockito.Mockito.never
18-
import org.mockito.Mockito.`when`
1922
import org.mockito.MockitoAnnotations
2023
import org.mockito.junit.MockitoJUnitRunner
21-
import org.mockito.kotlin.any
2224
import org.mockito.kotlin.verify
25+
import org.mockito.kotlin.whenever
2326

2427
@RunWith(MockitoJUnitRunner::class)
2528
class WebEidViewModelTest {
@@ -29,129 +32,177 @@ class WebEidViewModelTest {
2932
@Mock
3033
private lateinit var authService: WebEidAuthService
3134

32-
@Mock
33-
private lateinit var activity: Activity
34-
3535
private lateinit var viewModel: WebEidViewModel
3636

3737
@Before
3838
fun setup() {
3939
MockitoAnnotations.openMocks(this)
40-
41-
`when`(authService.authRequest).thenReturn(MutableStateFlow(null))
42-
`when`(authService.signRequest).thenReturn(MutableStateFlow(null))
43-
`when`(authService.errorState).thenReturn(MutableStateFlow(null))
44-
`when`(authService.redirectUri).thenReturn(MutableStateFlow(null))
45-
4640
viewModel = WebEidViewModel(authService)
4741
}
4842

4943
@Test
50-
fun handleAuth_callsParseAuthUri() {
51-
val uri = Uri.parse("web-eid-mobile://auth#dummyData")
52-
viewModel.handleAuth(uri)
53-
verify(authService).parseAuthUri(uri)
44+
fun webEidViewModel_handleAuth_parsesAuthUriAndSetsStateFlow() {
45+
runTest {
46+
val uri =
47+
Uri.parse(
48+
"web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0",
49+
)
50+
viewModel.handleAuth(uri)
51+
val authRequest = viewModel.authRequest.value
52+
val signRequest = viewModel.signRequest.value
53+
assert(authRequest != null)
54+
assert(signRequest == null)
55+
assertEquals("test-challenge-00000000000000000000000000000", authRequest?.challenge)
56+
assertEquals("https://example.com/response", authRequest?.loginUri)
57+
assertEquals("https://example.com", authRequest?.origin)
58+
assertEquals(true, authRequest?.getSigningCertificate)
59+
}
5460
}
5561

5662
@Test
57-
fun handleSign_callsParseSignUri() {
58-
val uri = Uri.parse("web-eid-mobile://sign#dummyData")
59-
viewModel.handleSign(uri)
60-
verify(authService).parseSignUri(uri)
63+
fun webEidViewModel_handleAuth_emitErrorResponseEventWhenChallengeMinLength() {
64+
val uri =
65+
Uri.parse(
66+
"web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwibG9naW5fdXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldF9zaWduaW5nX2NlcnRpZmljYXRlIjp0cnVlfQ",
67+
)
68+
webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri)
6169
}
6270

6371
@Test
64-
fun reset_callsResetValues() {
65-
viewModel.reset()
66-
verify(authService).resetValues()
72+
fun webEidViewModel_handleAuth_emitErrorResponseEventWhenChallengeMaxLength() {
73+
val uri =
74+
Uri.parse(
75+
"web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJsb2dpbl91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwiZ2V0X3NpZ25pbmdfY2VydGlmaWNhdGUiOnRydWV9",
76+
)
77+
webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri)
6778
}
6879

69-
@Test
70-
fun redirectUri_isExposedFromAuthService() {
71-
val redirectFlow = MutableStateFlow("https://example.com#encodedPayload")
72-
`when`(authService.redirectUri).thenReturn(redirectFlow)
73-
val vm = WebEidViewModel(authService)
74-
assert(vm.redirectUri.value == "https://example.com#encodedPayload")
80+
@OptIn(ExperimentalCoroutinesApi::class)
81+
private fun webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri: Uri) {
82+
runTest(UnconfinedTestDispatcher()) {
83+
val deferred =
84+
async {
85+
viewModel.relyingPartyResponseEvents.first()
86+
}
87+
88+
viewModel.handleAuth(uri)
89+
90+
val emittedUri = deferred.await()
91+
assert(emittedUri.toString().startsWith("https://example.com/response#"))
92+
assert(emittedUri.fragment != null)
93+
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
94+
val jsonPayload = JSONObject(decodedPayload)
95+
assertEquals("ERR_WEBEID_MOBILE_INVALID_REQUEST", jsonPayload.getString("code"))
96+
assertEquals("Invalid challenge length", jsonPayload.getString("message"))
97+
}
7598
}
7699

77100
@Test
78-
fun redirectUri_updatesWhenServiceUpdates() {
79-
val redirectFlow = MutableStateFlow<String?>(null)
80-
`when`(authService.redirectUri).thenReturn(redirectFlow)
81-
val vm = WebEidViewModel(authService)
82-
redirectFlow.value = "https://example.com#updatedPayload"
83-
assert(vm.redirectUri.value == "https://example.com#updatedPayload")
101+
@OptIn(ExperimentalCoroutinesApi::class)
102+
fun webEidViewModel_handleAuth_emitErrorResponseEventWhenOriginMaxLength() {
103+
runTest(UnconfinedTestDispatcher()) {
104+
val uri =
105+
Uri.parse(
106+
"web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS54eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eC5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0",
107+
)
108+
val deferred =
109+
async {
110+
viewModel.relyingPartyResponseEvents.first()
111+
}
112+
113+
viewModel.handleAuth(uri)
114+
115+
val emittedUri = deferred.await()
116+
assert(
117+
emittedUri.toString().startsWith(
118+
"https://example.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.com/response#",
119+
),
120+
)
121+
assert(emittedUri.fragment != null)
122+
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
123+
val jsonPayload = JSONObject(decodedPayload)
124+
assertEquals("ERR_WEBEID_MOBILE_INVALID_REQUEST", jsonPayload.getString("code"))
125+
assertEquals("Invalid origin length", jsonPayload.getString("message"))
126+
}
84127
}
85128

86129
@Test
87-
fun handleWebEidAuthResult_callsBuildAuthToken_whenPayloadValid() {
88-
val cert = byteArrayOf(1, 2, 3)
89-
val signature = byteArrayOf(4, 5, 6)
90-
val challenge = "test-challenge"
91-
val loginUri = "https://example.com/login"
92-
val getSigningCertificate = true
93-
val origin = "https://example.com"
94-
95-
val authRequest =
96-
WebEidAuthRequest(
97-
challenge = challenge,
98-
loginUri = loginUri,
99-
getSigningCertificate = getSigningCertificate,
100-
origin = origin,
130+
fun webEidViewModel_handleSign_parsesSignUriAndSetsStateFlow() {
131+
val uri =
132+
Uri.parse(
133+
"web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0",
101134
)
102-
`when`(authService.authRequest).thenReturn(MutableStateFlow(authRequest))
103-
104-
val token = JSONObject().put("mock", "token")
105-
`when`(authService.buildAuthToken(cert, signature, challenge)).thenReturn(token)
106-
107-
viewModel = WebEidViewModel(authService)
108-
109-
viewModel.handleWebEidAuthResult(cert, signature, activity)
110-
111-
verify(authService).buildAuthToken(cert, signature, challenge)
112-
verify(activity).startActivity(any())
113-
verify(activity).finish()
135+
viewModel.handleSign(uri)
136+
val authRequest = viewModel.authRequest.value
137+
val signRequest = viewModel.signRequest.value
138+
assert(authRequest == null)
139+
assert(signRequest != null)
140+
assertEquals("https://example.com/response", signRequest?.responseUri)
141+
assertEquals("signing_certificate", signRequest?.signCertificate)
142+
assertEquals("hash", signRequest?.hash)
143+
assertEquals("hash_function", signRequest?.hashFunction)
114144
}
115145

116146
@Test
117-
fun handleWebEidAuthResult_doesNothing_whenChallengeMissing() {
118-
val cert = byteArrayOf(1)
119-
val signature = byteArrayOf(2)
120-
121-
val authRequest =
122-
WebEidAuthRequest(
123-
challenge = "",
124-
loginUri = "https://example.com",
125-
getSigningCertificate = true,
126-
origin = "https://example.com",
127-
)
128-
`when`(authService.authRequest).thenReturn(MutableStateFlow(authRequest))
129-
130-
viewModel = WebEidViewModel(authService)
131-
viewModel.handleWebEidAuthResult(cert, signature, activity)
132-
133-
verify(authService, never()).buildAuthToken(any(), any(), any())
134-
verify(activity, never()).startActivity(any())
147+
@OptIn(ExperimentalCoroutinesApi::class)
148+
fun webEidViewModel_handleWebEidAuthResult_buildsAuthTokenAndEmitsResponseEvent() {
149+
runTest(UnconfinedTestDispatcher()) {
150+
val cert = byteArrayOf(1, 2, 3)
151+
val signingCert = byteArrayOf(9, 9, 9)
152+
val signature = byteArrayOf(4, 5, 6)
153+
val uri =
154+
Uri.parse(
155+
"web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0",
156+
)
157+
whenever(authService.buildAuthToken(cert, signingCert, signature))
158+
.thenReturn(JSONObject().put("format", "web-eid:1.0"))
159+
val deferred =
160+
async {
161+
viewModel.relyingPartyResponseEvents.first()
162+
}
163+
viewModel.handleAuth(uri)
164+
viewModel.handleWebEidAuthResult(cert, signingCert, signature)
165+
166+
verify(authService).buildAuthToken(cert, signingCert, signature)
167+
val emittedUri = deferred.await()
168+
assert(emittedUri.toString().startsWith("https://example.com/response#"))
169+
assert(emittedUri.fragment != null)
170+
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
171+
val jsonPayload = JSONObject(decodedPayload)
172+
val authToken = jsonPayload.getJSONObject("auth-token")
173+
assertEquals("web-eid:1.0", authToken.getString("format"))
174+
}
135175
}
136176

137177
@Test
138-
fun handleWebEidAuthResult_doesNothing_whenLoginUriMissing() {
139-
val cert = byteArrayOf(1)
140-
val signature = byteArrayOf(2)
141-
142-
val authRequest =
143-
WebEidAuthRequest(
144-
challenge = "abc",
145-
loginUri = "",
146-
getSigningCertificate = true,
147-
origin = "https://example.com",
148-
)
149-
`when`(authService.authRequest).thenReturn(MutableStateFlow(authRequest))
150-
151-
viewModel = WebEidViewModel(authService)
152-
viewModel.handleWebEidAuthResult(cert, signature, activity)
153-
154-
verify(authService, never()).buildAuthToken(any(), any(), any())
155-
verify(activity, never()).startActivity(any())
178+
@OptIn(ExperimentalCoroutinesApi::class)
179+
fun webEidViewModel_handleWebEidAuthResult_emitErrorResponseEventWhenException() {
180+
runTest(UnconfinedTestDispatcher()) {
181+
val cert = byteArrayOf(1, 2, 3)
182+
val signingCert = byteArrayOf(9, 9, 9)
183+
val signature = byteArrayOf(4, 5, 6)
184+
val uri =
185+
Uri.parse(
186+
"web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0",
187+
)
188+
whenever(authService.buildAuthToken(cert, signingCert, signature))
189+
.thenThrow(RuntimeException("Test exception"))
190+
val deferred =
191+
async {
192+
viewModel.relyingPartyResponseEvents.first()
193+
}
194+
viewModel.handleAuth(uri)
195+
196+
viewModel.handleWebEidAuthResult(cert, signingCert, signature)
197+
198+
verify(authService).buildAuthToken(cert, signingCert, signature)
199+
val emittedUri = deferred.await()
200+
assert(emittedUri.toString().startsWith("https://example.com/response#"))
201+
assert(emittedUri.fragment != null)
202+
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
203+
val jsonPayload = JSONObject(decodedPayload)
204+
assertEquals("ERR_WEBEID_MOBILE_UNKNOWN_ERROR", jsonPayload.getString("code"))
205+
assertEquals("Unexpected error", jsonPayload.getString("message"))
206+
}
156207
}
157208
}

app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
package ee.ria.DigiDoc.fragment
44

5+
import android.app.Activity
6+
import android.content.Intent
57
import android.content.res.Configuration
68
import android.net.Uri
9+
import androidx.activity.compose.LocalActivity
710
import androidx.compose.foundation.background
811
import androidx.compose.foundation.layout.fillMaxSize
912
import androidx.compose.material3.MaterialTheme
@@ -21,7 +24,6 @@ import androidx.navigation.NavHostController
2124
import androidx.navigation.compose.rememberNavController
2225
import ee.ria.DigiDoc.fragment.screen.WebEidScreen
2326
import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme
24-
import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog
2527
import ee.ria.DigiDoc.viewmodel.WebEidViewModel
2628
import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel
2729
import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel
@@ -38,12 +40,27 @@ fun WebEidFragment(
3840
sharedContainerViewModel: SharedContainerViewModel = hiltViewModel(),
3941
sharedMenuViewModel: SharedMenuViewModel = hiltViewModel(),
4042
) {
43+
val activity = LocalActivity.current as Activity
44+
45+
LaunchedEffect(viewModel) {
46+
viewModel.relyingPartyResponseEvents.collect { responseUri ->
47+
val browserIntent =
48+
Intent(Intent.ACTION_VIEW, responseUri).apply {
49+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
50+
}
51+
activity.startActivity(browserIntent)
52+
activity.finishAndRemoveTask()
53+
}
54+
}
55+
4156
LaunchedEffect(webEidUri) {
4257
webEidUri?.let {
4358
when (it.host) {
4459
"auth" -> viewModel.handleAuth(it)
4560
"sign" -> viewModel.handleSign(it)
46-
else -> debugLog("WebEidFragment", "Unknown Web eID URI host: ${it.host}")
61+
else -> {
62+
viewModel.handleUnknown(it)
63+
}
4764
}
4865
}
4966
}

0 commit comments

Comments
 (0)