Skip to content
This repository was archived by the owner on Mar 9, 2022. It is now read-only.

Commit 981c840

Browse files
severinrudieKami
authored and
Kami
committed
New non-polling authentication flow.
- Fixes intermittent hanging during auth - Implements PKCE
1 parent 40c281d commit 981c840

39 files changed

+985
-518
lines changed

Diff for: app/build.gradle

+12-1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ android {
6464
viewBinding {
6565
enabled = true
6666
}
67+
68+
testOptions {
69+
unitTests {
70+
includeAndroidResources = true
71+
}
72+
}
6773
}
6874

6975
configurations {
@@ -102,13 +108,18 @@ dependencies {
102108
ktlint 'com.pinterest:ktlint:0.35.0'
103109

104110
testImplementation 'junit:junit:4.12'
105-
testImplementation 'org.robolectric:robolectric:3.4.2'
111+
testImplementation 'org.robolectric:robolectric:4.4'
112+
testImplementation 'androidx.test:core:1.3.0'
113+
testImplementation 'androidx.test.ext:junit:1.1.1'
114+
106115
testImplementation 'androidx.arch.core:core-testing:2.1.0'
107116
testImplementation 'com.google.truth:truth:1.0.1'
108117
testImplementation 'io.mockk:mockk:1.9'
109118
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.7'
110119
androidTestImplementation 'androidx.test:runner:1.2.0'
111120
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
121+
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
122+
androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0"
112123
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
113124
}
114125

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
5+
package org.mozilla.firefox.vpn
6+
7+
import android.content.Intent
8+
import android.net.Uri
9+
import androidx.test.espresso.Espresso.onView
10+
import androidx.test.espresso.assertion.ViewAssertions.matches
11+
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
12+
import androidx.test.espresso.matcher.ViewMatchers.withId
13+
import androidx.test.ext.junit.runners.AndroidJUnit4
14+
import androidx.test.filters.FlakyTest
15+
import androidx.test.filters.LargeTest
16+
import androidx.test.rule.ActivityTestRule
17+
import kotlinx.coroutines.CompletableDeferred
18+
import kotlinx.coroutines.delay
19+
import kotlinx.coroutines.runBlocking
20+
import kotlinx.coroutines.withTimeout
21+
import org.junit.Assert.assertNotNull
22+
import org.junit.Rule
23+
import org.junit.Test
24+
import org.junit.runner.RunWith
25+
import org.mozilla.firefox.vpn.splash.SplashActivity
26+
27+
@LargeTest
28+
@RunWith(AndroidJUnit4::class)
29+
class PkceRegressionTest {
30+
31+
@Rule
32+
@JvmField
33+
val splashActivityTestRule = ActivityTestRule(SplashActivity::class.java)
34+
35+
@Rule
36+
@JvmField
37+
val intentReceiverActivityTestRule = ActivityTestRule(IntentReceiverActivity::class.java)
38+
39+
@Test
40+
@FlakyTest
41+
/**
42+
* Flaky for two reasons. 1) this needs to touch the network, 2) Espresso tests are just flaky.
43+
*/
44+
fun pkce_regression_test() {
45+
// Simulate response from user logging in at:
46+
// "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net/api/v2/vpn/login/android?code_challenge=fx-O4_N_sfGrXxLgDkByfVNgZUPCI1s5PqWp8k1fG8M=&code_challenge_method=S256"
47+
val authCode = "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d"
48+
val intentUri = Uri.parse(
49+
"https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
50+
"/vpn/client/login/success?" +
51+
"code=$authCode" +
52+
"#Intent;category=android.intent.category.BROWSABLE;" +
53+
"launchFlags=0x14000000;" +
54+
"component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
55+
"i.org.chromium.chrome.browser.referrer_id=18;" +
56+
"S.com.android.browser.application_id=com.android.chrome;end"
57+
)
58+
val intent = Intent("android.intent.action.VIEW", intentUri)
59+
60+
val receivedCode = CompletableDeferred<AuthCode>()
61+
IntentReceiverActivity.setAuthCodeReceivedDeferred(receivedCode)
62+
63+
// IntentReceiverActivity launched with the above auth code
64+
intentReceiverActivityTestRule.launchActivity(intent)
65+
66+
// assert an auth code was received
67+
runBlocking {
68+
withTimeout(5_000) {
69+
assertNotNull(receivedCode.await())
70+
}
71+
}
72+
73+
runBlocking { delay(1_000) }
74+
75+
// assert onboarding screen still shown, login did not proceed
76+
onView(withId(R.id.auth_btn)).check(matches(isDisplayed()))
77+
}
78+
}

Diff for: app/src/main/AndroidManifest.xml

+14
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@
5050
android:screenOrientation="portrait"
5151
tools:ignore="LockedOrientationActivity" />
5252

53+
<activity android:name="org.mozilla.firefox.vpn.IntentReceiverActivity"
54+
android:screenOrientation="portrait"
55+
tools:ignore="LockedOrientationActivity">
56+
<intent-filter>
57+
<action android:name="android.intent.action.VIEW" />
58+
<category android:name="android.intent.category.DEFAULT" />
59+
<category android:name="android.intent.category.BROWSABLE" />
60+
<data
61+
android:scheme="mozilla-vpn"
62+
android:host="login"
63+
android:path="/success" />
64+
</intent-filter>
65+
</activity>
66+
5367
<service
5468
android:name="org.mozilla.firefox.vpn.main.vpn.GuardianVpnService"
5569
android:permission="android.permission.BIND_VPN_SERVICE">

Diff for: app/src/main/java/org/mozilla/firefox/vpn/GuardianApp.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import org.mozilla.firefox.vpn.report.ReportUtil
88
import org.mozilla.firefox.vpn.service.MockGuardianService
99
import org.mozilla.firefox.vpn.util.NotificationUtil
1010

11-
class GuardianApp : Application() {
11+
open class GuardianApp : Application() {
1212

1313
val coreComponent: CoreComponent by lazy {
1414
CoreComponentImpl(this)

Diff for: app/src/main/java/org/mozilla/firefox/vpn/GuardianComponent.kt

+1-4
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import org.mozilla.firefox.vpn.servers.domain.SelectedServerProvider
1111
import org.mozilla.firefox.vpn.service.GuardianService
1212
import org.mozilla.firefox.vpn.service.newInstance
1313
import org.mozilla.firefox.vpn.update.UpdateManager
14-
import org.mozilla.firefox.vpn.user.data.ReferralManager
1514
import org.mozilla.firefox.vpn.user.data.SessionManager
1615
import org.mozilla.firefox.vpn.user.data.UserRepository
1716

@@ -33,12 +32,10 @@ class GuardianComponentImpl(
3332

3433
private val sessionManager = SessionManager(prefs)
3534

36-
private val referralManager = ReferralManager(coreComponent.app.applicationContext, prefs)
37-
3835
var service = GuardianService.newInstance(sessionManager)
3936

4037
override val userRepo: UserRepository by lazy {
41-
UserRepository(service, sessionManager, referralManager)
38+
UserRepository(service, sessionManager)
4239
}
4340

4441
override val deviceRepo: DeviceRepository by lazy {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package org.mozilla.firefox.vpn
6+
7+
import android.content.Intent
8+
import android.os.Bundle
9+
import androidx.appcompat.app.AppCompatActivity
10+
import kotlinx.coroutines.CompletableDeferred
11+
import org.mozilla.firefox.vpn.ext.toCode
12+
import org.mozilla.firefox.vpn.user.domain.AuthToken
13+
14+
/**
15+
* Code that is included in the `verify` request that is used to retrieve an [AuthToken].
16+
*/
17+
typealias AuthCode = String
18+
19+
/**
20+
* Abstraction that handles incoming [Intent]s for use elsewhere in the app. After
21+
* processing an [Intent], this activity will finish itself.
22+
*/
23+
open class IntentReceiverActivity : AppCompatActivity() {
24+
25+
companion object {
26+
27+
/**
28+
* Set a [CompletableDeferred] that will be completed with an [AuthCode] the next
29+
* time one is received. Note that this is not guaranteed to ever complete.
30+
*
31+
* This is static because the alternative was a similarly bad practice: weaving
32+
* the reference through many layers of Android framework code.
33+
*/
34+
fun setAuthCodeReceivedDeferred(authCodeReceived: CompletableDeferred<AuthCode>) {
35+
_authCodeReceived = authCodeReceived
36+
}
37+
private var _authCodeReceived: CompletableDeferred<AuthCode> = CompletableDeferred()
38+
}
39+
40+
override fun onCreate(savedInstanceState: Bundle?) {
41+
super.onCreate(savedInstanceState)
42+
43+
intent?.data?.toCode()?.let { authCode ->
44+
_authCodeReceived.complete(authCode)
45+
}
46+
47+
finish()
48+
}
49+
}

Diff for: app/src/main/java/org/mozilla/firefox/vpn/MockedGuardianComponent.kt

+1-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import org.mozilla.firefox.vpn.servers.data.ServerRepository
1010
import org.mozilla.firefox.vpn.servers.domain.SelectedServerProvider
1111
import org.mozilla.firefox.vpn.service.MockGuardianService
1212
import org.mozilla.firefox.vpn.update.UpdateManager
13-
import org.mozilla.firefox.vpn.user.data.ReferralManager
1413
import org.mozilla.firefox.vpn.user.data.SessionManager
1514
import org.mozilla.firefox.vpn.user.data.UserRepository
1615

@@ -20,12 +19,10 @@ class MockedGuardianComponent(
2019

2120
private val sessionManager = SessionManager(prefs)
2221

23-
private val referralManager = ReferralManager(coreComponent.app.applicationContext, prefs)
24-
2522
var service = MockGuardianService()
2623

2724
override val userRepo: UserRepository by lazy {
28-
UserRepository(service, sessionManager, referralManager)
25+
UserRepository(service, sessionManager)
2926
}
3027

3128
override val deviceRepo: DeviceRepository by lazy {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package org.mozilla.firefox.vpn.crypto
6+
7+
import android.util.Base64
8+
import java.security.MessageDigest
9+
import java.security.SecureRandom
10+
import kotlin.random.Random
11+
import kotlin.random.asKotlinRandom
12+
13+
/**
14+
* PKCE code verifier.
15+
*/
16+
typealias CodeVerifier = String
17+
/**
18+
* PKCE code challenge.
19+
*/
20+
typealias CodeChallenge = String
21+
22+
private const val CHALLENGE_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP
23+
24+
/**
25+
* Contains utility functions for generating secure codes.
26+
*/
27+
object AuthCodeHelper {
28+
29+
/**
30+
* Returns a cryptographically random key. Sent during token request as part of PKCE.
31+
*/
32+
fun generateCodeVerifier(random: Random = SecureRandom().asKotlinRandom()): CodeVerifier {
33+
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', '.', '~')
34+
35+
val size = (43..128).random(random)
36+
37+
val sb = StringBuilder()
38+
for (i in 1..size) {
39+
sb.append(allowedChars.random(random))
40+
}
41+
return sb.toString()
42+
}
43+
44+
/**
45+
* Returns a SHA-256 encoded hash based on [verifier]. Used to retrieve a token as
46+
* part of PKCE.
47+
*/
48+
@Synchronized // MessageDigest is not thread-safe
49+
fun generateCodeChallenge(verifier: CodeVerifier): CodeChallenge {
50+
val bytes = verifier.toByteArray(Charsets.US_ASCII)
51+
val messageDigest = MessageDigest.getInstance("SHA-256")
52+
messageDigest.update(bytes, 0, bytes.size)
53+
val digest = messageDigest.digest()
54+
55+
return Base64.encodeToString(digest, CHALLENGE_FLAGS)
56+
}
57+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package org.mozilla.firefox.vpn.ext
6+
7+
import com.hadilq.liveevent.LiveEvent
8+
9+
/**
10+
* Adds an event on to the [LiveEvent]. This makes usage more idiomatic by letting
11+
* [LiveEvent]s be called as functions.
12+
*
13+
* EXAMPLE
14+
*
15+
* With this extension:
16+
* ```
17+
* promptLogin(info.loginUrl)
18+
* ```
19+
*
20+
* Without this extension:
21+
* ```
22+
* promptLogin.value = info.loginUrl
23+
* ```
24+
*/
25+
operator fun <T> LiveEvent<T>.invoke(value: T) {
26+
this.value = value
27+
}
28+
29+
/**
30+
* Adds an event on to the [LiveEvent]. This makes usage more idiomatic by letting
31+
* [LiveEvent]s be called as functions.
32+
*
33+
* EXAMPLE
34+
*
35+
* With this extension:
36+
* ```
37+
* promptLogin()
38+
* ```
39+
*
40+
* Without this extension:
41+
* ```
42+
* promptLogin.value = Unit
43+
* ```
44+
*/
45+
operator fun LiveEvent<Unit>.invoke() {
46+
this.value = Unit
47+
}

Diff for: app/src/main/java/org/mozilla/firefox/vpn/ext/Uri.kt

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package org.mozilla.firefox.vpn.ext
6+
7+
import android.net.Uri
8+
import org.mozilla.firefox.vpn.AuthCode
9+
10+
private const val CODE_QUERY_PARAM = "code"
11+
12+
private val ALLOWED_CODE_CHARS = (('0'..'9') + ('a'..'f')).toSet()
13+
14+
fun Uri.toCode(): AuthCode? {
15+
val code = getQueryParameter(CODE_QUERY_PARAM)
16+
17+
return if (
18+
code == null ||
19+
code.length != 80 ||
20+
code.any { char -> !ALLOWED_CODE_CHARS.contains(char) }
21+
) {
22+
null
23+
} else {
24+
code
25+
}
26+
}

Diff for: app/src/main/java/org/mozilla/firefox/vpn/main/settings/domain/SignOutUseCase.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class SignOutUseCase(
2727
CoroutineScope(coroutineContext + NonCancellable).launch {
2828
deviceRepository.getDevice()?.let {
2929
deviceRepository.unregisterDevice(it.device.pubKey)
30-
userRepository.removeUserInfo()
30+
userRepository.invalidateSession()
3131
}
3232
}
3333
}

0 commit comments

Comments
 (0)