diff --git a/.changeset/fix-invalid-state-error-45.md b/.changeset/fix-invalid-state-error-45.md new file mode 100644 index 0000000..e44d108 --- /dev/null +++ b/.changeset/fix-invalid-state-error-45.md @@ -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 diff --git a/example/src/app/index.tsx b/example/src/app/index.tsx index 25dc629..b2ebb89 100644 --- a/example/src/app/index.tsx +++ b/example/src/app/index.tsx @@ -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) { + console.error("excludeCredentials error (expected) -", e); + alert("Expected Error (Issue #45)", JSON.stringify(e, null, 2)); + } + }; + return ( Authenticate + + Test excludeCredentials + Add Blob diff --git a/ios/PasskeyExceptions.swift b/ios/PasskeyExceptions.swift index 0138931..9583e32 100644 --- a/ios/PasskeyExceptions.swift +++ b/ios/PasskeyExceptions.swift @@ -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 { @@ -67,9 +66,8 @@ 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 { @@ -77,3 +75,23 @@ internal class InvalidLargeBlobWriteInputException: Exception { "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 +} diff --git a/ios/ReactNativePasskeysModule.swift b/ios/ReactNativePasskeysModule.swift index 06e9aa9..691365c 100644 --- a/ios/ReactNativePasskeysModule.swift +++ b/ios/ReactNativePasskeysModule.swift @@ -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) } }