Implement Android Credential Manager and Autofill providers (#109)
This commit is contained in:
1 parent
316a875d63
commit
0c8366d34f
48 files changed
+4138
-78
No files matched your search
@@ -0,0 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- This password-only fixture invokes Credential Manager on API 34+ and declares no website association. -->
|
||||
<application android:allowBackup="false" android:fullBackupContent="@xml/backup_rules"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules" android:icon="@android:drawable/ic_lock_lock" android:label="Credential provider fixture"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
android:usesCleartextTraffic="false" tools:ignore="CredentialDependency,CredManMissingDal">
|
||||
<activity android:name=".ProviderClientActivity" android:exported="true">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter>
|
||||
</activity>
|
||||
<provider android:name=".BrowserFixtureProvider"
|
||||
android:authorities="de.rfc1437.ironstorage.providerclient.fixture"
|
||||
android:exported="false" android:grantUriPermissions="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>IronStorage browser fixture</title>
|
||||
<style>body{font:20px sans-serif;padding:24px}input,button{display:block;font:inherit;margin:16px 0;padding:12px;max-width:100%;box-sizing:border-box}</style>
|
||||
<h1>Browser credential fixture</h1>
|
||||
<p>This local page uses public synthetic credentials. It sends no network requests.</p>
|
||||
<form autocomplete="on" onsubmit="return false">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password">
|
||||
<button type="button" onclick="document.getElementById('status').textContent=document.getElementById('username').value==='fixture-alice'&&document.getElementById('password').value==='public-provider-fixture-password'?'Chrome received the expected synthetic credential':'The fixture has not been filled'">Verify filled fixture</button>
|
||||
</form><p id="status">Waiting for an explicit fill.</p>
|
||||
</html>
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package de.rfc1437.ironstorage.providerclient
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.OpenableColumns
|
||||
import java.io.File
|
||||
|
||||
/** A single public, read-only fixture asset; there is no application-data access. */
|
||||
class BrowserFixtureProvider : ContentProvider() {
|
||||
private lateinit var page: File
|
||||
override fun onCreate(): Boolean {
|
||||
val app = requireNotNull(context)
|
||||
page = File(app.cacheDir, "public_provider_form.html")
|
||||
app.assets.open("provider_form.html").use { source ->
|
||||
page.outputStream().use { target -> source.copyTo(target) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
private fun checkPage(uri: Uri) {
|
||||
require(uri.path == "/credential_form.html")
|
||||
}
|
||||
override fun getType(uri: Uri): String {
|
||||
checkPage(uri)
|
||||
return "text/html"
|
||||
}
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor {
|
||||
checkPage(uri)
|
||||
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply {
|
||||
addRow(
|
||||
arrayOf<Any>(
|
||||
"credential_form.html",
|
||||
page.length()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor {
|
||||
checkPage(uri)
|
||||
require(mode == "r")
|
||||
return AssetFileDescriptor(openFile(uri, mode), 0, page.length())
|
||||
}
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
|
||||
checkPage(uri)
|
||||
require(mode == "r")
|
||||
return ParcelFileDescriptor.open(page, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
}
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri =
|
||||
throw UnsupportedOperationException("Read-only fixture")
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int =
|
||||
throw UnsupportedOperationException("Read-only fixture")
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?
|
||||
): Int = throw UnsupportedOperationException("Read-only fixture")
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package de.rfc1437.ironstorage.providerclient
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.view.View
|
||||
import android.view.autofill.AutofillManager
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.credentials.CreatePasswordRequest
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.GetPasswordOption
|
||||
import androidx.credentials.PasswordCredential
|
||||
import androidx.credentials.exceptions.NoCredentialException
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** A separate test APK represents an ordinary requesting application. Secrets are synthetic. */
|
||||
@SuppressLint("SetTextI18n")
|
||||
class ProviderClientActivity : FragmentActivity() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val form = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(32, 60, 32, 32)
|
||||
}
|
||||
val status = TextView(this).apply { text = "Provider client: synthetic credentials only" }
|
||||
val username = EditText(this).apply {
|
||||
id = View.generateViewId()
|
||||
hint = "Username"
|
||||
inputType = InputType.TYPE_CLASS_TEXT
|
||||
setAutofillHints(View.AUTOFILL_HINT_USERNAME)
|
||||
}
|
||||
val password = EditText(this).apply {
|
||||
id = View.generateViewId()
|
||||
hint = "Password"
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
setAutofillHints(View.AUTOFILL_HINT_PASSWORD)
|
||||
}
|
||||
form.addView(status)
|
||||
form.addView(username)
|
||||
form.addView(password)
|
||||
fun button(title: String, action: () -> Unit) {
|
||||
form.addView(
|
||||
Button(this).apply {
|
||||
text =
|
||||
title
|
||||
setOnClickListener { action() }
|
||||
}
|
||||
)
|
||||
}
|
||||
button("Open local Chrome fixture") {
|
||||
val uri = "content://$packageName.fixture/credential_form.html".toUri()
|
||||
startActivity(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW
|
||||
).setDataAndType(
|
||||
uri,
|
||||
"text/html"
|
||||
).setPackage("com.android.chrome").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
)
|
||||
}
|
||||
button("Request Autofill") {
|
||||
val manager = getSystemService(AutofillManager::class.java)
|
||||
manager.cancel()
|
||||
username.text.clear()
|
||||
password.text.clear()
|
||||
manager.requestAutofill(username)
|
||||
}
|
||||
button("Open loopback Chrome fixture") {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW, "http://localhost:8765/provider_form.html".toUri())
|
||||
.setPackage("com.android.chrome")
|
||||
)
|
||||
}
|
||||
button("Verify filled form") {
|
||||
status.text =
|
||||
if (username.text.toString() == "fixture-alice" &&
|
||||
password.text.toString() == "public-provider-fixture-password"
|
||||
) {
|
||||
"Autofill returned the expected synthetic credential"
|
||||
} else {
|
||||
"Autofill has not returned the expected fixture"
|
||||
}
|
||||
}
|
||||
button("Request Credential Manager") {
|
||||
if (Build.VERSION.SDK_INT <
|
||||
34
|
||||
) {
|
||||
status.text = "Credential Manager provider requires API 34"
|
||||
return@button
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
val response = CredentialManager.create(
|
||||
this@ProviderClientActivity
|
||||
).getCredential(
|
||||
this@ProviderClientActivity,
|
||||
GetCredentialRequest(listOf(GetPasswordOption()))
|
||||
)
|
||||
val value = response.credential as PasswordCredential
|
||||
status.text =
|
||||
if (value.id == "fixture-alice" &&
|
||||
value.password == "public-provider-fixture-password"
|
||||
) {
|
||||
"Credential Manager returned the expected synthetic credential"
|
||||
} else {
|
||||
"Credential Manager returned an unexpected fixture"
|
||||
}
|
||||
} catch (_: NoCredentialException) {
|
||||
status.text = "No enabled provider offered a password credential"
|
||||
} catch (_: Exception) {
|
||||
status.text =
|
||||
"Credential Manager request cancelled or rejected"
|
||||
}
|
||||
}
|
||||
}
|
||||
button("Commit form for save offer") {
|
||||
getSystemService(AutofillManager::class.java).commit()
|
||||
}
|
||||
button("Populate synthetic save fields") {
|
||||
username.setText("fixture-bob")
|
||||
password.setText("public-provider-new-password")
|
||||
}
|
||||
button("Request Credential Manager save") {
|
||||
if (Build.VERSION.SDK_INT < 34) {
|
||||
status.text = "Credential Manager provider requires API 34"
|
||||
return@button
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
CredentialManager.create(
|
||||
this@ProviderClientActivity
|
||||
).createCredential(
|
||||
this@ProviderClientActivity,
|
||||
CreatePasswordRequest("fixture-bob", "public-provider-new-password")
|
||||
)
|
||||
status.text = "Credential Manager save confirmed"
|
||||
} catch (_: Exception) {
|
||||
status.text =
|
||||
"Credential Manager save cancelled or rejected"
|
||||
}
|
||||
}
|
||||
}
|
||||
setContentView(form)
|
||||
}
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<full-backup-content>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,24 @@
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
Reference in new issue
Block a user