Merge pull request #2751 from basecamp/passkey-improvements

Passkey improvements
This commit is contained in:
Stanko Krtalić
2026-03-25 18:19:09 +01:00
committed by GitHub
31 changed files with 520 additions and 489 deletions
+9 -9
View File
@@ -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)
+10 -10
View File
@@ -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
-8
View File
@@ -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;
}
}
+2 -2
View File
@@ -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
@@ -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|
+1 -1
View File
@@ -8,7 +8,7 @@ class SessionsController < ApplicationController
layout "public"
def new
@request_options = passkey_request_options
@authentication_options = passkey_authentication_options
end
def create
+140 -186
View File
@@ -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).
// <rails-passkey-registration-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):
// 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
}
})
// 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)
}
}
}).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()
}
disconnectedCallback() {
this.button.removeEventListener("click", this.#perform)
this.button.disabled = false
this.#hideErrors()
}
// 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 button() {
return this.querySelector("[data-passkey]")
}
if (form) {
button.disabled = true
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
get form() {
return this.querySelector("form")
}
get options() {
return JSON.parse(this.getAttribute("options"))
}
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
this.button.disabled = false
this.#handleError(error)
}
}
#handleError(error) {
console.error("Passkey ceremony failed", error)
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
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 <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, 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 <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"]')
}
+2 -15
View File
@@ -1,9 +1,5 @@
<% @page_title = "Passkeys" %>
<% content_for :head do %>
<%= passkey_creation_options_meta_tag(@creation_options) %>
<% end %>
<% content_for :header do %>
<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" %>
@@ -12,7 +8,7 @@
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% 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>
<% if @passkeys.any? %>
@@ -22,7 +18,7 @@
<% end %>
<footer>
<%= 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" %>
<span>Register a passkey</span>
<% end %>
@@ -30,14 +26,5 @@
<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
</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>
</section>
+1 -5
View File
@@ -1,9 +1,5 @@
<% @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">
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
@@ -26,7 +22,7 @@
</button>
<% 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 %>
</div>
+16 -17
View File
@@ -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
@@ -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
+77 -42
View File
@@ -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 <meta>
# 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 <rails-passkey-registration-button> web component with
# a form, hidden fields, and error messages for the registration ceremony.
# - +passkey_sign_in_button+ — render a <rails-passkey-sign-in-button> web component with
# a form, hidden fields, and error messages for the authentication ceremony.
module ActionPack::Passkey::FormHelper
# Renders +<meta>+ 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 +<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.
# Renders a +<rails-passkey-registration-button>+ web component containing a form with hidden
# fields for the passkey registration ceremony and error messages. The form POSTs to +url+ and
# includes hidden fields for +client_data_json+, +attestation_object+, and +transports+ —
# populated by the web component 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
# - +options+: WebAuthn creation options (JSON-serializable hash)
# - +challenge_url+: endpoint to refresh the challenge nonce
# - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
# to set the form parameter namespace (default: +:passkey+)
# - +error+: HTML attributes for the error message +<div>+. Supports a +:message+ key
# to override the default error text
# - +cancellation+: HTML attributes for the cancellation message +<div>+. Supports a
# +:message+ key to override the default cancellation text
# - All other options are passed to the +<button>+ tag
def passkey_creation_button(name = nil, url = nil, param: :passkey, form: {}, **options, &block)
def passkey_registration_button(name = nil, url = nil, **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, form_options, button_options, error_options = partition_passkey_options(url, options)
error_options[:error][:message] ||= REGISTRATION_ERROR_MESSAGE
error_options[:cancellation][:message] ||= REGISTRATION_CANCELLED_MESSAGE
param = form_options.delete(:param)
content_tag("rails-passkey-registration-button", **component_options.transform_keys { |key| key.to_s.dasherize }) do
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)
tag.button(name, type: :button, data: { passkey: "register" }, **button_options)
end + passkey_error_messages(**error_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+
# 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:
# - +param+: the form parameter namespace (default: +:passkey+)
# - +options+: WebAuthn request options (JSON-serializable hash)
# - +challenge_url+: endpoint to refresh the challenge nonce
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
# - +form+: additional HTML attributes for the +<form>+ tag
# - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
# to set the form parameter namespace (default: +:passkey+)
# - +error+: HTML attributes for the error message +<div>+. Supports a +:message+ key
# to override the default error text
# - +cancellation+: HTML attributes for the cancellation message +<div>+. Supports a
# +:message+ key to override the default cancellation text
# - 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_sign_in_button(name = nil, url = nil, **options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
form_data = {}
form_data[:passkey_mediation] = mediation if mediation
form_options = form.reverse_merge(method: :post, action: url, class: "button_to", data: form_data)
component_options, form_options, button_options, error_options = partition_passkey_options(url, options)
error_options[:error][:message] ||= SIGN_IN_ERROR_MESSAGE
error_options[:cancellation][:message] ||= SIGN_IN_CANCELLED_MESSAGE
param = form_options.delete(:param)
content_tag("rails-passkey-sign-in-button", **component_options.transform_keys { |key| key.to_s.dasherize }) do
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" }, **options)
tag.button(name, type: :button, data: { passkey: "sign_in" }, **button_options)
end + passkey_error_messages(**error_options)
end
end
private
def passkey_challenge_url_meta_tag(challenge_url: nil)
tag.meta(name: "passkey-challenge-url", content: challenge_url || default_passkey_challenge_url)
def partition_passkey_options(url, options)
passkey_options = options.fetch(:options, {})
component_options = options
.slice(:challenge_url, :mediation)
.reverse_merge(challenge_url: default_passkey_challenge_url, options: passkey_options.to_json(except: :challenge))
form_options = options
.fetch(:form, {})
.reverse_merge(method: :post, action: url, class: "button_to", param: :passkey)
error_options = options.slice(:error, :cancellation).reverse_merge(error: {}, cancellation: {})
button_options = options.except(:options, :form, *component_options.keys, *error_options.keys)
[ component_options, form_options, button_options, error_options ]
end
def default_passkey_challenge_url
@@ -84,4 +105,18 @@ module ActionPack::Passkey::FormHelper
passkey_challenge_path
end
end
def passkey_error_messages(error: {}, cancellation: {})
error_message = error[:message]
error_attributes = error.except(:message)
error_attributes[:data] ||= {}
error_attributes[:data][:passkey_error] = "error"
cancellation_message = cancellation[:message]
cancellation_attributes = cancellation.except(:message)
cancellation_attributes[:data] ||= {}
cancellation_attributes[:data][:passkey_error] = "cancelled"
tag.div(error_message, hidden: true, **error_attributes) + tag.div(cancellation_message, hidden: true, **cancellation_attributes)
end
end
+26 -26
View File
@@ -9,8 +9,8 @@
# This sets up a polymorphic +has_many :passkeys+ association and defines two methods on the
# model that supply holder-specific options for the WebAuthn ceremonies:
#
# - +passkey_creation_options+ — merged into ActionPack::Passkey.creation_options
# - +passkey_request_options+ — merged into ActionPack::Passkey.request_options
# - +passkey_registration_options+ — merged into ActionPack::Passkey.registration_options
# - +passkey_authentication_options+ — merged into ActionPack::Passkey.authentication_options
#
# == Options
#
@@ -31,15 +31,15 @@
# For more complex configuration, pass a block that receives a ActionPack::Passkey::Holder::Config:
#
# has_passkeys do |config|
# config.creation_options { { name: email, display_name: name } }
# config.request_options { { user_verification: "required" } }
# config.registration_options { { name: email, display_name: name } }
# config.authentication_options { { user_verification: "required" } }
# end
module ActionPack::Passkey::Holder
extend ActiveSupport::Concern
class_methods do
# Declares that this model can hold passkeys. Sets up a polymorphic +has_many+ association
# and defines +passkey_creation_options+ and +passkey_request_options+ instance methods used
# and defines +passkey_registration_options+ and +passkey_authentication_options+ instance methods used
# by ActionPack::Passkey to build ceremony options.
#
# Keyword arguments matching CreationOptions or RequestOptions fields are extracted and
@@ -54,15 +54,15 @@ module ActionPack::Passkey::Holder
dependent: config.dependent,
class_name: "ActionPack::Passkey"
define_method(:passkey_creation_options) do
define_method(:passkey_registration_options) do
{
id: id,
exclude_credentials: public_send(config.association_name)
}.merge(config.evaluate_creation_options(self))
}.merge(config.evaluate_registration_options(self))
end
define_method(:passkey_request_options) do
{ credentials: public_send(config.association_name) }.merge(config.evaluate_request_options(self))
define_method(:passkey_authentication_options) do
{ credentials: public_send(config.association_name) }.merge(config.evaluate_authentication_options(self))
end
end
end
@@ -77,43 +77,43 @@ module ActionPack::Passkey::Holder
@dependent = options.delete(:dependent) || :destroy
if creation_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::CreationOptions, options)
@creation_options = options_to_proc(creation_opts)
@registration_options = options_to_proc(creation_opts)
end
if request_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::RequestOptions, options)
@request_options = options_to_proc(request_opts)
@authentication_options = options_to_proc(request_opts)
end
end
# Sets a block to evaluate in the holder's context to produce additional request options.
# Sets a block to evaluate in the holder's context to produce additional authentication options.
#
# config.request_options { { user_verification: "required" } }
def request_options(&block)
@request_options = block
# config.authentication_options { { user_verification: "required" } }
def authentication_options(&block)
@authentication_options = block
end
# Sets a block to evaluate in the holder's context to produce additional creation options.
#
# config.creation_options { { name: email, display_name: name } }
def creation_options(&block)
@creation_options = block
# config.registration_options { { name: email, display_name: name } }
def registration_options(&block)
@registration_options = block
end
# Evaluates the request options block (if any) in the context of the given +record+. Called
# internally by the +passkey_request_options+ method defined on the holder.
def evaluate_request_options(record)
if @request_options
record.instance_exec(&@request_options)
# internally by the +passkey_authentication_options+ method defined on the holder.
def evaluate_authentication_options(record)
if @authentication_options
record.instance_exec(&@authentication_options)
else
{}
end
end
# Evaluates the creation options block (if any) in the context of the given +record+. Called
# internally by the +passkey_creation_options+ method defined on the holder.
def evaluate_creation_options(record)
if @creation_options
record.instance_exec(&@creation_options)
# internally by the +passkey_registration_options+ method defined on the holder.
def evaluate_registration_options(record)
if @registration_options
record.instance_exec(&@registration_options)
else
{}
end
+12 -16
View File
@@ -10,12 +10,12 @@
# include ActionPack::Passkey::Request
#
# def new
# @creation_options = passkey_creation_options(holder: Current.user)
# @registration_options = passkey_registration_options(holder: Current.user)
# end
#
# def create
# @passkey = ActionPack::Passkey.register(
# passkey_creation_params, holder: Current.user
# passkey_registration_params, holder: Current.user
# )
# redirect_to settings_path
# end
@@ -27,11 +27,11 @@
# include ActionPack::Passkey::Request
#
# def new
# @request_options = passkey_request_options
# @authentication_options = passkey_authentication_options
# end
#
# def create
# if passkey = ActionPack::Passkey.authenticate(passkey_request_params)
# if passkey = ActionPack::Passkey.authenticate(passkey_authentication_params)
# sign_in passkey.holder
# redirect_to root_path
# else
@@ -43,9 +43,7 @@
# == Before Action
#
# Automatically populates +ActionPack::WebAuthn::Current+ with the request
# host, origin, and challenge (read from the encrypted cookie set by
# ChallengesController). The cookie is deleted after being read to prevent
# replay.
# host and origin.
#
module ActionPack::Passkey::Request
extend ActiveSupport::Concern
@@ -54,28 +52,26 @@ module ActionPack::Passkey::Request
before_action do
ActionPack::WebAuthn::Current.host = request.host
ActionPack::WebAuthn::Current.origin = request.base_url
ActionPack::WebAuthn::Current.challenge = cookies.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]
cookies.delete(ActionPack::Passkey::ChallengesController::COOKIE_NAME)
end
end
# Returns strong parameters for the passkey registration ceremony.
def passkey_creation_params(param: :passkey)
def passkey_registration_params(param: :passkey)
params.expect(param => [ :client_data_json, :attestation_object, transports: [] ])
end
# Returns strong parameters for the passkey authentication ceremony.
def passkey_request_params(param: :passkey)
def passkey_authentication_params(param: :passkey)
params.expect(param => [ :id, :client_data_json, :authenticator_data, :signature ])
end
# Returns RequestOptions for the authentication ceremony.
def passkey_request_options(**options)
ActionPack::Passkey.request_options(**options)
def passkey_authentication_options(**options)
ActionPack::Passkey.authentication_options(**options)
end
# Returns CreationOptions for the registration ceremony.
def passkey_creation_options(**options)
ActionPack::Passkey.creation_options(**options)
# Returns RegistrationOptions for the registration ceremony.
def passkey_registration_options(**options)
ActionPack::Passkey.registration_options(**options)
end
end
@@ -17,7 +17,6 @@
# authenticator_data: params[:response][:authenticatorData],
# signature: params[:response][:signature],
# credential: credential.to_public_key_credential,
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com"
# )
#
@@ -48,6 +47,10 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponse < ActionPack::WebAu
end
private
def challenge_purpose
"authentication"
end
def client_data_type_must_be_get
unless client_data["type"] == "webauthn.get"
errors.add(:base, "Client data type is not webauthn.get")
@@ -9,7 +9,6 @@
# response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
# client_data_json: params[:response][:clientDataJSON],
# attestation_object: params[:response][:attestationObject],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com"
# )
#
@@ -50,6 +49,10 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::Web
end
private
def challenge_purpose
"registration"
end
def client_data_type_must_be_create
unless client_data["type"] == "webauthn.create"
errors.add(:base, "Client data type is not webauthn.create")
@@ -23,7 +23,6 @@
# authenticator_data: authenticator_data,
# signature: signature,
# credential: credential,
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com",
# user_verification: :required
# )
@@ -34,9 +33,9 @@ class ActionPack::WebAuthn::Authenticator::Response
include ActiveModel::Validations
attr_reader :client_data_json
attr_accessor :challenge, :origin, :user_verification
attr_accessor :origin, :user_verification
validate :challenge_must_match
validate :challenge_must_be_present
validate :challenge_must_not_be_expired
validate :origin_must_match
validate :must_not_be_cross_origin
@@ -45,9 +44,8 @@ class ActionPack::WebAuthn::Authenticator::Response
validate :user_must_be_present
validate :user_must_be_verified_when_required
def initialize(client_data_json:, challenge: nil, origin: nil, user_verification: :preferred)
def initialize(client_data_json:, origin: nil, user_verification: :preferred)
@client_data_json = client_data_json
@challenge = challenge
@origin = origin
@user_verification = user_verification.to_sym
end
@@ -76,28 +74,28 @@ class ActionPack::WebAuthn::Authenticator::Response
end
private
def challenge_must_match
if challenge.blank?
def challenge_must_be_present
if client_data["challenge"].blank?
errors.add(:base, "Challenge missing")
elsif client_data["challenge"].blank?
errors.add(:base, "Challenge missing in client data")
elsif !ActiveSupport::SecurityUtils.secure_compare(challenge.to_s, client_data["challenge"].to_s)
errors.add(:base, "Challenge does not match")
end
end
def challenge_must_not_be_expired
return if errors.any? || challenge.blank?
return if errors.any?
signed_message = Base64.urlsafe_decode64(challenge)
signed_message = Base64.urlsafe_decode64(client_data["challenge"])
unless ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
unless ActionPack::WebAuthn.challenge_verifier.verified(signed_message, purpose: challenge_purpose)
errors.add(:base, "Challenge has expired")
end
rescue ArgumentError
errors.add(:base, "Challenge is invalid")
end
def challenge_purpose
nil
end
def origin_must_match
if origin.blank?
errors.add(:base, "Origin missing")
+1 -5
View File
@@ -14,10 +14,6 @@
# The expected origin (typically +request.base_url+). Validated against the
# +origin+ field in the authenticator's client data.
#
# [+challenge+]
# The Base64URL-encoded challenge read from the encrypted cookie. Validated
# against the +challenge+ field in the authenticator's client data.
#
class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes
attribute :host, :origin, :challenge
attribute :host, :origin
end
@@ -10,7 +10,6 @@
#
# credential = ActionPack::WebAuthn::PublicKeyCredential.register(
# params[:passkey],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: ActionPack::WebAuthn::Current.origin
# )
#
@@ -80,11 +79,10 @@ class ActionPack::WebAuthn::PublicKeyCredential
# PublicKeyCredential with the registered credential data.
#
# Raises +InvalidResponseError+ if the attestation is invalid.
def register(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
def register(params, origin: ActionPack::WebAuthn::Current.origin)
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: params[:client_data_json],
attestation_object: params[:attestation_object],
challenge: challenge,
origin: origin
)
@@ -126,13 +124,12 @@ class ActionPack::WebAuthn::PublicKeyCredential
# Updates +sign_count+ and +backed_up+ on success.
#
# Raises +InvalidResponseError+ if the assertion is invalid.
def authenticate(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
def authenticate(params, origin: ActionPack::WebAuthn::Current.origin)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: params[:client_data_json],
authenticator_data: params[:authenticator_data],
signature: params[:signature],
credential: self,
challenge: challenge,
origin: origin
)
@@ -53,6 +53,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
attribute :exclude_credentials, default: -> { [] }
attribute :attestation, default: :none
attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.creation_challenge_expiration }
attribute :challenge_purpose, default: "registration"
validates :id, :name, :display_name, presence: true
validates :resident_key, inclusion: { in: RESIDENT_KEY_OPTIONS }
@@ -67,7 +68,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
# Returns a Hash suitable for JSON serialization and passing to the
# WebAuthn JavaScript API.
def as_json(*)
def as_json(options = {})
json = {
challenge: challenge,
rp: relying_party.as_json,
@@ -96,7 +97,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
json[:attestation] = attestation.to_s
end
json
json.as_json(options)
end
private
@@ -36,6 +36,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::Options
attribute :user_verification, default: :preferred
attribute :relying_party, default: -> { ActionPack::WebAuthn.relying_party }
attribute :challenge_expiration
attribute :challenge_purpose
validates :user_verification, inclusion: { in: USER_VERIFICATION_OPTIONS }
@@ -70,7 +71,8 @@ class ActionPack::WebAuthn::PublicKeyCredential::Options
@challenge ||= Base64.urlsafe_encode64(
ActionPack::WebAuthn.challenge_verifier.generate(
Base64.strict_encode64(SecureRandom.random_bytes(CHALLENGE_LENGTH)),
expires_in: challenge_expiration
expires_in: challenge_expiration,
purpose: challenge_purpose
),
padding: false
)
@@ -26,6 +26,7 @@
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
attribute :credentials, default: -> { [] }
attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.request_challenge_expiration }
attribute :challenge_purpose, default: "authentication"
def initialize(attributes = {})
super
@@ -34,13 +35,15 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptions < ActionPack::We
# Returns a Hash suitable for JSON serialization and passing to the
# WebAuthn JavaScript API.
def as_json(*)
{
def as_json(options = {})
json = {
challenge: challenge,
rpId: relying_party.id,
allowCredentials: credentials.map { |credential| allow_credential_json(credential) },
userVerification: user_verification.to_s
}
json.as_json(options)
end
private
@@ -10,15 +10,6 @@ class My::PasskeyChallengesControllerTest < ActionDispatch::IntegrationTest
end
end
test "stores challenge in cookie" do
untenanted do
post my_passkey_challenge_url
jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
assert_equal response.parsed_body["challenge"], jar.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]
end
end
test "returns a different challenge each time" do
untenanted do
post my_passkey_challenge_url
@@ -30,4 +21,38 @@ class My::PasskeyChallengesControllerTest < ActionDispatch::IntegrationTest
assert_not_equal first_challenge, second_challenge
end
end
test "uses registration challenge expiration for registration purpose" do
untenanted do
post my_passkey_challenge_url, params: { purpose: "registration" }
assert_response :success
challenge = response.parsed_body["challenge"]
signed_message = Base64.urlsafe_decode64(challenge)
travel Rails.configuration.action_pack.web_authn.creation_challenge_expiration - 1.second
assert ActionPack::WebAuthn.challenge_verifier.verified(signed_message, purpose: "registration")
travel 2.seconds
assert_nil ActionPack::WebAuthn.challenge_verifier.verified(signed_message, purpose: "registration")
end
end
test "uses authentication challenge expiration by default" do
untenanted do
post my_passkey_challenge_url
assert_response :success
challenge = response.parsed_body["challenge"]
signed_message = Base64.urlsafe_decode64(challenge)
travel Rails.configuration.action_pack.web_authn.request_challenge_expiration - 1.second
assert ActionPack::WebAuthn.challenge_verifier.verified(signed_message, purpose: "authentication")
travel 2.seconds
assert_nil ActionPack::WebAuthn.challenge_verifier.verified(signed_message, purpose: "authentication")
end
end
end
@@ -13,7 +13,7 @@ class My::PasskeysControllerTest < ActionDispatch::IntegrationTest
end
test "register a passkey" do
challenge = request_webauthn_challenge
challenge = request_webauthn_challenge(purpose: "registration")
assert_difference -> { identities(:kevin).passkeys.count }, 1 do
post my_passkeys_path, params: build_attestation_params(challenge: challenge)
+6 -6
View File
@@ -17,27 +17,27 @@ class ActionPack::PasskeyTest < ActiveSupport::TestCase
end
test "authenticate with valid assertion" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
challenge = ActionPack::Passkey.authentication_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge)
result = @passkey.authenticate(assertion, challenge: challenge)
result = @passkey.authenticate(assertion)
assert_equal @passkey, result
end
test "authenticate returns nil with invalid signature" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
challenge = ActionPack::Passkey.authentication_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge)
assertion[:signature] = Base64.urlsafe_encode64("invalid", padding: false)
assert_nil @passkey.authenticate(assertion, challenge: challenge)
assert_nil @passkey.authenticate(assertion)
end
test "authenticate updates sign count and backed_up" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
challenge = ActionPack::Passkey.authentication_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge, sign_count: 5, backed_up: true)
@passkey.authenticate(assertion, challenge: challenge)
@passkey.authenticate(assertion)
assert_equal 5, @passkey.reload.sign_count
assert @passkey.backed_up?
@@ -6,7 +6,7 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = webauthn_challenge
@challenge = webauthn_challenge(purpose: "authentication")
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
@@ -27,7 +27,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: @authenticator_data,
signature: @signature,
credential: @credential,
challenge: @challenge,
origin: @origin
)
end
@@ -56,7 +55,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: @authenticator_data,
signature: sign(@authenticator_data, client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin
)
@@ -73,7 +71,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: @authenticator_data,
signature: Base64.urlsafe_encode64("invalid-signature", padding: false),
credential: @credential,
challenge: @challenge,
origin: @origin
)
@@ -84,14 +81,28 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
assert_equal "Invalid signature", error.message
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
test "validate! raises when challenge in client data is invalid" do
client_data_json = {
challenge: "not-a-valid-signed-challenge",
origin: @origin,
type: "webauthn.get"
}.to_json
authenticator_data = build_authenticator_data(user_verified: true)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: client_data_json,
authenticator_data: authenticator_data,
signature: sign(authenticator_data, client_data_json),
credential: @credential,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
response.validate!
end
assert_equal "Challenge does not match", error.message
assert_match /Challenge (is invalid|has expired)/, error.message
end
test "validate! raises when origin does not match" do
@@ -111,7 +122,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :preferred
)
@@ -128,7 +138,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
@@ -145,7 +154,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
@@ -37,7 +37,7 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = webauthn_challenge
@challenge = webauthn_challenge(purpose: "registration")
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
@@ -48,7 +48,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
@response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin
)
end
@@ -67,7 +66,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_NONE_NOT_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :preferred
)
@@ -81,7 +79,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
@@ -95,7 +92,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_NONE_NOT_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
@@ -117,7 +113,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: client_data_json,
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin
)
@@ -128,14 +123,24 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
assert_equal "Client data type is not webauthn.create", error.message
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
test "validate! raises when challenge in client data is invalid" do
client_data_json = {
challenge: "not-a-valid-signed-challenge",
origin: @origin,
type: "webauthn.create"
}.to_json
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: client_data_json,
attestation_object: ATTESTATION_NONE_VERIFIED,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
response.validate!
end
assert_equal "Challenge does not match", error.message
assert_match /Challenge (is invalid|has expired)/, error.message
end
test "validate! raises when origin does not match" do
@@ -152,7 +157,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_PACKED_VERIFIED,
challenge: @challenge,
origin: @origin
)
@@ -173,7 +177,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_PACKED_VERIFIED,
challenge: @challenge,
origin: @origin
)
@@ -18,7 +18,6 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
@response = TestableResponse.new(
client_data_json: @client_data_json,
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
end
@@ -41,9 +40,20 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
assert @response.valid?
end
test "valid? returns false when challenge does not match" do
@response.challenge = "wrong-challenge"
assert_not @response.valid?
test "valid? returns false when challenge in client data is invalid" do
client_data_json = {
challenge: "not-a-valid-signed-challenge",
origin: @origin,
type: "webauthn.create"
}.to_json
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
origin: @origin
)
assert_not response.valid?
end
test "valid? returns false when origin does not match" do
@@ -51,14 +61,24 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
assert_not @response.valid?
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
test "validate! raises when challenge in client data is invalid" do
client_data_json = {
challenge: "not-a-valid-signed-challenge",
origin: @origin,
type: "webauthn.create"
}.to_json
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
response.validate!
end
assert_equal "Challenge does not match", error.message
assert_match /Challenge (is invalid|has expired)/, error.message
end
test "validate! raises when origin does not match" do
@@ -82,7 +102,6 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
@@ -108,7 +127,6 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
response = TestableResponse.new(
client_data_json: @client_data_json,
authenticator_data: wrong_rp_data,
challenge: @challenge,
origin: @origin
)
@@ -130,7 +148,6 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
@@ -24,31 +24,33 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
test "generates signed challenge containing nonce" do
signed_message = Base64.urlsafe_decode64(@options.challenge)
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message, purpose: "registration")
assert_not_nil nonce
assert_equal 32, Base64.strict_decode64(nonce).bytesize
end
test "as_json" do
assert_equal @options.challenge, @options.as_json[:challenge]
json = @options.as_json
assert_equal({ id: "example.com", name: "Example App" }, @options.as_json[:rp])
assert_equal @options.challenge, json["challenge"]
user = @options.as_json[:user]
assert_equal Base64.urlsafe_encode64("user-123", padding: false), user[:id]
assert_equal "user@example.com", user[:name]
assert_equal "Test User", user[:displayName]
assert_equal({ "id" => "example.com", "name" => "Example App" }, json["rp"])
user = json["user"]
assert_equal Base64.urlsafe_encode64("user-123", padding: false), user["id"]
assert_equal "user@example.com", user["name"]
assert_equal "Test User", user["displayName"]
assert_equal [
{ type: "public-key", alg: -7 },
{ type: "public-key", alg: -8 },
{ type: "public-key", alg: -257 }
], @options.as_json[:pubKeyCredParams]
{ "type" => "public-key", "alg" => -7 },
{ "type" => "public-key", "alg" => -8 },
{ "type" => "public-key", "alg" => -257 }
], json["pubKeyCredParams"]
assert_equal "required", @options.as_json[:authenticatorSelection][:residentKey]
assert_equal true, @options.as_json[:authenticatorSelection][:requireResidentKey]
assert_equal "preferred", @options.as_json[:authenticatorSelection][:userVerification]
assert_equal "required", json["authenticatorSelection"]["residentKey"]
assert_equal true, json["authenticatorSelection"]["requireResidentKey"]
assert_equal "preferred", json["authenticatorSelection"]["userVerification"]
end
test "as_json includes residentKey in authenticatorSelection" do
@@ -60,12 +62,12 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
relying_party: @relying_party
)
assert_equal "required", options.as_json[:authenticatorSelection][:residentKey]
assert_equal true, options.as_json[:authenticatorSelection][:requireResidentKey]
assert_equal "required", options.as_json["authenticatorSelection"]["residentKey"]
assert_equal true, options.as_json["authenticatorSelection"]["requireResidentKey"]
end
test "as_json excludes excludeCredentials when empty" do
assert_nil @options.as_json[:excludeCredentials]
assert_nil @options.as_json["excludeCredentials"]
end
test "as_json includes excludeCredentials" do
@@ -83,13 +85,13 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
)
assert_equal [
{ type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] },
{ type: "public-key", id: "cred-2", transports: [ "internal" ] }
], options.as_json[:excludeCredentials]
{ "type" => "public-key", "id" => "cred-1", "transports" => [ "usb", "nfc" ] },
{ "type" => "public-key", "id" => "cred-2", "transports" => [ "internal" ] }
], options.as_json["excludeCredentials"]
end
test "as_json excludes attestation when none" do
assert_nil @options.as_json[:attestation]
assert_nil @options.as_json["attestation"]
end
test "as_json includes attestation when not none" do
@@ -101,7 +103,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
relying_party: @relying_party
)
assert_equal "direct", options.as_json[:attestation]
assert_equal "direct", options.as_json["attestation"]
end
test "raises with invalid attestation preference" do
@@ -126,8 +128,8 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
)
assert_equal [
{ type: "public-key", id: "cred-1" }
], options.as_json[:excludeCredentials]
{ "type" => "public-key", "id" => "cred-1" }
], options.as_json["excludeCredentials"]
end
private
@@ -24,20 +24,22 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp
test "generates signed challenge containing nonce" do
signed_message = Base64.urlsafe_decode64(@options.challenge)
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message, purpose: "authentication")
assert_not_nil nonce
assert_equal 32, Base64.strict_decode64(nonce).bytesize
end
test "as_json" do
assert_equal @options.challenge, @options.as_json[:challenge]
assert_equal "example.com", @options.as_json[:rpId]
json = @options.as_json
assert_equal @options.challenge, json["challenge"]
assert_equal "example.com", json["rpId"]
assert_equal [
{ type: "public-key", id: "credential-1" },
{ type: "public-key", id: "credential-2" }
], @options.as_json[:allowCredentials]
assert_equal "preferred", @options.as_json[:userVerification]
{ "type" => "public-key", "id" => "credential-1" },
{ "type" => "public-key", "id" => "credential-2" }
], json["allowCredentials"]
assert_equal "preferred", json["userVerification"]
end
test "as_json includes transports when present" do
@@ -52,9 +54,9 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp
)
assert_equal [
{ type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] },
{ type: "public-key", id: "cred-2", transports: [ "internal" ] }
], options.as_json[:allowCredentials]
{ "type" => "public-key", "id" => "cred-1", "transports" => [ "usb", "nfc" ] },
{ "type" => "public-key", "id" => "cred-2", "transports" => [ "internal" ] }
], options.as_json["allowCredentials"]
end
test "as_json omits transports when empty" do
@@ -66,8 +68,8 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp
)
assert_equal [
{ type: "public-key", id: "cred-1" }
], options.as_json[:allowCredentials]
{ "type" => "public-key", "id" => "cred-1" }
], options.as_json["allowCredentials"]
end
private
+4 -4
View File
@@ -20,13 +20,13 @@ module WebauthnTestHelper
[ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4" ].pack("H*")
private
def request_webauthn_challenge
untenanted { post my_passkey_challenge_url }
def request_webauthn_challenge(purpose: "authentication")
untenanted { post my_passkey_challenge_url, params: { purpose: purpose } }
response.parsed_body["challenge"]
end
def webauthn_challenge
ActionPack::WebAuthn::PublicKeyCredential::Options.new.challenge
def webauthn_challenge(purpose: nil)
ActionPack::WebAuthn::PublicKeyCredential::Options.new(challenge_purpose: purpose).challenge
end
def webauthn_private_key