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 +