CVE-2020-15679: Session Fixation (CWE-384)
value: An OAuth session fixation vulnerability existed in the VPN login flow, where an attacker could craft a custom login URL, convince a VPN user to login via that URL, and obtain authenticated access as that user. This issue is limited to cases where attacker and victim are sharing the same source IP and could allow the ability to view session states and disconnect VPN sessions. This vulnerability affects Mozilla VPN iOS 1.0.7 < (929), Mozilla VPN Windows < 1.2.2, and Mozilla VPN Android 1.1.0 < (1360).
CWE description: Authenticating a user, or otherwise establishing a new user session, without invalidating any existing session identifier gives an attacker the opportunity to steal authenticated sessions.
CVSS v3 base score: 7.6
Published: 2022-12-22T20:15Z
Repository: https://github.com/mozilla-mobile/guardian-vpn-android
Language: Kotlin
Files changed: app/build.gradle, app/src/androidTest/java/org/mozilla/firefox/vpn/PkceRegressionTest.kt, app/src/main/AndroidManifest.xml, app/src/main/java/org/mozilla/firefox/vpn/GuardianApp.kt, app/src/main/java/org/mozilla/firefox/vpn/GuardianComponent.kt, app/src/main/java/org/mozilla/firefox/vpn/IntentReceiverActivity.kt, app/src/main/java/org/mozilla/firefox/vpn/MockedGuardianComponent.kt, app/src/main/java/org/mozilla/firefox/vpn/crypto/AuthCodeHelper.kt, app/src/main/java/org/mozilla/firefox/vpn/ext/LiveEvent.kt, app/src/main/java/org/mozilla/firefox/vpn/ext/Uri.kt, app/src/main/java/org/mozilla/firefox/vpn/main/settings/domain/SignOutUseCase.kt, app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingActivity.kt, app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingComponent.kt, app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingViewModel.kt, app/src/main/java/org/mozilla/firefox/vpn/report/Report.kt, app/src/main/java/org/mozilla/firefox/vpn/service/GuardianService.kt, app/src/main/java/org/mozilla/firefox/vpn/service/MockGuardianService.kt, app/src/main/java/org/mozilla/firefox/vpn/user/data/SessionManager.kt, app/src/main/java/org/mozilla/firefox/vpn/user/data/UserRepository.kt, app/src/main/java/org/mozilla/firefox/vpn/user/domain/CreateUserUseCase.kt, app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetAuthCodeUseCase.kt, app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetTokenUseCase.kt, app/src/main/java/org/mozilla/firefox/vpn/user/domain/LogoutUseCase.kt, app/src/main/java/org/mozilla/firefox/vpn/user/domain/SaveAuthTokenUseCase.kt, app/src/main/java/org/mozilla/firefox/vpn/util/CustomTabUtil.kt, app/src/main/java/org/mozilla/firefox/vpn/util/ResourceUtil.kt, app/src/main/java/org/mozilla/firefox/vpn/util/Result.kt, app/src/test/java/org/mozilla/firefox/vpn/IntentReceiverActivityTest.kt, app/src/test/java/org/mozilla/firefox/vpn/TestConstants.kt, app/src/test/java/org/mozilla/firefox/vpn/TestGuardianApp.kt, app/src/test/java/org/mozilla/firefox/vpn/crypto/AuthCodeHelperTest.kt, app/src/test/java/org/mozilla/firefox/vpn/servers/domain/SetSelectedServerUseCaseTest.kt
Commit date: 2020-10-06 16:31:27 +0800
Commit message: New non-polling authentication flow.

- Fixes intermittent hanging during auth
- Implements PKCE

Diff: diff --git a/app/build.gradle b/app/build.gradle
index bc29c85..fc32004 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -62,10 +62,16 @@ android {
     }
 
     viewBinding {
         enabled = true
     }
+
+    testOptions {
+        unitTests {
+            includeAndroidResources = true
+        }
+    }
 }
 
 configurations {
     ktlint
 }
@@ -100,17 +106,22 @@ dependencies {
     implementation 'com.airbnb.android:lottie:3.2.2'
     implementation 'com.github.bosphere.android-filelogger:filelogger:1.0.7'
     ktlint 'com.pinterest:ktlint:0.35.0'
 
     testImplementation 'junit:junit:4.12'
-    testImplementation 'org.robolectric:robolectric:3.4.2'
+    testImplementation 'org.robolectric:robolectric:4.4'
+    testImplementation 'androidx.test:core:1.3.0'
+    testImplementation 'androidx.test.ext:junit:1.1.1'
+
     testImplementation 'androidx.arch.core:core-testing:2.1.0'
     testImplementation 'com.google.truth:truth:1.0.1'
     testImplementation 'io.mockk:mockk:1.9'
     testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.7'
     androidTestImplementation 'androidx.test:runner:1.2.0'
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
+    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
+    androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0"
     androidTestImplementation 'androidx.test.ext:junit:1.1.1'
 }
 
 task ktlint(type: JavaExec, group: "verification") {
     description = "Check Kotlin code style."
diff --git a/app/src/androidTest/java/org/mozilla/firefox/vpn/PkceRegressionTest.kt b/app/src/androidTest/java/org/mozilla/firefox/vpn/PkceRegressionTest.kt
new file mode 100644
index 0000000..5e429b8
--- /dev/null
+++ b/app/src/androidTest/java/org/mozilla/firefox/vpn/PkceRegressionTest.kt
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn
+
+import android.content.Intent
+import android.net.Uri
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.FlakyTest
+import androidx.test.filters.LargeTest
+import androidx.test.rule.ActivityTestRule
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Assert.assertNotNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.firefox.vpn.splash.SplashActivity
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class PkceRegressionTest {
+
+    @Rule
+    @JvmField
+    val splashActivityTestRule = ActivityTestRule(SplashActivity::class.java)
+
+    @Rule
+    @JvmField
+    val intentReceiverActivityTestRule = ActivityTestRule(IntentReceiverActivity::class.java)
+
+    @Test
+    @FlakyTest
+    /**
+     * Flaky for two reasons.  1) this needs to touch the network, 2) Espresso tests are just flaky.
+     */
+    fun pkce_regression_test() {
+        // Simulate response from user logging in at:
+        // "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net/api/v2/vpn/login/android?code_challenge=fx-O4_N_sfGrXxLgDkByfVNgZUPCI1s5PqWp8k1fG8M=&code_challenge_method=S256"
+        val authCode = "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d"
+        val intentUri = Uri.parse(
+            "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
+                    "/vpn/client/login/success?" +
+                    "code=$authCode" +
+                    "#Intent;category=android.intent.category.BROWSABLE;" +
+                    "launchFlags=0x14000000;" +
+                    "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
+                    "i.org.chromium.chrome.browser.referrer_id=18;" +
+                    "S.com.android.browser.application_id=com.android.chrome;end"
+        )
+        val intent = Intent("android.intent.action.VIEW", intentUri)
+
+        val receivedCode = CompletableDeferred<AuthCode>()
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(receivedCode)
+
+        // IntentReceiverActivity launched with the above auth code
+        intentReceiverActivityTestRule.launchActivity(intent)
+
+        // assert an auth code was received
+        runBlocking {
+            withTimeout(5_000) {
+                assertNotNull(receivedCode.await())
+            }
+        }
+
+        runBlocking { delay(1_000) }
+
+        // assert onboarding screen still shown, login did not proceed
+        onView(withId(R.id.auth_btn)).check(matches(isDisplayed()))
+    }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7c4de9b..61ecbfa 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -48,10 +48,24 @@
         <activity android:name="org.mozilla.firefox.vpn.ui.UiDemoActivity"
             android:exported="false"
             android:screenOrientation="portrait"
             tools:ignore="LockedOrientationActivity" />
 
+        <activity android:name="org.mozilla.firefox.vpn.IntentReceiverActivity"
+            android:screenOrientation="portrait"
+            tools:ignore="LockedOrientationActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+                <data
+                    android:scheme="mozilla-vpn"
+                    android:host="login"
+                    android:path="/success" />
+            </intent-filter>
+        </activity>
+
         <service
             android:name="org.mozilla.firefox.vpn.main.vpn.GuardianVpnService"
             android:permission="android.permission.BIND_VPN_SERVICE">
             <intent-filter>
                 <action android:name="android.net.VpnService" />
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/GuardianApp.kt b/app/src/main/java/org/mozilla/firefox/vpn/GuardianApp.kt
index 2252844..72e85b7 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/GuardianApp.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/GuardianApp.kt
@@ -6,11 +6,11 @@ import org.mozilla.firefox.vpn.main.vpn.MockVpnManager
 import org.mozilla.firefox.vpn.main.vpn.VpnNotificationSender
 import org.mozilla.firefox.vpn.report.ReportUtil
 import org.mozilla.firefox.vpn.service.MockGuardianService
 import org.mozilla.firefox.vpn.util.NotificationUtil
 
-class GuardianApp : Application() {
+open class GuardianApp : Application() {
 
     val coreComponent: CoreComponent by lazy {
         CoreComponentImpl(this)
     }
 
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/GuardianComponent.kt b/app/src/main/java/org/mozilla/firefox/vpn/GuardianComponent.kt
index d3e14d0..8af66c6 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/GuardianComponent.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/GuardianComponent.kt
@@ -9,11 +9,10 @@ import org.mozilla.firefox.vpn.main.vpn.VpnManager
 import org.mozilla.firefox.vpn.servers.data.ServerRepository
 import org.mozilla.firefox.vpn.servers.domain.SelectedServerProvider
 import org.mozilla.firefox.vpn.service.GuardianService
 import org.mozilla.firefox.vpn.service.newInstance
 import org.mozilla.firefox.vpn.update.UpdateManager
-import org.mozilla.firefox.vpn.user.data.ReferralManager
 import org.mozilla.firefox.vpn.user.data.SessionManager
 import org.mozilla.firefox.vpn.user.data.UserRepository
 
 interface GuardianComponent {
     val userRepo: UserRepository
@@ -31,16 +30,14 @@ class GuardianComponentImpl(
     private val coreComponent: CoreComponent
 ) : GuardianComponent, CoreComponent by coreComponent {
 
     private val sessionManager = SessionManager(prefs)
 
-    private val referralManager = ReferralManager(coreComponent.app.applicationContext, prefs)
-
     var service = GuardianService.newInstance(sessionManager)
 
     override val userRepo: UserRepository by lazy {
-        UserRepository(service, sessionManager, referralManager)
+        UserRepository(service, sessionManager)
     }
 
     override val deviceRepo: DeviceRepository by lazy {
         DeviceRepository(service, prefs)
     }
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/IntentReceiverActivity.kt b/app/src/main/java/org/mozilla/firefox/vpn/IntentReceiverActivity.kt
new file mode 100644
index 0000000..aa803dd
--- /dev/null
+++ b/app/src/main/java/org/mozilla/firefox/vpn/IntentReceiverActivity.kt
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import kotlinx.coroutines.CompletableDeferred
+import org.mozilla.firefox.vpn.ext.toCode
+import org.mozilla.firefox.vpn.user.domain.AuthToken
+
+/**
+ * Code that is included in the `verify` request that is used to retrieve an [AuthToken].
+ */
+typealias AuthCode = String
+
+/**
+ * Abstraction that handles incoming [Intent]s for use elsewhere in the app.  After
+ * processing an [Intent], this activity will finish itself.
+ */
+open class IntentReceiverActivity : AppCompatActivity() {
+
+    companion object {
+
+        /**
+         * Set a [CompletableDeferred] that will be completed with an [AuthCode] the next
+         * time one is received.  Note that this is not guaranteed to ever complete.
+         *
+         * This is static because the alternative was a similarly bad practice: weaving
+         * the reference through many layers of Android framework code.
+         */
+        fun setAuthCodeReceivedDeferred(authCodeReceived: CompletableDeferred<AuthCode>) {
+            _authCodeReceived = authCodeReceived
+        }
+        private var _authCodeReceived: CompletableDeferred<AuthCode> = CompletableDeferred()
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        intent?.data?.toCode()?.let { authCode ->
+            _authCodeReceived.complete(authCode)
+        }
+
+        finish()
+    }
+}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/MockedGuardianComponent.kt b/app/src/main/java/org/mozilla/firefox/vpn/MockedGuardianComponent.kt
index e25db8b..f5a1cb2 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/MockedGuardianComponent.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/MockedGuardianComponent.kt
@@ -8,26 +8,23 @@ import org.mozilla.firefox.vpn.main.vpn.MockVpnManager
 import org.mozilla.firefox.vpn.main.vpn.VpnManager
 import org.mozilla.firefox.vpn.servers.data.ServerRepository
 import org.mozilla.firefox.vpn.servers.domain.SelectedServerProvider
 import org.mozilla.firefox.vpn.service.MockGuardianService
 import org.mozilla.firefox.vpn.update.UpdateManager
-import org.mozilla.firefox.vpn.user.data.ReferralManager
 import org.mozilla.firefox.vpn.user.data.SessionManager
 import org.mozilla.firefox.vpn.user.data.UserRepository
 
 class MockedGuardianComponent(
     private val coreComponent: CoreComponent
 ) : GuardianComponent, CoreComponent by coreComponent {
 
     private val sessionManager = SessionManager(prefs)
 
-    private val referralManager = ReferralManager(coreComponent.app.applicationContext, prefs)
-
     var service = MockGuardianService()
 
     override val userRepo: UserRepository by lazy {
-        UserRepository(service, sessionManager, referralManager)
+        UserRepository(service, sessionManager)
     }
 
     override val deviceRepo: DeviceRepository by lazy {
         DeviceRepository(service, prefs)
     }
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/crypto/AuthCodeHelper.kt b/app/src/main/java/org/mozilla/firefox/vpn/crypto/AuthCodeHelper.kt
new file mode 100644
index 0000000..de320f2
--- /dev/null
+++ b/app/src/main/java/org/mozilla/firefox/vpn/crypto/AuthCodeHelper.kt
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.crypto
+
+import android.util.Base64
+import java.security.MessageDigest
+import java.security.SecureRandom
+import kotlin.random.Random
+import kotlin.random.asKotlinRandom
+
+/**
+ * PKCE code verifier.
+ */
+typealias CodeVerifier = String
+/**
+ * PKCE code challenge.
+ */
+typealias CodeChallenge = String
+
+private const val CHALLENGE_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP
+
+/**
+ * Contains utility functions for generating secure codes.
+ */
+object AuthCodeHelper {
+
+    /**
+     * Returns a cryptographically random key. Sent during token request as part of PKCE.
+     */
+    fun generateCodeVerifier(random: Random = SecureRandom().asKotlinRandom()): CodeVerifier {
+        val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', '.', '~')
+
+        val size = (43..128).random(random)
+
+        val sb = StringBuilder()
+        for (i in 1..size) {
+            sb.append(allowedChars.random(random))
+        }
+        return sb.toString()
+    }
+
+    /**
+     * Returns a SHA-256 encoded hash based on [verifier]. Used to retrieve a token as
+     * part of PKCE.
+     */
+    @Synchronized // MessageDigest is not thread-safe
+    fun generateCodeChallenge(verifier: CodeVerifier): CodeChallenge {
+        val bytes = verifier.toByteArray(Charsets.US_ASCII)
+        val messageDigest = MessageDigest.getInstance("SHA-256")
+        messageDigest.update(bytes, 0, bytes.size)
+        val digest = messageDigest.digest()
+
+        return Base64.encodeToString(digest, CHALLENGE_FLAGS)
+    }
+}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/ext/LiveEvent.kt b/app/src/main/java/org/mozilla/firefox/vpn/ext/LiveEvent.kt
new file mode 100644
index 0000000..b6ba6e5
--- /dev/null
+++ b/app/src/main/java/org/mozilla/firefox/vpn/ext/LiveEvent.kt
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.ext
+
+import com.hadilq.liveevent.LiveEvent
+
+/**
+ * Adds an event on to the [LiveEvent]. This makes usage more idiomatic by letting
+ * [LiveEvent]s be called as functions.
+ *
+ * EXAMPLE
+ *
+ * With this extension:
+ * ```
+ * promptLogin(info.loginUrl)
+ * ```
+ *
+ * Without this extension:
+ * ```
+ * promptLogin.value = info.loginUrl
+ * ```
+ */
+operator fun <T> LiveEvent<T>.invoke(value: T) {
+    this.value = value
+}
+
+/**
+ * Adds an event on to the [LiveEvent]. This makes usage more idiomatic by letting
+ * [LiveEvent]s be called as functions.
+ *
+ * EXAMPLE
+ *
+ * With this extension:
+ * ```
+ * promptLogin()
+ * ```
+ *
+ * Without this extension:
+ * ```
+ * promptLogin.value = Unit
+ * ```
+ */
+operator fun LiveEvent<Unit>.invoke() {
+    this.value = Unit
+}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/ext/Uri.kt b/app/src/main/java/org/mozilla/firefox/vpn/ext/Uri.kt
new file mode 100644
index 0000000..23c0247
--- /dev/null
+++ b/app/src/main/java/org/mozilla/firefox/vpn/ext/Uri.kt
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.ext
+
+import android.net.Uri
+import org.mozilla.firefox.vpn.AuthCode
+
+private const val CODE_QUERY_PARAM = "code"
+
+private val ALLOWED_CODE_CHARS = (('0'..'9') + ('a'..'f')).toSet()
+
+fun Uri.toCode(): AuthCode? {
+    val code = getQueryParameter(CODE_QUERY_PARAM)
+
+    return if (
+        code == null ||
+        code.length != 80 ||
+        code.any { char -> !ALLOWED_CODE_CHARS.contains(char) }
+    ) {
+        null
+    } else {
+        code
+    }
+}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/main/settings/domain/SignOutUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/main/settings/domain/SignOutUseCase.kt
index da516d0..a3749a1 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/main/settings/domain/SignOutUseCase.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/main/settings/domain/SignOutUseCase.kt
@@ -25,11 +25,11 @@ class SignOutUseCase(
 
     private suspend fun removeDeviceAsync() {
         CoroutineScope(coroutineContext + NonCancellable).launch {
             deviceRepository.getDevice()?.let {
                 deviceRepository.unregisterDevice(it.device.pubKey)
-                userRepository.removeUserInfo()
+                userRepository.invalidateSession()
             }
         }
     }
 
     companion object {
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingActivity.kt b/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingActivity.kt
index 1cb33c1..00a55da 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingActivity.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingActivity.kt
@@ -1,11 +1,10 @@
 package org.mozilla.firefox.vpn.onboarding
 
 import android.content.Context
 import android.content.Intent
 import android.os.Bundle
-import android.view.View
 import android.widget.Toast
 import androidx.appcompat.app.AppCompatActivity
 import androidx.lifecycle.Observer
 import org.mozilla.firefox.vpn.coreComponent
 import org.mozilla.firefox.vpn.databinding.ActivityOnboardingBinding
@@ -38,15 +37,15 @@ class OnboardingActivity : AppCompatActivity() {
         setContentView(binding.root)
         customTab = LoginCustomTab(this)
 
         viewModel.isLoggedOut = isLoggedOut()
 
-        viewModel.toast.observe(this, Observer {
+        viewModel.bus.toast.observe(this, Observer {
             Toast.makeText(this, it.resolve(this), Toast.LENGTH_SHORT).show()
         })
 
-        viewModel.showLoggedOutMessage.observe(this, Observer {
+        viewModel.bus.showLoggedOutMessage.observe(this, Observer {
             val message = it.resolve(this) ?: return@Observer
             val sb = GuardianSnackbar.make(
                 binding.container,
                 InAppNotificationView.Config(
                     style = InAppNotificationView.Style.Red,
@@ -55,37 +54,27 @@ class OnboardingActivity : AppCompatActivity() {
                 GuardianSnackbar.LENGTH_LONG
             )
             sb.show()
         })
 
-        viewModel.promptLogin.observe(this, Observer {
+        viewModel.bus.promptLogin.observe(this, Observer {
             customTab.launchUrl(it)
         })
 
-        viewModel.launchMainPage.observerUntilOnDestroy(this, Observer {
+        viewModel.bus.launchMainPage.observerUntilOnDestroy(this, Observer {
             if (redirectHandled) {
                 handlePreviousFailedRedirection()
             } else {
-                redirectHandled = true
                 redirectToMainPage()
             }
         })
 
-        viewModel.uiModel.observe(this, Observer {
-            binding.loadingView.visibility = if (it.isLoading) {
-                View.VISIBLE
-            } else {
-                View.GONE
-            }
+        viewModel.bus.closeTabsToOnboarding.observerUntilOnDestroy(this, Observer {
+            closeCustomTabsToOnboarding()
         })
     }
 
-    override fun onResume() {
-        super.onResume()
-        viewModel.resumeLoginFlow()
-    }
-
     override fun onNewIntent(intent: Intent?) {
         super.onNewIntent(intent)
         if (redirectHandled) {
             startMainActivity()
         }
@@ -104,10 +93,20 @@ class OnboardingActivity : AppCompatActivity() {
         // main activity, he will see the custom tab, which is undesired.
 
         // To close custom tab first, current approach launches OnboardingActivity again with
         // FLAG_ACTIVITY_CLEAR_TOP to clear the custom tab, and since onNewIntent() will be called
         // in this case, we launch main activity there
+        redirectHandled = true
+
+        startActivity(getStartIntent(this@OnboardingActivity).apply {
+            addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+        })
+    }
+
+    private fun closeCustomTabsToOnboarding() {
+        // See #redirectToMainPage for explanation
+        redirectHandled = false
 
         startActivity(getStartIntent(this@OnboardingActivity).apply {
             addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
         })
     }
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingComponent.kt b/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingComponent.kt
index 44a77b1..a1002d3 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingComponent.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingComponent.kt
@@ -1,16 +1,16 @@
 package org.mozilla.firefox.vpn.onboarding
 
+import com.hadilq.liveevent.LiveEvent
 import org.mozilla.firefox.vpn.CoreComponent
 import org.mozilla.firefox.vpn.GuardianComponent
 import org.mozilla.firefox.vpn.device.domain.AddDeviceUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.ClearPendingLoginInfoUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.GetPendingLoginInfoUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.SetPendingLoginInfoUseCase
 import org.mozilla.firefox.vpn.user.domain.CreateUserUseCase
-import org.mozilla.firefox.vpn.user.domain.GetLoginInfoUseCase
-import org.mozilla.firefox.vpn.user.domain.VerifyLoginUseCase
+import org.mozilla.firefox.vpn.user.domain.GetAuthCodeUseCase
+import org.mozilla.firefox.vpn.user.domain.GetTokenUseCase
+import org.mozilla.firefox.vpn.user.domain.SaveAuthTokenUseCase
+import org.mozilla.firefox.vpn.util.StringResource
 
 interface OnboardingComponent {
     val viewModel: OnboardingViewModel
 }
 
@@ -18,15 +18,29 @@ class OnboardingComponentImpl(
     private val coreComponent: CoreComponent,
     private val guardianComponent: GuardianComponent
 ) : OnboardingComponent, GuardianComponent by guardianComponent, CoreComponent by coreComponent {
 
     override val viewModel: OnboardingViewModel
-        get() = OnboardingViewModel(
-            loginInfoUseCase = GetLoginInfoUseCase(userRepo),
-            verifyLoginUseCase = VerifyLoginUseCase(userRepo),
-            createUserUseCase = CreateUserUseCase(userRepo, userStateResolver),
-            addDeviceUseCase = AddDeviceUseCase(deviceRepo, userRepo),
-            setPendingLoginInfoUseCase = SetPendingLoginInfoUseCase(prefs),
-            getPendingLoginInfoUseCase = GetPendingLoginInfoUseCase(prefs),
-            clearPendingLoginInfoUseCase = ClearPendingLoginInfoUseCase(prefs)
-        )
+        get() {
+            val bus = Bus()
+            return OnboardingViewModel(
+                bus = bus,
+                createUserUseCase = CreateUserUseCase(userRepo, userStateResolver),
+                addDeviceUseCase = AddDeviceUseCase(deviceRepo, userRepo),
+                getTokenUseCase = GetTokenUseCase(userRepo, GetAuthCodeUseCase(bus)),
+                saveAuthTokenUseCase = SaveAuthTokenUseCase(userRepo)
+            )
+        }
+}
+
+/**
+ * Used to pass data to the presentation layer.
+ *
+ * Not technically a bus, but CollectionOfChannels is a bad name.
+ */
+class Bus {
+    val toast = LiveEvent<StringResource>()
+    val showLoggedOutMessage = LiveEvent<StringResource>()
+    val launchMainPage = LiveEvent<Unit>()
+    val closeTabsToOnboarding = LiveEvent<Unit>()
+    val promptLogin = LiveEvent<String>()
 }
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingViewModel.kt b/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingViewModel.kt
index 6a47650..f212953 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingViewModel.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/onboarding/OnboardingViewModel.kt
@@ -1,153 +1,75 @@
 package org.mozilla.firefox.vpn.onboarding
 
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
-import com.hadilq.liveevent.LiveEvent
-import java.util.concurrent.atomic.AtomicBoolean
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelAndJoin
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.withContext
 import org.mozilla.firefox.vpn.R
 import org.mozilla.firefox.vpn.device.domain.AddDeviceUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.ClearPendingLoginInfoUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.GetPendingLoginInfoUseCase
-import org.mozilla.firefox.vpn.onboarding.domain.SetPendingLoginInfoUseCase
-import org.mozilla.firefox.vpn.service.LoginInfo
-import org.mozilla.firefox.vpn.service.LoginResult
 import org.mozilla.firefox.vpn.user.domain.CreateUserUseCase
-import org.mozilla.firefox.vpn.user.domain.GetLoginInfoUseCase
-import org.mozilla.firefox.vpn.user.domain.VerifyLoginUseCase
+import org.mozilla.firefox.vpn.user.domain.GetTokenUseCase
+import org.mozilla.firefox.vpn.user.domain.SaveAuthTokenUseCase
 import org.mozilla.firefox.vpn.util.GLog
 import org.mozilla.firefox.vpn.util.Result
 import org.mozilla.firefox.vpn.util.StringResource
-import org.mozilla.firefox.vpn.util.addCompletionHandler
 import org.mozilla.firefox.vpn.util.onError
-import org.mozilla.firefox.vpn.util.onSuccess
 
 class OnboardingViewModel(
-    private val loginInfoUseCase: GetLoginInfoUseCase,
-    private val verifyLoginUseCase: VerifyLoginUseCase,
+    val bus: Bus,
     private val createUserUseCase: CreateUserUseCase,
     private val addDeviceUseCase: AddDeviceUseCase,
-    private val setPendingLoginInfoUseCase: SetPendingLoginInfoUseCase,
-    private val getPendingLoginInfoUseCase: GetPendingLoginInfoUseCase,
-    private val clearPendingLoginInfoUseCase: ClearPendingLoginInfoUseCase
+    private val getTokenUseCase: GetTokenUseCase,
+    private val saveAuthTokenUseCase: SaveAuthTokenUseCase
 ) : ViewModel() {
 
-    val toast = LiveEvent<StringResource>()
-    val showLoggedOutMessage = LiveEvent<StringResource>()
-    val launchMainPage = LiveEvent<Unit>()
-    val promptLogin = LiveEvent<String>()
-
-    private val _uiModel = MutableLiveData<UiModel>()
-    val uiModel: LiveData<UiModel> = _uiModel
-
-    // See #153 & #204 before modifying the login flow
-    private val loginSuccess = AtomicBoolean(false)
-
-    private var verificationJob: Job? = null
-
     var isLoggedOut: Boolean = false
         set(value) {
             if (value) {
-                showLoggedOutMessage.postValue(StringResource(R.string.onboarding_logged_out))
+                bus.showLoggedOutMessage.postValue(StringResource(R.string.onboarding_logged_out))
             }
             field = value
         }
 
-    fun startLoginFlow() {
-        viewModelScope.launch(Dispatchers.Main) { getLoginInfo().onSuccess { login(it) } }
-    }
-
-    fun resumeLoginFlow() {
-        viewModelScope.launch(Dispatchers.Main.immediate) {
-            _uiModel.value = UiModel(true)
-
-            if (loginSuccess.get()) {
-                // Already being verified by the verifyLoginAsync()
-                gotoMainPage()
-            } else {
-                getPendingLoginInfoUseCase()
-                    ?.let { verifyLoginOnce(it) }
-                    ?: run { _uiModel.value = UiModel(false) }
-            }
-        }
-    }
-
-    override fun onCleared() {
-        if (loginSuccess.get()) {
-            clearPendingLoginInfoUseCase()
-        }
-        super.onCleared()
-    }
-
-    private fun cancelLoginFlow() {
-        verificationJob?.cancel("verification cancelled")
-    }
-
-    private suspend fun getLoginInfo() = withContext(Dispatchers.IO) {
-        loginInfoUseCase()
-    }
-
-    private suspend fun login(info: LoginInfo) = withContext(Dispatchers.Main) {
-        // Save login info persistently so it survives even if the view(activity) is recycled
-        setPendingLoginInfoUseCase(info)
+    private var loginJob: Job = Job()
 
-        promptLogin.value = info.loginUrl
-        verificationJob = verifyLoginPeriodically(info).addCompletionHandler { verificationJob = null }
-    }
-
-    private suspend fun verifyLogin(info: LoginInfo, retry: Boolean = false) = withContext(Dispatchers.IO) {
-        when (val result = verifyLoginUseCase(info, retry)) {
-            is Result.Success -> onLoginSuccess(result.value)
-            is Result.Fail -> {
-                _uiModel.postValue(UiModel(false))
-                cancelLoginFlow()
-                GLog.d(TAG, "verify login failed: ${result.exception}")
+    fun startLoginFlow() {
+        // Some edge cases (e.g., rapidly clicking the "get started" button) can cause this
+        // method to be repeatedly called.  We cancel any previous attempts here, so that
+        // at most one is running at any time.
+        runBlocking { loginJob.cancelAndJoin() }
+        loginJob = viewModelScope.launch(Dispatchers.IO) {
+
+            when (val token = getTokenUseCase(scope = this)) {
+                is Result.Success -> {
+                    saveAuthTokenUseCase(token.value)
+
+                    setupNewUser()
+
+                    bus.launchMainPage.postValue(Unit)
+                }
+                is Result.Fail -> {
+                    // TODO this would be a good place to show an error toast, if we had
+                    // copy. This at least prevents them from watching an endless progress
+                    // bar on failure.
+                    bus.closeTabsToOnboarding.postValue(Unit)
+                    GLog.d(TAG, "verify login failed: ${token.exception}")
+                }
             }
         }
     }
 
-    private suspend fun verifyLoginOnce(info: LoginInfo) = withContext(Dispatchers.IO) {
-        verifyLogin(info, false)
-    }
-
-    private suspend fun verifyLoginPeriodically(info: LoginInfo) = viewModelScope.launch(Dispatchers.IO) {
-        verifyLogin(info, true)
-    }
-
-    private suspend fun onLoginSuccess(
-        loginResult: LoginResult
-    ) = withContext(Dispatchers.Main) {
-        if (loginSuccess.compareAndSet(false, true)) {
-            setupNewUser(loginResult)
-        }
-
-        gotoMainPage()
-    }
-
-    private suspend fun gotoMainPage() = withContext(Dispatchers.Main.immediate) {
-        launchMainPage.value = Unit
-    }
-
-    private suspend fun setupNewUser(
-        loginResult: LoginResult
-    ) = withContext(Dispatchers.IO) {
-        createUserUseCase(loginResult)
-
+    private suspend fun setupNewUser() = withContext(Dispatchers.IO) {
+        // Note: `createUserUseCase` and `AddDeviceUseCase` are temporally coupled
+        createUserUseCase()
         addDeviceUseCase()
             .onError { GLog.d(TAG, "add device failed: $it") }
     }
 
-    data class UiModel(
-        val isLoading: Boolean
-    )
-
     companion object {
         private const val TAG = "OnboardingViewModel"
     }
 }
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/domain/ClearPendingLoginInfoUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/onboarding/domain/ClearPendingLoginInfoUseCase.kt
deleted file mode 100644
index e522863..0000000
--- a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/domain/ClearPendingLoginInfoUseCase.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.mozilla.firefox.vpn.onboarding.domain
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-
-class ClearPendingLoginInfoUseCase(
-    private val prefs: SharedPreferences
-) {
-
-    operator fun invoke() {
-        prefs.edit { remove(SetPendingLoginInfoUseCase.PREF_PENDING_LOGIN_INFO) }
-    }
-}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/domain/GetPendingLoginInfoUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/onboarding/domain/GetPendingLoginInfoUseCase.kt
deleted file mode 100644
index 50eaa11..0000000
--- a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/domain/GetPendingLoginInfoUseCase.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package org.mozilla.firefox.vpn.onboarding.domain
-
-import android.content.SharedPreferences
-import com.google.gson.Gson
-import org.mozilla.firefox.vpn.service.LoginInfo
-
-class GetPendingLoginInfoUseCase(
-    private val prefs: SharedPreferences
-) {
-
-    operator fun invoke(): LoginInfo? {
-        return prefs.getString(SetPendingLoginInfoUseCase.PREF_PENDING_LOGIN_INFO, null)
-            ?.let {
-                try {
-                    Gson().fromJson(it, LoginInfo::class.java)
-                } catch (e: Exception) {
-                    null
-                }
-            }
-    }
-}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/domain/SetPendingLoginInfoUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/onboarding/domain/SetPendingLoginInfoUseCase.kt
deleted file mode 100644
index 3dc1aee..0000000
--- a/app/src/main/java/org/mozilla/firefox/vpn/onboarding/domain/SetPendingLoginInfoUseCase.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.mozilla.firefox.vpn.onboarding.domain
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import com.google.gson.Gson
-import org.mozilla.firefox.vpn.service.LoginInfo
-
-class SetPendingLoginInfoUseCase(
-    private val prefs: SharedPreferences
-) {
-
-    operator fun invoke(info: LoginInfo) {
-        prefs.edit { putString(PREF_PENDING_LOGIN_INFO, Gson().toJson(info)) }
-    }
-
-    companion object {
-        const val PREF_PENDING_LOGIN_INFO = "pending_login_info"
-    }
-}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/report/Report.kt b/app/src/main/java/org/mozilla/firefox/vpn/report/Report.kt
index 67876e6..8ec4781 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/report/Report.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/report/Report.kt
@@ -9,19 +9,16 @@ import androidx.core.content.FileProvider
 import com.bosphere.filelogger.FL
 import com.bosphere.filelogger.FLConfig
 import com.bosphere.filelogger.FLConst
 import java.io.File
 import org.mozilla.firefox.vpn.service.DeviceInfo
-import org.mozilla.firefox.vpn.service.LoginInfo
-import org.mozilla.firefox.vpn.service.LoginResult
+import org.mozilla.firefox.vpn.service.User
 import org.mozilla.firefox.vpn.user.data.UserInfo
 import org.mozilla.firefox.vpn.user.data.isDeviceLimitReached
 import org.mozilla.firefox.vpn.user.data.isSubscribed
 import org.mozilla.firefox.vpn.util.GLog
 import org.mozilla.firefox.vpn.util.Result
-import org.mozilla.firefox.vpn.util.TimeFormat
-import org.mozilla.firefox.vpn.util.TimeUtil
 import org.mozilla.firefox.vpn.util.onError
 import org.mozilla.firefox.vpn.util.onSuccess
 
 object ReportUtil {
     private const val DEFAULT_TAG = "GuardianReport"
@@ -124,12 +121,11 @@ inline fun <reified T : Any> Result<T>.doReport(
         }
 }
 
 inline fun <reified T : Any> reportDataType(value: T): String {
     return when (value) {
-        is LoginInfo -> value.toReport()
-        is LoginResult -> value.toReport()
+        is User -> value.toReport()
         is UserInfo -> value.toReport()
         is DeviceInfo -> value.toReport()
         is List<*> -> {
             val type = value.random()?.let { it::class.java.simpleName } ?: "Unknown"
             "List(type=$type, size=${value.size})"
@@ -144,25 +140,13 @@ fun reportSuccess(tag: String, msg: String) {
 
 fun reportError(tag: String, msg: String) {
     GLog.report(tag, "error($msg)")
 }
 
-fun LoginInfo.toReport(): String {
-    val time = System.currentTimeMillis()
-    val expire = try {
-        TimeUtil.parse(this.expiresOn, TimeFormat.Iso8601).time
-    } catch (e: Exception) {
-        0L
-    }
-    val expireIn = expire - time
-    val interval = this.pollInterval
-    return "expireIn=$expireIn, pollInterval=$interval"
-}
-
-fun LoginResult.toReport(): String {
-    val subscribed = user.subscription.isSubscribed
-    val numDevices = user.devices
+fun User.toReport(): String {
+    val subscribed = subscription.isSubscribed
+    val numDevices = devices
     return "subscribed=$subscribed, numDevices=${numDevices.size}"
 }
 
 fun UserInfo.toReport(): String {
     val subscribed = isSubscribed
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/service/GuardianService.kt b/app/src/main/java/org/mozilla/firefox/vpn/service/GuardianService.kt
index 91ecb4d..a7af94c 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/service/GuardianService.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/service/GuardianService.kt
@@ -1,9 +1,8 @@
 package org.mozilla.firefox.vpn.service
 
 import android.os.Build
-import androidx.core.net.toUri
 import com.google.gson.Gson
 import com.google.gson.GsonBuilder
 import com.google.gson.JsonDeserializationContext
 import com.google.gson.JsonDeserializer
 import com.google.gson.JsonElement
@@ -15,13 +14,17 @@ import java.util.concurrent.TimeUnit
 import okhttp3.ConnectionPool
 import okhttp3.Interceptor
 import okhttp3.OkHttpClient
 import okhttp3.ResponseBody
 import okhttp3.logging.HttpLoggingInterceptor
+import org.mozilla.firefox.vpn.AuthCode
 import org.mozilla.firefox.vpn.BuildConfig
 import org.mozilla.firefox.vpn.const.ENDPOINT
+import org.mozilla.firefox.vpn.crypto.CodeChallenge
+import org.mozilla.firefox.vpn.crypto.CodeVerifier
 import org.mozilla.firefox.vpn.user.data.SessionManager
+import org.mozilla.firefox.vpn.user.domain.AuthToken
 import org.mozilla.firefox.vpn.util.Result
 import org.mozilla.firefox.vpn.util.mapError
 import retrofit2.Response
 import retrofit2.Retrofit
 import retrofit2.converter.gson.GsonConverterFactory
@@ -29,19 +32,20 @@ import retrofit2.http.Body
 import retrofit2.http.DELETE
 import retrofit2.http.GET
 import retrofit2.http.Header
 import retrofit2.http.POST
 import retrofit2.http.Path
-import retrofit2.http.QueryMap
-import retrofit2.http.Url
 
 interface GuardianService {
-    @POST("api/v1/vpn/login")
-    suspend fun getLoginInfo(@QueryMap referral: Map<String, String>): Response<LoginInfo>
 
-    @GET
-    suspend fun verifyLogin(@Url verifyUrl: String): Response<LoginResult>
+    @POST("/api/v2/vpn/login/verify/")
+    suspend fun verifyLogin(@Body postData: PostData): Response<LoginResult>
+
+    data class PostData(
+        @SerializedName("code") val code: AuthCode,
+        @SerializedName("code_verifier") val codeVerifier: CodeVerifier
+    )
 
     @GET("api/v1/vpn/account")
     suspend fun getUserInfo(
         @Header(TimeoutInterceptor.CONNECT_TIMEOUT) connectTimeout: String,
         @Header(TimeoutInterceptor.READ_TIMEOUT) readTimeout: String
@@ -65,10 +69,16 @@ interface GuardianService {
         const val HOST_FEEDBACK = "$HOST_GUARDIAN/r/vpn/client/feedback"
         const val HOST_SUPPORT = "$HOST_GUARDIAN/r/vpn/support"
         const val HOST_CONTACT = "$HOST_GUARDIAN/r/vpn/contact"
         const val HOST_TERMS = "$HOST_GUARDIAN/r/vpn/terms"
         const val HOST_PRIVACY = "$HOST_GUARDIAN/r/vpn/privacy"
+
+        fun getLoginUrl(
+            codeChallenge: CodeChallenge,
+            challengeMethod: String = "S256"
+        ) = "$HOST_GUARDIAN/api/v2/vpn/login/android?" +
+                "code_challenge=$codeChallenge&code_challenge_method=$challengeMethod"
     }
 }
 
 fun GuardianService.Companion.newInstance(sessionManager: SessionManager): GuardianService {
     val client = OkHttpClient.Builder()
@@ -76,11 +86,11 @@ fun GuardianService.Companion.newInstance(sessionManager: SessionManager): Guard
             val original = it.request()
             val request = original.newBuilder()
                 .header("User-Agent", getUserAgent())
                 .method(original.method(), original.body())
             if (original.isHttps) {
-                request.addHeader("Authorization", "Bearer ${sessionManager.getUserInfo()?.token}")
+                request.addHeader("Authorization", "Bearer ${sessionManager.getAuthToken()}")
             }
             it.proceed(request.build())
         }
         .addInterceptor(HttpLoggingInterceptor().apply {
             level = HttpLoggingInterceptor.Level.BODY
@@ -148,34 +158,17 @@ private class TimeoutInterceptor : Interceptor {
     }
 }
 
 private fun getUserAgent(): String {
     val os = "Android ${Build.VERSION.RELEASE}"
-    val abi = Build.SUPPORTED_ABIS.firstOrNull()?.let { it } ?: "no-support-abi"
+    val abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "no-support-abi"
     return "FirefoxPrivateNetworkVPN/${BuildConfig.VERSION_NAME} ($os; $abi)"
 }
 
-data class LoginInfo(
-    @SerializedName("login_url")
-    val loginUrl: String,
-
-    @SerializedName("verification_url")
-    val verificationUrl: String,
-
-    @SerializedName("expires_on")
-    val expiresOn: String,
-
-    @SerializedName("poll_interval")
-    val pollInterval: Int
-)
-
 data class LoginResult(
-    @SerializedName("user")
-    val user: User,
-
     @SerializedName("token")
-    val token: String
+    val token: AuthToken
 )
 
 data class User(
     @SerializedName("email")
     val email: String,
@@ -322,22 +315,10 @@ data class Version(
 
     @SerializedName("message")
     val message: String
 )
 
-class LoginQueryBuilder(private val referral: String) {
-
-    fun build(): Map<String, String> {
-        return "mozilla.com?$referral".toUri().let { uri ->
-            uri.queryParameterNames
-                .filter { it.isNotEmpty() } // parameter name shouldn't be empty
-                .map { it to (uri.getQueryParameter(it) ?: "") }
-                .toMap()
-        }
-    }
-}
-
 data class DeviceRequestBody(
     val name: String,
     val pubkey: String
 )
 
@@ -421,12 +402,14 @@ object DeviceNotFound : UnauthorizedException()
 object NoActiveSubscription : UnauthorizedException()
 object LoginTokenNotFound : UnauthorizedException()
 object LoginTokenExpired : UnauthorizedException()
 object LoginTokenUnverified : UnauthorizedException()
 
+object BrowserClosedWithoutLogin : RuntimeException()
+
 data class ExpiredException(val currentTime: String, val expireTime: String) : RuntimeException()
 class ErrorCodeException(val code: Int, val errorBody: ResponseBody?) : RuntimeException()
 
 object NetworkException : RuntimeException()
 
-open class UnknownException(private val msg: String) : RuntimeException(msg)
+open class UnknownException(msg: String) : RuntimeException(msg)
 data class UnknownErrorBody(val body: ResponseBody?) : UnknownException("${body?.string()}")
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/service/MockGuardianService.kt b/app/src/main/java/org/mozilla/firefox/vpn/service/MockGuardianService.kt
index 2f87f45..ef2738b 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/service/MockGuardianService.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/service/MockGuardianService.kt
@@ -57,23 +57,12 @@ class MockGuardianService : GuardianService {
         City("Mock city 2", "LA", 0.0, 0.0, servers)
     )
 
     private val countries = listOf(Country("Mock country", "US", cities))
 
-    override suspend fun getLoginInfo(loginRequestBody: Map<String, String>): Response<LoginInfo> {
-        delay()
-        return Response.success(LoginInfo(
-            loginUrl,
-            verifyUrl,
-            nextYear(),
-            2000
-        ))
-    }
-
-    override suspend fun verifyLogin(verifyUrl: String): Response<LoginResult> {
-        delay()
-        return Response.success(LoginResult(user, "mock_token"))
+    override suspend fun verifyLogin(data: GuardianService.PostData): Response<LoginResult> {
+        return Response.success(LoginResult(("mock_token")))
     }
 
     override suspend fun getUserInfo(connectTimeout: String, readTimeout: String): Response<User> {
         delay()
         return Response.success(user)
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/user/data/ReferralManager.kt b/app/src/main/java/org/mozilla/firefox/vpn/user/data/ReferralManager.kt
deleted file mode 100644
index 9ee90e2..0000000
--- a/app/src/main/java/org/mozilla/firefox/vpn/user/data/ReferralManager.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-package org.mozilla.firefox.vpn.user.data
-
-import android.content.Context
-import android.content.SharedPreferences
-import com.android.installreferrer.api.InstallReferrerClient
-import com.android.installreferrer.api.InstallReferrerStateListener
-import com.android.installreferrer.api.ReferrerDetails
-import kotlin.coroutines.resume
-import kotlinx.coroutines.suspendCancellableCoroutine
-import org.mozilla.firefox.vpn.util.GLog
-
-class ReferralManager(context: Context, private val prefs: SharedPreferences) {
-
-    companion object {
-        private const val TAG = "ReferralManager"
-        private const val SIR_PREF_REFERRAL = "sir_pref_referral"
-        private const val DEFAULT_REFERRAL = ""
-    }
-
-    private var referrerClient: InstallReferrerClient =
-        InstallReferrerClient.newBuilder(context).build()
-
-    suspend fun getUserReferral() = suspendCancellableCoroutine<String> { cont ->
-        val cacheReferral = prefs.getString(SIR_PREF_REFERRAL, null)
-        if (cacheReferral != null) {
-            GLog.d(TAG, "referral already submitted")
-            if (cont.isActive) {
-                cont.resume(cacheReferral)
-                return@suspendCancellableCoroutine
-            }
-        }
-        referrerClient.startConnection(object : InstallReferrerStateListener {
-
-            override fun onInstallReferrerSetupFinished(responseCode: Int) {
-                when (responseCode) {
-                    InstallReferrerClient.InstallReferrerResponse.OK -> {
-                        val response: ReferrerDetails = referrerClient.installReferrer
-                        val referrerUrl: String = response.installReferrer
-                        prefs.edit().putString(SIR_PREF_REFERRAL, referrerUrl).apply()
-                        if (cont.isActive) {
-                            GLog.d(TAG, "onInstallReferrerSetupFinished")
-                            cont.resume(referrerUrl)
-                            return
-                        }
-                    }
-                    InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED -> {
-                        // API not available on the current Play Store app or there's no Play Store
-                        GLog.w(TAG, "FEATURE_NOT_SUPPORTED")
-                    }
-                    InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE -> {
-                        // Connection couldn't be established.
-                        GLog.w(TAG, "SERVICE_UNAVAILABLE")
-                    }
-                }
-                if (cont.isActive) {
-                    cont.resume(DEFAULT_REFERRAL)
-                    return
-                }
-            }
-
-            override fun onInstallReferrerServiceDisconnected() {
-                // Try to restart the connection on the next request to
-                // Google Play by calling the startConnection() method.
-                if (cont.isActive) {
-                    GLog.w(TAG, "onInstallReferrerServiceDisconnected")
-                    cont.resume(DEFAULT_REFERRAL)
-                    return
-                }
-            }
-        })
-    }
-}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/user/data/SessionManager.kt b/app/src/main/java/org/mozilla/firefox/vpn/user/data/SessionManager.kt
index f4e796c..e1798c4 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/user/data/SessionManager.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/user/data/SessionManager.kt
@@ -1,13 +1,14 @@
 package org.mozilla.firefox.vpn.user.data
 
 import android.content.SharedPreferences
 import com.google.gson.Gson
+import org.mozilla.firefox.vpn.user.domain.AuthToken
 
 class SessionManager(private val prefs: SharedPreferences) {
 
-    fun createUserInfo(user: UserInfo) {
+    fun saveUserInfo(user: UserInfo) {
         val json = Gson().toJson(user)
         prefs.edit()
             .putString(PREF_USER_INFO, json)
             .apply()
     }
@@ -16,13 +17,25 @@ class SessionManager(private val prefs: SharedPreferences) {
         return prefs.getString(PREF_USER_INFO, null)?.let {
             Gson().fromJson(it, UserInfo::class.java)
         }
     }
 
-    fun removeUserInfo() {
-        prefs.edit().remove(PREF_USER_INFO).apply()
+    fun saveAuthToken(token: AuthToken) {
+        prefs.edit()
+            .putString(PREF_AUTH_TOKEN, token)
+            .apply()
+    }
+
+    fun getAuthToken(): AuthToken? = prefs.getString(PREF_AUTH_TOKEN, null)
+
+    fun invalidateSession() {
+        prefs.edit()
+            .remove(PREF_USER_INFO)
+            .remove(PREF_AUTH_TOKEN)
+            .apply()
     }
 
     companion object {
         private const val PREF_USER_INFO = "user_info"
+        private const val PREF_AUTH_TOKEN = "auth_token"
     }
 }
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/user/data/UserRepository.kt b/app/src/main/java/org/mozilla/firefox/vpn/user/data/UserRepository.kt
index c46ad96..7da0144 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/user/data/UserRepository.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/user/data/UserRepository.kt
@@ -1,11 +1,12 @@
 package org.mozilla.firefox.vpn.user.data
 
 import java.net.UnknownHostException
+import org.mozilla.firefox.vpn.AuthCode
+import org.mozilla.firefox.vpn.crypto.CodeVerifier
+import org.mozilla.firefox.vpn.report.doReport
 import org.mozilla.firefox.vpn.service.GuardianService
-import org.mozilla.firefox.vpn.service.LoginInfo
-import org.mozilla.firefox.vpn.service.LoginQueryBuilder
 import org.mozilla.firefox.vpn.service.LoginResult
 import org.mozilla.firefox.vpn.service.NetworkException
 import org.mozilla.firefox.vpn.service.Subscription
 import org.mozilla.firefox.vpn.service.UnauthorizedException
 import org.mozilla.firefox.vpn.service.UnknownErrorBody
@@ -15,84 +16,73 @@ import org.mozilla.firefox.vpn.service.Versions
 import org.mozilla.firefox.vpn.service.getUserInfo
 import org.mozilla.firefox.vpn.service.handleError
 import org.mozilla.firefox.vpn.service.resolveBody
 import org.mozilla.firefox.vpn.service.toErrorBody
 import org.mozilla.firefox.vpn.service.toUnauthorizedError
+import org.mozilla.firefox.vpn.user.domain.AuthToken
 import org.mozilla.firefox.vpn.util.GLog
 import org.mozilla.firefox.vpn.util.Result
 import org.mozilla.firefox.vpn.util.TimeFormat
 import org.mozilla.firefox.vpn.util.TimeFormatException
 import org.mozilla.firefox.vpn.util.TimeUtil
 import org.mozilla.firefox.vpn.util.mapValue
 import org.mozilla.firefox.vpn.util.onError
 import org.mozilla.firefox.vpn.util.onSuccess
 
+private const val TAG = "UserRepository"
+
 class UserRepository(
     private val guardianService: GuardianService,
-    private val sessionManager: SessionManager,
-    private val referralManager: ReferralManager
+    private val sessionManager: SessionManager
 ) {
 
     /**
-     * @return Result.Success(loginInfo) or Result.Fail(NetworkException|Otherwise)
+     *
      */
-    suspend fun getLoginInfo(): Result<LoginInfo> {
-        return try {
-            val loginQueryBuilder = LoginQueryBuilder(referralManager.getUserReferral())
-            guardianService.getLoginInfo(loginQueryBuilder.build()).resolveBody()
-        } catch (e: UnknownHostException) {
-            Result.Fail(NetworkException)
-        } catch (e: Exception) {
-            Result.Fail(UnknownException("Unknown exception=$e"))
-        }
+    suspend fun verifyLogin(
+        authCode: AuthCode,
+        codeVerifier: CodeVerifier
+    ): Result<LoginResult> = try {
+        val response =
+            guardianService.verifyLogin(GuardianService.PostData(authCode, codeVerifier))
+
+        response.resolveBody()
+            .handleError(401) {
+                it?.toErrorBody()
+                    ?.toUnauthorizedError()
+                    ?: UnknownErrorBody(it)
+            }
+    } catch (e: UnknownHostException) {
+        Result.Fail(NetworkException)
+    } catch (e: Exception) {
+        Result.Fail(UnknownException("Unknown exception=$e"))
     }
 
-    /**
-     * @return Result.Success(loginResult) or Result.Fail(UnauthorizedException|NetworkException|Otherwise)
-     */
-    suspend fun verifyLogin(info: LoginInfo): Result<LoginResult> {
-        return try {
-            val response = guardianService.verifyLogin(info.verificationUrl)
-            response.resolveBody()
-                .handleError(401) {
-                    it?.toErrorBody()
-                        ?.toUnauthorizedError()
-                        ?: UnknownErrorBody(it)
-                }
-        } catch (e: UnknownHostException) {
-            Result.Fail(NetworkException)
-        } catch (e: Exception) {
-            Result.Fail(UnknownException("Unknown exception=$e"))
-        }
-    }
+    fun saveUserInfo(user: UserInfo) = sessionManager.saveUserInfo(user)
 
-    fun createUserInfo(user: UserInfo) {
-        sessionManager.createUserInfo(user)
-    }
+    fun getUserInfo(): UserInfo? = sessionManager.getUserInfo()
 
-    fun getUserInfo(): UserInfo? {
-        return sessionManager.getUserInfo()
-    }
+    /**
+     * Remove stored user info and auth token.
+     */
+    fun invalidateSession() = sessionManager.invalidateSession()
 
-    fun removeUserInfo() {
-        sessionManager.removeUserInfo()
-    }
+    fun saveAuthToken(token: AuthToken) = sessionManager.saveAuthToken(token)
 
     /**
      * @return Result.Success(user) or Result.Fail(UnauthorizedException|NetworkException|Otherwise)
      */
     suspend fun refreshUserInfo(connectTimeout: Long = 0, readTimeout: Long = 0): Result<UserInfo> {
-        val userInfo = getUserInfo() ?: return Result.Fail(UnauthorizedException())
-
         return try {
             val response = guardianService.getUserInfo(connectTimeout, readTimeout)
             response.resolveBody()
+                .doReport(tag = TAG)
                 .mapValue {
-                    userInfo.copy(
+                    UserInfo(
                         user = it,
                         latestUpdateTime = System.currentTimeMillis()
-                    ).apply { createUserInfo(this) }
+                    ).apply { saveUserInfo(this) }
                 }
                 .handleError(401) {
                     it?.toErrorBody()
                         ?.toUnauthorizedError()
                         ?: UnknownErrorBody(it)
@@ -116,11 +106,10 @@ class UserRepository(
     }
 }
 
 data class UserInfo(
     val user: User,
-    val token: String,
     val latestUpdateTime: Long
 )
 
 val UserInfo.isSubscribed: Boolean
     get() {
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/CreateUserUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/CreateUserUseCase.kt
index 025d540..20875c4 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/CreateUserUseCase.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/CreateUserUseCase.kt
@@ -1,23 +1,16 @@
 package org.mozilla.firefox.vpn.user.domain
 
 import org.mozilla.firefox.vpn.UserStateResolver
-import org.mozilla.firefox.vpn.service.LoginResult
-import org.mozilla.firefox.vpn.user.data.UserInfo
 import org.mozilla.firefox.vpn.user.data.UserRepository
 
 class CreateUserUseCase(
     private val userRepository: UserRepository,
     private val userStateResolver: UserStateResolver
 ) {
 
-    operator fun invoke(loginResult: LoginResult): UserInfo {
-        return UserInfo(
-            user = loginResult.user,
-            token = loginResult.token,
-            latestUpdateTime = System.currentTimeMillis()
-        ).apply {
-            userRepository.createUserInfo(this)
-            userStateResolver.refresh()
-        }
+    suspend operator fun invoke() {
+        // Note: `refreshUserInfo` and `refresh` are temporally coupled
+        userRepository.refreshUserInfo()
+        userStateResolver.refresh()
     }
 }
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetAuthCodeUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetAuthCodeUseCase.kt
new file mode 100644
index 0000000..0462413
--- /dev/null
+++ b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetAuthCodeUseCase.kt
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.user.domain
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ensureActive
+import org.mozilla.firefox.vpn.AuthCode
+import org.mozilla.firefox.vpn.IntentReceiverActivity
+import org.mozilla.firefox.vpn.crypto.CodeChallenge
+import org.mozilla.firefox.vpn.onboarding.Bus
+import org.mozilla.firefox.vpn.service.BrowserClosedWithoutLogin
+import org.mozilla.firefox.vpn.service.GuardianService
+import org.mozilla.firefox.vpn.service.NetworkException
+import org.mozilla.firefox.vpn.service.UnknownException
+import org.mozilla.firefox.vpn.util.LoginCustomTab
+import org.mozilla.firefox.vpn.util.Result
+
+class GetAuthCodeUseCase(
+    private val bus: Bus
+) {
+
+    /**
+     * Navigates the user to FxA login, then retrieves the auth code from the response.
+     *
+     * Note: this will block until the user either completes their login or closes the
+     * custom tab.  Programmatically, this is a very long time.  Maybe don't use the main
+     * thread.
+     */
+    suspend operator fun invoke(
+        codeChallenge: CodeChallenge,
+        scope: CoroutineScope
+    ): Result<AuthCode> {
+
+        val authCodeReceived = CompletableDeferred<AuthCode>().also {
+            IntentReceiverActivity.setAuthCodeReceivedDeferred(it)
+        }
+        val customTabsClosed = CompletableDeferred<Unit>().also {
+            LoginCustomTab.setCustomTabsClosedEvent(it)
+        }
+
+        val loginUrl = GuardianService.getLoginUrl(codeChallenge)
+        bus.promptLogin.postValue(loginUrl)
+
+        return try {
+            while (!authCodeReceived.isCompleted && !customTabsClosed.isCompleted) {
+                scope.ensureActive() // Yield to check for cancellation
+                /* block */
+            }
+
+            when {
+                authCodeReceived.isCompleted -> Result.Success(authCodeReceived.await())
+                customTabsClosed.isCompleted -> Result.Fail(BrowserClosedWithoutLogin)
+                else -> Result.Fail(UnknownException("Get secret failed unexpectedly"))
+            }
+        } finally {
+            authCodeReceived.cancel()
+            customTabsClosed.cancel()
+
+            Result.Fail(NetworkException)
+        }
+    }
+}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetLoginInfoUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetLoginInfoUseCase.kt
deleted file mode 100644
index 8df2f3c..0000000
--- a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetLoginInfoUseCase.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.mozilla.firefox.vpn.user.domain
-
-import org.mozilla.firefox.vpn.report.doReport
-import org.mozilla.firefox.vpn.service.LoginInfo
-import org.mozilla.firefox.vpn.user.data.UserRepository
-import org.mozilla.firefox.vpn.util.Result
-
-class GetLoginInfoUseCase(
-    private val userRepository: UserRepository
-) {
-
-    suspend operator fun invoke(): Result<LoginInfo> {
-        return userRepository.getLoginInfo().doReport(tag = TAG)
-    }
-
-    companion object {
-        private const val TAG = "GetLoginInfoUseCase"
-    }
-}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetTokenUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetTokenUseCase.kt
new file mode 100644
index 0000000..e00e7e1
--- /dev/null
+++ b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/GetTokenUseCase.kt
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.user.domain
+
+import kotlinx.coroutines.CoroutineScope
+import org.mozilla.firefox.vpn.crypto.AuthCodeHelper
+import org.mozilla.firefox.vpn.user.data.UserRepository
+import org.mozilla.firefox.vpn.util.Result
+import org.mozilla.firefox.vpn.util.flatMapValue
+import org.mozilla.firefox.vpn.util.mapValue
+
+/**
+ * Token that is attached to all requests made by an authenticated user.
+ */
+typealias AuthToken = String
+
+class GetTokenUseCase(
+    private val userRepository: UserRepository,
+    private val getAuthCodeUseCase: GetAuthCodeUseCase
+) {
+
+    /**
+     * Begin login flow and await either a token or failure.
+     *
+     * This is not guaranteed to complete if the user 1) never finishes signing in and 2) never
+     * cancels their login flow.
+     */
+    suspend operator fun invoke(scope: CoroutineScope): Result<AuthToken> {
+        val codeVerifier = AuthCodeHelper.generateCodeVerifier()
+        val codeChallenge = AuthCodeHelper.generateCodeChallenge(codeVerifier)
+
+        return getAuthCodeUseCase(codeChallenge, scope)
+            .flatMapValue { secret -> userRepository.verifyLogin(secret, codeVerifier) }
+            .mapValue { loginResult -> loginResult.token }
+    }
+}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/LogoutUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/LogoutUseCase.kt
index 0201fd5..fbe6bf9 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/LogoutUseCase.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/LogoutUseCase.kt
@@ -9,11 +9,11 @@ class LogoutUseCase(
     private val deviceRepository: DeviceRepository
 ) {
 
     operator fun invoke() {
         GLog.report(TAG, "logged out")
-        userRepository.removeUserInfo()
+        userRepository.invalidateSession()
         deviceRepository.removeDevice()
     }
 
     companion object {
         private const val TAG = "LogoutUseCase"
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/SaveAuthTokenUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/SaveAuthTokenUseCase.kt
new file mode 100644
index 0000000..ded0ab5
--- /dev/null
+++ b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/SaveAuthTokenUseCase.kt
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.user.domain
+
+import org.mozilla.firefox.vpn.user.data.UserRepository
+
+class SaveAuthTokenUseCase(private val userRepository: UserRepository) {
+    operator fun invoke(token: AuthToken) {
+        userRepository.saveAuthToken(token)
+    }
+}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/VerifyLoginUseCase.kt b/app/src/main/java/org/mozilla/firefox/vpn/user/domain/VerifyLoginUseCase.kt
deleted file mode 100644
index 5b30606..0000000
--- a/app/src/main/java/org/mozilla/firefox/vpn/user/domain/VerifyLoginUseCase.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package org.mozilla.firefox.vpn.user.domain
-
-import kotlinx.coroutines.delay
-import org.mozilla.firefox.vpn.report.doReport
-import org.mozilla.firefox.vpn.service.LoginInfo
-import org.mozilla.firefox.vpn.service.LoginResult
-import org.mozilla.firefox.vpn.service.LoginTokenExpired
-import org.mozilla.firefox.vpn.service.LoginTokenNotFound
-import org.mozilla.firefox.vpn.user.data.UserRepository
-import org.mozilla.firefox.vpn.util.Result
-
-class VerifyLoginUseCase(
-    private val userRepository: UserRepository
-) {
-
-    suspend operator fun invoke(info: LoginInfo, retry: Boolean): Result<LoginResult> {
-        var result = verifyLogin(info)
-        if (!retry) {
-            return result
-        }
-
-        while (result is Result.Fail) {
-            // Nothing we can do to invalid/expired token. User will have to close the custom tab
-            // and click login button again to retrieve new token
-            when (result.exception) {
-                is LoginTokenNotFound, is LoginTokenExpired -> return result
-            }
-
-            delay(info.pollInterval * 1000L)
-            result = verifyLogin(info)
-        }
-        return result
-    }
-
-    private suspend fun verifyLogin(info: LoginInfo) = userRepository
-        .verifyLogin(info)
-        .doReport(tag = TAG)
-
-    companion object {
-        private const val TAG = "VerifyLoginUseCase"
-    }
-}
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/util/CustomTabUtil.kt b/app/src/main/java/org/mozilla/firefox/vpn/util/CustomTabUtil.kt
index 6002290..af065e8 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/util/CustomTabUtil.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/util/CustomTabUtil.kt
@@ -2,19 +2,24 @@ package org.mozilla.firefox.vpn.util
 
 import android.app.Activity
 import android.content.ComponentName
 import android.content.Context
 import android.net.Uri
+import android.os.Bundle
 import androidx.appcompat.app.AppCompatActivity
+import androidx.browser.customtabs.CustomTabsCallback
 import androidx.browser.customtabs.CustomTabsClient
 import androidx.browser.customtabs.CustomTabsIntent
 import androidx.browser.customtabs.CustomTabsServiceConnection
 import androidx.browser.customtabs.CustomTabsSession
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.DefaultLifecycleObserver
 import androidx.lifecycle.LifecycleOwner
+import java.util.ArrayDeque
+import java.util.Queue
 import kotlin.coroutines.resume
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.suspendCancellableCoroutine
 import kotlinx.coroutines.withContext
 
 class LoginCustomTab(private val activity: AppCompatActivity) : DefaultLifecycleObserver {
@@ -61,11 +66,11 @@ class LoginCustomTab(private val activity: AppCompatActivity) : DefaultLifecycle
     private fun createConnection(): CustomTabsServiceConnection {
         return object : CustomTabsServiceConnection() {
             override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
                 GLog.d(TAG, "service connected")
                 client.warmup(0)
-                this@LoginCustomTab.session = client.newSession(null)
+                this@LoginCustomTab.session = client.newSession(customTabsCallback)
                 this@LoginCustomTab.client = client
             }
 
             override fun onServiceDisconnected(p0: ComponentName?) {
                 GLog.d(TAG, "service disconnected")
@@ -88,10 +93,65 @@ class LoginCustomTab(private val activity: AppCompatActivity) : DefaultLifecycle
             "org.mozilla.firefox_beta",
             "org.mozilla.firefox",
             "org.mozilla.focus",
             "com.android.chrome"
         )
+
+        /**
+         * Set a [CompletableDeferred] that will complete the next time custom tabs are closed.
+         *
+         * IMPORTANT: this should be set _before_ custom tabs are opened.
+         *
+         * Note that custom tab callbacks are asynchronous and _extremely_ slow.  This
+         * class attempts to handle cases where the user quickly opens and closes their
+         * tabs, but if you encounter problems around that use case this is a good
+         * place to look.
+         *
+         * This is static because the alternative was a similarly bad practice: weaving
+         * the reference through many layers of Android framework code.
+         */
+        fun setCustomTabsClosedEvent(authCodeReceived: CompletableDeferred<Unit>) {
+            queuedCloses.add(authCodeReceived)
+        }
+        private val queuedCloses: Queue<CompletableDeferred<Unit>> = ArrayDeque()
+    }
+
+    private val customTabsCallback = object : CustomTabsCallback() {
+        private var openCount = 0
+
+        override fun onNavigationEvent(navigationEvent: Int, extras: Bundle?) {
+            when (navigationEvent) {
+                // From testing, this seems to always be sent before TAB_HIDDEN
+                TAB_SHOWN -> { openCount++ }
+                // If the user clicks the button multiple times, we may have more completables
+                // than we do TAB_SHOWN events.  Also note that these callbacks are
+                // asynchronous and extremely slow, and from testing do not always occur in
+                // order, or even at all.  E.g., a very fast `open -> close -> open` might
+                // send `SHOWN -> SHOWN`.
+                //
+                // As a result, we are very conservative here and only close as many
+                // completables as we have seen SHOWN events.  Others will be automatically
+                // canceled when their parent job ends anyway.
+                //
+                // Be very careful changing this.  Mistakes here can cause auth to hang
+                // indefinitely, as requests are immediately canceled due to extra calls to
+                // `complete`.
+                TAB_HIDDEN -> {
+                    openCount--
+                    queuedCloses.poll()?.complete(Unit)
+
+                    var head = queuedCloses.peek()
+                    while (head != null && openCount > 0) {
+                        head = queuedCloses.poll()
+                        head?.complete(Unit)
+                        openCount--
+                    }
+                }
+                else -> { /* Do nothing */
+                }
+            }
+        }
     }
 }
 
 suspend fun Fragment.launchUrl(url: String): Boolean {
     return withContext(Dispatchers.Main) {
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/util/ResourceUtil.kt b/app/src/main/java/org/mozilla/firefox/vpn/util/ResourceUtil.kt
index 20a6562..4216435 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/util/ResourceUtil.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/util/ResourceUtil.kt
@@ -6,12 +6,12 @@ import androidx.annotation.ColorRes
 import androidx.core.content.ContextCompat
 import java.util.Locale
 
 class StringResource {
     var id: Int? = null
-    var str: String? = null
-    var formatArgs: Array<out String>? = null
+    private var str: String? = null
+    private var formatArgs: Array<out String>? = null
 
     constructor(str: String) {
         this.str = str
     }
 
diff --git a/app/src/main/java/org/mozilla/firefox/vpn/util/Result.kt b/app/src/main/java/org/mozilla/firefox/vpn/util/Result.kt
index fab43a0..3dd35ad 100644
--- a/app/src/main/java/org/mozilla/firefox/vpn/util/Result.kt
+++ b/app/src/main/java/org/mozilla/firefox/vpn/util/Result.kt
@@ -10,10 +10,16 @@ inline fun <T : Any, R : Any> Result<T>.mapValue(function: (T) -> R): Result<R>
         is Result.Success -> Result.Success(function(value))
         is Result.Fail -> this
     }
 }
 
+inline fun <T : Any, R : Any> Result<T>.flatMapValue(function: (T) -> Result<R>): Result<R> =
+    when (this) {
+        is Result.Success -> function(value)
+        is Result.Fail -> Result.Fail(exception)
+    }
+
 inline fun <T : Any> Result<T>.mapError(function: (Exception) -> Exception): Result<T> {
     return when (this) {
         is Result.Success -> this
         is Result.Fail -> Result.Fail(function(exception))
     }
diff --git a/app/src/test/java/org/mozilla/firefox/vpn/IntentReceiverActivityTest.kt b/app/src/test/java/org/mozilla/firefox/vpn/IntentReceiverActivityTest.kt
new file mode 100644
index 0000000..25904b2
--- /dev/null
+++ b/app/src/test/java/org/mozilla/firefox/vpn/IntentReceiverActivityTest.kt
@@ -0,0 +1,214 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn
+
+import android.content.Intent
+import android.net.Uri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import java.lang.RuntimeException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric.buildActivity
+import org.robolectric.android.controller.ActivityController
+import org.robolectric.annotation.Config
+
+@ExperimentalCoroutinesApi
+@RunWith(AndroidJUnit4::class)
+@Config(application = TestGuardianApp::class, sdk = [WORKING_SDK])
+class IntentReceiverActivityTest {
+
+    private val code = "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d"
+    private val intentUri = Uri.parse(
+        "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
+                "/vpn/client/login/success?" +
+                "code=$code" +
+                "#Intent;category=android.intent.category.BROWSABLE;" +
+                "launchFlags=0x14000000;" +
+                "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
+                "i.org.chromium.chrome.browser.referrer_id=18;" +
+                "S.com.android.browser.application_id=com.android.chrome;end"
+    )
+    private val intent = Intent("android.intent.action.VIEW", intentUri)
+
+    private lateinit var activity: ActivityController<IntentReceiverActivity>
+    private lateinit var first: CompletableDeferred<AuthCode>
+    private lateinit var second: CompletableDeferred<AuthCode>
+
+    @Before
+    fun setup() {
+        first = CompletableDeferred()
+        second = CompletableDeferred()
+    }
+
+    @Test
+    fun `WHEN no intent is sent THEN nextLogin should not complete`() {
+        assertFalse(first.isCompleted)
+
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
+        activity = buildActivity(IntentReceiverActivity::class.java).setup()
+
+        assertFalse(first.isCompleted)
+    }
+
+    @Test
+    fun `WHEN intent is sent THEN nextLogin should complete`() = runBlockingTest {
+        assertFalse(first.isCompleted)
+
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
+        buildActivity(IntentReceiverActivity::class.java, intent).setup()
+
+        assertTrue(first.isCompleted)
+        assertEquals(code, first.await())
+
+        // new deferred should be set
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(second)
+        assertNotEquals(first, second)
+        assertFalse(second.isCompleted)
+    }
+
+    @Test
+    fun `GIVEN intent was sent WHEN activity is recreated several times THEN only one token should be emitted`() = runBlockingTest {
+        assertFalse(first.isCompleted)
+
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
+        val controller =
+            buildActivity(IntentReceiverActivity::class.java, intent).setup()
+
+        assertTrue(first.isCompleted)
+        assertEquals(code, first.await())
+
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(second)
+        assertFalse(second.isCompleted)
+
+        controller.pause().stop().start().resume()
+
+        controller.pause().stop().start().resume()
+
+        assertFalse(second.isCompleted)
+    }
+
+    @Test
+    fun `GIVEN code is included WHEN other query params are included THEN they should be ignored in favor of code`() = runBlockingTest {
+            val intentUri = Uri.parse(
+                "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
+                        "/vpn/client/login/success?" +
+                        "code=$code&" +
+                        "token=myToken&" +
+                        "secret=mySecret&" +
+                        "lotteryTicketNumber=5" +
+                        "#Intent;category=android.intent.category.BROWSABLE;" +
+                        "launchFlags=0x14000000;" +
+                        "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
+                        "i.org.chromium.chrome.browser.referrer_id=18;" +
+                        "S.com.android.browser.application_id=com.android.chrome;end"
+            )
+            val intent = Intent("android.intent.action.VIEW", intentUri)
+
+            IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
+            assertFalse(first.isCompleted)
+
+            buildActivity(IntentReceiverActivity::class.java, intent).setup()
+
+            assertTrue(first.isCompleted)
+            assertEquals(code, first.await())
+        }
+
+    @Test
+    fun `GIVEN code is not included WHEN other query params are included THEN nextAuthCodeReceived should not complete`() {
+        val intentUri = Uri.parse(
+            "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
+                    "/vpn/client/login/success?" +
+                    "token=myToken&" +
+                    "secret=mySecret&" +
+                    "lotteryTicketNumber=5" +
+                    "#Intent;category=android.intent.category.BROWSABLE;" +
+                    "launchFlags=0x14000000;" +
+                    "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
+                    "i.org.chromium.chrome.browser.referrer_id=18;" +
+                    "S.com.android.browser.application_id=com.android.chrome;end"
+        )
+        val intent = Intent("android.intent.action.VIEW", intentUri)
+
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
+        assertFalse(first.isCompleted)
+
+        buildActivity(IntentReceiverActivity::class.java, intent).setup()
+
+        assertFalse(first.isCompleted)
+    }
+
+    @Test
+    fun `valid codes should pass`() {
+        val failures = listOf(
+            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d",
+            "d6e9d39217dca1c1e09f742cd9d43705436f9b508eb460191d3743a62ad4bbedd6f64db531243b8b",
+            "94261d5d6baf934f4285ff7c89f0c855e82647a8fa5a44f6e4ce00ce41ea8ba22fb10f74c7bcf206",
+            "b1fe04adfef209ede45d5144cdbb9ab87924bded27b1a2310989da597fadf7b559886d07e9ca2394",
+            "572e46b7773c5fa358211aa5338f8ddbda8375e9047d662cb97e49803efd91883627a10a52e9ac1a",
+            "a9598960552e7fdd2d6ea9c403b762b198d318de7dc4f361c52ab2d6a2fd82b83417c3e33ce2c48f",
+            "a250663294300db88f5a065b96cb9ff257feb095173d643fb7560d39ba615cdd4d43682d1c9d5063",
+            "28aa232db5769b9510c428cb28fd563817a17d05301ff8f58b0f992de7576fcc8b1a4bf624d0d1c8"
+        ).filter { !validateAuthCode(it, shouldPass = true) }
+
+        if (failures.isNotEmpty()) {
+            println("${failures.size} valid codes failed validation: $failures")
+            throw RuntimeException()
+        }
+    }
+
+    @Test
+    fun `invalid codes should not pass`() {
+        // Codes should always be 80 characters of lowercase hexadecimal
+        val successes = listOf(
+            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37", // 79 chars
+            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37a8", // 81 chars
+            "d60b4de6f4a8a6e2228e82b328729d9cc1-666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37", // includes dash
+            "60b4de6f4a8a6e2228E82b328729d9cc1666b96A1f7a5202fdc563c925bb7a3e~a3f4efa1ef3c37", // includes tilde
+            "d60b4d e6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37", // includes space
+            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a.5202fdc563c925bb7a3ea3f4efa1ef3c37", // includes .
+            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c92/5bb7a3ea3f4efa1ef3c37", // includes /
+            "60b4de6f4a8a6e2228g82b328729d9cc1666b96A1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d", // includes non-hexadecimal char (g)
+            "60b4de6f4a8a6e2228E82b328729d9cc1666b96A1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d" // includes some uppercase
+        ).filter { !validateAuthCode(it, shouldPass = false) }
+
+        if (successes.isNotEmpty()) {
+            println("${successes.size} invalid codes passed validation: $successes")
+            throw RuntimeException()
+        }
+    }
+
+    private fun validateAuthCode(code: String, shouldPass: Boolean): Boolean {
+        val intentUri = Uri.parse(
+            "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
+                    "/vpn/client/login/success?" +
+                    "code=$code&" +
+                    "#Intent;category=android.intent.category.BROWSABLE;" +
+                    "launchFlags=0x14000000;" +
+                    "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
+                    "i.org.chromium.chrome.browser.referrer_id=18;" +
+                    "S.com.android.browser.application_id=com.android.chrome;end"
+        )
+        val intent = Intent("android.intent.action.VIEW", intentUri)
+
+        val authCodeReceived = CompletableDeferred<AuthCode>()
+        IntentReceiverActivity.setAuthCodeReceivedDeferred(authCodeReceived)
+        assertFalse(authCodeReceived.isCompleted)
+
+        buildActivity(IntentReceiverActivity::class.java, intent).setup()
+
+        return runBlocking {
+            shouldPass == (authCodeReceived.isCompleted && code == authCodeReceived.await())
+        }
+    }
+}
diff --git a/app/src/test/java/org/mozilla/firefox/vpn/TestConstants.kt b/app/src/test/java/org/mozilla/firefox/vpn/TestConstants.kt
new file mode 100644
index 0000000..cd5d890
--- /dev/null
+++ b/app/src/test/java/org/mozilla/firefox/vpn/TestConstants.kt
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn
+
+/**
+ * SDK level that works with Robolectric.
+ *
+ * Android Studio is (as of 9/8/20) packaged with JDK 1.8, while Android API 29+ requires
+ * Java 9 or newer. Aiming Robolectric tests at API 28 avoids this problem.
+ */
+const val WORKING_SDK = 28
diff --git a/app/src/test/java/org/mozilla/firefox/vpn/TestGuardianApp.kt b/app/src/test/java/org/mozilla/firefox/vpn/TestGuardianApp.kt
new file mode 100644
index 0000000..3b55e37
--- /dev/null
+++ b/app/src/test/java/org/mozilla/firefox/vpn/TestGuardianApp.kt
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn
+
+class TestGuardianApp : GuardianApp() {
+
+    override fun onCreate() {
+        super.onCreate()
+        // Avoid Robolectric IllegalStateException 'no theme'
+        setTheme(R.style.AppTheme)
+    }
+}
diff --git a/app/src/test/java/org/mozilla/firefox/vpn/crypto/AuthCodeHelperTest.kt b/app/src/test/java/org/mozilla/firefox/vpn/crypto/AuthCodeHelperTest.kt
new file mode 100644
index 0000000..1db5256
--- /dev/null
+++ b/app/src/test/java/org/mozilla/firefox/vpn/crypto/AuthCodeHelperTest.kt
@@ -0,0 +1,105 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.firefox.vpn.crypto
+
+import kotlin.random.Random
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.firefox.vpn.TestGuardianApp
+import org.mozilla.firefox.vpn.WORKING_SDK
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = TestGuardianApp::class, sdk = [WORKING_SDK])
+class AuthCodeHelperTest {
+
+    private lateinit var deterministicRandom: Random
+
+    @Before
+    fun setup() {
+        deterministicRandom = Random(seed = 42)
+    }
+
+    @Test
+    fun `code_challenges generated from the same code_verifier will always match`() {
+        val verifier = "i'm a code verifier!"
+
+        val challenge = AuthCodeHelper.generateCodeChallenge(verifier)
+
+        AuthCodeHelper.generateCodeChallenge("some")
+        AuthCodeHelper.generateCodeChallenge("other")
+        AuthCodeHelper.generateCodeChallenge("verifiers")
+
+        val challenge2 = AuthCodeHelper.generateCodeChallenge(verifier)
+
+        assertEquals(challenge, challenge2)
+    }
+
+    @Test
+    fun `code_challenges generated different code_verifiers should not match`() {
+        val challenge1 = AuthCodeHelper.generateCodeChallenge("I'm the first verifier!")
+        val challenge2 = AuthCodeHelper.generateCodeChallenge("I'm totally unrelated to the other one")
+
+        assertNotEquals(challenge1, challenge2)
+    }
+
+    @Test
+    fun `deterministicRandom is deterministic`() {
+        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
+        assertEquals(
+            "qpUrMTIZVoB31IKqc.rznkjs9RiK.qdLWXktKUrqXnm-To8VSRb7iMELP4WH4vDknyPD3AuPDlHDJu8y8~DDb8G-Rdljl3r9rxsB5b",
+            verifier
+        )
+    }
+
+    @Test
+    fun `code_challenges should contain a trailing '=' for padding`() {
+        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
+        val challenge = AuthCodeHelper.generateCodeChallenge(verifier)
+        assertEquals("=", challenge.takeLast(1))
+    }
+
+    @Test
+    fun `code_verifiers should not contain a trailing '='`() {
+        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
+        assertNotEquals("=", verifier.takeLast(1))
+    }
+
+    @Test
+    fun `code_challenges should be 44 characters long`() {
+        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
+        val challenge = AuthCodeHelper.generateCodeChallenge(verifier)
+        assertEquals(44, challenge.length)
+    }
+
+    @Test
+    fun `code_challenge should sha256 hash its input`() {
+        // This uses hardcoded values provided by the backend team for manual testing
+        val verifier = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+        val codeChallenge = AuthCodeHelper.generateCodeChallenge(verifier)
+
+        assertEquals("1uxomN6H3axuWzYRcIp6ocLSmCkzScwabCmaHbcUnTg=", codeChallenge)
+    }
+
+    @Test
+    fun `code_challenge hashing should match server expectation`() {
+        val verifier = "pqpUrMTIZVoB31IKqc.rznkjs9RiK.qdLWXktKUrqXn"
+        val codeChallenge = AuthCodeHelper.generateCodeChallenge(verifier)
+
+        assertEquals("o23Jen9tjz5kkUrEuwWiPr-XaNszrAKusCj80QxipsU=", codeChallenge)
+    }
+
+    @Test
+    fun `code_challenge hashing should handle all expected characters`() {
+        val verifier = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
+        val codeChallenge = AuthCodeHelper.generateCodeChallenge(verifier)
+
+        assertEquals("RZ77XZltYSfl0BLxuGd8pHGJ4EoMoVDVuSWHgNq3RY8=", codeChallenge)
+    }
+}
diff --git a/app/src/test/java/org/mozilla/firefox/vpn/servers/domain/SetSelectedServerUseCaseTest.kt b/app/src/test/java/org/mozilla/firefox/vpn/servers/domain/SetSelectedServerUseCaseTest.kt
index 288d9fb..5152c9d 100644
--- a/app/src/test/java/org/mozilla/firefox/vpn/servers/domain/SetSelectedServerUseCaseTest.kt
+++ b/app/src/test/java/org/mozilla/firefox/vpn/servers/domain/SetSelectedServerUseCaseTest.kt
@@ -25,13 +25,13 @@ class SetSelectedServerUseCaseTest {
     private val sharedPref = mockk<SharedPreferences> {
         every { getString(any(), any()) } returns Gson().toJson(selectedServer)
         every { edit().putString(any(), any()).apply() } just Runs
     }
 
-    lateinit var serverRepository: ServerRepository
-    lateinit var selectedServerProvider: SelectedServerProvider
-    lateinit var setSelectedServerUseCase: SetSelectedServerUseCase
+    private lateinit var serverRepository: ServerRepository
+    private lateinit var selectedServerProvider: SelectedServerProvider
+    private lateinit var setSelectedServerUseCase: SetSelectedServerUseCase
 
     @Before
     fun setUp() {
         serverRepository = ServerRepository(MockGuardianService(), sharedPref)
         selectedServerProvider = SelectedServerProvider(serverRepository)
diff --git a/app/src/test/java/org/mozilla/firefox/vpn/service/LoginQueryBuilderTest.kt b/app/src/test/java/org/mozilla/firefox/vpn/service/LoginQueryBuilderTest.kt
deleted file mode 100644
index 50fb563..0000000
--- a/app/src/test/java/org/mozilla/firefox/vpn/service/LoginQueryBuilderTest.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.mozilla.firefox.vpn.service
-
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-
-@RunWith(RobolectricTestRunner::class)
-class LoginQueryBuilderTest {
-
-    @Test
-    fun `only send valid referral info to backend`() {
-        var loginQueryBuilder = LoginQueryBuilder("")
-        assertTrue(loginQueryBuilder.build().isEmpty())
-
-        loginQueryBuilder = LoginQueryBuilder("aaa")
-        assertTrue(loginQueryBuilder.build().size == 1)
-
-        loginQueryBuilder = LoginQueryBuilder("aaa=")
-        assertTrue(loginQueryBuilder.build().size == 1)
-
-        loginQueryBuilder = LoginQueryBuilder("=aaa")
-        assertTrue(loginQueryBuilder.build().isEmpty())
-
-        loginQueryBuilder = LoginQueryBuilder("aaa=xxx")
-        assertTrue(loginQueryBuilder.build().size == 1)
-
-        loginQueryBuilder = LoginQueryBuilder("?aaa")
-        assertTrue(loginQueryBuilder.build().size == 1)
-
-        loginQueryBuilder = LoginQueryBuilder("aaa=xxx&bbb=zzz")
-        assertTrue(loginQueryBuilder.build().size == 2)
-
-        loginQueryBuilder = LoginQueryBuilder("aaa=xxx&=zzz")
-        assertTrue(loginQueryBuilder.build().size == 1)
-    }
-}

Vulnerable code:     testImplementation 'org.robolectric:robolectric:3.4.2'
class GuardianApp : Application() {
import org.mozilla.firefox.vpn.user.data.ReferralManager
    private val referralManager = ReferralManager(coreComponent.app.applicationContext, prefs)

        UserRepository(service, sessionManager, referralManager)
import org.mozilla.firefox.vpn.user.data.ReferralManager
    private val referralManager = ReferralManager(coreComponent.app.applicationContext, prefs)

        UserRepository(service, sessionManager, referralManager)
                userRepository.removeUserInfo()
import android.view.View
        viewModel.toast.observe(this, Observer {
        viewModel.showLoggedOutMessage.observe(this, Observer {
        viewModel.promptLogin.observe(this, Observer {
        viewModel.launchMainPage.observerUntilOnDestroy(this, Observer {
                redirectHandled = true
        viewModel.uiModel.observe(this, Observer {
            binding.loadingView.visibility = if (it.isLoading) {
                View.VISIBLE
            } else {
                View.GONE
            }
    override fun onResume() {
        super.onResume()
        viewModel.resumeLoginFlow()
    }

import org.mozilla.firefox.vpn.onboarding.domain.ClearPendingLoginInfoUseCase
import org.mozilla.firefox.vpn.onboarding.domain.GetPendingLoginInfoUseCase
import org.mozilla.firefox.vpn.onboarding.domain.SetPendingLoginInfoUseCase
import org.mozilla.firefox.vpn.user.domain.GetLoginInfoUseCase
import org.mozilla.firefox.vpn.user.domain.VerifyLoginUseCase
        get() = OnboardingViewModel(
            loginInfoUseCase = GetLoginInfoUseCase(userRepo),
            verifyLoginUseCase = VerifyLoginUseCase(userRepo),
            createUserUseCase = CreateUserUseCase(userRepo, userStateResolver),
            addDeviceUseCase = AddDeviceUseCase(deviceRepo, userRepo),
            setPendingLoginInfoUseCase = SetPendingLoginInfoUseCase(prefs),
            getPendingLoginInfoUseCase = GetPendingLoginInfoUseCase(prefs),
            clearPendingLoginInfoUseCase = ClearPendingLoginInfoUseCase(prefs)
        )
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.hadilq.liveevent.LiveEvent
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.cancel
import org.mozilla.firefox.vpn.onboarding.domain.ClearPendingLoginInfoUseCase
import org.mozilla.firefox.vpn.onboarding.domain.GetPendingLoginInfoUseCase
import org.mozilla.firefox.vpn.onboarding.domain.SetPendingLoginInfoUseCase
import org.mozilla.firefox.vpn.service.LoginInfo
import org.mozilla.firefox.vpn.service.LoginResult
import org.mozilla.firefox.vpn.user.domain.GetLoginInfoUseCase
import org.mozilla.firefox.vpn.user.domain.VerifyLoginUseCase
import org.mozilla.firefox.vpn.util.addCompletionHandler
import org.mozilla.firefox.vpn.util.onSuccess
    private val loginInfoUseCase: GetLoginInfoUseCase,
    private val verifyLoginUseCase: VerifyLoginUseCase,
    private val setPendingLoginInfoUseCase: SetPendingLoginInfoUseCase,
    private val getPendingLoginInfoUseCase: GetPendingLoginInfoUseCase,
    private val clearPendingLoginInfoUseCase: ClearPendingLoginInfoUseCase
    val toast = LiveEvent<StringResource>()
    val showLoggedOutMessage = LiveEvent<StringResource>()
    val launchMainPage = LiveEvent<Unit>()
    val promptLogin = LiveEvent<String>()

    private val _uiModel = MutableLiveData<UiModel>()
    val uiModel: LiveData<UiModel> = _uiModel

    // See #153 & #204 before modifying the login flow
    private val loginSuccess = AtomicBoolean(false)

    private var verificationJob: Job? = null

                showLoggedOutMessage.postValue(StringResource(R.string.onboarding_logged_out))
    fun startLoginFlow() {
        viewModelScope.launch(Dispatchers.Main) { getLoginInfo().onSuccess { login(it) } }
    }

    fun resumeLoginFlow() {
        viewModelScope.launch(Dispatchers.Main.immediate) {
            _uiModel.value = UiModel(true)

            if (loginSuccess.get()) {
                // Already being verified by the verifyLoginAsync()
                gotoMainPage()
            } else {
                getPendingLoginInfoUseCase()
                    ?.let { verifyLoginOnce(it) }
                    ?: run { _uiModel.value = UiModel(false) }
            }
        }
    }

    override fun onCleared() {
        if (loginSuccess.get()) {
            clearPendingLoginInfoUseCase()
        }
        super.onCleared()
    }

    private fun cancelLoginFlow() {
        verificationJob?.cancel("verification cancelled")
    }

    private suspend fun getLoginInfo() = withContext(Dispatchers.IO) {
        loginInfoUseCase()
    }

    private suspend fun login(info: LoginInfo) = withContext(Dispatchers.Main) {
        // Save login info persistently so it survives even if the view(activity) is recycled
        setPendingLoginInfoUseCase(info)
        promptLogin.value = info.loginUrl
        verificationJob = verifyLoginPeriodically(info).addCompletionHandler { verificationJob = null }
    }

    private suspend fun verifyLogin(info: LoginInfo, retry: Boolean = false) = withContext(Dispatchers.IO) {
        when (val result = verifyLoginUseCase(info, retry)) {
            is Result.Success -> onLoginSuccess(result.value)
            is Result.Fail -> {
                _uiModel.postValue(UiModel(false))
                cancelLoginFlow()
                GLog.d(TAG, "verify login failed: ${result.exception}")
    private suspend fun verifyLoginOnce(info: LoginInfo) = withContext(Dispatchers.IO) {
        verifyLogin(info, false)
    }

    private suspend fun verifyLoginPeriodically(info: LoginInfo) = viewModelScope.launch(Dispatchers.IO) {
        verifyLogin(info, true)
    }

    private suspend fun onLoginSuccess(
        loginResult: LoginResult
    ) = withContext(Dispatchers.Main) {
        if (loginSuccess.compareAndSet(false, true)) {
            setupNewUser(loginResult)
        }

        gotoMainPage()
    }

    private suspend fun gotoMainPage() = withContext(Dispatchers.Main.immediate) {
        launchMainPage.value = Unit
    }

    private suspend fun setupNewUser(
        loginResult: LoginResult
    ) = withContext(Dispatchers.IO) {
        createUserUseCase(loginResult)

    data class UiModel(
        val isLoading: Boolean
    )

package org.mozilla.firefox.vpn.onboarding.domain

import android.content.SharedPreferences
import androidx.core.content.edit

class ClearPendingLoginInfoUseCase(
    private val prefs: SharedPreferences
) {

    operator fun invoke() {
        prefs.edit { remove(SetPendingLoginInfoUseCase.PREF_PENDING_LOGIN_INFO) }
    }
}
package org.mozilla.firefox.vpn.onboarding.domain

import android.content.SharedPreferences
import com.google.gson.Gson
import org.mozilla.firefox.vpn.service.LoginInfo

class GetPendingLoginInfoUseCase(
    private val prefs: SharedPreferences
) {

    operator fun invoke(): LoginInfo? {
        return prefs.getString(SetPendingLoginInfoUseCase.PREF_PENDING_LOGIN_INFO, null)
            ?.let {
                try {
                    Gson().fromJson(it, LoginInfo::class.java)
                } catch (e: Exception) {
                    null
                }
            }
    }
}
package org.mozilla.firefox.vpn.onboarding.domain

import android.content.SharedPreferences
import androidx.core.content.edit
import com.google.gson.Gson
import org.mozilla.firefox.vpn.service.LoginInfo

class SetPendingLoginInfoUseCase(
    private val prefs: SharedPreferences
) {

    operator fun invoke(info: LoginInfo) {
        prefs.edit { putString(PREF_PENDING_LOGIN_INFO, Gson().toJson(info)) }
    }

    companion object {
        const val PREF_PENDING_LOGIN_INFO = "pending_login_info"
    }
}
import org.mozilla.firefox.vpn.service.LoginInfo
import org.mozilla.firefox.vpn.service.LoginResult
import org.mozilla.firefox.vpn.util.TimeFormat
import org.mozilla.firefox.vpn.util.TimeUtil
        is LoginInfo -> value.toReport()
        is LoginResult -> value.toReport()
fun LoginInfo.toReport(): String {
    val time = System.currentTimeMillis()
    val expire = try {
        TimeUtil.parse(this.expiresOn, TimeFormat.Iso8601).time
    } catch (e: Exception) {
        0L
    }
    val expireIn = expire - time
    val interval = this.pollInterval
    return "expireIn=$expireIn, pollInterval=$interval"
}

fun LoginResult.toReport(): String {
    val subscribed = user.subscription.isSubscribed
    val numDevices = user.devices
import androidx.core.net.toUri
import retrofit2.http.QueryMap
import retrofit2.http.Url
    @POST("api/v1/vpn/login")
    suspend fun getLoginInfo(@QueryMap referral: Map<String, String>): Response<LoginInfo>
    @GET
    suspend fun verifyLogin(@Url verifyUrl: String): Response<LoginResult>
                request.addHeader("Authorization", "Bearer ${sessionManager.getUserInfo()?.token}")
    val abi = Build.SUPPORTED_ABIS.firstOrNull()?.let { it } ?: "no-support-abi"
data class LoginInfo(
    @SerializedName("login_url")
    val loginUrl: String,

    @SerializedName("verification_url")
    val verificationUrl: String,

    @SerializedName("expires_on")
    val expiresOn: String,

    @SerializedName("poll_interval")
    val pollInterval: Int
)

    @SerializedName("user")
    val user: User,

    val token: String
class LoginQueryBuilder(private val referral: String) {

    fun build(): Map<String, String> {
        return "mozilla.com?$referral".toUri().let { uri ->
            uri.queryParameterNames
                .filter { it.isNotEmpty() } // parameter name shouldn't be empty
                .map { it to (uri.getQueryParameter(it) ?: "") }
                .toMap()
        }
    }
}

open class UnknownException(private val msg: String) : RuntimeException(msg)
    override suspend fun getLoginInfo(loginRequestBody: Map<String, String>): Response<LoginInfo> {
        delay()
        return Response.success(LoginInfo(
            loginUrl,
            verifyUrl,
            nextYear(),
            2000
        ))
    }

    override suspend fun verifyLogin(verifyUrl: String): Response<LoginResult> {
        delay()
        return Response.success(LoginResult(user, "mock_token"))
package org.mozilla.firefox.vpn.user.data

import android.content.Context
import android.content.SharedPreferences
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener
import com.android.installreferrer.api.ReferrerDetails
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import org.mozilla.firefox.vpn.util.GLog

class ReferralManager(context: Context, private val prefs: SharedPreferences) {

    companion object {
        private const val TAG = "ReferralManager"
        private const val SIR_PREF_REFERRAL = "sir_pref_referral"
        private const val DEFAULT_REFERRAL = ""
    }

    private var referrerClient: InstallReferrerClient =
        InstallReferrerClient.newBuilder(context).build()

    suspend fun getUserReferral() = suspendCancellableCoroutine<String> { cont ->
        val cacheReferral = prefs.getString(SIR_PREF_REFERRAL, null)
        if (cacheReferral != null) {
            GLog.d(TAG, "referral already submitted")
            if (cont.isActive) {
                cont.resume(cacheReferral)
                return@suspendCancellableCoroutine
            }
        }
        referrerClient.startConnection(object : InstallReferrerStateListener {

            override fun onInstallReferrerSetupFinished(responseCode: Int) {
                when (responseCode) {
                    InstallReferrerClient.InstallReferrerResponse.OK -> {
                        val response: ReferrerDetails = referrerClient.installReferrer
                        val referrerUrl: String = response.installReferrer
                        prefs.edit().putString(SIR_PREF_REFERRAL, referrerUrl).apply()
                        if (cont.isActive) {
                            GLog.d(TAG, "onInstallReferrerSetupFinished")
                            cont.resume(referrerUrl)
                            return
                        }
                    }
                    InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED -> {
                        // API not available on the current Play Store app or there's no Play Store
                        GLog.w(TAG, "FEATURE_NOT_SUPPORTED")
                    }
                    InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE -> {
                        // Connection couldn't be established.
                        GLog.w(TAG, "SERVICE_UNAVAILABLE")
                    }
                }
                if (cont.isActive) {
                    cont.resume(DEFAULT_REFERRAL)
                    return
                }
            }

            override fun onInstallReferrerServiceDisconnected() {
                // Try to restart the connection on the next request to
                // Google Play by calling the startConnection() method.
                if (cont.isActive) {
                    GLog.w(TAG, "onInstallReferrerServiceDisconnected")
                    cont.resume(DEFAULT_REFERRAL)
                    return
                }
            }
        })
    }
}
    fun createUserInfo(user: UserInfo) {
    fun removeUserInfo() {
        prefs.edit().remove(PREF_USER_INFO).apply()
import org.mozilla.firefox.vpn.service.LoginInfo
import org.mozilla.firefox.vpn.service.LoginQueryBuilder
    private val sessionManager: SessionManager,
    private val referralManager: ReferralManager
     * @return Result.Success(loginInfo) or Result.Fail(NetworkException|Otherwise)
    suspend fun getLoginInfo(): Result<LoginInfo> {
        return try {
            val loginQueryBuilder = LoginQueryBuilder(referralManager.getUserReferral())
            guardianService.getLoginInfo(loginQueryBuilder.build()).resolveBody()
        } catch (e: UnknownHostException) {
            Result.Fail(NetworkException)
        } catch (e: Exception) {
            Result.Fail(UnknownException("Unknown exception=$e"))
        }
    /**
     * @return Result.Success(loginResult) or Result.Fail(UnauthorizedException|NetworkException|Otherwise)
     */
    suspend fun verifyLogin(info: LoginInfo): Result<LoginResult> {
        return try {
            val response = guardianService.verifyLogin(info.verificationUrl)
            response.resolveBody()
                .handleError(401) {
                    it?.toErrorBody()
                        ?.toUnauthorizedError()
                        ?: UnknownErrorBody(it)
                }
        } catch (e: UnknownHostException) {
            Result.Fail(NetworkException)
        } catch (e: Exception) {
            Result.Fail(UnknownException("Unknown exception=$e"))
        }
    }
    fun createUserInfo(user: UserInfo) {
        sessionManager.createUserInfo(user)
    }
    fun getUserInfo(): UserInfo? {
        return sessionManager.getUserInfo()
    }
    fun removeUserInfo() {
        sessionManager.removeUserInfo()
    }
        val userInfo = getUserInfo() ?: return Result.Fail(UnauthorizedException())

                    userInfo.copy(
                    ).apply { createUserInfo(this) }
    val token: String,
import org.mozilla.firefox.vpn.service.LoginResult
import org.mozilla.firefox.vpn.user.data.UserInfo
    operator fun invoke(loginResult: LoginResult): UserInfo {
        return UserInfo(
            user = loginResult.user,
            token = loginResult.token,
            latestUpdateTime = System.currentTimeMillis()
        ).apply {
            userRepository.createUserInfo(this)
            userStateResolver.refresh()
        }
package org.mozilla.firefox.vpn.user.domain

import org.mozilla.firefox.vpn.report.doReport
import org.mozilla.firefox.vpn.service.LoginInfo
import org.mozilla.firefox.vpn.user.data.UserRepository
import org.mozilla.firefox.vpn.util.Result

class GetLoginInfoUseCase(
    private val userRepository: UserRepository
) {

    suspend operator fun invoke(): Result<LoginInfo> {
        return userRepository.getLoginInfo().doReport(tag = TAG)
    }

    companion object {
        private const val TAG = "GetLoginInfoUseCase"
    }
}
        userRepository.removeUserInfo()
package org.mozilla.firefox.vpn.user.domain

import kotlinx.coroutines.delay
import org.mozilla.firefox.vpn.report.doReport
import org.mozilla.firefox.vpn.service.LoginInfo
import org.mozilla.firefox.vpn.service.LoginResult
import org.mozilla.firefox.vpn.service.LoginTokenExpired
import org.mozilla.firefox.vpn.service.LoginTokenNotFound
import org.mozilla.firefox.vpn.user.data.UserRepository
import org.mozilla.firefox.vpn.util.Result

class VerifyLoginUseCase(
    private val userRepository: UserRepository
) {

    suspend operator fun invoke(info: LoginInfo, retry: Boolean): Result<LoginResult> {
        var result = verifyLogin(info)
        if (!retry) {
            return result
        }

        while (result is Result.Fail) {
            // Nothing we can do to invalid/expired token. User will have to close the custom tab
            // and click login button again to retrieve new token
            when (result.exception) {
                is LoginTokenNotFound, is LoginTokenExpired -> return result
            }

            delay(info.pollInterval * 1000L)
            result = verifyLogin(info)
        }
        return result
    }

    private suspend fun verifyLogin(info: LoginInfo) = userRepository
        .verifyLogin(info)
        .doReport(tag = TAG)

    companion object {
        private const val TAG = "VerifyLoginUseCase"
    }
}
                this@LoginCustomTab.session = client.newSession(null)
    var str: String? = null
    var formatArgs: Array<out String>? = null
    lateinit var serverRepository: ServerRepository
    lateinit var selectedServerProvider: SelectedServerProvider
    lateinit var setSelectedServerUseCase: SetSelectedServerUseCase
package org.mozilla.firefox.vpn.service

import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class LoginQueryBuilderTest {

    @Test
    fun `only send valid referral info to backend`() {
        var loginQueryBuilder = LoginQueryBuilder("")
        assertTrue(loginQueryBuilder.build().isEmpty())

        loginQueryBuilder = LoginQueryBuilder("aaa")
        assertTrue(loginQueryBuilder.build().size == 1)

        loginQueryBuilder = LoginQueryBuilder("aaa=")
        assertTrue(loginQueryBuilder.build().size == 1)

        loginQueryBuilder = LoginQueryBuilder("=aaa")
        assertTrue(loginQueryBuilder.build().isEmpty())

        loginQueryBuilder = LoginQueryBuilder("aaa=xxx")
        assertTrue(loginQueryBuilder.build().size == 1)

        loginQueryBuilder = LoginQueryBuilder("?aaa")
        assertTrue(loginQueryBuilder.build().size == 1)

        loginQueryBuilder = LoginQueryBuilder("aaa=xxx&bbb=zzz")
        assertTrue(loginQueryBuilder.build().size == 2)

        loginQueryBuilder = LoginQueryBuilder("aaa=xxx&=zzz")
        assertTrue(loginQueryBuilder.build().size == 1)
    }
}
Fixed code: 
    testOptions {
        unitTests {
            includeAndroidResources = true
        }
    }
    testImplementation 'org.robolectric:robolectric:4.4'
    testImplementation 'androidx.test:core:1.3.0'
    testImplementation 'androidx.test.ext:junit:1.1.1'

    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
    androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0"
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn

import android.content.Intent
import android.net.Uri
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.FlakyTest
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertNotNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.firefox.vpn.splash.SplashActivity

@LargeTest
@RunWith(AndroidJUnit4::class)
class PkceRegressionTest {

    @Rule
    @JvmField
    val splashActivityTestRule = ActivityTestRule(SplashActivity::class.java)

    @Rule
    @JvmField
    val intentReceiverActivityTestRule = ActivityTestRule(IntentReceiverActivity::class.java)

    @Test
    @FlakyTest
    /**
     * Flaky for two reasons.  1) this needs to touch the network, 2) Espresso tests are just flaky.
     */
    fun pkce_regression_test() {
        // Simulate response from user logging in at:
        // "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net/api/v2/vpn/login/android?code_challenge=fx-O4_N_sfGrXxLgDkByfVNgZUPCI1s5PqWp8k1fG8M=&code_challenge_method=S256"
        val authCode = "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d"
        val intentUri = Uri.parse(
            "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
                    "/vpn/client/login/success?" +
                    "code=$authCode" +
                    "#Intent;category=android.intent.category.BROWSABLE;" +
                    "launchFlags=0x14000000;" +
                    "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
                    "i.org.chromium.chrome.browser.referrer_id=18;" +
                    "S.com.android.browser.application_id=com.android.chrome;end"
        )
        val intent = Intent("android.intent.action.VIEW", intentUri)

        val receivedCode = CompletableDeferred<AuthCode>()
        IntentReceiverActivity.setAuthCodeReceivedDeferred(receivedCode)

        // IntentReceiverActivity launched with the above auth code
        intentReceiverActivityTestRule.launchActivity(intent)

        // assert an auth code was received
        runBlocking {
            withTimeout(5_000) {
                assertNotNull(receivedCode.await())
            }
        }

        runBlocking { delay(1_000) }

        // assert onboarding screen still shown, login did not proceed
        onView(withId(R.id.auth_btn)).check(matches(isDisplayed()))
    }
}
        <activity android:name="org.mozilla.firefox.vpn.IntentReceiverActivity"
            android:screenOrientation="portrait"
            tools:ignore="LockedOrientationActivity">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data
                    android:scheme="mozilla-vpn"
                    android:host="login"
                    android:path="/success" />
            </intent-filter>
        </activity>

open class GuardianApp : Application() {
        UserRepository(service, sessionManager)
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn

import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.CompletableDeferred
import org.mozilla.firefox.vpn.ext.toCode
import org.mozilla.firefox.vpn.user.domain.AuthToken

/**
 * Code that is included in the `verify` request that is used to retrieve an [AuthToken].
 */
typealias AuthCode = String

/**
 * Abstraction that handles incoming [Intent]s for use elsewhere in the app.  After
 * processing an [Intent], this activity will finish itself.
 */
open class IntentReceiverActivity : AppCompatActivity() {

    companion object {

        /**
         * Set a [CompletableDeferred] that will be completed with an [AuthCode] the next
         * time one is received.  Note that this is not guaranteed to ever complete.
         *
         * This is static because the alternative was a similarly bad practice: weaving
         * the reference through many layers of Android framework code.
         */
        fun setAuthCodeReceivedDeferred(authCodeReceived: CompletableDeferred<AuthCode>) {
            _authCodeReceived = authCodeReceived
        }
        private var _authCodeReceived: CompletableDeferred<AuthCode> = CompletableDeferred()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        intent?.data?.toCode()?.let { authCode ->
            _authCodeReceived.complete(authCode)
        }

        finish()
    }
}
        UserRepository(service, sessionManager)
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn.crypto

import android.util.Base64
import java.security.MessageDigest
import java.security.SecureRandom
import kotlin.random.Random
import kotlin.random.asKotlinRandom

/**
 * PKCE code verifier.
 */
typealias CodeVerifier = String
/**
 * PKCE code challenge.
 */
typealias CodeChallenge = String

private const val CHALLENGE_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP

/**
 * Contains utility functions for generating secure codes.
 */
object AuthCodeHelper {

    /**
     * Returns a cryptographically random key. Sent during token request as part of PKCE.
     */
    fun generateCodeVerifier(random: Random = SecureRandom().asKotlinRandom()): CodeVerifier {
        val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', '.', '~')

        val size = (43..128).random(random)

        val sb = StringBuilder()
        for (i in 1..size) {
            sb.append(allowedChars.random(random))
        }
        return sb.toString()
    }

    /**
     * Returns a SHA-256 encoded hash based on [verifier]. Used to retrieve a token as
     * part of PKCE.
     */
    @Synchronized // MessageDigest is not thread-safe
    fun generateCodeChallenge(verifier: CodeVerifier): CodeChallenge {
        val bytes = verifier.toByteArray(Charsets.US_ASCII)
        val messageDigest = MessageDigest.getInstance("SHA-256")
        messageDigest.update(bytes, 0, bytes.size)
        val digest = messageDigest.digest()

        return Base64.encodeToString(digest, CHALLENGE_FLAGS)
    }
}
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn.ext

import com.hadilq.liveevent.LiveEvent

/**
 * Adds an event on to the [LiveEvent]. This makes usage more idiomatic by letting
 * [LiveEvent]s be called as functions.
 *
 * EXAMPLE
 *
 * With this extension:
 * ```
 * promptLogin(info.loginUrl)
 * ```
 *
 * Without this extension:
 * ```
 * promptLogin.value = info.loginUrl
 * ```
 */
operator fun <T> LiveEvent<T>.invoke(value: T) {
    this.value = value
}

/**
 * Adds an event on to the [LiveEvent]. This makes usage more idiomatic by letting
 * [LiveEvent]s be called as functions.
 *
 * EXAMPLE
 *
 * With this extension:
 * ```
 * promptLogin()
 * ```
 *
 * Without this extension:
 * ```
 * promptLogin.value = Unit
 * ```
 */
operator fun LiveEvent<Unit>.invoke() {
    this.value = Unit
}
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn.ext

import android.net.Uri
import org.mozilla.firefox.vpn.AuthCode

private const val CODE_QUERY_PARAM = "code"

private val ALLOWED_CODE_CHARS = (('0'..'9') + ('a'..'f')).toSet()

fun Uri.toCode(): AuthCode? {
    val code = getQueryParameter(CODE_QUERY_PARAM)

    return if (
        code == null ||
        code.length != 80 ||
        code.any { char -> !ALLOWED_CODE_CHARS.contains(char) }
    ) {
        null
    } else {
        code
    }
}
                userRepository.invalidateSession()
        viewModel.bus.toast.observe(this, Observer {
        viewModel.bus.showLoggedOutMessage.observe(this, Observer {
        viewModel.bus.promptLogin.observe(this, Observer {
        viewModel.bus.launchMainPage.observerUntilOnDestroy(this, Observer {
        viewModel.bus.closeTabsToOnboarding.observerUntilOnDestroy(this, Observer {
            closeCustomTabsToOnboarding()
        redirectHandled = true

        startActivity(getStartIntent(this@OnboardingActivity).apply {
            addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
        })
    }

    private fun closeCustomTabsToOnboarding() {
        // See #redirectToMainPage for explanation
        redirectHandled = false
import com.hadilq.liveevent.LiveEvent
import org.mozilla.firefox.vpn.user.domain.GetAuthCodeUseCase
import org.mozilla.firefox.vpn.user.domain.GetTokenUseCase
import org.mozilla.firefox.vpn.user.domain.SaveAuthTokenUseCase
import org.mozilla.firefox.vpn.util.StringResource
        get() {
            val bus = Bus()
            return OnboardingViewModel(
                bus = bus,
                createUserUseCase = CreateUserUseCase(userRepo, userStateResolver),
                addDeviceUseCase = AddDeviceUseCase(deviceRepo, userRepo),
                getTokenUseCase = GetTokenUseCase(userRepo, GetAuthCodeUseCase(bus)),
                saveAuthTokenUseCase = SaveAuthTokenUseCase(userRepo)
            )
        }
}

/**
 * Used to pass data to the presentation layer.
 *
 * Not technically a bus, but CollectionOfChannels is a bad name.
 */
class Bus {
    val toast = LiveEvent<StringResource>()
    val showLoggedOutMessage = LiveEvent<StringResource>()
    val launchMainPage = LiveEvent<Unit>()
    val closeTabsToOnboarding = LiveEvent<Unit>()
    val promptLogin = LiveEvent<String>()
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import org.mozilla.firefox.vpn.user.domain.GetTokenUseCase
import org.mozilla.firefox.vpn.user.domain.SaveAuthTokenUseCase
    val bus: Bus,
    private val getTokenUseCase: GetTokenUseCase,
    private val saveAuthTokenUseCase: SaveAuthTokenUseCase
                bus.showLoggedOutMessage.postValue(StringResource(R.string.onboarding_logged_out))
    private var loginJob: Job = Job()
    fun startLoginFlow() {
        // Some edge cases (e.g., rapidly clicking the "get started" button) can cause this
        // method to be repeatedly called.  We cancel any previous attempts here, so that
        // at most one is running at any time.
        runBlocking { loginJob.cancelAndJoin() }
        loginJob = viewModelScope.launch(Dispatchers.IO) {

            when (val token = getTokenUseCase(scope = this)) {
                is Result.Success -> {
                    saveAuthTokenUseCase(token.value)

                    setupNewUser()

                    bus.launchMainPage.postValue(Unit)
                }
                is Result.Fail -> {
                    // TODO this would be a good place to show an error toast, if we had
                    // copy. This at least prevents them from watching an endless progress
                    // bar on failure.
                    bus.closeTabsToOnboarding.postValue(Unit)
                    GLog.d(TAG, "verify login failed: ${token.exception}")
                }
    private suspend fun setupNewUser() = withContext(Dispatchers.IO) {
        // Note: `createUserUseCase` and `AddDeviceUseCase` are temporally coupled
        createUserUseCase()
import org.mozilla.firefox.vpn.service.User
        is User -> value.toReport()
fun User.toReport(): String {
    val subscribed = subscription.isSubscribed
    val numDevices = devices
import org.mozilla.firefox.vpn.AuthCode
import org.mozilla.firefox.vpn.crypto.CodeChallenge
import org.mozilla.firefox.vpn.crypto.CodeVerifier
import org.mozilla.firefox.vpn.user.domain.AuthToken
    @POST("/api/v2/vpn/login/verify/")
    suspend fun verifyLogin(@Body postData: PostData): Response<LoginResult>

    data class PostData(
        @SerializedName("code") val code: AuthCode,
        @SerializedName("code_verifier") val codeVerifier: CodeVerifier
    )

        fun getLoginUrl(
            codeChallenge: CodeChallenge,
            challengeMethod: String = "S256"
        ) = "$HOST_GUARDIAN/api/v2/vpn/login/android?" +
                "code_challenge=$codeChallenge&code_challenge_method=$challengeMethod"
                request.addHeader("Authorization", "Bearer ${sessionManager.getAuthToken()}")
    val abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "no-support-abi"
    val token: AuthToken
object BrowserClosedWithoutLogin : RuntimeException()

open class UnknownException(msg: String) : RuntimeException(msg)
    override suspend fun verifyLogin(data: GuardianService.PostData): Response<LoginResult> {
        return Response.success(LoginResult(("mock_token")))
import org.mozilla.firefox.vpn.user.domain.AuthToken
    fun saveUserInfo(user: UserInfo) {
    fun saveAuthToken(token: AuthToken) {
        prefs.edit()
            .putString(PREF_AUTH_TOKEN, token)
            .apply()
    }

    fun getAuthToken(): AuthToken? = prefs.getString(PREF_AUTH_TOKEN, null)

    fun invalidateSession() {
        prefs.edit()
            .remove(PREF_USER_INFO)
            .remove(PREF_AUTH_TOKEN)
            .apply()
        private const val PREF_AUTH_TOKEN = "auth_token"
import org.mozilla.firefox.vpn.AuthCode
import org.mozilla.firefox.vpn.crypto.CodeVerifier
import org.mozilla.firefox.vpn.report.doReport
import org.mozilla.firefox.vpn.user.domain.AuthToken
private const val TAG = "UserRepository"

    private val sessionManager: SessionManager
     *
    suspend fun verifyLogin(
        authCode: AuthCode,
        codeVerifier: CodeVerifier
    ): Result<LoginResult> = try {
        val response =
            guardianService.verifyLogin(GuardianService.PostData(authCode, codeVerifier))

        response.resolveBody()
            .handleError(401) {
                it?.toErrorBody()
                    ?.toUnauthorizedError()
                    ?: UnknownErrorBody(it)
            }
    } catch (e: UnknownHostException) {
        Result.Fail(NetworkException)
    } catch (e: Exception) {
        Result.Fail(UnknownException("Unknown exception=$e"))
    fun saveUserInfo(user: UserInfo) = sessionManager.saveUserInfo(user)
    fun getUserInfo(): UserInfo? = sessionManager.getUserInfo()
    /**
     * Remove stored user info and auth token.
     */
    fun invalidateSession() = sessionManager.invalidateSession()
    fun saveAuthToken(token: AuthToken) = sessionManager.saveAuthToken(token)
                .doReport(tag = TAG)
                    UserInfo(
                    ).apply { saveUserInfo(this) }
    suspend operator fun invoke() {
        // Note: `refreshUserInfo` and `refresh` are temporally coupled
        userRepository.refreshUserInfo()
        userStateResolver.refresh()
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn.user.domain

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ensureActive
import org.mozilla.firefox.vpn.AuthCode
import org.mozilla.firefox.vpn.IntentReceiverActivity
import org.mozilla.firefox.vpn.crypto.CodeChallenge
import org.mozilla.firefox.vpn.onboarding.Bus
import org.mozilla.firefox.vpn.service.BrowserClosedWithoutLogin
import org.mozilla.firefox.vpn.service.GuardianService
import org.mozilla.firefox.vpn.service.NetworkException
import org.mozilla.firefox.vpn.service.UnknownException
import org.mozilla.firefox.vpn.util.LoginCustomTab
import org.mozilla.firefox.vpn.util.Result

class GetAuthCodeUseCase(
    private val bus: Bus
) {

    /**
     * Navigates the user to FxA login, then retrieves the auth code from the response.
     *
     * Note: this will block until the user either completes their login or closes the
     * custom tab.  Programmatically, this is a very long time.  Maybe don't use the main
     * thread.
     */
    suspend operator fun invoke(
        codeChallenge: CodeChallenge,
        scope: CoroutineScope
    ): Result<AuthCode> {

        val authCodeReceived = CompletableDeferred<AuthCode>().also {
            IntentReceiverActivity.setAuthCodeReceivedDeferred(it)
        }
        val customTabsClosed = CompletableDeferred<Unit>().also {
            LoginCustomTab.setCustomTabsClosedEvent(it)
        }

        val loginUrl = GuardianService.getLoginUrl(codeChallenge)
        bus.promptLogin.postValue(loginUrl)

        return try {
            while (!authCodeReceived.isCompleted && !customTabsClosed.isCompleted) {
                scope.ensureActive() // Yield to check for cancellation
                /* block */
            }

            when {
                authCodeReceived.isCompleted -> Result.Success(authCodeReceived.await())
                customTabsClosed.isCompleted -> Result.Fail(BrowserClosedWithoutLogin)
                else -> Result.Fail(UnknownException("Get secret failed unexpectedly"))
            }
        } finally {
            authCodeReceived.cancel()
            customTabsClosed.cancel()

            Result.Fail(NetworkException)
        }
    }
}
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn.user.domain

import kotlinx.coroutines.CoroutineScope
import org.mozilla.firefox.vpn.crypto.AuthCodeHelper
import org.mozilla.firefox.vpn.user.data.UserRepository
import org.mozilla.firefox.vpn.util.Result
import org.mozilla.firefox.vpn.util.flatMapValue
import org.mozilla.firefox.vpn.util.mapValue

/**
 * Token that is attached to all requests made by an authenticated user.
 */
typealias AuthToken = String

class GetTokenUseCase(
    private val userRepository: UserRepository,
    private val getAuthCodeUseCase: GetAuthCodeUseCase
) {

    /**
     * Begin login flow and await either a token or failure.
     *
     * This is not guaranteed to complete if the user 1) never finishes signing in and 2) never
     * cancels their login flow.
     */
    suspend operator fun invoke(scope: CoroutineScope): Result<AuthToken> {
        val codeVerifier = AuthCodeHelper.generateCodeVerifier()
        val codeChallenge = AuthCodeHelper.generateCodeChallenge(codeVerifier)

        return getAuthCodeUseCase(codeChallenge, scope)
            .flatMapValue { secret -> userRepository.verifyLogin(secret, codeVerifier) }
            .mapValue { loginResult -> loginResult.token }
    }
}
        userRepository.invalidateSession()
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn.user.domain

import org.mozilla.firefox.vpn.user.data.UserRepository

class SaveAuthTokenUseCase(private val userRepository: UserRepository) {
    operator fun invoke(token: AuthToken) {
        userRepository.saveAuthToken(token)
    }
}
import android.os.Bundle
import androidx.browser.customtabs.CustomTabsCallback
import java.util.ArrayDeque
import java.util.Queue
import kotlinx.coroutines.CompletableDeferred
                this@LoginCustomTab.session = client.newSession(customTabsCallback)

        /**
         * Set a [CompletableDeferred] that will complete the next time custom tabs are closed.
         *
         * IMPORTANT: this should be set _before_ custom tabs are opened.
         *
         * Note that custom tab callbacks are asynchronous and _extremely_ slow.  This
         * class attempts to handle cases where the user quickly opens and closes their
         * tabs, but if you encounter problems around that use case this is a good
         * place to look.
         *
         * This is static because the alternative was a similarly bad practice: weaving
         * the reference through many layers of Android framework code.
         */
        fun setCustomTabsClosedEvent(authCodeReceived: CompletableDeferred<Unit>) {
            queuedCloses.add(authCodeReceived)
        }
        private val queuedCloses: Queue<CompletableDeferred<Unit>> = ArrayDeque()
    }

    private val customTabsCallback = object : CustomTabsCallback() {
        private var openCount = 0

        override fun onNavigationEvent(navigationEvent: Int, extras: Bundle?) {
            when (navigationEvent) {
                // From testing, this seems to always be sent before TAB_HIDDEN
                TAB_SHOWN -> { openCount++ }
                // If the user clicks the button multiple times, we may have more completables
                // than we do TAB_SHOWN events.  Also note that these callbacks are
                // asynchronous and extremely slow, and from testing do not always occur in
                // order, or even at all.  E.g., a very fast `open -> close -> open` might
                // send `SHOWN -> SHOWN`.
                //
                // As a result, we are very conservative here and only close as many
                // completables as we have seen SHOWN events.  Others will be automatically
                // canceled when their parent job ends anyway.
                //
                // Be very careful changing this.  Mistakes here can cause auth to hang
                // indefinitely, as requests are immediately canceled due to extra calls to
                // `complete`.
                TAB_HIDDEN -> {
                    openCount--
                    queuedCloses.poll()?.complete(Unit)

                    var head = queuedCloses.peek()
                    while (head != null && openCount > 0) {
                        head = queuedCloses.poll()
                        head?.complete(Unit)
                        openCount--
                    }
                }
                else -> { /* Do nothing */
                }
            }
        }
    private var str: String? = null
    private var formatArgs: Array<out String>? = null
inline fun <T : Any, R : Any> Result<T>.flatMapValue(function: (T) -> Result<R>): Result<R> =
    when (this) {
        is Result.Success -> function(value)
        is Result.Fail -> Result.Fail(exception)
    }

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn

import android.content.Intent
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.lang.RuntimeException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric.buildActivity
import org.robolectric.android.controller.ActivityController
import org.robolectric.annotation.Config

@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
@Config(application = TestGuardianApp::class, sdk = [WORKING_SDK])
class IntentReceiverActivityTest {

    private val code = "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d"
    private val intentUri = Uri.parse(
        "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
                "/vpn/client/login/success?" +
                "code=$code" +
                "#Intent;category=android.intent.category.BROWSABLE;" +
                "launchFlags=0x14000000;" +
                "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
                "i.org.chromium.chrome.browser.referrer_id=18;" +
                "S.com.android.browser.application_id=com.android.chrome;end"
    )
    private val intent = Intent("android.intent.action.VIEW", intentUri)

    private lateinit var activity: ActivityController<IntentReceiverActivity>
    private lateinit var first: CompletableDeferred<AuthCode>
    private lateinit var second: CompletableDeferred<AuthCode>

    @Before
    fun setup() {
        first = CompletableDeferred()
        second = CompletableDeferred()
    }

    @Test
    fun `WHEN no intent is sent THEN nextLogin should not complete`() {
        assertFalse(first.isCompleted)

        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
        activity = buildActivity(IntentReceiverActivity::class.java).setup()

        assertFalse(first.isCompleted)
    }

    @Test
    fun `WHEN intent is sent THEN nextLogin should complete`() = runBlockingTest {
        assertFalse(first.isCompleted)

        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
        buildActivity(IntentReceiverActivity::class.java, intent).setup()

        assertTrue(first.isCompleted)
        assertEquals(code, first.await())

        // new deferred should be set
        IntentReceiverActivity.setAuthCodeReceivedDeferred(second)
        assertNotEquals(first, second)
        assertFalse(second.isCompleted)
    }

    @Test
    fun `GIVEN intent was sent WHEN activity is recreated several times THEN only one token should be emitted`() = runBlockingTest {
        assertFalse(first.isCompleted)

        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
        val controller =
            buildActivity(IntentReceiverActivity::class.java, intent).setup()

        assertTrue(first.isCompleted)
        assertEquals(code, first.await())

        IntentReceiverActivity.setAuthCodeReceivedDeferred(second)
        assertFalse(second.isCompleted)

        controller.pause().stop().start().resume()

        controller.pause().stop().start().resume()

        assertFalse(second.isCompleted)
    }

    @Test
    fun `GIVEN code is included WHEN other query params are included THEN they should be ignored in favor of code`() = runBlockingTest {
            val intentUri = Uri.parse(
                "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
                        "/vpn/client/login/success?" +
                        "code=$code&" +
                        "token=myToken&" +
                        "secret=mySecret&" +
                        "lotteryTicketNumber=5" +
                        "#Intent;category=android.intent.category.BROWSABLE;" +
                        "launchFlags=0x14000000;" +
                        "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
                        "i.org.chromium.chrome.browser.referrer_id=18;" +
                        "S.com.android.browser.application_id=com.android.chrome;end"
            )
            val intent = Intent("android.intent.action.VIEW", intentUri)

            IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
            assertFalse(first.isCompleted)

            buildActivity(IntentReceiverActivity::class.java, intent).setup()

            assertTrue(first.isCompleted)
            assertEquals(code, first.await())
        }

    @Test
    fun `GIVEN code is not included WHEN other query params are included THEN nextAuthCodeReceived should not complete`() {
        val intentUri = Uri.parse(
            "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
                    "/vpn/client/login/success?" +
                    "token=myToken&" +
                    "secret=mySecret&" +
                    "lotteryTicketNumber=5" +
                    "#Intent;category=android.intent.category.BROWSABLE;" +
                    "launchFlags=0x14000000;" +
                    "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
                    "i.org.chromium.chrome.browser.referrer_id=18;" +
                    "S.com.android.browser.application_id=com.android.chrome;end"
        )
        val intent = Intent("android.intent.action.VIEW", intentUri)

        IntentReceiverActivity.setAuthCodeReceivedDeferred(first)
        assertFalse(first.isCompleted)

        buildActivity(IntentReceiverActivity::class.java, intent).setup()

        assertFalse(first.isCompleted)
    }

    @Test
    fun `valid codes should pass`() {
        val failures = listOf(
            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d",
            "d6e9d39217dca1c1e09f742cd9d43705436f9b508eb460191d3743a62ad4bbedd6f64db531243b8b",
            "94261d5d6baf934f4285ff7c89f0c855e82647a8fa5a44f6e4ce00ce41ea8ba22fb10f74c7bcf206",
            "b1fe04adfef209ede45d5144cdbb9ab87924bded27b1a2310989da597fadf7b559886d07e9ca2394",
            "572e46b7773c5fa358211aa5338f8ddbda8375e9047d662cb97e49803efd91883627a10a52e9ac1a",
            "a9598960552e7fdd2d6ea9c403b762b198d318de7dc4f361c52ab2d6a2fd82b83417c3e33ce2c48f",
            "a250663294300db88f5a065b96cb9ff257feb095173d643fb7560d39ba615cdd4d43682d1c9d5063",
            "28aa232db5769b9510c428cb28fd563817a17d05301ff8f58b0f992de7576fcc8b1a4bf624d0d1c8"
        ).filter { !validateAuthCode(it, shouldPass = true) }

        if (failures.isNotEmpty()) {
            println("${failures.size} valid codes failed validation: $failures")
            throw RuntimeException()
        }
    }

    @Test
    fun `invalid codes should not pass`() {
        // Codes should always be 80 characters of lowercase hexadecimal
        val successes = listOf(
            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37", // 79 chars
            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37a8", // 81 chars
            "d60b4de6f4a8a6e2228e82b328729d9cc1-666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37", // includes dash
            "60b4de6f4a8a6e2228E82b328729d9cc1666b96A1f7a5202fdc563c925bb7a3e~a3f4efa1ef3c37", // includes tilde
            "d60b4d e6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37", // includes space
            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a.5202fdc563c925bb7a3ea3f4efa1ef3c37", // includes .
            "d60b4de6f4a8a6e2228e82b328729d9cc1666b96a1f7a5202fdc563c92/5bb7a3ea3f4efa1ef3c37", // includes /
            "60b4de6f4a8a6e2228g82b328729d9cc1666b96A1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d", // includes non-hexadecimal char (g)
            "60b4de6f4a8a6e2228E82b328729d9cc1666b96A1f7a5202fdc563c925bb7a3ea3f4efa1ef3c37d" // includes some uppercase
        ).filter { !validateAuthCode(it, shouldPass = false) }

        if (successes.isNotEmpty()) {
            println("${successes.size} invalid codes passed validation: $successes")
            throw RuntimeException()
        }
    }

    private fun validateAuthCode(code: String, shouldPass: Boolean): Boolean {
        val intentUri = Uri.parse(
            "https://stage-vpn.guardian.nonprod.cloudops.mozgcp.net" +
                    "/vpn/client/login/success?" +
                    "code=$code&" +
                    "#Intent;category=android.intent.category.BROWSABLE;" +
                    "launchFlags=0x14000000;" +
                    "component=org.mozilla.firefox.vpn.debug/org.mozilla.firefox.vpn.IntentReceiverActivity;" +
                    "i.org.chromium.chrome.browser.referrer_id=18;" +
                    "S.com.android.browser.application_id=com.android.chrome;end"
        )
        val intent = Intent("android.intent.action.VIEW", intentUri)

        val authCodeReceived = CompletableDeferred<AuthCode>()
        IntentReceiverActivity.setAuthCodeReceivedDeferred(authCodeReceived)
        assertFalse(authCodeReceived.isCompleted)

        buildActivity(IntentReceiverActivity::class.java, intent).setup()

        return runBlocking {
            shouldPass == (authCodeReceived.isCompleted && code == authCodeReceived.await())
        }
    }
}
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn

/**
 * SDK level that works with Robolectric.
 *
 * Android Studio is (as of 9/8/20) packaged with JDK 1.8, while Android API 29+ requires
 * Java 9 or newer. Aiming Robolectric tests at API 28 avoids this problem.
 */
const val WORKING_SDK = 28
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn

class TestGuardianApp : GuardianApp() {

    override fun onCreate() {
        super.onCreate()
        // Avoid Robolectric IllegalStateException 'no theme'
        setTheme(R.style.AppTheme)
    }
}
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

package org.mozilla.firefox.vpn.crypto

import kotlin.random.Random
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.firefox.vpn.TestGuardianApp
import org.mozilla.firefox.vpn.WORKING_SDK
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(application = TestGuardianApp::class, sdk = [WORKING_SDK])
class AuthCodeHelperTest {

    private lateinit var deterministicRandom: Random

    @Before
    fun setup() {
        deterministicRandom = Random(seed = 42)
    }

    @Test
    fun `code_challenges generated from the same code_verifier will always match`() {
        val verifier = "i'm a code verifier!"

        val challenge = AuthCodeHelper.generateCodeChallenge(verifier)

        AuthCodeHelper.generateCodeChallenge("some")
        AuthCodeHelper.generateCodeChallenge("other")
        AuthCodeHelper.generateCodeChallenge("verifiers")

        val challenge2 = AuthCodeHelper.generateCodeChallenge(verifier)

        assertEquals(challenge, challenge2)
    }

    @Test
    fun `code_challenges generated different code_verifiers should not match`() {
        val challenge1 = AuthCodeHelper.generateCodeChallenge("I'm the first verifier!")
        val challenge2 = AuthCodeHelper.generateCodeChallenge("I'm totally unrelated to the other one")

        assertNotEquals(challenge1, challenge2)
    }

    @Test
    fun `deterministicRandom is deterministic`() {
        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
        assertEquals(
            "qpUrMTIZVoB31IKqc.rznkjs9RiK.qdLWXktKUrqXnm-To8VSRb7iMELP4WH4vDknyPD3AuPDlHDJu8y8~DDb8G-Rdljl3r9rxsB5b",
            verifier
        )
    }

    @Test
    fun `code_challenges should contain a trailing '=' for padding`() {
        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
        val challenge = AuthCodeHelper.generateCodeChallenge(verifier)
        assertEquals("=", challenge.takeLast(1))
    }

    @Test
    fun `code_verifiers should not contain a trailing '='`() {
        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
        assertNotEquals("=", verifier.takeLast(1))
    }

    @Test
    fun `code_challenges should be 44 characters long`() {
        val verifier = AuthCodeHelper.generateCodeVerifier(deterministicRandom)
        val challenge = AuthCodeHelper.generateCodeChallenge(verifier)
        assertEquals(44, challenge.length)
    }

    @Test
    fun `code_challenge should sha256 hash its input`() {
        // This uses hardcoded values provided by the backend team for manual testing
        val verifier = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
        val codeChallenge = AuthCodeHelper.generateCodeChallenge(verifier)

        assertEquals("1uxomN6H3axuWzYRcIp6ocLSmCkzScwabCmaHbcUnTg=", codeChallenge)
    }

    @Test
    fun `code_challenge hashing should match server expectation`() {
        val verifier = "pqpUrMTIZVoB31IKqc.rznkjs9RiK.qdLWXktKUrqXn"
        val codeChallenge = AuthCodeHelper.generateCodeChallenge(verifier)

        assertEquals("o23Jen9tjz5kkUrEuwWiPr-XaNszrAKusCj80QxipsU=", codeChallenge)
    }

    @Test
    fun `code_challenge hashing should handle all expected characters`() {
        val verifier = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
        val codeChallenge = AuthCodeHelper.generateCodeChallenge(verifier)

        assertEquals("RZ77XZltYSfl0BLxuGd8pHGJ4EoMoVDVuSWHgNq3RY8=", codeChallenge)
    }
}
    private lateinit var serverRepository: ServerRepository
    private lateinit var selectedServerProvider: SelectedServerProvider
    private lateinit var setSelectedServerUseCase: SetSelectedServerUseCase