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
14 changes: 14 additions & 0 deletions .changeset/fix-invalid-state-error-45.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"react-native-passkeys": patch
---

Fix iOS error handling to provide descriptive error messages for excludeCredentials

Previously, iOS would return generic error messages like "(null)" or "The operation couldn't be completed" when handling excludeCredentials errors. This fix enhances error handling on iOS to detect generic error messages and replace them with clear, descriptive messages that match the WebAuthn specification.

Changes:
- Added detection for generic/useless error messages from iOS
- Map ASAuthorizationError codes to proper WebAuthn error names
- Return descriptive error messages for all error cases including InvalidStateError for excludeCredentials

Fixes #45
26 changes: 26 additions & 0 deletions example/src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,29 @@ export default function App() {
setResult(json);
};

const testExcludeCredentials = async () => {
if (!credentialId) {
alert("No credential", "Create a passkey first to test excludeCredentials");
return;
}

try {
// Attempt to create a new passkey with the existing credential in excludeCredentials
// This should fail with InvalidStateError
const json = await passkey.create({
...createOptions,
excludeCredentials: [{ id: credentialId, type: "public-key" }],
});

console.log("excludeCredentials test result -", json);
alert("Unexpected Success", "This should have failed with InvalidStateError");
setResult(json);
} catch (e) {
Comment on lines +227 to +235
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix undefined createOptions reference before shipping.

testExcludeCredentials spreads createOptions, but nothing with that name is defined in this component. Hitting the new button will immediately throw a ReferenceError instead of reproducing the iOS error flow, so the demo can’t validate the fix. Bring the full creation payload inline (or share it from createPasskey) before calling passkey.create.

-			const json = await passkey.create({
-				...createOptions,
-				excludeCredentials: [{ id: credentialId, type: "public-key" }],
-			});
+			const json = await passkey.create({
+				challenge,
+				pubKeyCredParams: [{ alg: -7, type: "public-key" }],
+				rp,
+				user,
+				authenticatorSelection,
+				extensions: {
+					...(Platform.OS !== "android" && { largeBlob: { support: "required" } }),
+					prf: {},
+				},
+				excludeCredentials: [{ id: credentialId, type: "public-key" }],
+			});

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In example/src/app/index.tsx around lines 227 to 235, the function
testExcludeCredentials references createOptions which is undefined causing a
ReferenceError; replace the spread with the actual creation payload (either
inline the full create options object used elsewhere or import/share the
createPasskey payload) so passkey.create receives a complete, defined options
object including challenge, rp, user, pubKeyCredParams, timeout, and any other
required fields; ensure excludeCredentials is merged into that defined object
before calling passkey.create.

console.error("excludeCredentials error (expected) -", e);
alert("Expected Error (Issue #45)", JSON.stringify(e, null, 2));
}
};

return (
<View style={{ flex: 1 }}>
<ScrollView
Expand All @@ -238,6 +261,9 @@ export default function App() {
<Pressable style={styles.button} onPress={authenticatePasskey}>
<Text>Authenticate</Text>
</Pressable>
<Pressable style={styles.button} onPress={testExcludeCredentials}>
<Text>Test excludeCredentials</Text>
</Pressable>
<Pressable style={styles.button} onPress={writeBlob}>
<Text>Add Blob</Text>
</Pressable>
Expand Down
30 changes: 24 additions & 6 deletions ios/PasskeyExceptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ internal class BiometricException: Exception {
}

internal class UserCancelledException: Exception {
override var reason: String {
"User cancelled the passkey interaction"
}
// Don't override reason - use the description passed to the initializer
// Maps to WebAuthn NotAllowedError
}

internal class InvalidChallengeException: Exception {
Expand Down Expand Up @@ -67,13 +66,32 @@ internal class InvalidPRFInputException: Exception {
}

internal class UnknownException: Exception {
override var reason: String {
"An unknown exception occured"
}
// Don't override reason - use the description passed to the initializer
// This allows propagating the actual error message from ASAuthorizationError
}

internal class InvalidLargeBlobWriteInputException: Exception {
override var reason: String {
"The provided large blob write input was invalid"
}
}

internal class MatchedExcludedCredentialException: Exception {
// Don't override reason - use the description passed to the initializer
// This will contain the localized message from iOS about the matched credential
}

internal class InvalidResponseException: Exception {
// Don't override reason - use the description passed to the initializer
// Maps to WebAuthn EncodingError
}

internal class NotHandledException: Exception {
// Don't override reason - use the description passed to the initializer
// Maps to WebAuthn NotSupportedError
}

internal class NotInteractiveException: Exception {
// Don't override reason - use the description passed to the initializer
// Maps to WebAuthn InvalidStateError
}
80 changes: 72 additions & 8 deletions ios/ReactNativePasskeysModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -431,18 +431,82 @@ private func preparePlatformAssertionRequest(
}

func handleASAuthorizationError(errorCode: Int, localizedDescription: String = "") -> Exception {
// Helper to check if iOS gave us a generic/useless error message
func isGenericErrorMessage(_ description: String) -> Bool {
let isEmpty = description.isEmpty
let hasNull = description.contains("(null)")

// Remove all non-alphabetic characters and check for the key phrase
let alphaOnly = description.filter { $0.isLetter || $0.isWhitespace }
let hasOperationNotCompleted = alphaOnly.contains("The operation couldnt be completed")

return isEmpty || hasNull || hasOperationNotCompleted
}

switch errorCode {
case 1000:
// ASAuthorizationErrorUnknown
let message =
isGenericErrorMessage(localizedDescription)
? "The authorization attempt failed for an unknown reason."
: localizedDescription
return UnknownException(name: "UnknownError", description: message)

case 1001:
return UserCancelledException(
name: "UserCancelledException", description: localizedDescription)
// ASAuthorizationErrorCanceled - maps to WebAuthn NotAllowedError
let message =
isGenericErrorMessage(localizedDescription)
? "The user canceled the authorization attempt."
: localizedDescription
return UserCancelledException(name: "NotAllowedError", description: message)

case 1002:
// ASAuthorizationErrorInvalidResponse - maps to WebAuthn EncodingError
let message =
isGenericErrorMessage(localizedDescription)
? "The authorization request received an invalid response."
: localizedDescription
return InvalidResponseException(name: "EncodingError", description: message)

case 1003:
// ASAuthorizationErrorNotHandled - maps to WebAuthn NotSupportedError
let message =
isGenericErrorMessage(localizedDescription)
? "The authorization request was not handled."
: localizedDescription
return NotHandledException(name: "NotSupportedError", description: message)

case 1004:
return PasskeyRequestFailedException(
name: "PasskeyRequestFailedException", description: localizedDescription)
case 4004:
return NotConfiguredException(
name: "NotConfiguredException", description: localizedDescription)
// ASAuthorizationErrorFailed - maps to WebAuthn OperationError
let message =
isGenericErrorMessage(localizedDescription)
? "The authorization attempt failed."
: localizedDescription
return PasskeyRequestFailedException(name: "OperationError", description: message)

case 1005:
// ASAuthorizationErrorNotInteractive - maps to WebAuthn InvalidStateError
let message =
isGenericErrorMessage(localizedDescription)
? "The authorization request cannot be interactive."
: localizedDescription
return NotInteractiveException(name: "InvalidStateError", description: message)

case 1006:
// ASAuthorizationErrorMatchedExcludedCredential (iOS 18.0+) - maps to WebAuthn InvalidStateError
let message =
isGenericErrorMessage(localizedDescription)
? "The user attempted to register an authenticator that contains one of the credentials already registered with the relying party."
: localizedDescription
return MatchedExcludedCredentialException(name: "InvalidStateError", description: message)

default:
return UnknownException(name: "UnknownException", description: localizedDescription)
// Unknown error code
let message =
isGenericErrorMessage(localizedDescription)
? "An unknown authorization error occurred (code: \(errorCode))."
: localizedDescription
return UnknownException(name: "UnknownError", description: message)
}
}

Expand Down