From 4fb1cddb4cdb84c85dacb8f9bfa6c4c53cdd07c4 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 25 Mar 2026 10:40:40 +0100 Subject: [PATCH 01/13] Use WebComponents for buttons This simplifies the loading and cleanup logic, while providing ubiquitous support across JS frameworks. Buttons can now be added in any way imaginable and still work without requiring additional initialization. The upside of this aproach is that it doesn't require a mutation observer nor a global click listener, and is supported by all browsers that also support passkeys. --- app/assets/stylesheets/credentials.css | 6 +- app/javascript/lib/action_pack/passkey.js | 326 ++++++++++------------ app/views/my/passkeys/index.html.erb | 17 +- app/views/sessions/new.html.erb | 6 +- lib/action_pack/passkey/form_helper.rb | 136 ++++----- 5 files changed, 223 insertions(+), 268 deletions(-) diff --git a/app/assets/stylesheets/credentials.css b/app/assets/stylesheets/credentials.css index d57583017..61a4c0585 100644 --- a/app/assets/stylesheets/credentials.css +++ b/app/assets/stylesheets/credentials.css @@ -32,12 +32,12 @@ opacity: 0; } - [data-passkey-errors] [data-passkey-error] { + :is(rails-passkey-creation-button, rails-passkey-sign-in-button) [data-passkey-error] { display: none; } - [data-passkey-errors][data-passkey-error-state="error"] [data-passkey-error="error"], - [data-passkey-errors][data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] { + :is(rails-passkey-creation-button, rails-passkey-sign-in-button)[data-passkey-error-state="error"] [data-passkey-error="error"], + :is(rails-passkey-creation-button, rails-passkey-sign-in-button)[data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] { display: block; } } diff --git a/app/javascript/lib/action_pack/passkey.js b/app/javascript/lib/action_pack/passkey.js index 05a1772e1..057713fed 100644 --- a/app/javascript/lib/action_pack/passkey.js +++ b/app/javascript/lib/action_pack/passkey.js @@ -1,143 +1,183 @@ -// JS companion for the ActionPack::Passkey Ruby helpers. +// Web components for the ActionPack::Passkey Ruby helpers. // -// Binds click handlers to passkey buttons and manages the WebAuthn ceremony -// lifecycle (challenge refresh, credential creation/authentication, form submission). +// — wraps a registration ceremony form +// — wraps an authentication ceremony form // -// Expected data attributes: -// [data-passkey="create"] — triggers the registration ceremony -// [data-passkey="sign_in"] — triggers the authentication ceremony -// [data-passkey-mediation="conditional"] — on a
, enables autofill-assisted sign in -// [data-passkey-errors] — container whose data-passkey-error-state is set on failure -// [data-passkey-error="error|cancelled"] — children shown/hidden via CSS based on error state -// [data-passkey-field="..."] — hidden fields populated before form submission +// The Ruby form helpers render the component markup including the inner form, +// hidden fields, button, and error messages. The components handle the WebAuthn +// ceremony lifecycle (challenge refresh, credential creation/authentication, +// form submission) and error state toggling. // // Custom events (all bubble): // passkey:start — ceremony begun // passkey:success — credential obtained, form about to submit // passkey:error — ceremony failed; detail: { error, cancelled } // -// Meta tags (rendered by the Ruby form helpers): -// — JSON WebAuthn creation options -// — JSON WebAuthn request options -// — endpoint to refresh the challenge nonce +// Attributes (rendered by the Ruby form helpers): +// creation-options — JSON WebAuthn creation options (on rails-passkey-creation-button) +// request-options — JSON WebAuthn request options (on rails-passkey-sign-in-button) +// challenge-url — endpoint to refresh the challenge nonce (on both) +// mediation — WebAuthn mediation hint, e.g. "conditional" (on rails-passkey-sign-in-button) import { register, authenticate } from "lib/action_pack/webauthn" -const mediated = new WeakSet() -const mediationSelector = 'form[data-passkey-mediation="conditional"]' - -// Delegate click events for passkey buttons. Walks up from the click target -// to find the nearest [data-passkey] element, like Rails UJS does. -document.addEventListener("click", (event) => { - const button = event.target.closest("[data-passkey]") - - if (button) { - if (button.dataset.passkey === "create") createPasskey(button) - else if (button.dataset.passkey === "sign_in") signInWithPasskey(button) - } -}) - -// Set error state on the nearest [data-passkey-errors] container. -// The app's CSS is responsible for showing/hiding children based on -// the data-passkey-error-state attribute ("error" or "cancelled"). -document.addEventListener("passkey:error", ({ target, detail: { cancelled } }) => { - const container = target.closest("[data-passkey-errors]") - - if (container) { - container.dataset.passkeyErrorState = cancelled ? "cancelled" : "error" - } -}) - -// Clean up transient DOM state before Turbo caches the page snapshot. -document.addEventListener("turbo:before-cache", () => { - for (const button of document.querySelectorAll("[data-passkey][disabled]")) { - button.disabled = false +class PasskeyCreationButton extends HTMLElement { + connectedCallback() { + this.button.addEventListener("click", this.#create) } - for (const container of document.querySelectorAll("[data-passkey-errors]")) { - delete container.dataset.passkeyErrorState + disconnectedCallback() { + this.button.removeEventListener("click", this.#create) + this.button.disabled = false + delete this.dataset.passkeyErrorState } -}) -// Attempt conditional mediation when a form with [data-passkey-mediation="conditional"] -// appears in the DOM. Uses a MutationObserver so it works regardless of how the form -// is inserted (initial page load, Turbo Drive, Turbo Streams, or plain JS). -new MutationObserver((mutations) => { - for (const { addedNodes } of mutations) { - for (const node of addedNodes) { - if (node.nodeType === Node.ELEMENT_NODE) mediateIn(node) - } + get button() { + return this.querySelector("[data-passkey='create']") } -}).observe(document.documentElement, { childList: true, subtree: true }) -mediateIn(document) - -function mediateIn(root) { - const form = root.matches?.(mediationSelector) ? root : root.querySelector?.(mediationSelector) - - if (form && !mediated.has(form)) { - mediated.add(form) - attemptConditionalMediation() + get form() { + return this.querySelector("form") } -} -// Run the WebAuthn registration ceremony: refresh the challenge, prompt the -// browser to create a credential, fill the form's hidden fields, and submit. -async function createPasskey(button) { - const form = button.closest("form") + get creationOptions() { + return JSON.parse(this.getAttribute("creation-options")) + } - if (form) { - button.disabled = true - button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) + get challengeUrl() { + return this.getAttribute("challenge-url") + } + + // Arrow function to preserve `this` binding for addEventListener/removeEventListener. + #create = async () => { + this.button.disabled = true + this.button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) try { if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") + if (!this.creationOptions) throw new Error("Missing passkey creation options") - const creationOptions = getCreationOptions() - if (!creationOptions) throw new Error("Missing passkey creation options") + const options = this.creationOptions + await refreshChallenge(options, this.challengeUrl) + const passkey = await register(options) - await refreshChallenge(creationOptions) - const passkey = await register(creationOptions) - - button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) - fillCreateForm(form, passkey) - form.submit() + this.button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) + fillCreateForm(this.form, passkey) + this.form.submit() } catch (error) { - button.disabled = false - - const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" - button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) + this.button.disabled = false + this.#handleError(error) } } + + #handleError(error) { + const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" + this.dataset.passkeyErrorState = cancelled ? "cancelled" : "error" + this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) + } } +class PasskeySignInButton extends HTMLElement { + connectedCallback() { + this.button.addEventListener("click", this.#signIn) + + if (this.mediation === "conditional") this.#attemptConditionalMediation() + } + + disconnectedCallback() { + this.button.removeEventListener("click", this.#signIn) + this.button.disabled = false + delete this.dataset.passkeyErrorState + } + + get button() { + return this.querySelector("[data-passkey='sign_in']") + } + + get form() { + return this.querySelector("form") + } + + get requestOptions() { + return JSON.parse(this.getAttribute("request-options")) + } + + get challengeUrl() { + return this.getAttribute("challenge-url") + } + + get mediation() { + return this.getAttribute("mediation") + } + + // Arrow function to preserve `this` binding for addEventListener/removeEventListener. + #signIn = async () => { + this.button.disabled = true + this.button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) + + try { + if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") + if (!this.requestOptions) throw new Error("Missing passkey request options") + + const options = this.requestOptions + await refreshChallenge(options, this.challengeUrl) + const passkey = await authenticate(options) + + this.button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) + fillSignInForm(this.form, passkey) + this.form.submit() + } catch (error) { + this.button.disabled = false + this.#handleError(error) + } + } + + async #attemptConditionalMediation() { + if (await this.#conditionalMediationAvailable()) { + const options = this.requestOptions + + this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) + + try { + await refreshChallenge(options, this.challengeUrl) + const passkey = await authenticate(options, { mediation: this.mediation }) + + this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) + fillSignInForm(this.form, passkey) + this.form.submit() + } catch (error) { + this.#handleError(error) + } + } + } + + async #conditionalMediationAvailable() { + return this.requestOptions && + passkeysAvailable() && + await window.PublicKeyCredential.isConditionalMediationAvailable?.() + } + + #handleError(error) { + const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" + this.dataset.passkeyErrorState = cancelled ? "cancelled" : "error" + this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) + } +} + +customElements.define("rails-passkey-creation-button", PasskeyCreationButton) +customElements.define("rails-passkey-sign-in-button", PasskeySignInButton) + +// -- Shared helpers ---------------------------------------------------------- + function passkeysAvailable() { return !!window.PublicKeyCredential } -// Read WebAuthn creation options from the tag rendered by -// +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing. -function getCreationOptions() { - return getOptions("passkey-creation-options") -} - -// Parse and return the JSON content of a tag by name. -function getOptions(name) { - const meta = document.querySelector(`meta[name="${name}"]`) - - if (meta) { - return JSON.parse(meta.content) - } -} - -// POST to the challenge endpoint to get a fresh nonce, preventing replay attacks -// when the page has been open for a while before the user initiates the ceremony. -async function refreshChallenge(options) { - const url = document.querySelector('meta[name="passkey-challenge-url"]')?.content - if (!url) throw new Error("Missing passkey challenge URL") +async function refreshChallenge(options, challengeUrl) { + if (!challengeUrl) throw new Error("Missing passkey challenge URL") const token = document.querySelector('meta[name="csrf-token"]')?.content - const response = await fetch(url, { + const response = await fetch(challengeUrl, { method: "POST", credentials: "same-origin", headers: { @@ -152,8 +192,6 @@ async function refreshChallenge(options) { options.challenge = challenge } -// Populate the registration form's hidden fields with the credential response. -// Clones the transports template input for each reported transport. function fillCreateForm(form, passkey) { form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object @@ -167,85 +205,9 @@ function fillCreateForm(form, passkey) { template.remove() } -// Run the WebAuthn authentication ceremony: refresh the challenge, prompt the -// browser to sign with an existing credential, fill the form, and submit. -async function signInWithPasskey(button) { - const form = button.closest("form") - - if (form) { - button.disabled = true - button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) - - try { - if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") - - const requestOptions = getRequestOptions() - if (!requestOptions) throw new Error("Missing passkey request options") - - await refreshChallenge(requestOptions) - const passkey = await authenticate(requestOptions) - - button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) - fillSignInForm(form, passkey) - form.submit() - } catch (error) { - button.disabled = false - - const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" - button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) - } - } -} - -// Read WebAuthn request options from the tag rendered by -// +passkey_request_options_meta_tag+. Returns undefined if the tag is missing. -function getRequestOptions() { - return getOptions("passkey-request-options") -} - -// Populate the authentication form's hidden fields with the assertion response. function fillSignInForm(form, passkey) { form.querySelector('[data-passkey-field="id"]').value = passkey.id form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data form.querySelector('[data-passkey-field="signature"]').value = passkey.signature } - -// Start the conditional mediation (autofill) ceremony if the page opts in with -// a form[data-passkey-mediation="conditional"] and the browser supports it. -// Unlike the button-driven ceremonies, this runs automatically on page load. -async function attemptConditionalMediation() { - if (await conditionalMediationAvailable()) { - const form = document.querySelector('form[data-passkey-mediation="conditional"]') - form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) - - const requestOptions = getRequestOptions() - - try { - await refreshChallenge(requestOptions) - - const passkey = await authenticate(requestOptions, { mediation: "conditional" }) - - form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) - fillSignInForm(form, passkey) - form.submit() - } catch (error) { - const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" - form.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) - } - } -} - -// Check all preconditions for conditional mediation: the page has opted in, -// request options are present, the browser supports passkeys, and the browser -// supports the conditional mediation UI (autofill). -async function conditionalMediationAvailable() { - return isConditionalMediationFormPresent() && - getRequestOptions() && - passkeysAvailable() && - await window.PublicKeyCredential.isConditionalMediationAvailable?.() -} - -function isConditionalMediationFormPresent() { - return !!document.querySelector('form[data-passkey-mediation="conditional"]') -} diff --git a/app/views/my/passkeys/index.html.erb b/app/views/my/passkeys/index.html.erb index 545c15a43..8f6b8a176 100644 --- a/app/views/my/passkeys/index.html.erb +++ b/app/views/my/passkeys/index.html.erb @@ -1,9 +1,5 @@ <% @page_title = "Passkeys" %> -<% content_for :head do %> - <%= passkey_creation_options_meta_tag(@creation_options) %> -<% end %> - <% content_for :header do %>
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> @@ -12,7 +8,7 @@

<%= @page_title %>

<% end %> -
+

Passkeys let you sign in securely without a password or email code.

<% if @passkeys.any? %> @@ -22,7 +18,7 @@ <% end %>
- <%= passkey_creation_button my_passkeys_path, class: "btn btn--link center txt-medium" do %> + <%= passkey_creation_button my_passkeys_path, options: @creation_options, class: "btn btn--link center txt-medium" do %> <%= icon_tag "add" %> Register a passkey <% end %> @@ -30,14 +26,5 @@

Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key

- -

- Something went wrong while registering your passkey. -

- -

- Passkey registration was cancelled. - Try again when you are ready. -

diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 6d2567639..566706f4e 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,9 +1,5 @@ <% @page_title = "Enter your email" %> -<% content_for :head do %> - <%= passkey_request_options_meta_tag(@request_options) %> -<% end %> -

Get into Fizzy

@@ -26,7 +22,7 @@ <% end %> - <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %> + <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, options: @request_options, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %>
diff --git a/lib/action_pack/passkey/form_helper.rb b/lib/action_pack/passkey/form_helper.rb index 4fc9a4444..4cd136ac6 100644 --- a/lib/action_pack/passkey/form_helper.rb +++ b/lib/action_pack/passkey/form_helper.rb @@ -1,82 +1,87 @@ -# View helpers for rendering passkey forms and meta tags. +# View helpers for rendering passkey web components. # # Include this module in your helper or ApplicationHelper to get access to: # -# - +passkey_creation_options_meta_tag+ / +passkey_request_options_meta_tag+ — render a -# tag containing the JSON-serialized WebAuthn options for the browser credential API. -# - +passkey_creation_button+ — render a form with hidden fields for the registration ceremony. -# - +passkey_sign_in_button+ — render a form with hidden fields for the authentication -# ceremony. +# - +passkey_creation_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. module ActionPack::Passkey::FormHelper - # Renders ++ tags containing JSON-serialized creation options and the challenge endpoint - # URL for the WebAuthn registration ceremony. The companion JavaScript reads these tags to call - # +navigator.credentials.create()+. - def passkey_creation_options_meta_tag(creation_options, challenge_url: nil) - passkey_challenge_url_meta_tag(challenge_url: challenge_url) + - tag.meta(name: "passkey-creation-options", content: creation_options.to_json) - end - - # Renders ++ tags containing JSON-serialized request options and the challenge endpoint - # URL for the WebAuthn authentication ceremony. The companion JavaScript reads these tags to - # call +navigator.credentials.get()+. - def passkey_request_options_meta_tag(request_options, challenge_url: nil) - passkey_challenge_url_meta_tag(challenge_url: challenge_url) + - tag.meta(name: "passkey-request-options", content: request_options.to_json) - end - - # Renders a form with hidden fields for the passkey registration ceremony. The form POSTs to - # +url+ and includes hidden fields for +client_data_json+, +attestation_object+, and - # +transports+ — populated by the Stimulus controller after the browser credential API - # resolves. Accepts a +label+ string or a block for button content. - # - # Options: - # - +param+: the form parameter namespace (default: +:passkey+) - # - +form+: additional HTML attributes for the ++ tag - # - All other options are passed to the + <% end %> - <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, options: @request_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--link center txt-medium", hidden: true %>
diff --git a/lib/action_pack/passkey.rb b/lib/action_pack/passkey.rb index 81fa67190..7b81075af 100644 --- a/lib/action_pack/passkey.rb +++ b/lib/action_pack/passkey.rb @@ -18,7 +18,7 @@ # Generate options for the browser's +navigator.credentials.get()+ call, then authenticate the # response: # -# options = ActionPack::Passkey.request_options +# options = ActionPack::Passkey.authentication_options # # Pass options to the browser # # passkey = ActionPack::Passkey.authenticate(params[:passkey]) @@ -62,10 +62,10 @@ class ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class # +navigator.credentials.get()+ call. When a +holder+ is provided, their existing credentials # are included so the browser can offer them for selection. Merges global defaults, holder # options, and any additional +options+ overrides. - def request_options(holder: nil, **options) + def authentication_options(holder: nil, **options) ActionPack::WebAuthn::PublicKeyCredential.request_options( **Rails.configuration.action_pack.web_authn.default_request_options.to_h, - **holder&.passkey_request_options.to_h, + **holder&.passkey_authentication_options.to_h, **options ) end diff --git a/lib/action_pack/passkey/holder.rb b/lib/action_pack/passkey/holder.rb index 6db8200ca..e6d936a9e 100644 --- a/lib/action_pack/passkey/holder.rb +++ b/lib/action_pack/passkey/holder.rb @@ -10,7 +10,7 @@ # model that supply holder-specific options for the WebAuthn ceremonies: # # - +passkey_registration_options+ — merged into ActionPack::Passkey.registration_options -# - +passkey_request_options+ — merged into ActionPack::Passkey.request_options +# - +passkey_authentication_options+ — merged into ActionPack::Passkey.authentication_options # # == Options # @@ -32,14 +32,14 @@ # # has_passkeys do |config| # config.registration_options { { name: email, display_name: name } } -# config.request_options { { user_verification: "required" } } +# config.authentication_options { { user_verification: "required" } } # end module ActionPack::Passkey::Holder extend ActiveSupport::Concern class_methods do # Declares that this model can hold passkeys. Sets up a polymorphic +has_many+ association - # and defines +passkey_registration_options+ and +passkey_request_options+ instance methods used + # and defines +passkey_registration_options+ and +passkey_authentication_options+ instance methods used # by ActionPack::Passkey to build ceremony options. # # Keyword arguments matching CreationOptions or RequestOptions fields are extracted and @@ -61,8 +61,8 @@ module ActionPack::Passkey::Holder }.merge(config.evaluate_registration_options(self)) end - define_method(:passkey_request_options) do - { credentials: public_send(config.association_name) }.merge(config.evaluate_request_options(self)) + define_method(:passkey_authentication_options) do + { credentials: public_send(config.association_name) }.merge(config.evaluate_authentication_options(self)) end end end @@ -81,15 +81,15 @@ module ActionPack::Passkey::Holder end if request_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::RequestOptions, options) - @request_options = options_to_proc(request_opts) + @authentication_options = options_to_proc(request_opts) end end - # Sets a block to evaluate in the holder's context to produce additional request options. + # Sets a block to evaluate in the holder's context to produce additional authentication options. # - # config.request_options { { user_verification: "required" } } - def request_options(&block) - @request_options = block + # config.authentication_options { { user_verification: "required" } } + def authentication_options(&block) + @authentication_options = block end # Sets a block to evaluate in the holder's context to produce additional creation options. @@ -100,10 +100,10 @@ module ActionPack::Passkey::Holder end # Evaluates the request options block (if any) in the context of the given +record+. Called - # internally by the +passkey_request_options+ method defined on the holder. - def evaluate_request_options(record) - if @request_options - record.instance_exec(&@request_options) + # internally by the +passkey_authentication_options+ method defined on the holder. + def evaluate_authentication_options(record) + if @authentication_options + record.instance_exec(&@authentication_options) else {} end diff --git a/lib/action_pack/passkey/request.rb b/lib/action_pack/passkey/request.rb index 74de1bc16..6591183fc 100644 --- a/lib/action_pack/passkey/request.rb +++ b/lib/action_pack/passkey/request.rb @@ -27,11 +27,11 @@ # include ActionPack::Passkey::Request # # def new -# @request_options = passkey_request_options +# @authentication_options = passkey_authentication_options # end # # def create -# if passkey = ActionPack::Passkey.authenticate(passkey_request_params) +# if passkey = ActionPack::Passkey.authenticate(passkey_authentication_params) # sign_in passkey.holder # redirect_to root_path # else @@ -61,13 +61,13 @@ module ActionPack::Passkey::Request end # Returns strong parameters for the passkey authentication ceremony. - def passkey_request_params(param: :passkey) + def passkey_authentication_params(param: :passkey) params.expect(param => [ :id, :client_data_json, :authenticator_data, :signature ]) end # Returns RequestOptions for the authentication ceremony. - def passkey_request_options(**options) - ActionPack::Passkey.request_options(**options) + def passkey_authentication_options(**options) + ActionPack::Passkey.authentication_options(**options) end # Returns RegistrationOptions for the registration ceremony. diff --git a/test/lib/action_pack/passkey_test.rb b/test/lib/action_pack/passkey_test.rb index a2c6ad934..3494ed3f5 100644 --- a/test/lib/action_pack/passkey_test.rb +++ b/test/lib/action_pack/passkey_test.rb @@ -17,7 +17,7 @@ class ActionPack::PasskeyTest < ActiveSupport::TestCase end test "authenticate with valid assertion" do - challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge + challenge = ActionPack::Passkey.authentication_options(credentials: [ @passkey ]).challenge assertion = build_assertion(challenge: challenge) result = @passkey.authenticate(assertion) @@ -26,7 +26,7 @@ class ActionPack::PasskeyTest < ActiveSupport::TestCase end test "authenticate returns nil with invalid signature" do - challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge + challenge = ActionPack::Passkey.authentication_options(credentials: [ @passkey ]).challenge assertion = build_assertion(challenge: challenge) assertion[:signature] = Base64.urlsafe_encode64("invalid", padding: false) @@ -34,7 +34,7 @@ class ActionPack::PasskeyTest < ActiveSupport::TestCase end test "authenticate updates sign count and backed_up" do - challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge + challenge = ActionPack::Passkey.authentication_options(credentials: [ @passkey ]).challenge assertion = build_assertion(challenge: challenge, sign_count: 5, backed_up: true) @passkey.authenticate(assertion) From f5ae2f8076d5a8a434470572cd3b09109ef6da70 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 25 Mar 2026 16:34:57 +0100 Subject: [PATCH 06/13] Rename extract_passkey_component_options to partition_passkey_options --- lib/action_pack/passkey/form_helper.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/action_pack/passkey/form_helper.rb b/lib/action_pack/passkey/form_helper.rb index ee82ddf17..fe3767d70 100644 --- a/lib/action_pack/passkey/form_helper.rb +++ b/lib/action_pack/passkey/form_helper.rb @@ -30,7 +30,7 @@ module ActionPack::Passkey::FormHelper # - All other options are passed to the +