@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.2 → 1.0.0-canary.21

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.
Files changed (66) hide show
  1. package/.nvmrc +1 -0
  2. package/.oxfmtrc.json +4 -0
  3. package/.oxlintrc.json +14 -0
  4. package/.releaserc +30 -0
  5. package/ARCHITECTURE.md +8 -0
  6. package/CHANGELOG.md +115 -0
  7. package/CONTRIBUTING.md +15 -5
  8. package/README.md +150 -11
  9. package/android/build.gradle +33 -10
  10. package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
  11. package/android/src/main/AndroidManifest.xml +9 -3
  12. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
  13. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
  14. package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
  15. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
  16. package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
  17. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
  18. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
  19. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
  20. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
  21. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
  22. package/android/src/main/res/values/styles.xml +10 -0
  23. package/app.plugin.js +593 -60
  24. package/assets/banner.png +0 -0
  25. package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
  26. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  27. package/build/ReactNativePasskeyAutofill.types.js +3 -1
  28. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  29. package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
  30. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  31. package/build/ReactNativePasskeyAutofillModule.js +21 -2
  32. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  33. package/build/index.d.ts +2 -2
  34. package/build/index.d.ts.map +1 -1
  35. package/build/index.js +2 -2
  36. package/build/index.js.map +1 -1
  37. package/e2e/README.md +155 -0
  38. package/e2e/driver/adb.ts +73 -0
  39. package/e2e/driver/capabilities.ts +106 -0
  40. package/e2e/driver/credentialProvider.ts +104 -0
  41. package/e2e/driver/fingerprint.ts +885 -0
  42. package/e2e/driver/selectors.ts +103 -0
  43. package/e2e/driver/session.ts +25 -0
  44. package/e2e/jest.config.ts +20 -0
  45. package/e2e/package.json +11 -0
  46. package/e2e/tests/passkey.test.ts +190 -0
  47. package/e2e/tsconfig.json +16 -0
  48. package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
  49. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
  50. package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
  51. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
  52. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
  53. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
  54. package/ios/AutofillCredentialProvider/Prf.swift +89 -0
  55. package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
  56. package/ios/ReactNativePasskeyAutofill.podspec +11 -2
  57. package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
  58. package/lefthook.yml +14 -0
  59. package/package.json +115 -25
  60. package/pnpm-workspace.yaml +36 -0
  61. package/src/ReactNativePasskeyAutofill.types.ts +38 -0
  62. package/src/ReactNativePasskeyAutofillModule.ts +66 -3
  63. package/src/index.ts +2 -2
  64. package/tsconfig.json +2 -2
  65. package/.eslintrc.js +0 -5
  66. /package/android/libs/{dP256Android-release.aar → repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.aar} +0 -0
@@ -0,0 +1,103 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import type { Driver } from "./session";
4
+ import { PLATFORM } from "./capabilities";
5
+
6
+ /**
7
+ * Returns a locator string matching an element by its React Native
8
+ * `testID`. On iOS (XCUITest) `testID` is surfaced as the accessibility
9
+ * id, so the `~testID` tilde-selector works directly. On Android
10
+ * (UiAutomator2) RN maps `testID` to the view's `resource-id` instead,
11
+ * so we build a UiSelector querying `resourceId(...)`.
12
+ */
13
+ export const byTestId = (testID: string): string => {
14
+ if (PLATFORM === "ios") return `~${testID}`;
15
+ // Android: check both resource-id (used by RN) and content-desc (used by
16
+ // native activities). We also match the short :id/name suffix.
17
+ return `//*[@resource-id='${testID}' or @content-desc='${testID}' or contains(@resource-id, ':id/${testID}') or @text='${testID}']`;
18
+ };
19
+
20
+ /**
21
+ * Platform-neutral selector for native system dialogs triggered by
22
+ * WebAuthn (passkey prompts, biometrics, save-password sheets).
23
+ */
24
+ export const systemDialogButton = (label: string): string => {
25
+ if (PLATFORM === "ios") {
26
+ // iOS system alerts: CONTAINS match survives suffixes like "Create Passkey".
27
+ return `-ios predicate string:type == 'XCUIElementTypeButton' AND name CONTAINS[c] '${label}'`;
28
+ }
29
+ // Android: case-insensitive substring match — intentionally *contains*,
30
+ // not exact. We check both text and content-desc using XPath.
31
+ const l = label.toLowerCase();
32
+ const u = label.toUpperCase();
33
+ return `//*[contains(@text, '${label}') or contains(@text, '${l}') or contains(@text, '${u}') or contains(@content-desc, '${label}') or contains(@content-desc, '${l}') or contains(@content-desc, '${u}')]`;
34
+ };
35
+
36
+ /**
37
+ * Waits for an element to exist and be displayed, then taps it.
38
+ */
39
+ export async function tap(driver: Driver, selector: string, timeoutMs = 15_000): Promise<void> {
40
+ const el = await driver.$(selector);
41
+ await el.waitForDisplayed({ timeout: timeoutMs });
42
+ await el.click();
43
+ }
44
+
45
+ /**
46
+ * Attempts to dismiss any OS-level passkey/biometric dialog by pressing
47
+ * the affirmative button. No-op if the dialog is not present.
48
+ */
49
+ export async function confirmSystemPrompt(
50
+ driver: Driver,
51
+ labels: string[] = ["Continue", "Allow", "Save", "Use Passkey", "Sign In"],
52
+ timeoutMs = 10_000,
53
+ ): Promise<boolean> {
54
+ const deadline = Date.now() + timeoutMs;
55
+ while (Date.now() < deadline) {
56
+ for (const label of labels) {
57
+ const el = await driver.$(systemDialogButton(label));
58
+ if (await el.isExisting().catch(() => false)) {
59
+ await el.click().catch(() => undefined);
60
+ return true;
61
+ }
62
+ }
63
+ await driver.pause(500);
64
+ }
65
+ // eslint-disable-next-line no-console
66
+ console.log(
67
+ `[e2e] confirmSystemPrompt: timed out waiting for labels [${labels.join(", ")}]. Visible labels: ${await getVisibleLabels(driver)}`,
68
+ );
69
+ return false;
70
+ }
71
+
72
+ async function getVisibleLabels(driver: Driver): Promise<string[]> {
73
+ try {
74
+ const src = await driver.getPageSource();
75
+ const rx = /(?:text|content-desc)="([^"]+)"/g;
76
+ const labels = new Set<string>();
77
+ let m;
78
+ while ((m = rx.exec(src))) {
79
+ const l = m[1].trim();
80
+ if (l && l.length < 50) labels.add(l);
81
+ }
82
+ return [...labels];
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Capture a screenshot for review.
90
+ */
91
+ export async function takeScreenshot(driver: Driver, name: string): Promise<void> {
92
+ try {
93
+ const b64 = await driver.takeScreenshot();
94
+ const filename = `screenshot-${Date.now()}-${name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}.png`;
95
+ const fullPath = path.join(process.cwd(), filename);
96
+ fs.writeFileSync(fullPath, Buffer.from(b64, "base64"));
97
+ // eslint-disable-next-line no-console
98
+ console.log(`[e2e] screenshot saved to ${filename}`);
99
+ } catch (e) {
100
+ // eslint-disable-next-line no-console
101
+ console.warn(`[e2e] failed to take screenshot: ${e}`);
102
+ }
103
+ }
@@ -0,0 +1,25 @@
1
+ import { remote, type Browser } from "webdriverio";
2
+
3
+ import {
4
+ APPIUM_HOST,
5
+ APPIUM_PORT,
6
+ PLATFORM,
7
+ capabilitiesForPlatform,
8
+ type Platform,
9
+ } from "./capabilities";
10
+
11
+ export type Driver = Browser;
12
+
13
+ /**
14
+ * Creates a WebdriverIO session against a locally running Appium server.
15
+ * The caller is responsible for tearing the session down with `driver.deleteSession()`.
16
+ */
17
+ export async function createDriver(platform: Platform = PLATFORM): Promise<Driver> {
18
+ return remote({
19
+ hostname: APPIUM_HOST,
20
+ port: APPIUM_PORT,
21
+ path: "/",
22
+ logLevel: (process.env.WDIO_LOG_LEVEL as "error" | "warn" | "info" | "debug") ?? "warn",
23
+ capabilities: capabilitiesForPlatform(platform),
24
+ });
25
+ }
@@ -0,0 +1,20 @@
1
+ import type { Config } from "jest";
2
+
3
+ /**
4
+ * Jest configuration for Appium / WebdriverIO end-to-end tests.
5
+ *
6
+ * Each test file bootstraps its own WebdriverIO `remote()` session via
7
+ * `driver/session.ts`, so we run a single worker and give each spec a
8
+ * generous timeout to account for native app cold starts + WebAuthn UI.
9
+ */
10
+ const config: Config = {
11
+ preset: "ts-jest",
12
+ testEnvironment: "node",
13
+ rootDir: ".",
14
+ testMatch: ["<rootDir>/tests/**/*.test.ts"],
15
+ testTimeout: 5 * 60 * 1000,
16
+ maxWorkers: 1,
17
+ verbose: true,
18
+ };
19
+
20
+ export default config;
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@algorandfoundation/react-native-passkey-autofill-e2e",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "Appium + WebdriverIO + Jest end-to-end tests for the Passkey AutoFill example app.",
6
+ "scripts": {
7
+ "test": "node --experimental-vm-modules ../node_modules/jest/bin/jest.js --config jest.config.ts",
8
+ "test:android": "E2E_PLATFORM=android node --experimental-vm-modules ../node_modules/jest/bin/jest.js --config jest.config.ts",
9
+ "test:ios": "E2E_PLATFORM=ios node --experimental-vm-modules ../node_modules/jest/bin/jest.js --config jest.config.ts"
10
+ }
11
+ }
@@ -0,0 +1,190 @@
1
+ import { createDriver, type Driver } from "../driver/session";
2
+ import {
3
+ byTestId,
4
+ confirmSystemPrompt,
5
+ tap,
6
+ takeScreenshot,
7
+ systemDialogButton,
8
+ } from "../driver/selectors";
9
+ import { PLATFORM, ANDROID_PACKAGE } from "../driver/capabilities";
10
+ import {
11
+ ensureBiometricEnrolled,
12
+ pumpFingerprintUntil,
13
+ currentPackage,
14
+ currentActivity,
15
+ } from "../driver/fingerprint";
16
+ import { ensureAndroidCredentialProviderEnabled } from "../driver/credentialProvider";
17
+
18
+ /**
19
+ * End-to-end coverage for the Passkey AutoFill example app.
20
+ *
21
+ * The flow mirrors what the example does at runtime:
22
+ * 1. Mint a local XHD Ed25519 key (prerequisite for `handleCreatePasskey`).
23
+ * 2. Create a passkey via `https://debug.liquidauth.com/attestation/*`.
24
+ * 3. Re-use the passkey via `https://debug.liquidauth.com/assertion/*`.
25
+ *
26
+ * System-level WebAuthn prompts (e.g. "Continue", Face ID/Touch ID) are
27
+ * auto-confirmed where possible; on the Android emulator the
28
+ * `autoGrantPermissions` capability handles runtime permissions.
29
+ */
30
+ describe("Passkey AutoFill example", () => {
31
+ let driver: Driver;
32
+
33
+ beforeAll(async () => {
34
+ // eslint-disable-next-line no-console
35
+ console.log("[e2e] beforeAll: creating driver...");
36
+ driver = await createDriver();
37
+ // eslint-disable-next-line no-console
38
+ console.log("[e2e] beforeAll: driver created");
39
+
40
+ if (PLATFORM === "android") {
41
+ // eslint-disable-next-line no-console
42
+ console.log("[e2e] beforeAll: ensuring credential provider enabled...");
43
+ // Register our passkey service as the system's Credential Manager
44
+ // provider. A fresh AVD has no providers enabled, so Credential
45
+ // Manager would never route BeginCreate*/BeginGet* to our service
46
+ // and `provider-status` would stay `inactive` (observed in CI).
47
+ const providerRegistered = await ensureAndroidCredentialProviderEnabled();
48
+ // eslint-disable-next-line no-console
49
+ console.log("[e2e] beforeAll: android credential provider registered:", providerRegistered);
50
+ if (!providerRegistered) {
51
+ throw new Error(
52
+ "[e2e] Android Credential Provider registration failed — cannot exercise passkey flow",
53
+ );
54
+ }
55
+ }
56
+
57
+ // eslint-disable-next-line no-console
58
+ console.log("[e2e] beforeAll: ensuring biometrics enrolled...");
59
+ // Enroll biometrics so the system prompts can be satisfied
60
+ // (Touch ID/Face ID on iOS simulator, Fingerprint on Android emulator).
61
+ const enrolled = await ensureBiometricEnrolled(driver);
62
+ // eslint-disable-next-line no-console
63
+ console.log(`[e2e] beforeAll: ${PLATFORM} biometric enrolled:`, enrolled);
64
+ if (!enrolled) {
65
+ throw new Error(
66
+ `[e2e] ${PLATFORM} biometric enrollment failed — cannot exercise passkey flow. ` +
67
+ (PLATFORM === "android"
68
+ ? "See [e2e:fingerprint] logs for the Settings wizard labels observed on the AVD."
69
+ : ""),
70
+ );
71
+ }
72
+ // eslint-disable-next-line no-console
73
+ console.log("[e2e] beforeAll: finished");
74
+ }, 180_000);
75
+
76
+ afterAll(async () => {
77
+ if (driver) {
78
+ await driver.deleteSession().catch(() => undefined);
79
+ }
80
+ });
81
+
82
+ it("launches the example app and shows the header", async () => {
83
+ const header = await driver.$(
84
+ PLATFORM === "ios"
85
+ ? `-ios predicate string:name == 'Passkey AutoFill Example'`
86
+ : `android=new UiSelector().text("Passkey AutoFill Example")`,
87
+ );
88
+ await header.waitForDisplayed({ timeout: 30_000 });
89
+ expect(await header.isDisplayed()).toBe(true);
90
+ await driver.pause(200);
91
+ await takeScreenshot(driver, "app-start");
92
+ });
93
+
94
+ /**
95
+ * Smoke-read the provider status banner before the passkey flow runs.
96
+ *
97
+ * The native check is best-effort: on Android 12+ the underlying
98
+ * `credential_service` Secure key is `@hide`-restricted and throws
99
+ * `SecurityException` when read from a regular app, so the status can
100
+ * legitimately report `unknown` or `inactive` *before* the system has
101
+ * routed the first `BeginCreateCredentialRequest` to our service (which
102
+ * stamps MMKV and flips the status to `active`). That authoritative
103
+ * post-stamp assertion lives in the passkey creation test below; this
104
+ * one only surfaces the initial reading for log context.
105
+ */
106
+ it("surfaces the initial provider status", async () => {
107
+ const status = await driver.$(byTestId("provider-status"));
108
+ await status.waitForDisplayed({ timeout: 15_000 });
109
+ const text = (await status.getText()).toLowerCase();
110
+ // eslint-disable-next-line no-console
111
+ console.log("[e2e] initial provider status:", text);
112
+ expect(["active", "inactive", "unknown"].some((s) => text.includes(s))).toBe(true);
113
+ });
114
+
115
+ it("creates an XHD Ed25519 key", async () => {
116
+ await tap(driver, byTestId("create-ed25519-key-button"));
117
+ // Wait for the alert with the mnemonic to show.
118
+ await driver.$(systemDialogButton("XHD Ed25519 Key created")).waitForExist({ timeout: 10_000 });
119
+ await driver.pause(200);
120
+ await takeScreenshot(driver, "xhd-key-created-alert");
121
+ await confirmSystemPrompt(driver, ["OK", "Okay", "Dismiss"]);
122
+ });
123
+
124
+ /**
125
+ * Predicate: is the example app currently in the foreground? Used as
126
+ * the termination signal when we spam `adb emu finger touch` — as
127
+ * soon as Credential Manager closes its bottom-sheet and control
128
+ * returns to the app, we stop touching.
129
+ */
130
+ const appInForeground = async (): Promise<boolean> => {
131
+ const pkg = await currentPackage();
132
+ const act = await currentActivity();
133
+ if (pkg === ANDROID_PACKAGE) {
134
+ // Only return true if we are in the main activity. If a library activity
135
+ // like CreatePasskeyActivity is on top, we want to keep pumping.
136
+ return act.includes("MainActivity");
137
+ }
138
+ const el = await driver.$(byTestId("passkey-action-button"));
139
+ return el.isDisplayed().catch(() => false);
140
+ };
141
+
142
+ it("creates and then uses a passkey against debug.liquidauth.com", async () => {
143
+ // `beforeAll` has already thrown on Android if the Credential
144
+ // Provider wasn't registered or no fingerprint could be enrolled,
145
+ // so reaching this point means the biometric sheet can be satisfied
146
+ // deterministically via `adb emu finger touch`.
147
+ // Create passkey. The system first shows a consent screen ("Continue"
148
+ // / "Create"), then the biometric sheet. We dismiss the consent via
149
+ // `confirmSystemPrompt`, then feed simulated finger touches via
150
+ // `adb emu finger touch` until the sheet closes and the app returns
151
+ // to the foreground.
152
+ await tap(driver, byTestId("passkey-action-button"));
153
+ // pumpFingerprintUntil now handles both system prompts (Continue/Create)
154
+ // and our own intermediate activities (CreatePasskeyActivity) by
155
+ // repeatedly clicking affirmative buttons until back in the main app.
156
+ await pumpFingerprintUntil(driver, appInForeground, { timeoutMs: 45_000 });
157
+ // Wait for the app-level success alert to appear.
158
+ await driver.$(systemDialogButton("Passkey created")).waitForExist({ timeout: 10_000 });
159
+ await confirmSystemPrompt(driver, ["OK", "Okay", "Dismiss", "Got it"]);
160
+ // Allow time for the alert to clear before the final screenshot.
161
+ await driver.pause(500);
162
+ await takeScreenshot(driver, "passkey-create-success");
163
+
164
+ // After the first create, the provider stamp must flip the status to
165
+ // "active" — this is the authoritative assertion that *this* app
166
+ // served the system prompt (not a different installed provider).
167
+ const status = await driver.$(byTestId("provider-status"));
168
+ await status.waitForDisplayed({ timeout: 15_000 });
169
+ const deadline = Date.now() + 10_000;
170
+ let text = "";
171
+ while (Date.now() < deadline) {
172
+ text = (await status.getText()).toLowerCase();
173
+ if (text.includes("active") && !text.includes("inactive")) break;
174
+ await driver.pause(500);
175
+ }
176
+ expect(text).toMatch(/active/);
177
+ expect(text).not.toMatch(/inactive/);
178
+
179
+ // The button title flips to "Use Passkey" once one exists; re-tap it
180
+ // to exercise the assertion flow.
181
+ await tap(driver, byTestId("passkey-action-button"));
182
+ await pumpFingerprintUntil(driver, appInForeground, { timeoutMs: 45_000 });
183
+ await confirmSystemPrompt(driver, ["OK", "Okay", "Dismiss", "Got it"]);
184
+ });
185
+
186
+ it("clears all passkeys and keys", async () => {
187
+ await tap(driver, byTestId("clear-passkeys-button"));
188
+ await confirmSystemPrompt(driver, ["OK", "Okay", "Dismiss"]);
189
+ });
190
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2021",
4
+ "module": "CommonJS",
5
+ "moduleResolution": "node",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "resolveJsonModule": true,
10
+ "types": ["node", "jest", "@wdio/globals/types"],
11
+ "baseUrl": ".",
12
+ "outDir": "./dist"
13
+ },
14
+ "include": ["**/*.ts"],
15
+ "exclude": ["dist", "node_modules"]
16
+ }
@@ -0,0 +1,34 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+
4
+ /// Authentication requirement for passkey operations, configured at build time via the
5
+ /// `biometricRequirement` config-plugin prop and read from the extension Info.plist.
6
+ /// iOS has no weak-biometric concept, so both relaxed levels allow the device passcode.
7
+ enum BiometricRequirement: String {
8
+ case strong
9
+ case strongOrCredential
10
+ case weakOrCredential
11
+
12
+ private static let infoDictionaryKey = "ReactNativePasskeyAutofillBiometricRequirement"
13
+
14
+ static let current: BiometricRequirement = {
15
+ guard
16
+ let raw = Bundle.main.object(forInfoDictionaryKey: infoDictionaryKey) as? String,
17
+ let parsed = BiometricRequirement(rawValue: raw)
18
+ else {
19
+ return .strongOrCredential
20
+ }
21
+ return parsed
22
+ }()
23
+
24
+ /// `.deviceOwnerAuthenticationWithBiometrics` rejects the passcode; `.deviceOwnerAuthentication`
25
+ /// allows biometrics or passcode.
26
+ var laPolicy: LAPolicy {
27
+ switch self {
28
+ case .strong:
29
+ return .deviceOwnerAuthenticationWithBiometrics
30
+ case .strongOrCredential, .weakOrCredential:
31
+ return .deviceOwnerAuthentication
32
+ }
33
+ }
34
+ }