From 9614c1ecfb1c037f0055d5d11e61f76ac780c9c3 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 26 Mar 2026 15:59:09 +0100 Subject: [PATCH 1/4] Add a button to sign in with a passkey --- app/javascript/lib/action_pack/passkey.js | 47 ++++++++++++++++------- app/views/sessions/new.html.erb | 4 +- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/app/javascript/lib/action_pack/passkey.js b/app/javascript/lib/action_pack/passkey.js index 103cbc72e..fdda46c84 100644 --- a/app/javascript/lib/action_pack/passkey.js +++ b/app/javascript/lib/action_pack/passkey.js @@ -53,6 +53,7 @@ class PasskeyButton extends HTMLElement { // Arrow function to preserve `this` binding for addEventListener/removeEventListener. #perform = async () => { + await this.abortConditionalMediation?.() this.button.disabled = true this.#hideErrors() this.button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) @@ -105,6 +106,9 @@ class PasskeyRegistrationButton extends PasskeyButton { } class PasskeySignInButton extends PasskeyButton { + #conditionalMediationController = null + #conditionalMediationPromise = null + get purpose() { return "authentication" } connectedCallback() { @@ -116,32 +120,49 @@ class PasskeySignInButton extends PasskeyButton { return this.getAttribute("mediation") } - async perform(options, { mediation } = {}) { - return await authenticate(options, { mediation }) + async perform(options, { signal, mediation } = {}) { + return await authenticate(options, { signal, mediation }) } fillForm(passkey) { fillSignInForm(this.form, passkey) } + async abortConditionalMediation() { + if (this.#conditionalMediationController) { + this.#conditionalMediationController.abort() + await this.#conditionalMediationPromise + } + } + async #attemptConditionalMediation() { if (await this.#conditionalMediationAvailable()) { const options = this.options this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) - try { - await refreshChallenge(options, this.challengeUrl, this.purpose) - const passkey = await this.perform(options, { mediation: this.mediation }) + this.#conditionalMediationController = new AbortController() + this.#conditionalMediationPromise = this.#runConditionalMediation(options) + } + } - this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) - this.fillForm(passkey) - this.form.submit() - } catch (error) { - console.error("Passkey conditional mediation failed", error) - const type = errorType(error) - this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } })) - } + async #runConditionalMediation(options) { + try { + await refreshChallenge(options, this.challengeUrl, this.purpose) + const passkey = await this.perform(options, { signal: this.#conditionalMediationController.signal, mediation: this.mediation }) + + this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) + this.fillForm(passkey) + this.form.submit() + } catch (error) { + if (error.name === "AbortError") return + + console.error("Passkey conditional mediation failed", error) + const type = errorType(error) + this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } })) + } finally { + this.#conditionalMediationController = null + this.#conditionalMediationPromise = null } } diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index c44980e54..59aedf2e5 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -22,7 +22,9 @@ <% end %> - <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, options: @authentication_options, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %> +

+ <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, options: @authentication_options, mediation: "conditional", class: "btn btn--plain txt-link center txt-small" %> +

From 7ae219ab23358e35b449449f0d0c4ee367dbb3ea Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 26 Mar 2026 16:00:02 +0100 Subject: [PATCH 2/4] Make the duplicate key error message friendly --- lib/action_pack/passkey/form_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/action_pack/passkey/form_helper.rb b/lib/action_pack/passkey/form_helper.rb index 43d023c9d..9857a7f9c 100644 --- a/lib/action_pack/passkey/form_helper.rb +++ b/lib/action_pack/passkey/form_helper.rb @@ -9,7 +9,7 @@ module ActionPack::Passkey::FormHelper REGISTRATION_ERROR_MESSAGE = "Something went wrong while registering your passkey." REGISTRATION_CANCELLED_MESSAGE = "Passkey registration was cancelled. Try again when you are ready." - REGISTRATION_DUPLICATE_MESSAGE = "You already have a passkey registered on this device. Remove the existing one first to re-register." + REGISTRATION_DUPLICATE_MESSAGE = "You already have a passkey registered on this device. Remove the existing one first and try again." SIGN_IN_ERROR_MESSAGE = "Something went wrong while signing in with your passkey." SIGN_IN_CANCELLED_MESSAGE = "Passkey sign in was cancelled. Try again when you are ready." From 76d00d17c8c387ddaa9abe53fcde31fa25cf55da Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 26 Mar 2026 16:49:59 +0100 Subject: [PATCH 3/4] Abort conditional mediation when the button disconnects --- app/javascript/lib/action_pack/passkey.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/lib/action_pack/passkey.js b/app/javascript/lib/action_pack/passkey.js index fdda46c84..4714836b3 100644 --- a/app/javascript/lib/action_pack/passkey.js +++ b/app/javascript/lib/action_pack/passkey.js @@ -30,6 +30,7 @@ class PasskeyButton extends HTMLElement { } disconnectedCallback() { + this.abortConditionalMediation?.() this.button.removeEventListener("click", this.#perform) this.button.disabled = false this.#hideErrors() From c2fe41d37699be0df14816b06247c8e249d0e0e9 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 27 Mar 2026 10:13:57 +0100 Subject: [PATCH 4/4] Fix sign in flow --- app/views/sessions/new.html.erb | 11 +++-- lib/action_pack/passkey/form_helper.rb | 57 ++++++++++++-------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 59aedf2e5..aba27d540 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -22,9 +22,14 @@ <% end %> -

- <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, options: @authentication_options, mediation: "conditional", class: "btn btn--plain txt-link center txt-small" %> -

+
+ <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, + options: @authentication_options, + mediation: "conditional", + error: { class: "txt-negative margin-block-half" }, + cancellation: { class: "txt-subtle margin-block-half" }, + class: "btn btn--plain txt-link center txt-small" %> +
diff --git a/lib/action_pack/passkey/form_helper.rb b/lib/action_pack/passkey/form_helper.rb index 9857a7f9c..1bdd61fe0 100644 --- a/lib/action_pack/passkey/form_helper.rb +++ b/lib/action_pack/passkey/form_helper.rb @@ -1,11 +1,4 @@ -# View helpers for rendering passkey web components. -# -# Include this module in your helper or ApplicationHelper to get access to: -# -# - +passkey_registration_button+ — render a web component with -# a form, hidden fields, and error messages for the registration ceremony. -# - +passkey_sign_in_button+ — render a web component with -# a form, hidden fields, and error messages for the authentication ceremony. +# View helpers for rendering passkey registration and sign-in buttons. module ActionPack::Passkey::FormHelper REGISTRATION_ERROR_MESSAGE = "Something went wrong while registering your passkey." REGISTRATION_CANCELLED_MESSAGE = "Passkey registration was cancelled. Try again when you are ready." @@ -13,15 +6,13 @@ module ActionPack::Passkey::FormHelper SIGN_IN_ERROR_MESSAGE = "Something went wrong while signing in with your passkey." SIGN_IN_CANCELLED_MESSAGE = "Passkey sign in was cancelled. Try again when you are ready." - # Renders a ++ web component containing a form with hidden - # fields for the passkey registration ceremony and error messages. The form POSTs to +url+ and - # includes hidden fields for +client_data_json+, +attestation_object+, and +transports+ — - # populated by the web component after the browser credential API resolves. - # Accepts a +label+ string or a block for button content. + # Renders a button for registering a new passkey. Accepts a +label+ string or a block + # for button content. # # Options: # - +options+: WebAuthn creation options (JSON-serializable hash) # - +challenge_url+: endpoint to refresh the challenge nonce + # - +wrapper+: HTML attributes for the outer web component element # - +form+: additional HTML attributes for the +
+ tag. Supports a +:param+ key # to set the form parameter namespace (default: +:passkey+) # - +error+: HTML attributes for the error message +
+. Supports a +:message+ key @@ -48,15 +39,14 @@ module ActionPack::Passkey::FormHelper end end - # Renders a ++ web component containing a form with hidden - # fields for the passkey authentication ceremony and error messages. The form POSTs to +url+ - # and includes hidden fields for +id+, +client_data_json+, +authenticator_data+, and +signature+. - # Accepts a +label+ string or a block for button content. + # Renders a button for signing in with a passkey. Accepts a +label+ string or a block + # for button content. # # Options: # - +options+: WebAuthn request options (JSON-serializable hash) # - +challenge_url+: endpoint to refresh the challenge nonce # - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in) + # - +wrapper+: HTML attributes for the outer web component element # - +form+: additional HTML attributes for the ++ tag. Supports a +:param+ key # to set the form parameter namespace (default: +:passkey+) # - +error+: HTML attributes for the error message +
+. Supports a +:message+ key @@ -86,18 +76,21 @@ module ActionPack::Passkey::FormHelper private def partition_passkey_options(url, options) passkey_options = options.fetch(:options, {}) + wrapper_options = options.fetch(:wrapper, {}) component_options = options .slice(:challenge_url, :mediation) .reverse_merge(challenge_url: default_passkey_challenge_url, options: passkey_options.to_json(except: :challenge)) + form_options = options .fetch(:form, {}) .reverse_merge(method: :post, action: url, class: "button_to", param: :passkey) + error_options = options.slice(:error, :cancellation, :duplicate).reverse_merge(error: {}, cancellation: {}, duplicate: {}) - button_options = options.except(:options, :form, *component_options.keys, *error_options.keys) + button_options = options.except(:options, :form, :wrapper, *component_options.keys, *error_options.keys) - [ component_options, form_options, button_options, error_options ] + [ wrapper_options.merge(component_options), form_options, button_options, error_options ] end def default_passkey_challenge_url @@ -109,25 +102,27 @@ module ActionPack::Passkey::FormHelper end def passkey_error_messages(error: {}, cancellation: {}, duplicate: {}) - error_message = error[:message] - error_attributes = error.except(:message) - error_attributes[:data] ||= {} - error_attributes[:data][:passkey_error] = "error" - - cancellation_message = cancellation[:message] - cancellation_attributes = cancellation.except(:message) - cancellation_attributes[:data] ||= {} - cancellation_attributes[:data][:passkey_error] = "cancelled" + error_message, error_attributes = build_passkey_error_options("error", error) + cancellation_message, cancellation_attributes = build_passkey_error_options("cancelled", cancellation) messages = tag.div(error_message, hidden: true, **error_attributes) + tag.div(cancellation_message, hidden: true, **cancellation_attributes) if duplicate[:message] - duplicate_attributes = duplicate.except(:message) - duplicate_attributes[:data] = { passkey_error: "duplicate" } - messages += tag.div(duplicate[:message], hidden: true, **duplicate_attributes) + duplicate_message, duplicate_attributes = build_passkey_error_options("duplicate", duplicate) + messages += tag.div(duplicate_message, hidden: true, **duplicate_attributes) end messages end + + def build_passkey_error_options(type, options) + message = options[:message] + + attributes = options.except(:message) + attributes[:data] ||= {} + attributes[:data][:passkey_error] = type + + [ message, attributes ] + end end