refactor(android): Remove AppLink in favor of nonce+state (#2826)

* Updates Android auth to remove AppLink authentication in favor of the
custom URI -based scheme defined in #2823
* Default browser is opened instead of requiring Chrome

Fixes #2703

---------

Signed-off-by: Jamil <jamilbk@users.noreply.github.com>
Signed-off-by: Brian Manifold <bmanifold@users.noreply.github.com>
Signed-off-by: Reactor Scram <ReactorScram@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Reactor Scram <ReactorScram@users.noreply.github.com>
Co-authored-by: Andrew Dryga <andrew@dryga.com>
Co-authored-by: Brian Manifold <bmanifold@users.noreply.github.com>
Co-authored-by: Gabi <gabrielalejandro7@gmail.com>
Co-authored-by: Thomas Eizinger <thomas@eizinger.io>
Co-authored-by: Jason Elie Bou Kheir <5115126+jasonboukheir@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Roopesh Chander <roop@roopc.net>
This commit is contained in:
Jamil
2024-01-09 09:41:54 -08:00
committed by GitHub
parent 1fd5d8ed33
commit 6a9ba5412c
15 changed files with 88 additions and 210 deletions

View File

@@ -1,16 +0,0 @@
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "dev.firezone.android",
"sha256_cert_fingerprints": [
"82:86:46:E7:B7:FC:BD:01:4E:53:D5:92:E7:07:A6:90:B6:03:07:E5:02:E8:A9:20:EA:EE:54:6B:FA:E6:69:AA",
"CB:FA:24:CE:CE:87:AF:5C:EF:C4:E4:E8:04:BC:C1:B8:34:0B:4E:7E:77:E1:26:80:41:6D:A8:44:56:DF:BA:A5",
"70:FB:26:CB:DB:60:99:ED:E8:D1:11:7F:5F:0E:AC:35:9D:D9:23:14:24:9E:A0:35:C3:FC:5D:22:E2:08:EB:A8"
]
}
}
]

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -46,44 +47,14 @@
android:exported="false" />
<activity
android:name="dev.firezone.android.features.applink.ui.AppLinkHandlerActivity"
android:name="dev.firezone.android.features.customuri.ui.CustomUriHandlerActivity"
android:exported="true"
android:launchMode="singleTop">
<!-- Staging -->
<intent-filter
android:label="@string/app_name"
android:autoVerify="true">
<intent-filter android:label="@string/app_name">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- These must match the resulting URL from the Portal exactly.
Don't use variables here otherwise this can break when testing in the emulator.
For this to work in the emulator, you must use a host/IP that *both* the emulator and the
host can access. E.g. 10.0.2.2 will not work. -->
<data android:scheme="https" />
<data android:host="app.firez.one" />
<data android:pathPrefix="/handle_client_auth_callback" />
</intent-filter>
<!-- Prod -->
<intent-filter
android:label="@string/app_name"
android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- These must match the resulting URL from the Portal exactly.
Don't use variables here otherwise this can break when testing in the emulator.
For this to work in the emulator, you must use a host/IP that *both* the emulator and the
host can access. E.g. 10.0.2.2 will not work. -->
<data android:scheme="https" />
<data android:host="app.firezone.dev" />
<data android:pathPrefix="/handle_client_auth_callback" />
<data android:scheme="firezone-fd0020211111" />
</intent-filter>
</activity>

View File

@@ -4,5 +4,5 @@ package dev.firezone.android.core.data
import kotlinx.coroutines.flow.Flow
internal interface AuthRepository {
fun generateCsrfToken(): Flow<String>
fun generateNonce(key: String): Flow<String>
}

View File

@@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.security.SecureRandom
import java.util.Base64
import javax.inject.Inject
internal class AuthRepositoryImpl
@@ -16,23 +15,22 @@ internal class AuthRepositoryImpl
private val coroutineDispatcher: CoroutineDispatcher,
private val sharedPreferences: SharedPreferences,
) : AuthRepository {
override fun generateCsrfToken(): Flow<String> =
override fun generateNonce(key: String): Flow<String> =
flow {
val random = SecureRandom.getInstanceStrong()
val bytes = ByteArray(CSRF_LENGTH)
val bytes = ByteArray(NONCE_LENGTH)
random.nextBytes(bytes)
val encodedStr: String = Base64.getEncoder().encodeToString(bytes)
val encodedStr: String = bytes.joinToString("") { "%02x".format(it) }
sharedPreferences
.edit()
.putString(CSRF_KEY, encodedStr)
.putString(key, encodedStr)
.apply()
emit(encodedStr)
}.flowOn(coroutineDispatcher)
companion object {
private const val CSRF_KEY = "csrf"
private const val CSRF_LENGTH = 24
private const val NONCE_LENGTH = 32
}
}

View File

@@ -17,7 +17,7 @@ internal interface PreferenceRepository {
fun saveToken(value: String): Flow<Unit>
fun validateCsrfToken(value: String): Flow<Boolean>
fun validateState(value: String): Flow<Boolean>
fun clearToken()

View File

@@ -48,24 +48,26 @@ internal class PreferenceRepositoryImpl
override fun saveToken(value: String): Flow<Unit> =
flow {
val nonce = sharedPreferences.getString(NONCE_KEY, "").orEmpty()
emit(
sharedPreferences
.edit()
.putString(TOKEN_KEY, value)
.putString(TOKEN_KEY, nonce.plus(value))
.apply(),
)
}.flowOn(coroutineDispatcher)
override fun validateCsrfToken(value: String): Flow<Boolean> =
override fun validateState(value: String): Flow<Boolean> =
flow {
val token = sharedPreferences.getString(CSRF_KEY, "").orEmpty()
emit(MessageDigest.isEqual(token.toByteArray(), value.toByteArray()))
val state = sharedPreferences.getString(STATE_KEY, "").orEmpty()
emit(MessageDigest.isEqual(state.toByteArray(), value.toByteArray()))
}.flowOn(coroutineDispatcher)
override fun clearToken() {
sharedPreferences.edit().apply {
remove(CSRF_KEY)
remove(NONCE_KEY)
remove(TOKEN_KEY)
remove(STATE_KEY)
apply()
}
}
@@ -79,6 +81,7 @@ internal class PreferenceRepositoryImpl
private const val API_URL_KEY = "apiUrl"
private const val LOG_FILTER_KEY = "logFilter"
private const val TOKEN_KEY = "token"
private const val CSRF_KEY = "csrf"
private const val NONCE_KEY = "nonce"
private const val STATE_KEY = "state"
}
}

View File

@@ -5,10 +5,10 @@ import dev.firezone.android.core.data.AuthRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
internal class GetCsrfTokenUseCase
internal class GetNonceUseCase
@Inject
constructor(
private val repository: AuthRepository,
) {
operator fun invoke(): Flow<String> = repository.generateCsrfToken()
operator fun invoke(): Flow<String> = repository.generateNonce("nonce")
}

View File

@@ -0,0 +1,14 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.core.domain.auth
import dev.firezone.android.core.data.AuthRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
internal class GetStateUseCase
@Inject
constructor(
private val repository: AuthRepository,
) {
operator fun invoke(): Flow<String> = repository.generateNonce("state")
}

View File

@@ -5,10 +5,10 @@ import dev.firezone.android.core.data.PreferenceRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
internal class ValidateCsrfTokenUseCase
internal class ValidateStateUseCase
@Inject
constructor(
private val repository: PreferenceRepository,
) {
operator fun invoke(value: String): Flow<Boolean> = repository.validateCsrfToken(value)
operator fun invoke(value: String): Flow<Boolean> = repository.validateState(value)
}

View File

@@ -4,17 +4,13 @@ package dev.firezone.android.features.auth.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.R
import dev.firezone.android.core.presentation.MainActivity
import dev.firezone.android.databinding.ActivityAuthBinding
import dev.firezone.android.util.CustomTabsHelper
import java.lang.Exception
@AndroidEntryPoint
class AuthActivity : AppCompatActivity(R.layout.activity_auth) {
@@ -48,30 +44,8 @@ class AuthActivity : AppCompatActivity(R.layout.activity_auth) {
}
private fun setupWebView(url: String) {
if (CustomTabsHelper.checkIfChromeIsInstalled(this)) {
val intent = CustomTabsIntent.Builder().build()
val packageName = CustomTabsHelper.getPackageNameToUse(this)
if (CustomTabsHelper.checkIfChromeAppIsDefault()) {
if (packageName != null) {
intent.intent.setPackage(packageName)
}
} else {
intent.intent.setPackage(CustomTabsHelper.STABLE_PACKAGE)
}
try {
intent.launchUrl(this@AuthActivity, Uri.parse(url))
} catch (e: Exception) {
showChromeAppRequiredError()
}
} else {
showChromeAppRequiredError()
}
}
private fun showChromeAppRequiredError() {
Toast.makeText(this, getString(R.string.signing_in_requires_chrome_browser), Toast.LENGTH_LONG).show()
navigateToSignIn()
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
}
private fun navigateToSignIn() {

View File

@@ -6,7 +6,8 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.core.domain.auth.GetCsrfTokenUseCase
import dev.firezone.android.core.domain.auth.GetNonceUseCase
import dev.firezone.android.core.domain.auth.GetStateUseCase
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
@@ -18,7 +19,8 @@ internal class AuthViewModel
@Inject
constructor(
private val getConfigUseCase: GetConfigUseCase,
private val getCsrfTokenUseCase: GetCsrfTokenUseCase,
private val getStateUseCase: GetStateUseCase,
private val getNonceUseCase: GetNonceUseCase,
) : ViewModel() {
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
@@ -32,9 +34,13 @@ internal class AuthViewModel
getConfigUseCase()
.firstOrNull() ?: throw Exception("config cannot be null")
val csrfToken =
getCsrfTokenUseCase()
.firstOrNull() ?: throw Exception("csrfToken cannot be null")
val state =
getStateUseCase()
.firstOrNull() ?: throw Exception("state cannot be null")
val nonce =
getNonceUseCase()
.firstOrNull() ?: throw Exception("nonce cannot be null")
actionMutableLiveData.postValue(
if (authFlowLaunched || config.token != null) {
@@ -44,7 +50,7 @@ internal class AuthViewModel
ViewAction.LaunchAuthFlow(
url =
"${config.authBaseUrl}" +
"?client_csrf_token=$csrfToken&client_platform=android",
"?state=$state&nonce=$nonce&as=client",
)
},
)

View File

@@ -1,5 +1,5 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.features.applink.ui
package dev.firezone.android.features.customuri.ui
import android.content.Intent
import android.os.Bundle
@@ -9,33 +9,33 @@ import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.R
import dev.firezone.android.core.presentation.MainActivity
import dev.firezone.android.databinding.ActivityAppLinkHandlerBinding
import dev.firezone.android.databinding.ActivityCustomUriHandlerBinding
@AndroidEntryPoint
class AppLinkHandlerActivity : AppCompatActivity(R.layout.activity_app_link_handler) {
private lateinit var binding: ActivityAppLinkHandlerBinding
private val viewModel: AppLinkViewModel by viewModels()
class CustomUriHandlerActivity : AppCompatActivity(R.layout.activity_custom_uri_handler) {
private lateinit var binding: ActivityCustomUriHandlerBinding
private val viewModel: CustomUriViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAppLinkHandlerBinding.inflate(layoutInflater)
binding = ActivityCustomUriHandlerBinding.inflate(layoutInflater)
setupActionObservers()
viewModel.parseAppLink(intent)
viewModel.parseCustomUri(intent)
}
private fun setupActionObservers() {
viewModel.actionLiveData.observe(this) { action ->
when (action) {
AppLinkViewModel.ViewAction.AuthFlowComplete -> {
CustomUriViewModel.ViewAction.AuthFlowComplete -> {
startActivity(
Intent(this@AppLinkHandlerActivity, MainActivity::class.java).apply {
Intent(this@CustomUriHandlerActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
},
)
finish()
}
AppLinkViewModel.ViewAction.ShowError -> showError()
CustomUriViewModel.ViewAction.ShowError -> showError()
}
}
}
@@ -47,7 +47,7 @@ class AppLinkHandlerActivity : AppCompatActivity(R.layout.activity_app_link_hand
.setPositiveButton(
R.string.error_dialog_button_text,
) { _, _ ->
this@AppLinkHandlerActivity.finish()
this@CustomUriHandlerActivity.finish()
}
.setIcon(R.drawable.ic_firezone_logo)
.show()

View File

@@ -1,5 +1,5 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.features.applink.ui
package dev.firezone.android.features.customuri.ui
import android.content.Intent
import android.util.Log
@@ -9,42 +9,43 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.core.domain.preference.SaveTokenUseCase
import dev.firezone.android.core.domain.preference.ValidateCsrfTokenUseCase
import dev.firezone.android.core.domain.preference.ValidateStateUseCase
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class AppLinkViewModel
internal class CustomUriViewModel
@Inject
constructor(
private val validateCsrfTokenUseCase: ValidateCsrfTokenUseCase,
private val validateStateUseCase: ValidateStateUseCase,
private val saveTokenUseCase: SaveTokenUseCase,
) : ViewModel() {
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
fun parseAppLink(intent: Intent) {
Log.d("AppLinkViewModel", "Parsing app link...")
fun parseCustomUri(intent: Intent) {
Log.d("CustomUriViewModel", "Parsing app link...")
viewModelScope.launch {
Log.d("AppLinkViewModel", "viewmodelScope.launch")
Log.d("CustomUriViewModel", "viewmodelScope.launch")
when (intent.data?.lastPathSegment) {
PATH_CALLBACK -> {
Log.d("AppLinkViewModel", "PATH_CALLBACK")
intent.data?.getQueryParameter(QUERY_CLIENT_CSRF_TOKEN)?.let { csrfToken ->
Log.d("AppLinkViewModel", "csrfToken: $csrfToken")
if (validateCsrfTokenUseCase(csrfToken).firstOrNull() == true) {
Log.d("AppLinkViewModel", "Valid CSRF token. Continuing to save token...")
Log.d("CustomUriViewModel", "PATH_CALLBACK")
intent.data?.getQueryParameter(QUERY_CLIENT_STATE)?.let { state ->
Log.d("CustomUriViewModel", "state: $state")
if (validateStateUseCase(state).firstOrNull() == true) {
Log.d("CustomUriViewModel", "Valid state parameter. Continuing to save state...")
} else {
Log.d("AppLinkViewModel", "Invalid CSRF token! Continuing to save token anyway...")
Log.d("CustomUriViewModel", "Invalid state parameter! Ignoring...")
actionMutableLiveData.postValue(ViewAction.ShowError)
}
intent.data?.getQueryParameter(QUERY_CLIENT_AUTH_TOKEN)?.let { token ->
if (token.isNotBlank()) {
Log.d("AppLinkViewModel", "Found valid auth token in response")
saveTokenUseCase(token).collect()
intent.data?.getQueryParameter(QUERY_CLIENT_AUTH_FRAGMENT)?.let { fragment ->
if (fragment.isNotBlank()) {
Log.d("CustomUriViewModel", "Found valid auth fragment in response")
saveTokenUseCase(fragment).collect()
} else {
Log.d("AppLinkViewModel", "Didn't find auth token in response!")
Log.d("CustomUriViewModel", "Didn't find auth fragment in response!")
}
}
@@ -52,16 +53,16 @@ internal class AppLinkViewModel
}
}
else -> {
Log.d("AppLinkViewModel", "Unknown path segment: ${intent.data?.lastPathSegment}")
Log.d("CustomUriViewModel", "Unknown path segment: ${intent.data?.lastPathSegment}")
}
}
}
}
companion object {
private const val PATH_CALLBACK = "handle_client_auth_callback"
private const val QUERY_CLIENT_CSRF_TOKEN = "client_csrf_token"
private const val QUERY_CLIENT_AUTH_TOKEN = "client_auth_token"
private const val PATH_CALLBACK = "handle_client_sign_in_callback"
private const val QUERY_CLIENT_STATE = "state"
private const val QUERY_CLIENT_AUTH_FRAGMENT = "fragment"
private const val QUERY_ACTOR_NAME = "actor_name"
private const val QUERY_IDENTITY_PROVIDER_IDENTIFIER = "identity_provider_identifier"
}

View File

@@ -1,73 +0,0 @@
/* Licensed under Apache 2.0 (C) 2015 Firezone, Inc. */
package dev.firezone.android.util
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
class CustomTabsHelper {
companion object {
val STABLE_PACKAGE = "com.android.chrome"
val BETA_PACKAGE = "com.chrome.beta"
val DEV_PACKAGE = "com.chrome.dev"
val LOCAL_PACKAGE = "com.google.android.apps.chrome"
val ACTION_CUSTOM_TABS_CONNECTION = "android.support.customtabs.action.CustomTabsService"
private var sPackageNameToUse: String? = null
fun getPackageNameToUse(context: Context): String? {
if (sPackageNameToUse != null) return sPackageNameToUse
val pm = context.getPackageManager()
val activityIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com"))
val defaultViewHandlerInfo = pm.resolveActivity(activityIntent, 0)
var defaultViewHandlerPackageName: String? = null
if (defaultViewHandlerInfo != null) {
defaultViewHandlerPackageName = defaultViewHandlerInfo.activityInfo.packageName
}
val resolvedActivityList = pm.queryIntentActivities(activityIntent, 0)
val packagesSupportingCustomTabs: MutableList<String> = ArrayList()
for (info in resolvedActivityList) {
val serviceIntent = Intent()
serviceIntent.action = ACTION_CUSTOM_TABS_CONNECTION
serviceIntent.setPackage(info.activityInfo.packageName)
if (pm.resolveService(serviceIntent, 0) != null) {
packagesSupportingCustomTabs.add(info.activityInfo.packageName)
}
}
if (packagesSupportingCustomTabs.size == 1) {
sPackageNameToUse = packagesSupportingCustomTabs.get(0)
} else if (packagesSupportingCustomTabs.contains(STABLE_PACKAGE)) {
sPackageNameToUse = STABLE_PACKAGE
} else if (packagesSupportingCustomTabs.contains(BETA_PACKAGE)) {
sPackageNameToUse = BETA_PACKAGE
} else if (packagesSupportingCustomTabs.contains(DEV_PACKAGE)) {
sPackageNameToUse = DEV_PACKAGE
} else if (packagesSupportingCustomTabs.contains(LOCAL_PACKAGE)) {
sPackageNameToUse = LOCAL_PACKAGE
}
return sPackageNameToUse
}
fun checkIfChromeAppIsDefault() =
sPackageNameToUse == STABLE_PACKAGE ||
sPackageNameToUse == BETA_PACKAGE ||
sPackageNameToUse == DEV_PACKAGE ||
sPackageNameToUse == LOCAL_PACKAGE
fun checkIfChromeIsInstalled(context: Context): Boolean =
try {
val info =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.getPackageInfo(STABLE_PACKAGE, PackageManager.PackageInfoFlags.of(0L))
} else {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(STABLE_PACKAGE, 0)
}
info.applicationInfo.enabled
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
}

View File

@@ -5,7 +5,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".features.applink.ui.AppLinkHandlerActivity">
tools:context=".features.customuri.ui.CustomUriHandlerActivity">
<TextView
android:layout_width="wrap_content"