CVE-2020-15679 (Kotlin, published 2022-12-22T20:15Z)
Description: 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).
Severity: nan (CVSS v3 base score: 7.6)
Weakness: CWE-384 \u2014 Session Fixation
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.
Repository: https://github.com/mozilla-mobile/guardian-vpn-android
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 message: New non-polling authentication flow.

- Fixes intermittent hanging during auth
- Implements PKCE


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