Show separate error message when adding a Passkey twice#2756
Conversation
Turns out Google Passwords doesn't obey the exvlude list and allows only a single passkey per RP ID. If you try to add a new Passkey after you already created one in Google Passwords it will cause a client-side state error, which per spec can only mean that someone tried to re-add the same passkey twice and the client detected that.
There was a problem hiding this comment.
Pull request overview
This PR adds a distinct “duplicate passkey registration” error state to the passkey registration UI, addressing clients (e.g., Google Passwords) that can trigger a client-side InvalidStateError when attempting to register a passkey again.
Changes:
- Add a new duplicate-registration error message and emit it as a hidden
data-passkey-error="duplicate"element from the passkey form helper. - Update the passkey web component error handling to classify
InvalidStateErroras “duplicate” and includeduplicatein thepasskey:errorevent detail. - Wire the new duplicate error styling into the passkeys index page registration button.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| lib/action_pack/passkey/form_helper.rb | Adds duplicate error message/defaults and renders a new duplicate error element. |
| app/views/my/passkeys/index.html.erb | Passes styling options for the new duplicate error message. |
| app/javascript/lib/action_pack/passkey.js | Classifies InvalidStateError as duplicate and exposes it in event detail. |
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Comments suppressed due to low confidence (1)
lib/action_pack/passkey/form_helper.rb:47
- This change introduces a new duplicate-registration UI state (new constant/default message + new rendered error div), but there are no tests asserting the helper output includes the new
data-passkey-error="duplicate"element and message. Since the repo already usesActionView::TestCasefor helper rendering assertions, adding a focused helper test would help prevent regressions in the error-message markup/attributes expected by the web component.
def passkey_registration_button(name = nil, url = nil, **options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
component_options, form_options, button_options, error_options = partition_passkey_options(url, options)
error_options[:error][:message] ||= REGISTRATION_ERROR_MESSAGE
error_options[:cancellation][:message] ||= REGISTRATION_CANCELLED_MESSAGE
error_options[:duplicate][:message] ||= REGISTRATION_DUPLICATE_MESSAGE
param = form_options.delete(:param)
content_tag("rails-passkey-registration-button", **component_options.transform_keys { |key| key.to_s.dasherize }) do
tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[attestation_object]", nil, id: nil, data: { passkey_field: "attestation_object" }) +
hidden_field_tag("#{param}[transports][]", nil, id: nil, data: { passkey_field: "transports" }) +
tag.button(name, type: :button, data: { passkey: "register" }, **button_options)
end + passkey_error_messages(**error_options)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| tag.div(error_message, hidden: true, **error_attributes) + | ||
| tag.div(cancellation_message, hidden: true, **cancellation_attributes) + | ||
| tag.div(duplicate_message, hidden: true, **duplicate_attributes) |
There was a problem hiding this comment.
passkey_error_messages now always renders a data-passkey-error="duplicate" div even when no duplicate message is provided. In passkey_sign_in_button, duplicate defaults to {}, so this produces an empty hidden div in the markup (and could be unhidden by JS, resulting in no visible message). Consider only rendering the duplicate div when duplicate[:message] is present, or setting an explicit default/fallback behavior for sign-in.
| tag.div(duplicate_message, hidden: true, **duplicate_attributes) | |
| (duplicate_message ? tag.div(duplicate_message, hidden: true, **duplicate_attributes) : "") |
| #handleError(error) { | ||
| console.error("Passkey ceremony failed", error) | ||
| const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" | ||
| this.#showError(cancelled ? "cancelled" : "error") | ||
| this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) | ||
| const type = this.#errorType(error) | ||
| this.#showError(type) | ||
| this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } })) | ||
| } |
There was a problem hiding this comment.
InvalidStateError is mapped to the new "duplicate" error type for all passkey ceremonies, but only the registration helper sets a default duplicate message. Combined with the always-rendered (but potentially empty) duplicate div, this can lead to a blank error display. Consider scoping the "duplicate" handling to registration (this.purpose === "registration"), or making #showError fall back to the generic "error" message when the requested error element is missing/empty.
| this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } })) | ||
| } |
There was a problem hiding this comment.
The passkey:error event payload now includes duplicate, but other dispatch sites in this file still send { error, cancelled } (e.g. conditional mediation). This makes the event detail shape inconsistent for listeners. Consider updating all passkey:error dispatches to include a duplicate boolean (and update the file’s event documentation comment accordingly).
Turns out Google Passwords doesn't obey the exvlude list and allows only a single passkey per RP ID. If you try to add a new Passkey after you already created one in Google Passwords it will cause a client-side state error, which per spec can only mean that someone tried to re-add the same passkey twice and the client detected that.