@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.
- package/.nvmrc +1 -0
- package/.oxfmtrc.json +4 -0
- package/.oxlintrc.json +14 -0
- package/.releaserc +30 -0
- package/ARCHITECTURE.md +8 -0
- package/CHANGELOG.md +115 -0
- package/CONTRIBUTING.md +15 -5
- package/README.md +150 -11
- package/android/build.gradle +33 -10
- package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
- package/android/src/main/AndroidManifest.xml +9 -3
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
- package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
- package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
- package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/app.plugin.js +593 -60
- package/assets/banner.png +0 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
- package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofill.types.js +3 -1
- package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
- package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.js +21 -2
- package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
- package/build/index.d.ts +2 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -2
- package/build/index.js.map +1 -1
- package/e2e/README.md +155 -0
- package/e2e/driver/adb.ts +73 -0
- package/e2e/driver/capabilities.ts +106 -0
- package/e2e/driver/credentialProvider.ts +104 -0
- package/e2e/driver/fingerprint.ts +885 -0
- package/e2e/driver/selectors.ts +103 -0
- package/e2e/driver/session.ts +25 -0
- package/e2e/jest.config.ts +20 -0
- package/e2e/package.json +11 -0
- package/e2e/tests/passkey.test.ts +190 -0
- package/e2e/tsconfig.json +16 -0
- package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
- package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
- package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
- package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
- package/ios/AutofillCredentialProvider/Prf.swift +89 -0
- package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
- package/ios/ReactNativePasskeyAutofill.podspec +11 -2
- package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
- package/lefthook.yml +14 -0
- package/package.json +115 -25
- package/pnpm-workspace.yaml +36 -0
- package/src/ReactNativePasskeyAutofill.types.ts +38 -0
- package/src/ReactNativePasskeyAutofillModule.ts +66 -3
- package/src/index.ts +2 -2
- package/tsconfig.json +2 -2
- package/.eslintrc.js +0 -5
- /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;
|
package/e2e/package.json
ADDED
|
@@ -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
|
+
}
|