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:
@@ -32,12 +32,12 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-passkey-errors] [data-passkey-error] {
|
:is(rails-passkey-creation-button, rails-passkey-sign-in-button) [data-passkey-error] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-passkey-errors][data-passkey-error-state="error"] [data-passkey-error="error"],
|
:is(rails-passkey-creation-button, rails-passkey-sign-in-button)[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="cancelled"] [data-passkey-error="cancelled"] {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// <rails-passkey-creation-button> — wraps a registration ceremony form
|
||||||
// lifecycle (challenge refresh, credential creation/authentication, form submission).
|
// <rails-passkey-sign-in-button> — wraps an authentication ceremony form
|
||||||
//
|
//
|
||||||
// Expected data attributes:
|
// The Ruby form helpers render the component markup including the inner form,
|
||||||
// [data-passkey="create"] — triggers the registration ceremony
|
// hidden fields, button, and error messages. The components handle the WebAuthn
|
||||||
// [data-passkey="sign_in"] — triggers the authentication ceremony
|
// ceremony lifecycle (challenge refresh, credential creation/authentication,
|
||||||
// [data-passkey-mediation="conditional"] — on a <form>, enables autofill-assisted sign in
|
// form submission) and error state toggling.
|
||||||
// [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
|
|
||||||
//
|
//
|
||||||
// Custom events (all bubble):
|
// Custom events (all bubble):
|
||||||
// passkey:start — ceremony begun
|
// passkey:start — ceremony begun
|
||||||
// passkey:success — credential obtained, form about to submit
|
// passkey:success — credential obtained, form about to submit
|
||||||
// passkey:error — ceremony failed; detail: { error, cancelled }
|
// passkey:error — ceremony failed; detail: { error, cancelled }
|
||||||
//
|
//
|
||||||
// Meta tags (rendered by the Ruby form helpers):
|
// Attributes (rendered by the Ruby form helpers):
|
||||||
// <meta name="passkey-creation-options"> — JSON WebAuthn creation options
|
// creation-options — JSON WebAuthn creation options (on rails-passkey-creation-button)
|
||||||
// <meta name="passkey-request-options"> — JSON WebAuthn request options
|
// request-options — JSON WebAuthn request options (on rails-passkey-sign-in-button)
|
||||||
// <meta name="passkey-challenge-url"> — endpoint to refresh the challenge nonce
|
// 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"
|
import { register, authenticate } from "lib/action_pack/webauthn"
|
||||||
|
|
||||||
const mediated = new WeakSet()
|
class PasskeyCreationButton extends HTMLElement {
|
||||||
const mediationSelector = 'form[data-passkey-mediation="conditional"]'
|
connectedCallback() {
|
||||||
|
this.button.addEventListener("click", this.#create)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const container of document.querySelectorAll("[data-passkey-errors]")) {
|
disconnectedCallback() {
|
||||||
delete container.dataset.passkeyErrorState
|
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"]
|
get button() {
|
||||||
// appears in the DOM. Uses a MutationObserver so it works regardless of how the form
|
return this.querySelector("[data-passkey='create']")
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}).observe(document.documentElement, { childList: true, subtree: true })
|
|
||||||
|
|
||||||
mediateIn(document)
|
get form() {
|
||||||
|
return this.querySelector("form")
|
||||||
function mediateIn(root) {
|
|
||||||
const form = root.matches?.(mediationSelector) ? root : root.querySelector?.(mediationSelector)
|
|
||||||
|
|
||||||
if (form && !mediated.has(form)) {
|
|
||||||
mediated.add(form)
|
|
||||||
attemptConditionalMediation()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Run the WebAuthn registration ceremony: refresh the challenge, prompt the
|
get creationOptions() {
|
||||||
// browser to create a credential, fill the form's hidden fields, and submit.
|
return JSON.parse(this.getAttribute("creation-options"))
|
||||||
async function createPasskey(button) {
|
}
|
||||||
const form = button.closest("form")
|
|
||||||
|
|
||||||
if (form) {
|
get challengeUrl() {
|
||||||
button.disabled = true
|
return this.getAttribute("challenge-url")
|
||||||
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
}
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
|
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()
|
const options = this.creationOptions
|
||||||
if (!creationOptions) throw new Error("Missing passkey creation options")
|
await refreshChallenge(options, this.challengeUrl)
|
||||||
|
const passkey = await register(options)
|
||||||
|
|
||||||
await refreshChallenge(creationOptions)
|
this.button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||||
const passkey = await register(creationOptions)
|
fillCreateForm(this.form, passkey)
|
||||||
|
this.form.submit()
|
||||||
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
|
||||||
fillCreateForm(form, passkey)
|
|
||||||
form.submit()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
button.disabled = false
|
this.button.disabled = false
|
||||||
|
this.#handleError(error)
|
||||||
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
|
|
||||||
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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() {
|
function passkeysAvailable() {
|
||||||
return !!window.PublicKeyCredential
|
return !!window.PublicKeyCredential
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read WebAuthn creation options from the <meta> tag rendered by
|
async function refreshChallenge(options, challengeUrl) {
|
||||||
// +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing.
|
if (!challengeUrl) throw new Error("Missing passkey challenge URL")
|
||||||
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")
|
|
||||||
const token = document.querySelector('meta[name="csrf-token"]')?.content
|
const token = document.querySelector('meta[name="csrf-token"]')?.content
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(challengeUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -152,8 +192,6 @@ async function refreshChallenge(options) {
|
|||||||
options.challenge = challenge
|
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) {
|
function fillCreateForm(form, passkey) {
|
||||||
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
|
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
|
||||||
form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object
|
form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object
|
||||||
@@ -167,85 +205,9 @@ function fillCreateForm(form, passkey) {
|
|||||||
template.remove()
|
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) {
|
function fillSignInForm(form, passkey) {
|
||||||
form.querySelector('[data-passkey-field="id"]').value = passkey.id
|
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="client_data_json"]').value = passkey.client_data_json
|
||||||
form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data
|
form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data
|
||||||
form.querySelector('[data-passkey-field="signature"]').value = passkey.signature
|
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"]')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<% @page_title = "Passkeys" %>
|
<% @page_title = "Passkeys" %>
|
||||||
|
|
||||||
<% content_for :head do %>
|
|
||||||
<%= passkey_creation_options_meta_tag(@creation_options) %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% content_for :header do %>
|
<% content_for :header do %>
|
||||||
<div class="header__actions header__actions--start">
|
<div class="header__actions header__actions--start">
|
||||||
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||||
@@ -12,7 +8,7 @@
|
|||||||
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<section class="panel panel--wide shadow center" data-passkey-errors>
|
<section class="panel panel--wide shadow center">
|
||||||
<p class="font-weight-medium margin-none-block-start">Passkeys let you sign in securely without a password or email code.</p>
|
<p class="font-weight-medium margin-none-block-start">Passkeys let you sign in securely without a password or email code.</p>
|
||||||
|
|
||||||
<% if @passkeys.any? %>
|
<% if @passkeys.any? %>
|
||||||
@@ -22,7 +18,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<%= 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" %>
|
<%= icon_tag "add" %>
|
||||||
<span>Register a passkey</span>
|
<span>Register a passkey</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -30,14 +26,5 @@
|
|||||||
<p class="txt-small txt-subtle txt-balance margin-none-block-end">
|
<p class="txt-small txt-subtle txt-balance margin-none-block-end">
|
||||||
Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key
|
Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p data-passkey-error="error" class="txt-negative">
|
|
||||||
Something went wrong while registering your passkey.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p data-passkey-error="cancelled" class="txt-subtle">
|
|
||||||
Passkey registration was cancelled.
|
|
||||||
Try again when you are ready.
|
|
||||||
</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<% @page_title = "Enter your email" %>
|
<% @page_title = "Enter your email" %>
|
||||||
|
|
||||||
<% content_for :head do %>
|
|
||||||
<%= passkey_request_options_meta_tag(@request_options) %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="panel panel--centered flex flex-column gap-half">
|
<div class="panel panel--centered flex flex-column gap-half">
|
||||||
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
|
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
|
||||||
|
|
||||||
@@ -26,7 +22,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% 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 %>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
# Include this module in your helper or ApplicationHelper to get access to:
|
||||||
#
|
#
|
||||||
# - +passkey_creation_options_meta_tag+ / +passkey_request_options_meta_tag+ — render a <meta>
|
# - +passkey_creation_button+ — render a <rails-passkey-creation-button> web component with
|
||||||
# tag containing the JSON-serialized WebAuthn options for the browser credential API.
|
# a form, hidden fields, and error messages for the registration ceremony.
|
||||||
# - +passkey_creation_button+ — render a form with hidden fields for the registration ceremony.
|
# - +passkey_sign_in_button+ — render a <rails-passkey-sign-in-button> web component with
|
||||||
# - +passkey_sign_in_button+ — render a form with hidden fields for the authentication
|
# a form, hidden fields, and error messages for the authentication ceremony.
|
||||||
# ceremony.
|
|
||||||
module ActionPack::Passkey::FormHelper
|
module ActionPack::Passkey::FormHelper
|
||||||
# Renders +<meta>+ tags containing JSON-serialized creation options and the challenge endpoint
|
# Renders a +<rails-passkey-creation-button>+ web component containing a form with hidden
|
||||||
# URL for the WebAuthn registration ceremony. The companion JavaScript reads these tags to call
|
# fields for the passkey registration ceremony and error messages. The form POSTs to +url+ and
|
||||||
# +navigator.credentials.create()+.
|
# includes hidden fields for +client_data_json+, +attestation_object+, and +transports+ —
|
||||||
def passkey_creation_options_meta_tag(creation_options, challenge_url: nil)
|
# populated by the web component after the browser credential API resolves.
|
||||||
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
|
|
||||||
tag.meta(name: "passkey-creation-options", content: creation_options.to_json)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Renders +<meta>+ 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 +<form>+ tag
|
|
||||||
# - All other options are passed to the +<button>+ tag
|
|
||||||
def passkey_creation_button(name = nil, url = nil, param: :passkey, form: {}, **options, &block)
|
|
||||||
url, name = name, block ? capture(&block) : nil if block_given?
|
|
||||||
form_options = form.reverse_merge(method: :post, action: url, class: "button_to")
|
|
||||||
|
|
||||||
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: "create" }, **options)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Renders a form with hidden fields for the passkey authentication ceremony. 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.
|
# Accepts a +label+ string or a block for button content.
|
||||||
#
|
#
|
||||||
# Options:
|
# Options:
|
||||||
|
# - +options+: WebAuthn creation options (JSON-serializable hash)
|
||||||
|
# - +challenge_url+: endpoint to refresh the challenge nonce
|
||||||
# - +param+: the form parameter namespace (default: +:passkey+)
|
# - +param+: the form parameter namespace (default: +:passkey+)
|
||||||
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
|
# - +error_message+: message shown on ceremony failure
|
||||||
|
# - +cancelled_message+: message shown when ceremony is cancelled
|
||||||
# - +form+: additional HTML attributes for the +<form>+ tag
|
# - +form+: additional HTML attributes for the +<form>+ tag
|
||||||
# - All other options are passed to the +<button>+ tag
|
# - All other options are passed to the +<button>+ tag
|
||||||
def passkey_sign_in_button(name = nil, url = nil, param: :passkey, mediation: nil, form: {}, **options, &block)
|
def passkey_creation_button(name = nil, url = nil, options:, challenge_url: nil, param: :passkey, error_message: "Something went wrong while registering your passkey.", cancelled_message: "Passkey registration was cancelled. Try again when you are ready.", form: {}, **button_options, &block)
|
||||||
url, name = name, block ? capture(&block) : nil if block_given?
|
url, name = name, block ? capture(&block) : nil if block_given?
|
||||||
form_data = {}
|
form_options = form.reverse_merge(method: :post, action: url, class: "button_to")
|
||||||
form_data[:passkey_mediation] = mediation if mediation
|
|
||||||
form_options = form.reverse_merge(method: :post, action: url, class: "button_to", data: form_data)
|
|
||||||
|
|
||||||
tag.form(**form_options) do
|
component_options = {
|
||||||
hidden_field_tag(:authenticity_token, form_authenticity_token) +
|
"creation-options": options.to_json,
|
||||||
hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
|
"challenge-url": challenge_url || default_passkey_challenge_url
|
||||||
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
|
}
|
||||||
hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
|
|
||||||
hidden_field_tag("#{param}[signature]", nil, id: nil, data: { passkey_field: "signature" }) +
|
tag.send(:"rails-passkey-creation-button", **component_options) do
|
||||||
tag.button(name, type: :button, data: { passkey: "sign_in" }, **options)
|
form_tag = 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: "create" }, **button_options)
|
||||||
|
end
|
||||||
|
|
||||||
|
form_tag + passkey_error_messages(error_message, cancelled_message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Renders a +<rails-passkey-sign-in-button>+ 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.
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# - +options+: WebAuthn request options (JSON-serializable hash)
|
||||||
|
# - +challenge_url+: endpoint to refresh the challenge nonce
|
||||||
|
# - +param+: the form parameter namespace (default: +:passkey+)
|
||||||
|
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
|
||||||
|
# - +error_message+: message shown on ceremony failure
|
||||||
|
# - +cancelled_message+: message shown when ceremony is cancelled
|
||||||
|
# - +form+: additional HTML attributes for the +<form>+ tag
|
||||||
|
# - All other options are passed to the +<button>+ tag
|
||||||
|
def passkey_sign_in_button(name = nil, url = nil, options:, challenge_url: nil, param: :passkey, mediation: nil, error_message: "Something went wrong while signing in with your passkey.", cancelled_message: "Passkey sign in was cancelled. Try again when you are ready.", form: {}, **button_options, &block)
|
||||||
|
url, name = name, block ? capture(&block) : nil if block_given?
|
||||||
|
form_options = form.reverse_merge(method: :post, action: url, class: "button_to")
|
||||||
|
|
||||||
|
component_options = {
|
||||||
|
"request-options": options.to_json,
|
||||||
|
"challenge-url": challenge_url || default_passkey_challenge_url
|
||||||
|
}
|
||||||
|
component_options[:mediation] = mediation if mediation
|
||||||
|
|
||||||
|
tag.send(:"rails-passkey-sign-in-button", **component_options) do
|
||||||
|
form_tag = tag.form(**form_options) do
|
||||||
|
hidden_field_tag(:authenticity_token, form_authenticity_token) +
|
||||||
|
hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
|
||||||
|
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
|
||||||
|
hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
|
||||||
|
hidden_field_tag("#{param}[signature]", nil, id: nil, data: { passkey_field: "signature" }) +
|
||||||
|
tag.button(name, type: :button, data: { passkey: "sign_in" }, **button_options)
|
||||||
|
end
|
||||||
|
|
||||||
|
form_tag + passkey_error_messages(error_message, cancelled_message)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def passkey_challenge_url_meta_tag(challenge_url: nil)
|
|
||||||
tag.meta(name: "passkey-challenge-url", content: challenge_url || default_passkey_challenge_url)
|
|
||||||
end
|
|
||||||
|
|
||||||
def default_passkey_challenge_url
|
def default_passkey_challenge_url
|
||||||
if challenge_url = Rails.configuration.action_pack.passkey.challenge_url
|
if challenge_url = Rails.configuration.action_pack.passkey.challenge_url
|
||||||
instance_exec(&challenge_url)
|
instance_exec(&challenge_url)
|
||||||
@@ -84,4 +89,9 @@ module ActionPack::Passkey::FormHelper
|
|||||||
passkey_challenge_path
|
passkey_challenge_path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def passkey_error_messages(error_message, cancelled_message)
|
||||||
|
tag.p(error_message, data: { passkey_error: "error" }, class: "txt-negative") +
|
||||||
|
tag.p(cancelled_message, data: { passkey_error: "cancelled" }, class: "txt-subtle")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user