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.
This commit is contained in:
Stanko K.R.
2026-03-25 10:40:40 +01:00
parent 70c08877b7
commit 4fb1cddb4c
5 changed files with 223 additions and 268 deletions
+144 -182
View File
@@ -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).
// <rails-passkey-creation-button> — wraps a registration ceremony form
// <rails-passkey-sign-in-button> — 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 <form>, 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):
// <meta name="passkey-creation-options"> — JSON WebAuthn creation options
// <meta name="passkey-request-options"> — JSON WebAuthn request options
// <meta name="passkey-challenge-url"> — 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 <meta> 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 <meta> 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 <meta> 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"]')
}