Skip to content

Show separate error message when adding a Passkey twice#2756

Merged
monorkin merged 3 commits intomainfrom
friendly-error-message-on-duplicate-passkey
Mar 26, 2026
Merged

Show separate error message when adding a Passkey twice#2756
monorkin merged 3 commits intomainfrom
friendly-error-message-on-duplicate-passkey

Conversation

@monorkin
Copy link
Copy Markdown
Contributor

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.

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.
Copilot AI review requested due to automatic review settings March 26, 2026 12:45
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 InvalidStateError as “duplicate” and include duplicate in the passkey:error event 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 uses ActionView::TestCase for 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.

Comment thread lib/action_pack/passkey/form_helper.rb Outdated

tag.div(error_message, hidden: true, **error_attributes) +
tag.div(cancellation_message, hidden: true, **cancellation_attributes) +
tag.div(duplicate_message, hidden: true, **duplicate_attributes)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
tag.div(duplicate_message, hidden: true, **duplicate_attributes)
(duplicate_message ? tag.div(duplicate_message, hidden: true, **duplicate_attributes) : "")

Copilot uses AI. Check for mistakes.
Comment on lines 78 to +83
#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 } }))
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +83
this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } }))
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
@monorkin monorkin merged commit 09d5b8c into main Mar 26, 2026
13 checks passed
@monorkin monorkin deleted the friendly-error-message-on-duplicate-passkey branch March 26, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants