@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.3 โ 1.0.0-canary.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +3 -0
- package/.releaserc +23 -0
- package/CHANGELOG.md +7 -0
- package/CONTRIBUTING.md +10 -0
- package/README.md +44 -6
- package/android/build.gradle +18 -0
- package/android/src/main/AndroidManifest.xml +0 -1
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +181 -37
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +194 -32
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +65 -34
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +7 -0
- package/ios/ReactNativePasskeyAutofillModule.swift +14 -15
- package/lefthook.yml +13 -0
- package/package.json +28 -8
package/.eslintrc.js
CHANGED
package/.releaserc
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"branches": [
|
|
3
|
+
"release",
|
|
4
|
+
{
|
|
5
|
+
"name": "main",
|
|
6
|
+
"prerelease": "canary"
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"plugins": [
|
|
10
|
+
"@semantic-release/commit-analyzer",
|
|
11
|
+
"@semantic-release/release-notes-generator",
|
|
12
|
+
"@semantic-release/changelog",
|
|
13
|
+
"@semantic-release/npm",
|
|
14
|
+
[
|
|
15
|
+
"@semantic-release/git",
|
|
16
|
+
{
|
|
17
|
+
"assets": ["package.json", "CHANGELOG.md"],
|
|
18
|
+
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"@semantic-release/github"
|
|
22
|
+
]
|
|
23
|
+
}
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# [1.0.0-canary.5](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.4...v1.0.0-canary.5) (2026-03-24)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **android:** add allowed credentials mapping ([e7134fc](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/e7134fc1e5bf09649155e38010e4ef9728580812))
|
|
7
|
+
* **android:** credential compatibility with system-provided clientDataHash and compact JSON ([428e96f](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/428e96f89b73cd35fdbfaa6669f39da68403e17e))
|
package/CONTRIBUTING.md
CHANGED
|
@@ -50,6 +50,16 @@ The project includes an example app in the `example/` directory. You can use it
|
|
|
50
50
|
|
|
51
51
|
For more details on how the project is structured, see the [Architecture Guide](./ARCHITECTURE.md).
|
|
52
52
|
|
|
53
|
+
### Testing
|
|
54
|
+
|
|
55
|
+
Before submitting a Pull Request, ensure that both JavaScript and Native tests pass:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm run test:all
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
For more details on the testing strategy, refer to the [Testing section in README.md](./README.md#๐งช-testing).
|
|
62
|
+
|
|
53
63
|
## Submitting a Pull Request
|
|
54
64
|
|
|
55
65
|
1. Create a new branch for your feature or bugfix:
|
package/README.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @algorandfoundation/react-native-passkey-autofill
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="./example/assets/banner.png" width="100%" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Passkey AutoFill for React Native using DP256.
|
|
9
|
+
</p>
|
|
4
10
|
|
|
5
11
|
# ๐ Get Started
|
|
6
12
|
|
|
@@ -9,7 +15,7 @@ For bare React Native projects, you must ensure that you have [installed and con
|
|
|
9
15
|
### Add the package to your npm dependencies
|
|
10
16
|
|
|
11
17
|
```bash
|
|
12
|
-
npm install react-native-passkey-autofill
|
|
18
|
+
npm install @algorandfoundation/react-native-passkey-autofill
|
|
13
19
|
```
|
|
14
20
|
|
|
15
21
|
### Configure for Android
|
|
@@ -25,7 +31,7 @@ If you are using Expo, you can configure the plugin in your `app.json` or `app.c
|
|
|
25
31
|
"expo": {
|
|
26
32
|
"plugins": [
|
|
27
33
|
[
|
|
28
|
-
"react-native-passkey-autofill",
|
|
34
|
+
"@algorandfoundation/react-native-passkey-autofill",
|
|
29
35
|
{
|
|
30
36
|
"site": "https://your-fido-server.com",
|
|
31
37
|
"label": "My Custom Credential Provider"
|
|
@@ -60,17 +66,49 @@ await ReactNativePasskeyAutofill.configureIntentActions(
|
|
|
60
66
|
await ReactNativePasskeyAutofill.clearCredentials();
|
|
61
67
|
```
|
|
62
68
|
|
|
69
|
+
## ๐งช Testing
|
|
70
|
+
|
|
71
|
+
The project is set up with a comprehensive testing approach covering both JavaScript and Native (Kotlin) sides.
|
|
72
|
+
|
|
73
|
+
### JavaScript Tests
|
|
74
|
+
Run unit tests for the TypeScript module using Jest:
|
|
75
|
+
```bash
|
|
76
|
+
npm test
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Native Android Tests
|
|
80
|
+
Run unit tests for the Kotlin code using JUnit and Robolectric. These tests are executed via the example app's Gradle wrapper:
|
|
81
|
+
```bash
|
|
82
|
+
npm run test:android
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### All Tests
|
|
86
|
+
Run both JS and Native tests:
|
|
87
|
+
```bash
|
|
88
|
+
npm run test:all
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Continuous Integration (CI)
|
|
92
|
+
The project includes a GitHub Actions workflow that automatically runs linting, JS tests, and Native Android tests on every push and pull request to the `main` or `release` branches. You can find the configuration in `.github/workflows/ci.yml`.
|
|
93
|
+
|
|
94
|
+
### Integration Testing (E2E)
|
|
95
|
+
For full end-to-end testing that covers the bridge between JavaScript and Native, we recommend using [Maestro](https://maestro.mobile.dev/). It provides a clean, YAML-based way to automate UI flows and verify the integration of the Passkey Autofill service with the system UI.
|
|
96
|
+
|
|
97
|
+
## ๐ฑ Example App
|
|
98
|
+
|
|
99
|
+
The [example](./example) app demonstrates how to integrate this module with a full wallet implementation.
|
|
100
|
+
|
|
63
101
|
# ๐ค Contributing
|
|
64
102
|
|
|
65
103
|
Contributions are very welcome! Please refer to guidelines described in the [contributing guide](./CONTRIBUTING.md).
|
|
66
104
|
|
|
67
105
|
# ๐ Acknowledgements
|
|
68
106
|
|
|
69
|
-
This has been the
|
|
107
|
+
This has been the culmination of many different efforts and ideas. We would like to thank the following individuals and organizations for their contributions:
|
|
70
108
|
|
|
71
109
|
- [Bruno Martins](https://github.com/bmartins) the architect at [Algorand Foundation](https://github.com/algorandfoundation) for conceptualizing and guiding the project.
|
|
72
110
|
- [HashMapsData2Value](https://github.com/HashMapsData2Value) for his guidance and support in DP256 and XHD and his work on the native autofill libraries.
|
|
73
|
-
- [Will Beaumont](https://github.com/mjbeau) for working
|
|
111
|
+
- [Will Beaumont](https://github.com/mjbeau) for working through integration within the Pera wallet
|
|
74
112
|
- [Michael T Chuang](https://github.com/michaeltchuang) for his work in KMP integrations and client libraries.
|
|
75
113
|
|
|
76
114
|
# ๐ License
|
package/android/build.gradle
CHANGED
|
@@ -27,6 +27,7 @@ if (useManagedAndroidSdkVersions) {
|
|
|
27
27
|
defaultConfig {
|
|
28
28
|
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
29
29
|
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
|
30
|
+
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
}
|
|
@@ -43,6 +44,12 @@ android {
|
|
|
43
44
|
kotlinOptions {
|
|
44
45
|
freeCompilerArgs += ["-opt-in=kotlin.time.ExperimentalTime", "-opt-in=kotlin.RequiresOptIn"]
|
|
45
46
|
}
|
|
47
|
+
testOptions {
|
|
48
|
+
unitTests.includeAndroidResources = true
|
|
49
|
+
}
|
|
50
|
+
packagingOptions {
|
|
51
|
+
pickFirst '**/libc++_shared.so'
|
|
52
|
+
}
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
dependencies {
|
|
@@ -59,6 +66,17 @@ dependencies {
|
|
|
59
66
|
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
|
60
67
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
|
|
61
68
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0"
|
|
69
|
+
|
|
70
|
+
// Testing dependencies
|
|
71
|
+
testImplementation "junit:junit:4.13.2"
|
|
72
|
+
testImplementation "org.robolectric:robolectric:4.11.1"
|
|
73
|
+
testImplementation "io.mockk:mockk:1.13.8"
|
|
74
|
+
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0"
|
|
75
|
+
|
|
76
|
+
androidTestImplementation "androidx.test:runner:1.5.2"
|
|
77
|
+
androidTestImplementation "androidx.test:rules:1.5.0"
|
|
78
|
+
androidTestImplementation "androidx.test.ext:junit:1.1.5"
|
|
79
|
+
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
repositories {
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
android:enabled="true"
|
|
7
7
|
android:exported="true"
|
|
8
8
|
android:label="@string/passkey_autofill_label"
|
|
9
|
-
android:icon="@mipmap/ic_launcher"
|
|
10
9
|
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
|
|
11
10
|
tools:targetApi="upside_down_cake">
|
|
12
11
|
<intent-filter>
|
|
@@ -6,7 +6,12 @@ import android.content.Intent
|
|
|
6
6
|
import android.os.Build
|
|
7
7
|
import android.os.Bundle
|
|
8
8
|
import android.util.Log
|
|
9
|
+
import android.graphics.Color
|
|
10
|
+
import android.graphics.Typeface
|
|
11
|
+
import android.view.Gravity
|
|
9
12
|
import android.widget.Button
|
|
13
|
+
import android.widget.ImageView
|
|
14
|
+
import android.widget.LinearLayout
|
|
10
15
|
import android.widget.TextView
|
|
11
16
|
import androidx.annotation.RequiresApi
|
|
12
17
|
import androidx.appcompat.app.AppCompatActivity
|
|
@@ -31,61 +36,174 @@ class CreatePasskeyActivity : AppCompatActivity() {
|
|
|
31
36
|
const val TAG = "CreatePasskeyActivity"
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
private var origin: String = "unknown-origin"
|
|
39
|
+
private var origin: String = "unknown-origin" // Derivation origin (rpId or hash)
|
|
40
|
+
private var displayOrigin: String = "unknown-origin"
|
|
35
41
|
private var userHandle: String = "unknown-user"
|
|
36
42
|
private var userName: String = "User"
|
|
37
43
|
private var userId: String = "unknown-id"
|
|
44
|
+
private var bundleRequestJson: String? = null
|
|
38
45
|
private var request: ProviderCreateCredentialRequest? = null
|
|
39
46
|
|
|
40
47
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
41
48
|
super.onCreate(savedInstanceState)
|
|
42
49
|
|
|
43
50
|
request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
|
|
51
|
+
val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
|
|
52
|
+
|
|
53
|
+
if (credentialData != null) {
|
|
54
|
+
bundleRequestJson = credentialData.getString("requestJson")
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
if (request != null) {
|
|
45
58
|
try {
|
|
59
|
+
val systemOrigin = credentialRepository.getOrigin(request!!.callingAppInfo)
|
|
60
|
+
origin = systemOrigin
|
|
61
|
+
displayOrigin = systemOrigin
|
|
62
|
+
|
|
46
63
|
val publicKeyRequest = request!!.callingRequest as CreatePublicKeyCredentialRequest
|
|
47
|
-
val
|
|
64
|
+
val rawJson = bundleRequestJson ?: publicKeyRequest.requestJson
|
|
65
|
+
val requestJson = JSONObject(rawJson)
|
|
66
|
+
val pkJson = if (requestJson.has("publicKey")) requestJson.getJSONObject("publicKey") else requestJson
|
|
48
67
|
|
|
49
68
|
// Use RP ID as origin for derivation if available, otherwise fallback to app signature
|
|
50
|
-
val rpId =
|
|
51
|
-
|
|
69
|
+
val rpId = pkJson.optJSONObject("rp")?.optString("id")
|
|
70
|
+
if (rpId != null && rpId.isNotEmpty()) {
|
|
71
|
+
origin = rpId
|
|
72
|
+
displayOrigin = rpId
|
|
73
|
+
}
|
|
52
74
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
userId = userJson?.optString("id") ?: "unknown-id"
|
|
75
|
+
userHandle = pkJson.optJSONObject("user")?.optString("name") ?: "unknown-user"
|
|
76
|
+
userName = pkJson.optJSONObject("user")?.optString("displayName") ?: userHandle
|
|
77
|
+
userId = pkJson.optJSONObject("user")?.optString("id") ?: "unknown-id"
|
|
57
78
|
} catch (e: Exception) {
|
|
58
79
|
Log.e(TAG, "Error parsing request", e)
|
|
59
80
|
}
|
|
60
81
|
}
|
|
61
82
|
|
|
62
|
-
//
|
|
63
|
-
val layout =
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
// Improved UI
|
|
84
|
+
val layout = LinearLayout(this).apply {
|
|
85
|
+
val padding = (32 * resources.displayMetrics.density).toInt()
|
|
86
|
+
orientation = LinearLayout.VERTICAL
|
|
87
|
+
setPadding(padding, padding, padding, padding)
|
|
88
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
89
|
+
setBackgroundColor(Color.WHITE)
|
|
66
90
|
}
|
|
67
91
|
|
|
92
|
+
// Header with App Icon and Provider Label
|
|
93
|
+
try {
|
|
94
|
+
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
|
95
|
+
val appIcon = packageManager.getApplicationIcon(appInfo)
|
|
96
|
+
val appLabel = packageManager.getApplicationLabel(appInfo)
|
|
97
|
+
|
|
98
|
+
val density = resources.displayMetrics.density
|
|
99
|
+
val iconSize = (48 * density).toInt()
|
|
100
|
+
val iconMargin = (12 * density).toInt()
|
|
101
|
+
val headerPadding = (40 * density).toInt()
|
|
102
|
+
|
|
103
|
+
val header = LinearLayout(this).apply {
|
|
104
|
+
orientation = LinearLayout.HORIZONTAL
|
|
105
|
+
gravity = Gravity.CENTER_VERTICAL
|
|
106
|
+
setPadding(0, 0, 0, headerPadding)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
val iconView = ImageView(this).apply {
|
|
110
|
+
setImageDrawable(appIcon)
|
|
111
|
+
layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply {
|
|
112
|
+
marginEnd = iconMargin
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
header.addView(iconView)
|
|
116
|
+
|
|
117
|
+
val providerLabel = TextView(this).apply {
|
|
118
|
+
text = appLabel
|
|
119
|
+
textSize = 16f
|
|
120
|
+
typeface = Typeface.DEFAULT_BOLD
|
|
121
|
+
setTextColor(Color.DKGRAY)
|
|
122
|
+
}
|
|
123
|
+
header.addView(providerLabel)
|
|
124
|
+
layout.addView(header)
|
|
125
|
+
} catch (e: Exception) {
|
|
126
|
+
// Fallback if app info cannot be loaded
|
|
127
|
+
}
|
|
128
|
+
|
|
68
129
|
val title = TextView(this).apply {
|
|
69
|
-
text = "Create
|
|
70
|
-
textSize =
|
|
71
|
-
|
|
130
|
+
text = "Create Passkey"
|
|
131
|
+
textSize = 28f
|
|
132
|
+
typeface = Typeface.DEFAULT_BOLD
|
|
133
|
+
setPadding(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
|
|
134
|
+
setTextColor(Color.BLACK)
|
|
72
135
|
}
|
|
73
136
|
layout.addView(title)
|
|
74
137
|
|
|
75
|
-
val
|
|
76
|
-
text = "
|
|
77
|
-
|
|
138
|
+
val description = TextView(this).apply {
|
|
139
|
+
text = "Create a new passkey for $displayOrigin"
|
|
140
|
+
textSize = 16f
|
|
141
|
+
setPadding(0, 0, 0, (40 * resources.displayMetrics.density).toInt())
|
|
142
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
143
|
+
setTextColor(Color.GRAY)
|
|
144
|
+
}
|
|
145
|
+
layout.addView(description)
|
|
146
|
+
|
|
147
|
+
val userInfoContainer = LinearLayout(this).apply {
|
|
148
|
+
orientation = LinearLayout.VERTICAL
|
|
149
|
+
setPadding(0, 0, 0, (50 * resources.displayMetrics.density).toInt())
|
|
150
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
val userLabel = TextView(this).apply {
|
|
154
|
+
text = "ACCOUNT"
|
|
155
|
+
textSize = 12f
|
|
156
|
+
typeface = Typeface.DEFAULT_BOLD
|
|
157
|
+
setTextColor(Color.GRAY)
|
|
158
|
+
setPadding(0, 0, 0, (4 * resources.displayMetrics.density).toInt())
|
|
78
159
|
}
|
|
79
|
-
|
|
160
|
+
userInfoContainer.addView(userLabel)
|
|
161
|
+
|
|
162
|
+
val userValue = TextView(this).apply {
|
|
163
|
+
text = userName
|
|
164
|
+
textSize = 20f
|
|
165
|
+
typeface = Typeface.DEFAULT_BOLD
|
|
166
|
+
setTextColor(Color.BLACK)
|
|
167
|
+
}
|
|
168
|
+
userInfoContainer.addView(userValue)
|
|
169
|
+
|
|
170
|
+
if (userHandle != userName) {
|
|
171
|
+
val handleValue = TextView(this).apply {
|
|
172
|
+
text = userHandle
|
|
173
|
+
textSize = 14f
|
|
174
|
+
setTextColor(Color.GRAY)
|
|
175
|
+
}
|
|
176
|
+
userInfoContainer.addView(handleValue)
|
|
177
|
+
}
|
|
178
|
+
layout.addView(userInfoContainer)
|
|
80
179
|
|
|
81
180
|
val confirmButton = Button(this).apply {
|
|
82
181
|
text = "Create Passkey"
|
|
83
182
|
setOnClickListener {
|
|
84
183
|
handleCreation()
|
|
85
184
|
}
|
|
185
|
+
layoutParams = LinearLayout.LayoutParams(
|
|
186
|
+
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
187
|
+
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
188
|
+
).apply {
|
|
189
|
+
setMargins(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
|
|
190
|
+
}
|
|
86
191
|
}
|
|
87
192
|
layout.addView(confirmButton)
|
|
88
193
|
|
|
194
|
+
val cancelButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
|
|
195
|
+
text = "Cancel"
|
|
196
|
+
setOnClickListener {
|
|
197
|
+
setResult(RESULT_CANCELED)
|
|
198
|
+
finish()
|
|
199
|
+
}
|
|
200
|
+
layoutParams = LinearLayout.LayoutParams(
|
|
201
|
+
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
202
|
+
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
layout.addView(cancelButton)
|
|
206
|
+
|
|
89
207
|
setContentView(layout)
|
|
90
208
|
}
|
|
91
209
|
|
|
@@ -93,8 +211,25 @@ class CreatePasskeyActivity : AppCompatActivity() {
|
|
|
93
211
|
private fun handleCreation() {
|
|
94
212
|
try {
|
|
95
213
|
val req = request ?: throw IllegalStateException("No request found")
|
|
96
|
-
|
|
97
|
-
|
|
214
|
+
|
|
215
|
+
// Prefer using the request JSON from the bundle if available
|
|
216
|
+
val rawRequestJson = bundleRequestJson ?: run {
|
|
217
|
+
val publicKeyRequest = req.callingRequest as CreatePublicKeyCredentialRequest
|
|
218
|
+
publicKeyRequest.requestJson
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
val requestJson = JSONObject(rawRequestJson)
|
|
222
|
+
val passkeyReqJson = if (requestJson.has("publicKey")) {
|
|
223
|
+
requestJson.getJSONObject("publicKey").toString()
|
|
224
|
+
} else {
|
|
225
|
+
rawRequestJson
|
|
226
|
+
}
|
|
227
|
+
val requestOptions = try {
|
|
228
|
+
PublicKeyCredentialCreationOptions(passkeyReqJson)
|
|
229
|
+
} catch (e: org.json.JSONException) {
|
|
230
|
+
Log.e(TAG, "Invalid passkey creation request JSON: $passkeyReqJson")
|
|
231
|
+
throw e
|
|
232
|
+
}
|
|
98
233
|
|
|
99
234
|
val keyPair: KeyPair = credentialRepository.createDeterministicKeyPair(this, origin, userHandle)
|
|
100
235
|
val credentialId = credentialRepository.generateCredentialId(keyPair)
|
|
@@ -131,25 +266,34 @@ class CreatePasskeyActivity : AppCompatActivity() {
|
|
|
131
266
|
)
|
|
132
267
|
|
|
133
268
|
val resultIntent = Intent()
|
|
134
|
-
val
|
|
135
|
-
val
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
val
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
269
|
+
val fullJson = JSONObject(fidoCredential.json())
|
|
270
|
+
val respJson = fullJson.getJSONObject("response")
|
|
271
|
+
|
|
272
|
+
// Ensure compact clientDataJSON for registration too
|
|
273
|
+
val sanitizedOrigin = credentialRepository.getOrigin(req.callingAppInfo).replace(Regex("/$"), "")
|
|
274
|
+
val challenge = if (requestJson.has("publicKey")) {
|
|
275
|
+
requestJson.getJSONObject("publicKey").getString("challenge")
|
|
276
|
+
} else {
|
|
277
|
+
requestJson.getString("challenge")
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
val clientDataJSONString = if (sanitizedOrigin.startsWith("https://") || sanitizedOrigin.startsWith("http://")) {
|
|
281
|
+
"{\"type\":\"webauthn.create\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
|
|
282
|
+
} else {
|
|
283
|
+
val json = JSONObject()
|
|
284
|
+
json.put("type", "webauthn.create")
|
|
285
|
+
json.put("challenge", challenge)
|
|
286
|
+
json.put("origin", sanitizedOrigin)
|
|
287
|
+
json.put("crossOrigin", false)
|
|
288
|
+
if (sanitizedOrigin.startsWith("android:apk-key-hash:")) {
|
|
289
|
+
json.put("androidPackageName", req.callingAppInfo.packageName)
|
|
144
290
|
}
|
|
145
|
-
|
|
291
|
+
json.toString()
|
|
146
292
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
val y = to32(ecPubKey.w.affineY)
|
|
150
|
-
val s = to32(ecPrivKey.s)
|
|
293
|
+
val clientDataJSONb64 = AndroidBase64.encodeToString(clientDataJSONString.toByteArray(), AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
|
|
294
|
+
respJson.put("clientDataJSON", clientDataJSONb64)
|
|
151
295
|
|
|
152
|
-
val createResponse = CreatePublicKeyCredentialResponse(
|
|
296
|
+
val createResponse = CreatePublicKeyCredentialResponse(fullJson.toString())
|
|
153
297
|
|
|
154
298
|
PendingIntentHandler.setCreateCredentialResponse(
|
|
155
299
|
resultIntent,
|
|
@@ -6,7 +6,12 @@ import android.content.Intent
|
|
|
6
6
|
import android.os.Build
|
|
7
7
|
import android.os.Bundle
|
|
8
8
|
import android.util.Log
|
|
9
|
+
import android.graphics.Color
|
|
10
|
+
import android.graphics.Typeface
|
|
11
|
+
import android.view.Gravity
|
|
9
12
|
import android.widget.Button
|
|
13
|
+
import android.widget.ImageView
|
|
14
|
+
import android.widget.LinearLayout
|
|
10
15
|
import android.widget.TextView
|
|
11
16
|
import androidx.annotation.RequiresApi
|
|
12
17
|
import androidx.appcompat.app.AppCompatActivity
|
|
@@ -34,8 +39,10 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
private var origin: String = "unknown-origin"
|
|
42
|
+
private var displayOrigin: String = "unknown-origin"
|
|
37
43
|
private var userHandle: String = "unknown-user"
|
|
38
44
|
private var credentialIdEnc: String? = null
|
|
45
|
+
private var bundleRequestJson: String? = null
|
|
39
46
|
private var request: ProviderGetCredentialRequest? = null
|
|
40
47
|
|
|
41
48
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
@@ -44,39 +51,147 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
44
51
|
request = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
|
|
45
52
|
val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
|
|
46
53
|
|
|
47
|
-
if (
|
|
48
|
-
origin = credentialRepository.getOrigin(request!!.callingAppInfo)
|
|
54
|
+
if (credentialData != null) {
|
|
49
55
|
credentialIdEnc = credentialData.getString("credentialId")
|
|
50
56
|
userHandle = credentialData.getString("userHandle") ?: "unknown-user"
|
|
57
|
+
bundleRequestJson = credentialData.getString("requestJson")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (request != null) {
|
|
61
|
+
origin = credentialRepository.getOrigin(request!!.callingAppInfo)
|
|
62
|
+
displayOrigin = origin
|
|
63
|
+
|
|
64
|
+
// Try to extract rpId for better display
|
|
65
|
+
val rawJson = bundleRequestJson ?: (request?.credentialOptions?.get(0) as? GetPublicKeyCredentialOption)?.requestJson
|
|
66
|
+
if (rawJson != null) {
|
|
67
|
+
try {
|
|
68
|
+
val jsonObj = JSONObject(rawJson)
|
|
69
|
+
val pkJson = if (jsonObj.has("publicKey")) jsonObj.getJSONObject("publicKey") else jsonObj
|
|
70
|
+
val rpId = pkJson.optString("rpId")
|
|
71
|
+
if (rpId.isNotEmpty()) {
|
|
72
|
+
displayOrigin = rpId
|
|
73
|
+
}
|
|
74
|
+
} catch (e: Exception) {
|
|
75
|
+
// ignore
|
|
76
|
+
}
|
|
77
|
+
}
|
|
51
78
|
}
|
|
52
79
|
|
|
53
|
-
//
|
|
54
|
-
val layout =
|
|
55
|
-
|
|
56
|
-
|
|
80
|
+
// Improved UI
|
|
81
|
+
val layout = LinearLayout(this).apply {
|
|
82
|
+
val padding = (32 * resources.displayMetrics.density).toInt()
|
|
83
|
+
orientation = LinearLayout.VERTICAL
|
|
84
|
+
setPadding(padding, padding, padding, padding)
|
|
85
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
86
|
+
setBackgroundColor(Color.WHITE)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Header with App Icon and Provider Label
|
|
90
|
+
try {
|
|
91
|
+
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
|
92
|
+
val appIcon = packageManager.getApplicationIcon(appInfo)
|
|
93
|
+
val appLabel = packageManager.getApplicationLabel(appInfo)
|
|
94
|
+
|
|
95
|
+
val density = resources.displayMetrics.density
|
|
96
|
+
val iconSize = (48 * density).toInt()
|
|
97
|
+
val iconMargin = (12 * density).toInt()
|
|
98
|
+
val headerPadding = (40 * density).toInt()
|
|
99
|
+
|
|
100
|
+
val header = LinearLayout(this).apply {
|
|
101
|
+
orientation = LinearLayout.HORIZONTAL
|
|
102
|
+
gravity = Gravity.CENTER_VERTICAL
|
|
103
|
+
setPadding(0, 0, 0, headerPadding)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
val iconView = ImageView(this).apply {
|
|
107
|
+
setImageDrawable(appIcon)
|
|
108
|
+
layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply {
|
|
109
|
+
marginEnd = iconMargin
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
header.addView(iconView)
|
|
113
|
+
|
|
114
|
+
val providerLabel = TextView(this).apply {
|
|
115
|
+
text = appLabel
|
|
116
|
+
textSize = 16f
|
|
117
|
+
typeface = Typeface.DEFAULT_BOLD
|
|
118
|
+
setTextColor(Color.DKGRAY)
|
|
119
|
+
}
|
|
120
|
+
header.addView(providerLabel)
|
|
121
|
+
layout.addView(header)
|
|
122
|
+
} catch (e: Exception) {
|
|
123
|
+
// Fallback if app info cannot be loaded
|
|
57
124
|
}
|
|
58
125
|
|
|
59
126
|
val title = TextView(this).apply {
|
|
60
127
|
text = "Use Passkey"
|
|
61
|
-
textSize =
|
|
62
|
-
|
|
128
|
+
textSize = 28f
|
|
129
|
+
typeface = Typeface.DEFAULT_BOLD
|
|
130
|
+
setPadding(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
|
|
131
|
+
setTextColor(Color.BLACK)
|
|
63
132
|
}
|
|
64
133
|
layout.addView(title)
|
|
65
134
|
|
|
66
|
-
val
|
|
67
|
-
text = "
|
|
68
|
-
|
|
135
|
+
val description = TextView(this).apply {
|
|
136
|
+
text = "Sign in to $displayOrigin"
|
|
137
|
+
textSize = 16f
|
|
138
|
+
setPadding(0, 0, 0, (40 * resources.displayMetrics.density).toInt())
|
|
139
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
140
|
+
setTextColor(Color.GRAY)
|
|
141
|
+
}
|
|
142
|
+
layout.addView(description)
|
|
143
|
+
|
|
144
|
+
val userInfoContainer = LinearLayout(this).apply {
|
|
145
|
+
orientation = LinearLayout.VERTICAL
|
|
146
|
+
setPadding(0, 0, 0, (50 * resources.displayMetrics.density).toInt())
|
|
147
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
val userLabel = TextView(this).apply {
|
|
151
|
+
text = "ACCOUNT"
|
|
152
|
+
textSize = 12f
|
|
153
|
+
typeface = Typeface.DEFAULT_BOLD
|
|
154
|
+
setTextColor(Color.GRAY)
|
|
155
|
+
setPadding(0, 0, 0, (4 * resources.displayMetrics.density).toInt())
|
|
69
156
|
}
|
|
70
|
-
|
|
157
|
+
userInfoContainer.addView(userLabel)
|
|
158
|
+
|
|
159
|
+
val userValue = TextView(this).apply {
|
|
160
|
+
text = userHandle
|
|
161
|
+
textSize = 20f
|
|
162
|
+
typeface = Typeface.DEFAULT_BOLD
|
|
163
|
+
setTextColor(Color.BLACK)
|
|
164
|
+
}
|
|
165
|
+
userInfoContainer.addView(userValue)
|
|
166
|
+
layout.addView(userInfoContainer)
|
|
71
167
|
|
|
72
168
|
val confirmButton = Button(this).apply {
|
|
73
169
|
text = "Sign In"
|
|
74
170
|
setOnClickListener {
|
|
75
171
|
handleAssertion()
|
|
76
172
|
}
|
|
173
|
+
layoutParams = LinearLayout.LayoutParams(
|
|
174
|
+
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
175
|
+
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
176
|
+
).apply {
|
|
177
|
+
setMargins(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
|
|
178
|
+
}
|
|
77
179
|
}
|
|
78
180
|
layout.addView(confirmButton)
|
|
79
181
|
|
|
182
|
+
val cancelButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
|
|
183
|
+
text = "Cancel"
|
|
184
|
+
setOnClickListener {
|
|
185
|
+
setResult(RESULT_CANCELED)
|
|
186
|
+
finish()
|
|
187
|
+
}
|
|
188
|
+
layoutParams = LinearLayout.LayoutParams(
|
|
189
|
+
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
190
|
+
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
layout.addView(cancelButton)
|
|
194
|
+
|
|
80
195
|
setContentView(layout)
|
|
81
196
|
}
|
|
82
197
|
|
|
@@ -84,19 +199,61 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
84
199
|
private fun handleAssertion() {
|
|
85
200
|
try {
|
|
86
201
|
val req = request ?: throw IllegalStateException("No request found")
|
|
87
|
-
|
|
88
|
-
|
|
202
|
+
|
|
203
|
+
// Prefer using the request JSON from the bundle if available, as it's specifically for this entry
|
|
204
|
+
val rawRequestJson = bundleRequestJson ?: run {
|
|
205
|
+
val option = req.credentialOptions[0] as GetPublicKeyCredentialOption
|
|
206
|
+
option.requestJson
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
val requestJson = JSONObject(rawRequestJson)
|
|
210
|
+
val passkeyReqJson = if (requestJson.has("publicKey")) {
|
|
211
|
+
requestJson.getJSONObject("publicKey").toString()
|
|
212
|
+
} else {
|
|
213
|
+
rawRequestJson
|
|
214
|
+
}
|
|
215
|
+
val requestOptions = try {
|
|
216
|
+
PublicKeyCredentialRequestOptions(passkeyReqJson)
|
|
217
|
+
} catch (e: org.json.JSONException) {
|
|
218
|
+
Log.e(TAG, "Invalid passkey request JSON: $passkeyReqJson")
|
|
219
|
+
throw e
|
|
220
|
+
}
|
|
89
221
|
|
|
90
222
|
val credId = AndroidBase64.decode(credentialIdEnc!!, AndroidBase64.DEFAULT)
|
|
91
223
|
|
|
92
|
-
val
|
|
93
|
-
val challenge =
|
|
224
|
+
val passkeyRequestJsonObj = JSONObject(passkeyReqJson)
|
|
225
|
+
val challenge = if (passkeyRequestJsonObj.has("challenge")) {
|
|
226
|
+
passkeyRequestJsonObj.getString("challenge")
|
|
227
|
+
} else {
|
|
228
|
+
throw org.json.JSONException("No value for challenge in requestJson: $passkeyReqJson")
|
|
229
|
+
}
|
|
94
230
|
val sanitizedOrigin = origin.replace(Regex("/$"), "")
|
|
95
231
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
232
|
+
// Look for system-provided clientDataHash (e.g. from Chrome)
|
|
233
|
+
val systemClientDataHash = run {
|
|
234
|
+
val option = req.credentialOptions.find { opt ->
|
|
235
|
+
opt is GetPublicKeyCredentialOption && (opt.requestJson == rawRequestJson ||
|
|
236
|
+
try { JSONObject(opt.requestJson).toString() == JSONObject(rawRequestJson!!).toString() } catch(e: Exception) { false })
|
|
237
|
+
} as? GetPublicKeyCredentialOption
|
|
238
|
+
option?.requestData?.getByteArray("androidx.credentials.BUNDLE_KEY_CLIENT_DATA_HASH")
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
val clientDataJSONString = if (sanitizedOrigin.startsWith("https://") || sanitizedOrigin.startsWith("http://")) {
|
|
242
|
+
// Compact JSON for web origins to match browser hashing (no spaces, specific order)
|
|
243
|
+
"{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
|
|
244
|
+
} else {
|
|
245
|
+
val json = JSONObject()
|
|
246
|
+
json.put("type", "webauthn.get")
|
|
247
|
+
json.put("challenge", challenge)
|
|
248
|
+
json.put("origin", sanitizedOrigin)
|
|
249
|
+
json.put("crossOrigin", false)
|
|
250
|
+
if (sanitizedOrigin.startsWith("android:apk-key-hash:")) {
|
|
251
|
+
json.put("androidPackageName", req.callingAppInfo.packageName)
|
|
252
|
+
}
|
|
253
|
+
json.toString()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
val clientDataHash = systemClientDataHash ?: MessageDigest.getInstance("SHA-256").digest(clientDataJSONString.toByteArray(Charsets.UTF_8))
|
|
100
257
|
|
|
101
258
|
val dbCred = credentialRepository.getCredential(this, credId)
|
|
102
259
|
?: throw IllegalStateException("Credential not found")
|
|
@@ -104,7 +261,7 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
104
261
|
val response = AuthenticatorAssertionResponse(
|
|
105
262
|
requestOptions = requestOptions,
|
|
106
263
|
credentialId = credId,
|
|
107
|
-
origin =
|
|
264
|
+
origin = sanitizedOrigin,
|
|
108
265
|
up = true,
|
|
109
266
|
uv = true,
|
|
110
267
|
be = true,
|
|
@@ -125,13 +282,23 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
125
282
|
authenticatorAttachment = "platform"
|
|
126
283
|
)
|
|
127
284
|
|
|
128
|
-
// Manual addition of clientDataJSON
|
|
129
|
-
val clientDataJSONb64 =
|
|
285
|
+
// Manual addition of clientDataJSON and signature to the response JSON
|
|
286
|
+
val clientDataJSONb64 = AndroidBase64.encodeToString(clientDataJSONString.toByteArray(), AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
|
|
287
|
+
val signatureb64 = AndroidBase64.encodeToString(response.signature, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
|
|
288
|
+
|
|
289
|
+
val fullJson = JSONObject(fidoCredential.json())
|
|
290
|
+
val respJson = fullJson.getJSONObject("response")
|
|
291
|
+
respJson.put("clientDataJSON", clientDataJSONb64)
|
|
292
|
+
respJson.put("signature", signatureb64)
|
|
130
293
|
|
|
131
|
-
|
|
132
|
-
val
|
|
133
|
-
|
|
134
|
-
|
|
294
|
+
// Add clientExtensionResults as seen in the example
|
|
295
|
+
val clientExtensionResults = JSONObject()
|
|
296
|
+
val credProps = JSONObject()
|
|
297
|
+
credProps.put("rk", true)
|
|
298
|
+
clientExtensionResults.put("credProps", credProps)
|
|
299
|
+
fullJson.put("clientExtensionResults", clientExtensionResults)
|
|
300
|
+
|
|
301
|
+
val credentialJson = fullJson.toString()
|
|
135
302
|
|
|
136
303
|
val resultIntent = Intent()
|
|
137
304
|
val passkeyCredential = PublicKeyCredential(credentialJson)
|
|
@@ -149,9 +316,4 @@ class GetPasskeyActivity : AppCompatActivity() {
|
|
|
149
316
|
finish()
|
|
150
317
|
}
|
|
151
318
|
|
|
152
|
-
private fun getClientDataJSONb64(origin: String, challenge: String): String {
|
|
153
|
-
val sanitizedOrigin = origin.replace(Regex("/$"), "")
|
|
154
|
-
val jsonString = "{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
|
|
155
|
-
return AndroidBase64.encodeToString(jsonString.toByteArray(), AndroidBase64.NO_WRAP)
|
|
156
|
-
}
|
|
157
319
|
}
|
|
@@ -26,8 +26,10 @@ import androidx.credentials.provider.ProviderClearCredentialStateRequest
|
|
|
26
26
|
import androidx.credentials.provider.PublicKeyCredentialEntry
|
|
27
27
|
import co.algorand.passkeyautofill.credentials.CredentialRepository
|
|
28
28
|
import co.algorand.passkeyautofill.credentials.Credential
|
|
29
|
+
import co.algorand.passkeyautofill.utils.PasskeyUtils
|
|
29
30
|
import com.tencent.mmkv.MMKV
|
|
30
31
|
import kotlinx.coroutines.*
|
|
32
|
+
import org.json.JSONArray
|
|
31
33
|
import org.json.JSONObject
|
|
32
34
|
|
|
33
35
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
|
@@ -127,7 +129,7 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
127
129
|
/**
|
|
128
130
|
* Fake a list of available PublicKeyCredential Entries
|
|
129
131
|
*/
|
|
130
|
-
private fun processGetCredentialRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse{
|
|
132
|
+
private fun processGetCredentialRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse {
|
|
131
133
|
// Ensure we have a master key available. If not, we can't sign anything, so don't show entries.
|
|
132
134
|
if (!credentialRepository.isMasterKeyAvailable(this)) {
|
|
133
135
|
return BeginGetCredentialResponse(emptyList())
|
|
@@ -138,42 +140,66 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
val action = credentialRepository.getGetPasskeyAction(this) ?: DEFAULT_GET_PASSKEY_ACTION
|
|
143
|
+
val allEntries = mutableListOf<PublicKeyCredentialEntry>()
|
|
141
144
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// Find suitable option for this entry
|
|
145
|
-
val option = request.beginGetCredentialOptions.find { opt ->
|
|
146
|
-
if (opt !is BeginGetPublicKeyCredentialOption) return@find false
|
|
147
|
-
|
|
148
|
-
// In a real app, we'd check if this credential's origin matches the request's origin
|
|
149
|
-
// For now, we return all credentials that match the requested type.
|
|
150
|
-
true
|
|
151
|
-
} as? BeginGetPublicKeyCredentialOption
|
|
152
|
-
|
|
153
|
-
if (option == null) {
|
|
154
|
-
return@mapNotNull null
|
|
155
|
-
}
|
|
145
|
+
for (option in request.beginGetCredentialOptions) {
|
|
146
|
+
if (option !is BeginGetPublicKeyCredentialOption) continue
|
|
156
147
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
this@PasskeyAutofillCredentialProviderService,
|
|
164
|
-
credential.userHandle,
|
|
165
|
-
createNewPendingIntent(action, GET_PASSKEY_INTENT, data),
|
|
166
|
-
option
|
|
167
|
-
)
|
|
168
|
-
.setIcon(Icon.createWithResource(this@PasskeyAutofillCredentialProviderService, android.R.drawable.ic_lock_lock))
|
|
169
|
-
.build()
|
|
148
|
+
val requestJsonStr = option.requestJson
|
|
149
|
+
var allowCredentials: JSONArray? = null
|
|
150
|
+
try {
|
|
151
|
+
val json = JSONObject(requestJsonStr)
|
|
152
|
+
val pk = if (json.has("publicKey")) json.getJSONObject("publicKey") else json
|
|
153
|
+
allowCredentials = pk.optJSONArray("allowCredentials")
|
|
170
154
|
} catch (e: Exception) {
|
|
171
|
-
Log.e(TAG, "Error
|
|
155
|
+
Log.e(TAG, "Error parsing requestJson", e)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
val allowedIds = if (allowCredentials != null && allowCredentials.length() > 0) {
|
|
159
|
+
val ids = mutableSetOf<String>()
|
|
160
|
+
for (i in 0 until allowCredentials.length()) {
|
|
161
|
+
ids.add(allowCredentials.getJSONObject(i).getString("id"))
|
|
162
|
+
}
|
|
163
|
+
ids
|
|
164
|
+
} else {
|
|
172
165
|
null
|
|
173
166
|
}
|
|
167
|
+
|
|
168
|
+
for (credential in credentials) {
|
|
169
|
+
if (allowedIds != null) {
|
|
170
|
+
val isAllowed = allowedIds.any { allowedId ->
|
|
171
|
+
allowedId == credential.credentialId ||
|
|
172
|
+
PasskeyUtils.normalizeBase64(allowedId) == PasskeyUtils.normalizeBase64(credential.credentialId)
|
|
173
|
+
}
|
|
174
|
+
if (!isAllowed) continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
val data = Bundle()
|
|
179
|
+
data.putString("credentialId", credential.credentialId)
|
|
180
|
+
data.putString("userHandle", credential.userHandle)
|
|
181
|
+
data.putString("requestJson", option.requestJson)
|
|
182
|
+
|
|
183
|
+
// Use a unique requestCode for each PendingIntent to avoid data being overwritten
|
|
184
|
+
val uniqueRequestCode = (credential.credentialId + option.requestJson).hashCode()
|
|
185
|
+
|
|
186
|
+
val entry = PublicKeyCredentialEntry.Builder(
|
|
187
|
+
this,
|
|
188
|
+
credential.userHandle,
|
|
189
|
+
createNewPendingIntent(action, uniqueRequestCode, data),
|
|
190
|
+
option
|
|
191
|
+
)
|
|
192
|
+
.setIcon(Icon.createWithResource(this, android.R.drawable.ic_lock_lock))
|
|
193
|
+
.build()
|
|
194
|
+
allEntries.add(entry)
|
|
195
|
+
} catch (e: Exception) {
|
|
196
|
+
Log.e(TAG, "Error building PublicKeyCredentialEntry", e)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
174
199
|
}
|
|
175
|
-
return BeginGetCredentialResponse(
|
|
200
|
+
return BeginGetCredentialResponse(allEntries)
|
|
176
201
|
}
|
|
202
|
+
|
|
177
203
|
override fun onClearCredentialStateRequest(
|
|
178
204
|
request: ProviderClearCredentialStateRequest,
|
|
179
205
|
cancellationSignal: CancellationSignal,
|
|
@@ -186,12 +212,17 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
186
212
|
private fun createNewPendingIntent(action: String, requestCode: Int, extra: Bundle?): PendingIntent{
|
|
187
213
|
MMKV.initialize(this)
|
|
188
214
|
val intent = Intent(action)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
215
|
+
|
|
216
|
+
// Determine component based on action instead of requestCode
|
|
217
|
+
val getAction = credentialRepository.getGetPasskeyAction(this) ?: DEFAULT_GET_PASSKEY_ACTION
|
|
218
|
+
val createAction = credentialRepository.getCreatePasskeyAction(this) ?: DEFAULT_CREATE_PASSKEY_ACTION
|
|
219
|
+
|
|
220
|
+
val componentName = when(action) {
|
|
221
|
+
getAction -> android.content.ComponentName(packageName, "co.algorand.passkeyautofill.GetPasskeyActivity")
|
|
222
|
+
createAction -> android.content.ComponentName(packageName, "co.algorand.passkeyautofill.CreatePasskeyActivity")
|
|
193
223
|
else -> null
|
|
194
224
|
}
|
|
225
|
+
|
|
195
226
|
if (componentName != null) {
|
|
196
227
|
intent.component = componentName
|
|
197
228
|
} else {
|
|
@@ -10,26 +10,25 @@ public class ReactNativePasskeyAutofillModule: Module {
|
|
|
10
10
|
// The module will be accessible from `requireNativeModule('ReactNativePasskeyAutofill')` in JavaScript.
|
|
11
11
|
Name("ReactNativePasskeyAutofill")
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Double.pi
|
|
13
|
+
AsyncFunction("setMasterKey") { (secret: String) in
|
|
14
|
+
// TODO: Implement for iOS
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
AsyncFunction("setHdRootKeyId") { (id: String) in
|
|
18
|
+
// TODO: Implement for iOS
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
AsyncFunction("getHdRootKeyId") { () -> String? in
|
|
22
|
+
// TODO: Implement for iOS
|
|
23
|
+
return nil
|
|
24
|
+
}
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return "Hello world! ๐"
|
|
26
|
+
AsyncFunction("clearCredentials") {
|
|
27
|
+
// TODO: Implement for iOS
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
AsyncFunction("setValueAsync") { (value: String) in
|
|
29
|
-
// Send an event to JavaScript.
|
|
30
|
-
self.sendEvent("onChange", [
|
|
31
|
-
"value": value
|
|
32
|
-
])
|
|
30
|
+
AsyncFunction("configureIntentActions") { (getPasskeyAction: String, createPasskeyAction: String) in
|
|
31
|
+
// TODO: Implement for iOS
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
// Enables the module to be used as a native view. Definition components that are accepted as part of the
|
package/lefthook.yml
ADDED
package/package.json
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@algorandfoundation/react-native-passkey-autofill",
|
|
3
|
-
"version": "1.0.0-canary.
|
|
3
|
+
"version": "1.0.0-canary.5",
|
|
4
4
|
"description": "Passkey AutoFill React Native",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
7
7
|
"publishConfig": {
|
|
8
|
-
"access": "public"
|
|
8
|
+
"access": "public",
|
|
9
|
+
"provenance": true
|
|
9
10
|
},
|
|
10
11
|
"scripts": {
|
|
11
12
|
"build": "expo-module build",
|
|
12
13
|
"clean": "expo-module clean",
|
|
13
|
-
"lint": "expo-module lint",
|
|
14
|
-
"test": "expo-module test",
|
|
15
|
-
"
|
|
14
|
+
"lint": "ESLINT_USE_FLAT_CONFIG=false expo-module lint",
|
|
15
|
+
"test": "CI=true expo-module test",
|
|
16
|
+
"test:android": "cd example/android && ./gradlew :algorandfoundation-react-native-passkey-autofill:testDebugUnitTest --no-daemon",
|
|
17
|
+
"test:android:instrumented": "cd example/android && ./gradlew :algorandfoundation-react-native-passkey-autofill:connectedDebugAndroidTest --no-daemon",
|
|
18
|
+
"test:all": "npm run test && npm run test:android",
|
|
19
|
+
"prepare": "expo-module prepare && npx lefthook install",
|
|
16
20
|
"prepublishOnly": "expo-module prepublishOnly",
|
|
17
21
|
"expo-module": "expo-module",
|
|
18
22
|
"open:ios": "xed example/ios",
|
|
@@ -24,22 +28,38 @@
|
|
|
24
28
|
"@algorandfoundation/react-native-passkey-autofill",
|
|
25
29
|
"ReactNativePasskeyAutofill"
|
|
26
30
|
],
|
|
27
|
-
"repository":
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+ssh://git@github.com/algorandfoundation/react-native-passkey-autofill.git"
|
|
34
|
+
},
|
|
28
35
|
"bugs": {
|
|
29
36
|
"url": "https://github.com/algorandfoundation/react-native-passkey-autofill/issues"
|
|
30
37
|
},
|
|
31
|
-
"author": "Algorand Foundation
|
|
38
|
+
"author": "Algorand Foundation",
|
|
32
39
|
"license": "Apache-2.0",
|
|
33
40
|
"homepage": "https://github.com/algorandfoundation/react-native-passkey-autofill#readme",
|
|
34
41
|
"dependencies": {
|
|
35
42
|
"@algorandfoundation/react-native-keystore": "^1.0.0-canary.6"
|
|
36
43
|
},
|
|
44
|
+
"jest": {
|
|
45
|
+
"preset": "ts-jest",
|
|
46
|
+
"testEnvironment": "node",
|
|
47
|
+
"testMatch": [
|
|
48
|
+
"<rootDir>/src/**/__tests__/**/*.test.ts"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
37
51
|
"devDependencies": {
|
|
38
52
|
"@expo/config-plugins": "^9.0.0",
|
|
53
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
54
|
+
"@semantic-release/git": "^10.0.1",
|
|
55
|
+
"@semantic-release/github": "^12.0.6",
|
|
56
|
+
"@semantic-release/npm": "^13.1.5",
|
|
39
57
|
"@types/react": "~19.1.1",
|
|
40
58
|
"expo": "^55.0.1",
|
|
41
59
|
"expo-module-scripts": "^55.0.2",
|
|
42
|
-
"
|
|
60
|
+
"lefthook": "^2.1.4",
|
|
61
|
+
"react-native": "0.82.1",
|
|
62
|
+
"semantic-release": "^25.0.3"
|
|
43
63
|
},
|
|
44
64
|
"peerDependencies": {
|
|
45
65
|
"expo": "*",
|