From e4428194a00dc8e6099733d85c11b55ca91c7543 Mon Sep 17 00:00:00 2001 From: Lucas Lois Date: Mon, 19 May 2025 22:05:16 -0300 Subject: [PATCH 1/2] feat: adds an `ios.requireBiometrics` flag to specify the LAPolicy required --- .changeset/afraid-points-brush.md | 5 +++++ ios/ReactNativePasskeysModule.swift | 25 +++++++++++---------- src/ReactNativePasskeysModule.ts | 20 +++++++++++++++-- src/index.ts | 34 +++++++++++++++++++++++++++-- 4 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 .changeset/afraid-points-brush.md diff --git a/.changeset/afraid-points-brush.md b/.changeset/afraid-points-brush.md new file mode 100644 index 0000000..0f286b3 --- /dev/null +++ b/.changeset/afraid-points-brush.md @@ -0,0 +1,5 @@ +--- +"react-native-passkeys": minor +--- + +Adds an `ios.requireBiometrics` flag to specify the LAPolicy required diff --git a/ios/ReactNativePasskeysModule.swift b/ios/ReactNativePasskeysModule.swift index c42a593..c379c68 100644 --- a/ios/ReactNativePasskeysModule.swift +++ b/ios/ReactNativePasskeysModule.swift @@ -25,11 +25,11 @@ final public class ReactNativePasskeysModule: Module, PasskeyResultHandler { return false } - AsyncFunction("get") { (request: PublicKeyCredentialRequestOptions, promise: Promise) throws in + AsyncFunction("get") { (request: PublicKeyCredentialRequestOptions, requireBiometrics: Bool, promise: Promise) throws in do { // - all the throws are already in the helper `isAvailable` so we don't need to do anything // ? this seems like a code smell ... what is the best way to do this - let _ = try isAvailable() + let _ = try isAvailable(requireBiometrics: requireBiometrics) } catch let error { throw error @@ -49,11 +49,11 @@ final public class ReactNativePasskeysModule: Module, PasskeyResultHandler { passkeyDelegate.performAuthForController(controller: authController); }.runOnQueue(.main) - AsyncFunction("create") { (request: PublicKeyCredentialCreationOptions, promise: Promise) throws in + AsyncFunction("create") { (request: PublicKeyCredentialCreationOptions, requireBiometrics: Bool, promise: Promise) throws in do { // - all the throws are already in the helper `isAvailable` so we don't need to do anything // ? this seems like a code smell ... what is the best way to do this - let _ = try isAvailable() + let _ = try isAvailable(requireBiometrics: requireBiometrics) } catch let error { throw error @@ -98,7 +98,7 @@ final public class ReactNativePasskeysModule: Module, PasskeyResultHandler { } - private func isAvailable() throws -> Bool { + private func isAvailable(requireBiometrics: Bool = true) throws -> Bool { if #unavailable(iOS 15.0) { throw NotSupportedException() } @@ -107,7 +107,15 @@ final public class ReactNativePasskeysModule: Module, PasskeyResultHandler { throw PendingPasskeyRequestException() } - if LAContext().biometricType == .none { + let context = LAContext() + + // Check the local authentication policy can be evaluated + let policy: LAPolicy = requireBiometrics ? .deviceOwnerAuthenticationWithBiometrics : .deviceOwnerAuthentication + guard context.canEvaluatePolicy(policy, error: nil) else { + throw BiometricException() + } + + if requireBiometrics && context.biometricType == .none { throw BiometricException() } @@ -316,11 +324,6 @@ extension LAContext { var biometricType: BiometricType { var error: NSError? - guard self.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { - // Capture these recoverable error thru Crashlytics - return .none - } - if #available(iOS 11.0, *) { switch self.biometryType { case .none: diff --git a/src/ReactNativePasskeysModule.ts b/src/ReactNativePasskeysModule.ts index 5d496e1..76003ce 100644 --- a/src/ReactNativePasskeysModule.ts +++ b/src/ReactNativePasskeysModule.ts @@ -1,7 +1,9 @@ -import { requireNativeModule } from "expo-modules-core"; +import { Platform, requireNativeModule } from "expo-modules-core"; import type { PublicKeyCredentialCreationOptionsJSON, RegistrationResponseJSON, + PublicKeyCredentialRequestOptionsJSON, + AuthenticationResponseJSON, } from "./ReactNativePasskeys.types"; import { NotSupportedError } from "./errors"; @@ -12,12 +14,26 @@ const passkeys = requireNativeModule("ReactNativePasskeys"); export default { ...passkeys, + async get( + request: PublicKeyCredentialRequestOptionsJSON, + requireBiometrics: boolean + ): Promise { + return Platform.OS === "ios" + ? await passkeys.get(request, requireBiometrics) + : await passkeys.get(request); + }, + async create( request: PublicKeyCredentialCreationOptionsJSON, + requireBiometrics: boolean ): Promise { if (!this.isSupported) throw new NotSupportedError(); - const credential = await passkeys.create(request); + const credential = + Platform.OS === "ios" + ? await passkeys.create(request, requireBiometrics) + : await passkeys.create(request); + return { ...credential, response: { diff --git a/src/index.ts b/src/index.ts index fd86eba..71e3d57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,22 +22,52 @@ export function isAutoFillAvalilable(): boolean { return ReactNativePasskeysModule.isAutoFillAvalilable() } +export interface PasskeysConfig { + /** + * Options and configuration specific to the iOS platform. + */ + ios?: { + /** + * Defines the [local authentication policy](https://developer.apple.com/documentation/localauthentication/lapolicy) to use: + * - `true`: Use the `deviceOwnerAuthenticationWithBiometrics` policy. + * - `false`: Use the `deviceOwnerAuthentication` policy. + * Defaults to `true`. + * + * @see {@linkcode https://developer.apple.com/documentation/localauthentication/lapolicy/deviceownerauthenticationwithbiometrics|LAPolicy.deviceOwnerAuthenticationWithBiometrics} + * @see {@linkcode https://developer.apple.com/documentation/localauthentication/lapolicy/deviceownerauthentication|LAPolicy.deviceOwnerAuthentication} + */ + requireBiometrics?: boolean + } +} + +export interface PasskeysCreateOptions extends PasskeysConfig {} + export async function create( request: Omit & { // - only largeBlob is supported currently on iOS // - no extensions are currently supported on Android extensions?: { largeBlob?: AuthenticationExtensionsLargeBlobInputs } } & Pick, + options?: PasskeysCreateOptions ): Promise { - return await ReactNativePasskeysModule.create(request) + return ReactNativePasskeysModule.create( + request, + options?.ios?.requireBiometrics ?? true + ) } +export interface PasskeysGetOptions extends PasskeysConfig {} + export async function get( request: Omit & { // - only largeBlob is supported currently on iOS // - no extensions are currently supported on Android extensions?: { largeBlob?: AuthenticationExtensionsLargeBlobInputs } }, + options?: PasskeysGetOptions ): Promise { - return ReactNativePasskeysModule.get(request) + return ReactNativePasskeysModule.get( + request, + options?.ios?.requireBiometrics ?? true + ) } From 6dc2901a8c484c2b59bed27264a377f7bda721c0 Mon Sep 17 00:00:00 2001 From: Lucas Lois Date: Wed, 21 May 2025 22:49:28 -0300 Subject: [PATCH 2/2] test: adds buttons with 'requireBiometrics: false' to the example app --- example/src/app/index.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/example/src/app/index.tsx b/example/src/app/index.tsx index e803c9d..9a33fb7 100644 --- a/example/src/app/index.tsx +++ b/example/src/app/index.tsx @@ -79,7 +79,7 @@ export default function App() { >(null); const [credentialId, setCredentialId] = React.useState(""); - const createPasskey = async () => { + const createPasskey = async (config?: passkey.PasskeysConfig) => { try { const json = await passkey.create({ challenge, @@ -90,7 +90,7 @@ export default function App() { ...(Platform.OS !== "android" && { extensions: { largeBlob: { support: "required" } }, }), - }); + }, config); console.log("creation json -", json); @@ -103,14 +103,14 @@ export default function App() { } }; - const authenticatePasskey = async () => { + const authenticatePasskey = async (config?: passkey.PasskeysConfig) => { const json = await passkey.get({ rpId: rp.id, challenge, ...(credentialId && { allowCredentials: [{ id: credentialId, type: "public-key" }], }), - }); + }, config); console.log("authentication json -", json); @@ -176,12 +176,18 @@ export default function App() { Passkeys are {passkey.isSupported() ? "Supported" : "Not Supported"} {credentialId && User Credential ID: {credentialId}} - + createPasskey()}> Create - + createPasskey({ios:{requireBiometrics: false}})}> + Create (biometrics not required) + + authenticatePasskey()}> Authenticate + authenticatePasskey({ios:{requireBiometrics: false}})}> + Authenticate (biometrics not required) + Add Blob