Skip to content
This repository has been archived by the owner on Mar 9, 2022. It is now read-only.
Permalink
Browse files Browse the repository at this point in the history
New non-polling authentication flow.
- Fixes intermittent hanging during auth
- Implements PKCE
  • Loading branch information
severinrudie authored and Kami committed Oct 6, 2020
1 parent 40c281d commit 981c840
Show file tree
Hide file tree
Showing 39 changed files with 985 additions and 518 deletions.
13 changes: 12 additions & 1 deletion app/build.gradle
Expand Up @@ -64,6 +64,12 @@ android {
viewBinding {
enabled = true
}

testOptions {
unitTests {
includeAndroidResources = true
}
}
}

configurations {
Expand Down Expand Up @@ -102,13 +108,18 @@ dependencies {
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'
}

Expand Down
@@ -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()))
}
}
14 changes: 14 additions & 0 deletions app/src/main/AndroidManifest.xml
Expand Up @@ -50,6 +50,20 @@
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">
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/org/mozilla/firefox/vpn/GuardianApp.kt
Expand Up @@ -8,7 +8,7 @@ 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)
Expand Down
Expand Up @@ -11,7 +11,6 @@ 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

Expand All @@ -33,12 +32,10 @@ class GuardianComponentImpl(

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 {
Expand Down
@@ -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()
}
}
Expand Up @@ -10,7 +10,6 @@ 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

Expand All @@ -20,12 +19,10 @@ class MockedGuardianComponent(

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 {
Expand Down
57 changes: 57 additions & 0 deletions 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)
}
}
47 changes: 47 additions & 0 deletions 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
}
26 changes: 26 additions & 0 deletions 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
}
}
Expand Up @@ -27,7 +27,7 @@ class SignOutUseCase(
CoroutineScope(coroutineContext + NonCancellable).launch {
deviceRepository.getDevice()?.let {
deviceRepository.unregisterDevice(it.device.pubKey)
userRepository.removeUserInfo()
userRepository.invalidateSession()
}
}
}
Expand Down

0 comments on commit 981c840

Please sign in to comment.