diff --git a/Gemfile.lock b/Gemfile.lock index efe93383a..03031d000 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -258,7 +258,7 @@ GEM rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) - loofah (2.25.0) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -306,21 +306,21 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) - nokogiri (1.19.1-aarch64-linux-gnu) + nokogiri (1.19.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-aarch64-linux-musl) + nokogiri (1.19.2-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.1-arm-linux-gnu) + nokogiri (1.19.2-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-arm-linux-musl) + nokogiri (1.19.2-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.1-arm64-darwin) + nokogiri (1.19.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-darwin) + nokogiri (1.19.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-gnu) + nokogiri (1.19.2-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-musl) + nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) openssl (4.0.0) ostruct (0.6.3) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 55a8f4ac8..389c702a4 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -371,7 +371,7 @@ GEM rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) - loofah (2.25.0) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -422,21 +422,21 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) - nokogiri (1.19.1-aarch64-linux-gnu) + nokogiri (1.19.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-aarch64-linux-musl) + nokogiri (1.19.2-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.1-arm-linux-gnu) + nokogiri (1.19.2-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-arm-linux-musl) + nokogiri (1.19.2-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.1-arm64-darwin) + nokogiri (1.19.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-darwin) + nokogiri (1.19.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-gnu) + nokogiri (1.19.2-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-musl) + nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) openssl (4.0.0) os (1.1.4) @@ -707,7 +707,7 @@ DEPENDENCIES audits1984! autotuner aws-sdk-s3 - bcrypt (~> 3.1.7) + bcrypt (~> 3.1.22) benchmark bootsnap brakeman diff --git a/app/assets/stylesheets/credentials.css b/app/assets/stylesheets/credentials.css index d57583017..55199af7e 100644 --- a/app/assets/stylesheets/credentials.css +++ b/app/assets/stylesheets/credentials.css @@ -32,12 +32,4 @@ opacity: 0; } - [data-passkey-errors] [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"] { - display: block; - } } diff --git a/app/controllers/my/passkeys_controller.rb b/app/controllers/my/passkeys_controller.rb index 40ddaca24..f4a645ec3 100644 --- a/app/controllers/my/passkeys_controller.rb +++ b/app/controllers/my/passkeys_controller.rb @@ -5,11 +5,11 @@ class My::PasskeysController < ApplicationController def index @passkeys = Current.identity.passkeys.order(name: :asc, created_at: :desc) - @creation_options = passkey_creation_options(holder: Current.identity) + @registration_options = passkey_registration_options(holder: Current.identity) end def create - passkey = Current.identity.passkeys.register(passkey_creation_params) + passkey = Current.identity.passkeys.register(passkey_registration_params) redirect_to edit_my_passkey_path(passkey, created: true) end diff --git a/app/controllers/sessions/passkeys_controller.rb b/app/controllers/sessions/passkeys_controller.rb index 4dc087986..f6a24ceff 100644 --- a/app/controllers/sessions/passkeys_controller.rb +++ b/app/controllers/sessions/passkeys_controller.rb @@ -6,7 +6,7 @@ class Sessions::PasskeysController < ApplicationController rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded def create - if credential = ActionPack::Passkey.authenticate(passkey_request_params) + if credential = ActionPack::Passkey.authenticate(passkey_authentication_params) start_new_session_for credential.holder respond_to do |format| diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cfbb778f2..0db617d04 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -8,7 +8,7 @@ class SessionsController < ApplicationController layout "public" def new - @request_options = passkey_request_options + @authentication_options = passkey_authentication_options end def create diff --git a/app/javascript/lib/action_pack/passkey.js b/app/javascript/lib/action_pack/passkey.js index 05a1772e1..7ee20cb84 100644 --- a/app/javascript/lib/action_pack/passkey.js +++ b/app/javascript/lib/action_pack/passkey.js @@ -1,149 +1,181 @@ -// 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): +// options — JSON WebAuthn options (creation or request, on both) +// 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 +// Base class for passkey web components. Manages the shared ceremony lifecycle: +// challenge refresh, button state, error display, and event dispatch. +// Subclasses implement `perform()` to run the specific WebAuthn ceremony +// and `fillForm()` to populate hidden fields before submission. +class PasskeyButton extends HTMLElement { + connectedCallback() { + this.button.addEventListener("click", this.#perform) } - for (const container of document.querySelectorAll("[data-passkey-errors]")) { - delete container.dataset.passkeyErrorState + disconnectedCallback() { + this.button.removeEventListener("click", this.#perform) + this.button.disabled = false + this.#hideErrors() } -}) -// 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]") } -}).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 options() { + return JSON.parse(this.getAttribute("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. + #perform = async () => { + this.button.disabled = true + this.#hideErrors() + this.button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) try { + const options = this.options + if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") + if (!options) throw new Error("Missing passkey options") - const creationOptions = getCreationOptions() - if (!creationOptions) throw new Error("Missing passkey creation options") + await refreshChallenge(options, this.challengeUrl, this.purpose) + const passkey = await this.perform(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 })) + this.fillForm(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) { + console.error("Passkey ceremony failed", error) + const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" + this.#showError(cancelled ? "cancelled" : "error") + this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) + } + + #showError(type) { + const el = this.querySelector(`[data-passkey-error="${type}"]`) + if (el) el.hidden = false + } + + #hideErrors() { + for (const el of this.querySelectorAll("[data-passkey-error]")) el.hidden = true + } } +class PasskeyRegistrationButton extends PasskeyButton { + get purpose() { return "registration" } + + async perform(options) { + return await register(options) + } + + fillForm(passkey) { + fillRegistrationForm(this.form, passkey) + } +} + +class PasskeySignInButton extends PasskeyButton { + get purpose() { return "authentication" } + + connectedCallback() { + super.connectedCallback() + if (this.mediation === "conditional") this.#attemptConditionalMediation() + } + + get mediation() { + return this.getAttribute("mediation") + } + + async perform(options, { mediation } = {}) { + return await authenticate(options, { mediation }) + } + + fillForm(passkey) { + fillSignInForm(this.form, passkey) + } + + async #attemptConditionalMediation() { + if (await this.#conditionalMediationAvailable()) { + const options = this.options + + this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) + + try { + await refreshChallenge(options, this.challengeUrl, this.purpose) + const passkey = await this.perform(options, { mediation: this.mediation }) + + this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) + this.fillForm(passkey) + this.form.submit() + } catch (error) { + console.error("Passkey conditional mediation failed", error) + const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" + this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) + } + } + } + + async #conditionalMediationAvailable() { + return this.options && + passkeysAvailable() && + await window.PublicKeyCredential.isConditionalMediationAvailable?.() + } +} + +customElements.define("rails-passkey-registration-button", PasskeyRegistrationButton) +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, purpose) { + if (!challengeUrl) throw new Error("Missing passkey challenge URL") const token = document.querySelector('meta[name="csrf-token"]')?.content - const response = await fetch(url, { + const body = new URLSearchParams() + if (purpose) body.append("purpose", purpose) + + const response = await fetch(challengeUrl, { method: "POST", credentials: "same-origin", headers: { "X-CSRF-Token": token, "Accept": "application/json" - } + }, + body }) if (!response.ok) throw new Error("Failed to refresh challenge") @@ -152,9 +184,7 @@ 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) { +function fillRegistrationForm(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 +197,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..de143eeb4 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_registration_button my_passkeys_path, options: @registration_options, error: { class: "txt-negative margin-block-half" }, cancellation: { class: "txt-subtle margin-block-half" }, 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..c44980e54 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: @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 ace33176c..7b81075af 100644 --- a/lib/action_pack/passkey.rb +++ b/lib/action_pack/passkey.rb @@ -8,7 +8,7 @@ # Generate options for the browser's +navigator.credentials.create()+ call, then register the # response: # -# options = ActionPack::Passkey.creation_options(holder: current_user) +# options = ActionPack::Passkey.registration_options(holder: current_user) # # Pass options to the browser # # passkey = ActionPack::Passkey.register(params[:passkey], holder: current_user) @@ -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]) @@ -35,26 +35,25 @@ class ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class class << self # Returns a CreationOptions object for the given +holder+, suitable for passing to the # browser's +navigator.credentials.create()+ call. Merges global defaults from the Rails - # configuration, holder-specific options from +holder.passkey_creation_options+, and any + # configuration, holder-specific options from +holder.passkey_registration_options+, and any # additional +options+ overrides. - def creation_options(holder:, **options) + def registration_options(holder:, **options) ActionPack::WebAuthn::PublicKeyCredential.creation_options( **Rails.configuration.action_pack.web_authn.default_creation_options.to_h, - **holder.passkey_creation_options.to_h, + **holder.passkey_registration_options.to_h, **options ) end # Verifies the attestation response from the browser and persists a new passkey record. # The +passkey+ hash should contain +client_data_json+, +attestation_object+, and +transports+ - # as submitted by the registration form. The +challenge+ defaults to - # +ActionPack::WebAuthn::Current.challenge+, which is automatically populated from the session - # by ActionPack::Passkey::Request. Any additional +attributes+ (e.g. +holder+) are passed - # through to +create!+. + # as submitted by the registration form. The challenge is extracted from the authenticator's + # +clientDataJSON+ response and verified server-side. Any additional +attributes+ (e.g. +holder+) + # are passed through to +create!+. # # Raises ActionPack::WebAuthn::InvalidResponseError if the attestation is invalid. - def register(passkey, challenge: ActionPack::WebAuthn::Current.challenge, **attributes) - credential = ActionPack::WebAuthn::PublicKeyCredential.register(passkey, challenge: challenge) + def register(passkey, **attributes) + credential = ActionPack::WebAuthn::PublicKeyCredential.register(passkey) create!(**credential.to_h, **attributes) end @@ -63,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 @@ -74,17 +73,17 @@ class ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class # Looks up a passkey by credential ID and verifies the assertion response from the browser. # Returns the authenticated Passkey record, or +nil+ if the credential is not found or # verification fails. - def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge) - find_by(credential_id: passkey[:id])&.authenticate(passkey, challenge: challenge) + def authenticate(passkey) + find_by(credential_id: passkey[:id])&.authenticate(passkey) end end # Verifies the assertion response against this passkey's stored credential and updates the # +sign_count+ and +backed_up+ attributes. Returns +self+ on success, or +nil+ if the # response is invalid. - def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge) + def authenticate(passkey) credential = to_public_key_credential - credential.authenticate(passkey, challenge: challenge) + credential.authenticate(passkey) update!(sign_count: credential.sign_count, backed_up: credential.backed_up) self rescue ActionPack::WebAuthn::InvalidResponseError diff --git a/lib/action_pack/passkey/challenges_controller.rb b/lib/action_pack/passkey/challenges_controller.rb index 82886266a..c9e321b9f 100644 --- a/lib/action_pack/passkey/challenges_controller.rb +++ b/lib/action_pack/passkey/challenges_controller.rb @@ -5,9 +5,10 @@ # authentication ceremony so that the challenge is issued just-in-time rather # than embedded in the initial page load. # -# The generated challenge is stored in an encrypted, HTTP-only, same-site -# cookie and simultaneously returned in the JSON response body. The cookie is -# consumed by ActionPack::Passkey::Request on the subsequent form submission. +# The generated challenge is returned in the JSON response body. The challenge +# is a signed, expiring token that the server can verify on the subsequent +# form submission without needing server-side state — the challenge is +# extracted from the authenticator's +clientDataJSON+ response. # # == Route # @@ -15,23 +16,34 @@ # via +config.action_pack.passkey.routes_prefix+). # class ActionPack::Passkey::ChallengesController < ActionController::Base - COOKIE_NAME = :action_pack_passkey_challenge - include ActionPack::Passkey::Request - # Generates a fresh challenge, stores it in an encrypted cookie, and returns - # it as JSON. The cookie is consumed on the next passkey form submission. + # Generates a fresh challenge and returns it as JSON. Accepts an optional + # +purpose+ parameter ("registration" or "authentication") to select the + # appropriate challenge expiration. Defaults to "authentication". def create - challenge = create_passkey_challenge - - cookies.encrypted[COOKIE_NAME] = { value: challenge, httponly: true, same_site: :lax, secure: !request.local? && request.ssl? } - render json: { challenge: challenge } + render json: { challenge: create_passkey_challenge } end private def create_passkey_challenge ActionPack::WebAuthn::PublicKeyCredential::Options.new( - challenge_expiration: Rails.configuration.action_pack.web_authn.request_challenge_expiration + challenge_expiration: challenge_expiration, + challenge_purpose: challenge_purpose ).challenge end + + def challenge_purpose + params[:purpose] == "registration" ? "registration" : "authentication" + end + + def challenge_expiration + config = Rails.configuration.action_pack.web_authn + + if challenge_purpose == "registration" + config.creation_challenge_expiration + else + config.request_challenge_expiration + end + end end diff --git a/lib/action_pack/passkey/form_helper.rb b/lib/action_pack/passkey/form_helper.rb index 4fc9a4444..fe3767d70 100644 --- a/lib/action_pack/passkey/form_helper.rb +++ b/lib/action_pack/passkey/form_helper.rb @@ -1,80 +1,101 @@ -# 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_registration_button+ — render a web component with +# a form, hidden fields, and error messages for the registration ceremony. +# - +passkey_sign_in_button+ — render a web component with +# a form, hidden fields, and error messages for the authentication ceremony. 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 + REGISTRATION_ERROR_MESSAGE = "Something went wrong while registering your passkey." + REGISTRATION_CANCELLED_MESSAGE = "Passkey registration was cancelled. Try again when you are ready." + SIGN_IN_ERROR_MESSAGE = "Something went wrong while signing in with your passkey." + SIGN_IN_CANCELLED_MESSAGE = "Passkey sign in was cancelled. Try again when you are ready." - # Renders ++ 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 +