Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/afraid-points-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-native-passkeys": minor
---

Adds an `ios.requireBiometrics` flag to specify the LAPolicy required
18 changes: 12 additions & 6 deletions example/src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -90,7 +90,7 @@ export default function App() {
...(Platform.OS !== "android" && {
extensions: { largeBlob: { support: "required" } },
}),
});
}, config);

console.log("creation json -", json);

Expand All @@ -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);

Expand Down Expand Up @@ -176,12 +176,18 @@ export default function App() {
<Text>Passkeys are {passkey.isSupported() ? "Supported" : "Not Supported"}</Text>
{credentialId && <Text>User Credential ID: {credentialId}</Text>}
<View style={styles.buttonContainer}>
<Pressable style={styles.button} onPress={createPasskey}>
<Pressable style={styles.button} onPress={() => createPasskey()}>
<Text>Create</Text>
</Pressable>
<Pressable style={styles.button} onPress={authenticatePasskey}>
<Pressable style={styles.button} onPress={() => createPasskey({ios:{requireBiometrics: false}})}>
<Text>Create (biometrics not required)</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => authenticatePasskey()}>
<Text>Authenticate</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => authenticatePasskey({ios:{requireBiometrics: false}})}>
<Text>Authenticate (biometrics not required)</Text>
</Pressable>
<Pressable style={styles.button} onPress={writeBlob}>
<Text>Add Blob</Text>
</Pressable>
Expand Down
25 changes: 14 additions & 11 deletions ios/ReactNativePasskeysModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
Expand All @@ -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()
}

Expand Down Expand Up @@ -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:
Expand Down
20 changes: 18 additions & 2 deletions src/ReactNativePasskeysModule.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -12,12 +14,26 @@ const passkeys = requireNativeModule("ReactNativePasskeys");
export default {
...passkeys,

async get(
request: PublicKeyCredentialRequestOptionsJSON,
requireBiometrics: boolean
): Promise<AuthenticationResponseJSON | null> {
return Platform.OS === "ios"
? await passkeys.get(request, requireBiometrics)
: await passkeys.get(request);
},

async create(
request: PublicKeyCredentialCreationOptionsJSON,
requireBiometrics: boolean
): Promise<RegistrationResponseJSON | null> {
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: {
Expand Down
34 changes: 32 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublicKeyCredentialCreationOptionsJSON, 'extensions'> & {
// - only largeBlob is supported currently on iOS
// - no extensions are currently supported on Android
extensions?: { largeBlob?: AuthenticationExtensionsLargeBlobInputs }
} & Pick<CredentialCreationOptions, 'signal'>,
options?: PasskeysCreateOptions
): Promise<RegistrationResponseJSON | null> {
return await ReactNativePasskeysModule.create(request)
return ReactNativePasskeysModule.create(
request,
options?.ios?.requireBiometrics ?? true
)
}

export interface PasskeysGetOptions extends PasskeysConfig {}

export async function get(
request: Omit<PublicKeyCredentialRequestOptionsJSON, 'extensions'> & {
// - only largeBlob is supported currently on iOS
// - no extensions are currently supported on Android
extensions?: { largeBlob?: AuthenticationExtensionsLargeBlobInputs }
},
options?: PasskeysGetOptions
): Promise<AuthenticationResponseJSON | null> {
return ReactNativePasskeysModule.get(request)
return ReactNativePasskeysModule.get(
request,
options?.ios?.requireBiometrics ?? true
)
}