diff --git a/app/assets/stylesheets/credentials.css b/app/assets/stylesheets/credentials.css index bb6db0b37..d57583017 100644 --- a/app/assets/stylesheets/credentials.css +++ b/app/assets/stylesheets/credentials.css @@ -31,4 +31,13 @@ margin-inline-start: auto; 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/concerns/current_request.rb b/app/controllers/concerns/current_request.rb index 689bc146a..15f574ab0 100644 --- a/app/controllers/concerns/current_request.rb +++ b/app/controllers/concerns/current_request.rb @@ -8,9 +8,6 @@ module CurrentRequest Current.user_agent = request.user_agent Current.ip_address = request.ip Current.referrer = request.referrer - - ActionPack::WebAuthn::Current.host = request.host - ActionPack::WebAuthn::Current.origin = request.base_url end end end diff --git a/app/controllers/my/passkey_challenges_controller.rb b/app/controllers/my/passkey_challenges_controller.rb new file mode 100644 index 000000000..12029ef9e --- /dev/null +++ b/app/controllers/my/passkey_challenges_controller.rb @@ -0,0 +1,7 @@ +class My::PasskeyChallengesController < ActionPack::Passkey::ChallengesController + include Authentication + include Authorization + + allow_unauthenticated_access + disallow_account_scope +end diff --git a/app/controllers/my/passkeys_controller.rb b/app/controllers/my/passkeys_controller.rb new file mode 100644 index 000000000..40ddaca24 --- /dev/null +++ b/app/controllers/my/passkeys_controller.rb @@ -0,0 +1,34 @@ +class My::PasskeysController < ApplicationController + include ActionPack::Passkey::Request + + before_action :set_passkey, only: %i[ edit update destroy ] + + def index + @passkeys = Current.identity.passkeys.order(name: :asc, created_at: :desc) + @creation_options = passkey_creation_options(holder: Current.identity) + end + + def create + passkey = Current.identity.passkeys.register(passkey_creation_params) + + redirect_to edit_my_passkey_path(passkey, created: true) + end + + def edit + end + + def update + @passkey.update!(params.expect(passkey: [ :name ])) + redirect_to my_passkeys_path + end + + def destroy + @passkey.destroy! + redirect_to my_passkeys_path + end + + private + def set_passkey + @passkey = Current.identity.passkeys.find(params[:id]) + end +end diff --git a/app/controllers/sessions/passkeys_controller.rb b/app/controllers/sessions/passkeys_controller.rb index 18917583d..4dc087986 100644 --- a/app/controllers/sessions/passkeys_controller.rb +++ b/app/controllers/sessions/passkeys_controller.rb @@ -1,44 +1,27 @@ class Sessions::PasskeysController < ApplicationController + include ActionPack::Passkey::Request + disallow_account_scope require_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded def create - credential = Identity::Credential.authenticate( - passkey: passkey_params, - challenge: session.delete(:webauthn_challenge) - ) - - if credential - authentication_succeeded(credential.identity) - else - authentication_failed - end - end - - private - def passkey_params - params.expect(passkey: [ :id, :client_data_json, :authenticator_data, :signature ]) - end - - def authentication_succeeded(identity) - start_new_session_for identity + if credential = ActionPack::Passkey.authenticate(passkey_request_params) + start_new_session_for credential.holder respond_to do |format| format.html { redirect_to after_authentication_url } format.json { render json: { session_token: session_token } } end - end - - def authentication_failed - alert_message = "That passkey didn't work. Try again." - + else respond_to do |format| - format.html { redirect_to new_session_path, alert: alert_message } - format.json { render json: { message: alert_message }, status: :unauthorized } + format.html { redirect_to new_session_path, alert: "That passkey didn't work. Try again." } + format.json { render json: { message: "That passkey didn't work. Try again." }, status: :unauthorized } end end + end + private def rate_limit_exceeded rate_limit_exceeded_message = "Try again later." diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index eb7fa8309..cfbb778f2 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,4 +1,6 @@ class SessionsController < ApplicationController + include ActionPack::Passkey::Request + disallow_account_scope require_unauthenticated_access except: :destroy rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded @@ -6,8 +8,7 @@ class SessionsController < ApplicationController layout "public" def new - @request_options = Identity::Credential.request_options - session[:webauthn_challenge] = @request_options.challenge + @request_options = passkey_request_options end def create @@ -69,5 +70,4 @@ class SessionsController < ApplicationController end end end - end diff --git a/app/controllers/users/credentials_controller.rb b/app/controllers/users/credentials_controller.rb deleted file mode 100644 index aa0f3d8e9..000000000 --- a/app/controllers/users/credentials_controller.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Users::CredentialsController < ApplicationController - before_action :set_credential, only: %i[ edit update destroy ] - - def index - @credentials = Current.identity.credentials.order(name: :asc, created_at: :desc) - @creation_options = Identity::Credential.creation_options(identity: Current.identity, display_name: Current.user.name) - session[:webauthn_challenge] = @creation_options.challenge - end - - def create - credential = Current.identity.credentials.register( - passkey: passkey_params, - challenge: session.delete(:webauthn_challenge) - ) - - render json: { location: edit_user_credential_path(Current.user, credential, created: true) } - rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError => error - render json: { error: error.message }, status: :unprocessable_entity - end - - def edit - end - - def update - @credential.update!(credential_params) - redirect_to user_credentials_path(Current.user) - end - - def destroy - @credential.destroy! - redirect_to user_credentials_path(Current.user) - end - - private - def set_credential - @credential = Current.identity.credentials.find(params[:id]) - end - - def credential_params - params.expect(credential: [ :name ]) - end - - def passkey_params - params.expect(passkey: [ :client_data_json, :attestation_object, transports: [] ]) - end -end diff --git a/app/javascript/application.js b/app/javascript/application.js index c43a45eaa..6642e1ab1 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -6,3 +6,4 @@ import "controllers" import "lexxy" import "@rails/actiontext" +import "lib/action_pack/passkey" diff --git a/app/javascript/controllers/credential_controller.js b/app/javascript/controllers/credential_controller.js deleted file mode 100644 index ddc76085d..000000000 --- a/app/javascript/controllers/credential_controller.js +++ /dev/null @@ -1,60 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import { post } from "@rails/request.js" -import { base64urlToBuffer, bufferToBase64url } from "helpers/base64url_helpers" - -export default class extends Controller { - static values = { publicKey: Object, registerUrl: String } - static targets = ["button", "error", "cancelled"] - - async create() { - this.buttonTarget.disabled = true - this.errorTarget.hidden = true - this.cancelledTarget.hidden = true - - try { - const publicKey = this.#prepareOptions(this.publicKeyValue) - const credential = await navigator.credentials.create({ publicKey }) - await this.#registerCredential(credential) - } catch (error) { - if (error.name === "AbortError" || error.name === "NotAllowedError") { - this.cancelledTarget.hidden = false - } else { - this.errorTarget.hidden = false - } - this.buttonTarget.disabled = false - } - } - - async #registerCredential(credential) { - const response = await post(this.registerUrlValue, { - body: JSON.stringify({ - passkey: { - client_data_json: new TextDecoder().decode(credential.response.clientDataJSON), - attestation_object: bufferToBase64url(credential.response.attestationObject), - transports: credential.response.getTransports?.() || [] - } - }), - contentType: "application/json", - responseKind: "json" - }) - - if (response.ok) { - const { location } = await response.json - Turbo.visit(location) - } else { - throw new Error("Registration failed") - } - } - - #prepareOptions(options) { - return { - ...options, - challenge: base64urlToBuffer(options.challenge), - user: { ...options.user, id: base64urlToBuffer(options.user.id) }, - excludeCredentials: (options.excludeCredentials || []).map(cred => ({ - ...cred, - id: base64urlToBuffer(cred.id) - })) - } - } -} diff --git a/app/javascript/controllers/passkey_controller.js b/app/javascript/controllers/passkey_controller.js deleted file mode 100644 index 566518bb9..000000000 --- a/app/javascript/controllers/passkey_controller.js +++ /dev/null @@ -1,80 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import { base64urlToBuffer, bufferToBase64url } from "helpers/base64url_helpers" - -export default class extends Controller { - static values = { publicKey: Object, url: String, csrfToken: String } - - #abortController - - connect() { - this.#attemptConditionalMediation() - } - - disconnect() { - this.#abortController?.abort() - } - - async #attemptConditionalMediation() { - if (!await PublicKeyCredential?.isConditionalMediationAvailable?.()) return - - this.#abortController = new AbortController() - - try { - const credential = await navigator.credentials.get({ - publicKey: this.#prepareOptions(this.publicKeyValue), - mediation: "conditional", - signal: this.#abortController.signal - }) - - this.#submitAssertion(credential) - } catch (error) { - if (error.name !== "AbortError") { - console.error("Passkey error:", error) - } - } - } - - #submitAssertion(credential) { - const form = document.createElement("form") - form.method = "POST" - form.action = this.urlValue - form.style.display = "none" - - const fields = { - authenticity_token: this.csrfTokenValue, - "passkey[id]": credential.id, - "passkey[client_data_json]": new TextDecoder().decode(credential.response.clientDataJSON), - "passkey[authenticator_data]": bufferToBase64url(credential.response.authenticatorData), - "passkey[signature]": bufferToBase64url(credential.response.signature) - } - - for (const [name, value] of Object.entries(fields)) { - const input = document.createElement("input") - input.type = "hidden" - input.name = name - input.value = value - form.appendChild(input) - } - - document.body.appendChild(form) - form.submit() - } - - #prepareOptions(options) { - const prepared = { - ...options, - challenge: base64urlToBuffer(options.challenge) - } - - if (options.allowCredentials?.length) { - prepared.allowCredentials = options.allowCredentials.map(cred => ({ - ...cred, - id: base64urlToBuffer(cred.id) - })) - } else { - delete prepared.allowCredentials - } - - return prepared - } -} diff --git a/app/javascript/helpers/base64url_helpers.js b/app/javascript/helpers/base64url_helpers.js deleted file mode 100644 index 945826f58..000000000 --- a/app/javascript/helpers/base64url_helpers.js +++ /dev/null @@ -1,12 +0,0 @@ -export function base64urlToBuffer(base64url) { - const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/") - const padding = "=".repeat((4 - base64.length % 4) % 4) - const binary = atob(base64 + padding) - return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer -} - -export function bufferToBase64url(buffer) { - const bytes = new Uint8Array(buffer) - const binary = String.fromCharCode(...bytes) - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") -} diff --git a/app/javascript/lib/action_pack/passkey.js b/app/javascript/lib/action_pack/passkey.js new file mode 100644 index 000000000..115a630dc --- /dev/null +++ b/app/javascript/lib/action_pack/passkey.js @@ -0,0 +1,246 @@ +// JS companion 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). +// +// 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 +// +// 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 + +import { register, authenticate } from "lib/action_pack/webauthn" + +let listeners +let currentDocument + +document.addEventListener("DOMContentLoaded", setup) +document.addEventListener("turbo:load", setup) +document.addEventListener("turbo:before-cache", teardown) + +// 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" + } +}) + +// Bind click handlers to passkey buttons and attempt conditional mediation. +// Guards against duplicate setup. +function setup() { + if (currentDocument !== document.documentElement) { + currentDocument = document.documentElement + + listeners?.abort() + listeners = new AbortController() + + for (const button of document.querySelectorAll('[data-passkey="create"]')) { + button.addEventListener("click", () => createPasskey(button), { signal: listeners.signal }) + } + + for (const button of document.querySelectorAll('[data-passkey="sign_in"]')) { + button.addEventListener("click", () => signInWithPasskey(button), { signal: listeners.signal }) + } + + attemptConditionalMediation() + } +} + +// Reset transient DOM state and unbind event handlers to prevent leaks and duplicate handlers. +function teardown() { + currentDocument = null + listeners?.abort() + + for (const button of document.querySelectorAll('[data-passkey][disabled]')) { + button.disabled = false + } + + for (const container of document.querySelectorAll("[data-passkey-errors]")) { + delete container.dataset.passkeyErrorState + } +} + +// 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") + + 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 creationOptions = getCreationOptions() + if (!creationOptions) throw new Error("Missing passkey creation options") + + await refreshChallenge(creationOptions) + const passkey = await register(creationOptions) + + button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) + fillCreateForm(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 } })) + } + } +} + +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") + const token = document.querySelector('meta[name="csrf-token"]')?.content + + const response = await fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { + "X-CSRF-Token": token, + "Accept": "application/json" + } + }) + + if (!response.ok) throw new Error("Failed to refresh challenge") + + const { challenge } = await response.json() + options.challenge = challenge +} + +// Populate the registration form's hidden fields with the credential response. +// Clones the transports template input for each reported transport. +function fillCreateForm(form, passkey) { + form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json + form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object + + const template = form.querySelector('[data-passkey-field="transports"]') + for (const transport of passkey.transports) { + const input = template.cloneNode() + input.value = transport + template.before(input) + } + 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/javascript/lib/action_pack/webauthn.js b/app/javascript/lib/action_pack/webauthn.js new file mode 100644 index 000000000..5d9ec9c23 --- /dev/null +++ b/app/javascript/lib/action_pack/webauthn.js @@ -0,0 +1,83 @@ +// Thin wrapper around the browser WebAuthn API (navigator.credentials). +// +// Handles the base64url ↔ ArrayBuffer conversions required by the spec so +// callers can work with plain JSON objects from the server-rendered meta tags. + +// Call navigator.credentials.create() with the given creation options. +// Returns { client_data_json, attestation_object, transports } with all +// binary fields encoded as base64url strings ready for form submission. +export async function register(options) { + const publicKey = prepareCreationOptions(options) + const credential = await navigator.credentials.create({ publicKey }) + + return { + client_data_json: new TextDecoder().decode(credential.response.clientDataJSON), + attestation_object: bufferToBase64url(credential.response.attestationObject), + transports: credential.response.getTransports?.() || [] + } +} + +// Call navigator.credentials.get() with the given request options. +// Accepts an optional signal (AbortSignal) and mediation hint ("conditional" +// for autofill UI). Returns { id, client_data_json, authenticator_data, signature } +// with binary fields encoded as base64url strings. +export async function authenticate(options, { signal, mediation } = {}) { + const publicKey = prepareRequestOptions(options) + const credential = await navigator.credentials.get({ publicKey, signal, mediation }) + + return { + id: credential.id, + client_data_json: new TextDecoder().decode(credential.response.clientDataJSON), + authenticator_data: bufferToBase64url(credential.response.authenticatorData), + signature: bufferToBase64url(credential.response.signature) + } +} + +// Convert JSON creation options into the format expected by the browser: +// decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers. +function prepareCreationOptions(options) { + return { + ...options, + challenge: base64urlToBuffer(options.challenge), + user: { ...options.user, id: base64urlToBuffer(options.user.id) }, + excludeCredentials: (options.excludeCredentials || []).map(cred => ({ + ...cred, + id: base64urlToBuffer(cred.id) + })) + } +} + +// Convert JSON request options into the format expected by the browser: +// decode base64url challenge and allowCredentials[].id into ArrayBuffers. +// Strips allowCredentials entirely when empty so the browser prompts for +// any available credential (required for conditional mediation). +function prepareRequestOptions(options) { + const prepared = { + ...options, + challenge: base64urlToBuffer(options.challenge) + } + + if (options.allowCredentials?.length) { + prepared.allowCredentials = options.allowCredentials.map(cred => ({ + ...cred, + id: base64urlToBuffer(cred.id) + })) + } else { + delete prepared.allowCredentials + } + + return prepared +} + +function base64urlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/") + const padding = "=".repeat((4 - base64.length % 4) % 4) + const binary = atob(base64 + padding) + return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer +} + +function bufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer) + const binary = String.fromCharCode(...bytes) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} diff --git a/app/models/identity.rb b/app/models/identity.rb index 8e56ce196..ae4f7a8cb 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,8 +1,9 @@ class Identity < ApplicationRecord include Joinable, Transferable + has_passkeys name: :email_address, display_name: -> { Current.user&.name || email_address } + has_many :access_tokens, dependent: :destroy - has_many :credentials, dependent: :destroy has_many :magic_links, dependent: :destroy has_many :sessions, dependent: :destroy has_many :users, dependent: :nullify diff --git a/app/models/identity/credential.rb b/app/models/identity/credential.rb deleted file mode 100644 index d233fbcb6..000000000 --- a/app/models/identity/credential.rb +++ /dev/null @@ -1,74 +0,0 @@ -class Identity::Credential < ApplicationRecord - belongs_to :identity - - serialize :transports, coder: JSON, type: Array, default: [] - - class << self - def creation_options(identity:, display_name:) - ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( - id: identity.id, - name: identity.email_address, - display_name: display_name, - resident_key: :required, - exclude_credentials: identity.credentials.map(&:to_public_key_credential) - ) - end - - def request_options(credentials: []) - ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(credentials: credentials.map(&:to_public_key_credential)) - end - - def register(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin, **attributes) - public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create( - client_data_json: passkey[:client_data_json], - attestation_object: Base64.urlsafe_decode64(passkey[:attestation_object]), - challenge: challenge, - origin: origin, - transports: Array(passkey[:transports]) - ) - - create!( - **attributes, - name: attributes.fetch(:name, Authenticator.find_by_aaguid(public_key_credential.aaguid)&.name), - credential_id: public_key_credential.id, - public_key: public_key_credential.public_key.to_der, - sign_count: public_key_credential.sign_count, - aaguid: public_key_credential.aaguid, - backed_up: public_key_credential.backed_up, - transports: public_key_credential.transports - ) - end - - def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin) - find_by(credential_id: passkey[:id])&.authenticate(passkey: passkey, challenge: challenge, origin: origin) - end - end - - def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin) - pkc = to_public_key_credential - pkc.authenticate( - client_data_json: passkey[:client_data_json], - authenticator_data: Base64.urlsafe_decode64(passkey[:authenticator_data]), - signature: Base64.urlsafe_decode64(passkey[:signature]), - challenge: challenge, - origin: origin - ) - update!(sign_count: pkc.sign_count, backed_up: pkc.backed_up) - self - rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError - nil - end - - def authenticator - Authenticator.find_by_aaguid(aaguid) - end - - def to_public_key_credential - ActionPack::WebAuthn::PublicKeyCredential.new( - id: credential_id, - public_key: OpenSSL::PKey.read(public_key), - sign_count: sign_count, - transports: transports - ) - end -end diff --git a/app/models/identity/credential/authenticator.rb b/app/models/identity/credential/authenticator.rb deleted file mode 100644 index 72ebece0f..000000000 --- a/app/models/identity/credential/authenticator.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Identity::Credential::Authenticator < Data.define(:name, :icon) - REGISTRY = Rails.application.config_for(:passkey_aaguids).each_with_object({}) do |(_key, attrs), hash| - authenticator = new(name: attrs[:name], icon: attrs[:icon]) - attrs[:aaguids].each { |aaguid| hash[aaguid] = authenticator } - end.freeze - - class << self - def find_by_aaguid(aaguid) - REGISTRY[aaguid] - end - end -end diff --git a/app/models/passkey/authenticator.rb b/app/models/passkey/authenticator.rb new file mode 100644 index 000000000..2af6edc58 --- /dev/null +++ b/app/models/passkey/authenticator.rb @@ -0,0 +1,23 @@ +class Passkey::Authenticator < Data.define(:aaguids, :name, :icon) + class << self + def find_by_aaguid(aaguid) + registry[aaguid] + end + + def registry + @registry ||= Hash.new.tap do |registry| + all.each do |authenticator| + authenticator.aaguids.each do |aaguid| + registry[aaguid] = authenticator + end + end + end + end + + def all + Rails.application.config_for(:passkey_aaguids).each_value.map do |attrs| + new(aaguids: attrs[:aaguids], name: attrs[:name], icon: attrs[:icon]) + end + end + end +end diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb index 7ed9dc5bf..7873ad54b 100644 --- a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb @@ -11,6 +11,10 @@

This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

+<% if account = @magic_link.identity.accounts.last %> +

P.S. You can make your account more secure and sign-in faster with a <%= link_to "Passkey", my_passkeys_url(script_name: account.slug) %>

+<% end %> + diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb index e24141018..431566644 100644 --- a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb @@ -7,3 +7,7 @@ Please enter this 6-character verification code to finish creating your account: <%= @magic_link.code %> This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>. + +<% if account = @magic_link.identity.accounts.last %> +P.S. If you want to sign-in faster, and make your account more secure, add a passkey: <%= my_passkeys_url(script_name: account.slug) %> +<% end %> diff --git a/app/views/my/access_tokens/index.html.erb b/app/views/my/access_tokens/index.html.erb index 6cfd1abd8..7ed938d7f 100644 --- a/app/views/my/access_tokens/index.html.erb +++ b/app/views/my/access_tokens/index.html.erb @@ -11,7 +11,7 @@
<% if @access_tokens.any? %>

Tokens you have generated that can be used to access the Fizzy API.

- +
diff --git a/app/views/my/passkeys/_passkey.html.erb b/app/views/my/passkeys/_passkey.html.erb new file mode 100644 index 000000000..826dd8516 --- /dev/null +++ b/app/views/my/passkeys/_passkey.html.erb @@ -0,0 +1,13 @@ +
  • + <%= link_to edit_my_passkey_path(passkey), class: "credential__link" do %> + <% if icon = passkey.authenticator&.icon %> + <%= image_tag icon[:light], size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %> + <%= image_tag icon[:dark], size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %> + <% else %> + <%= image_tag "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %> + <%= image_tag "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %> + <% end %> + <%= passkey.name.presence || "Passkey" %> + + <% end %> +
  • diff --git a/app/views/users/credentials/edit.html.erb b/app/views/my/passkeys/edit.html.erb similarity index 73% rename from app/views/users/credentials/edit.html.erb rename to app/views/my/passkeys/edit.html.erb index 4beb0bba8..6a28ddff7 100644 --- a/app/views/users/credentials/edit.html.erb +++ b/app/views/my/passkeys/edit.html.erb @@ -2,7 +2,7 @@ <% content_for :header do %>
    - <%= back_link_to "Passkeys", user_credentials_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> + <%= back_link_to "Passkeys", my_passkeys_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    @@ -13,7 +13,7 @@

    Your passkey has been registered. Give it a name so you can identify it later.

    <% end %> - <%= form_with model: @credential, scope: :credential, url: user_credential_path(Current.user, @credential), html: { class: "flex flex-column gap" } do |form| %> + <%= form_with model: @passkey, scope: :passkey, url: my_passkey_path(@passkey), html: { class: "flex flex-column gap" } do |form| %>
    <%= form.label "Name your passkey" %> <%= form.text_field :name, autofocus: true, class: "input", placeholder: "e.g. MacBook Pro, iPhone", data: { "1p-ignore": "" }, autocomplete: "off" %> @@ -23,7 +23,7 @@ <% end %>
    - <%= button_to user_credential_path(Current.user, @credential), method: :delete, + <%= button_to my_passkey_path(@passkey), method: :delete, class: "btn txt-negative borderless txt-small", data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %> <%= icon_tag "trash" %> diff --git a/app/views/users/credentials/index.html.erb b/app/views/my/passkeys/index.html.erb similarity index 61% rename from app/views/users/credentials/index.html.erb rename to app/views/my/passkeys/index.html.erb index f37d8a373..545c15a43 100644 --- a/app/views/users/credentials/index.html.erb +++ b/app/views/my/passkeys/index.html.erb @@ -1,5 +1,9 @@ <% @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" %> @@ -8,34 +12,30 @@

    <%= @page_title %>

    <% end %> -
    - +

    Passkeys let you sign in securely without a password or email code.

    - <% if @credentials.any? %> + <% if @passkeys.any? %>
      - <%= render partial: "users/credentials/credential", collection: @credentials %> + <%= render partial: "my/passkeys/passkey", collection: @passkeys %>
    <% end %>
    - + <% end %>

    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 553236f1d..6d2567639 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,15 +1,16 @@ <% @page_title = "Enter your email" %> -
    +<% content_for :head do %> + <%= passkey_request_options_meta_tag(@request_options) %> +<% end %> + +

    Get into Fizzy

    <%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
    @@ -25,6 +26,8 @@ <% end %> + <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %> +
    <% content_for :footer do %> diff --git a/app/views/users/credentials/_credential.html.erb b/app/views/users/credentials/_credential.html.erb deleted file mode 100644 index fa1e733e6..000000000 --- a/app/views/users/credentials/_credential.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -
  • - <%= link_to edit_user_credential_path(Current.user, credential), class: "credential__link" do %> - <% authenticator = credential.authenticator %> - <%= image_tag authenticator&.icon&.[](:light) || "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %> - <%= image_tag authenticator&.icon&.[](:dark) || "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %> - <%= credential.name.presence || "Passkey" %> - - <% end %> -
  • diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index d96184e5f..c30f67de4 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -41,7 +41,7 @@ <% if Current.user == @user %> - <%= link_to user_credentials_path(@user), class: "btn txt-x-small center" do %> + <%= link_to my_passkeys_path, class: "btn txt-x-small center" do %> <%= icon_tag "authentication" %> Manage passkeys <% end %> diff --git a/config/application.rb b/config/application.rb index ced9d9305..33b8bd83d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,6 +1,7 @@ require_relative "boot" require "rails/all" require_relative "../lib/fizzy" +require_relative "../lib/action_pack/railtie" Bundler.require(*Rails.groups) @@ -25,6 +26,9 @@ module Fizzy g.orm :active_record, primary_key_type: :uuid end + config.action_pack.passkey.draw_routes = false + config.action_pack.passkey.challenge_url = -> { my_passkey_challenge_path(script_name: "") } + config.mission_control.jobs.http_basic_auth_enabled = false end end diff --git a/config/importmap.rb b/config/importmap.rb index f6910c3e1..82586dbae 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -10,6 +10,7 @@ pin "@rails/request.js", to: "@rails--request.js" # @0.0.13 pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/helpers", under: "helpers" +pin_all_from "app/javascript/lib", under: "lib" pin_all_from "app/javascript/initializers", under: "initializers" pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers" pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers" diff --git a/config/initializers/passkeys.rb b/config/initializers/passkeys.rb new file mode 100644 index 000000000..cd8b02dde --- /dev/null +++ b/config/initializers/passkeys.rb @@ -0,0 +1,3 @@ +Rails.application.config.to_prepare do + ActionPack::Passkey.prepend ActionPackPasskeyInferNameFromAaguid +end diff --git a/config/routes.rb b/config/routes.rb index 6353681d7..a21a0c457 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,8 +15,6 @@ Rails.application.routes.draw do resource :avatar resource :role resource :events - resources :credentials, except: %i[ show new ] - resources :push_subscriptions resources :email_addresses, param: :token do @@ -174,8 +172,10 @@ Rails.application.routes.draw do resource :landing namespace :my do + resource :passkey_challenge, only: :create resource :identity, only: :show resources :access_tokens + resources :passkeys, except: %i[ show new ] resources :pins resource :timezone resource :menu diff --git a/db/migrate/20260213154740_create_action_pack_passkeys.rb b/db/migrate/20260213154740_create_action_pack_passkeys.rb new file mode 100644 index 000000000..85991cd07 --- /dev/null +++ b/db/migrate/20260213154740_create_action_pack_passkeys.rb @@ -0,0 +1,20 @@ +class CreateActionPackPasskeys < ActiveRecord::Migration[8.2] + def change + create_table :action_pack_passkeys, id: :uuid do |t| + t.uuid :holder_id, null: false + t.string :holder_type, null: false + t.string :credential_id, null: false + t.binary :public_key, null: false + t.integer :sign_count, null: false, default: 0 + t.string :name + t.text :transports + t.string :aaguid + t.boolean :backed_up + + t.timestamps + + t.index [ :holder_type, :holder_id ] + t.index :credential_id, unique: true + end + end +end diff --git a/db/migrate/20260213154740_create_identity_credentials.rb b/db/migrate/20260213154740_create_identity_credentials.rb deleted file mode 100644 index b7bf70568..000000000 --- a/db/migrate/20260213154740_create_identity_credentials.rb +++ /dev/null @@ -1,17 +0,0 @@ -class CreateIdentityCredentials < ActiveRecord::Migration[8.2] - def change - create_table :identity_credentials, id: :uuid do |t| - t.uuid :identity_id, null: false - t.string :credential_id, null: false - t.binary :public_key, null: false - t.integer :sign_count, null: false, default: 0 - t.string :name - t.text :transports - - t.timestamps - - t.index :identity_id - t.index :credential_id, unique: true - end - end -end diff --git a/db/migrate/20260219095815_add_aaguid_and_backed_up_to_identity_credentials.rb b/db/migrate/20260219095815_add_aaguid_and_backed_up_to_identity_credentials.rb deleted file mode 100644 index 172d88839..000000000 --- a/db/migrate/20260219095815_add_aaguid_and_backed_up_to_identity_credentials.rb +++ /dev/null @@ -1,6 +0,0 @@ -class AddAaguidAndBackedUpToIdentityCredentials < ActiveRecord::Migration[8.2] - def change - add_column :identity_credentials, :aaguid, :string - add_column :identity_credentials, :backed_up, :boolean - end -end diff --git a/db/schema.rb b/db/schema.rb index 07248185c..99e6be953 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_02_19_095815) do +ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -69,6 +69,22 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_19_095815) do t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end + create_table "action_pack_passkeys", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "aaguid" + t.boolean "backed_up" + t.datetime "created_at", null: false + t.string "credential_id", null: false + t.uuid "holder_id", null: false + t.string "holder_type", null: false + t.string "name" + t.binary "public_key", null: false + t.integer "sign_count", default: 0, null: false + t.text "transports" + t.datetime "updated_at", null: false + t.index ["credential_id"], name: "index_action_pack_passkeys_on_credential_id", unique: true + t.index ["holder_type", "holder_id"], name: "index_action_pack_passkeys_on_holder_type_and_holder_id" + end + create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.text "body", size: :long @@ -344,21 +360,6 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_19_095815) do t.index ["identity_id"], name: "index_access_token_on_identity_id" end - create_table "identity_credentials", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "aaguid" - t.boolean "backed_up" - t.datetime "created_at", null: false - t.string "credential_id", null: false - t.uuid "identity_id", null: false - t.string "name" - t.binary "public_key", null: false - t.integer "sign_count", default: 0, null: false - t.text "transports" - t.datetime "updated_at", null: false - t.index ["credential_id"], name: "index_identity_credentials_on_credential_id", unique: true - t.index ["identity_id"], name: "index_identity_credentials_on_identity_id" - end - create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "code", null: false t.datetime "created_at", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index c8ce19e07..00125d1d6 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -69,6 +69,22 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end + create_table "action_pack_passkeys", id: :uuid, force: :cascade do |t| + t.string "aaguid", limit: 255 + t.boolean "backed_up" + t.datetime "created_at", null: false + t.string "credential_id", limit: 255, null: false + t.uuid "holder_id", null: false + t.string "holder_type", limit: 255, null: false + t.string "name", limit: 255 + t.binary "public_key", null: false + t.integer "sign_count", default: 0, null: false + t.text "transports", limit: 65535 + t.datetime "updated_at", null: false + t.index ["credential_id"], name: "index_action_pack_passkeys_on_credential_id", unique: true + t.index ["holder_type", "holder_id"], name: "index_action_pack_passkeys_on_holder_type_and_holder_id" + end + create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.text "body", limit: 4294967295 diff --git a/lib/action_pack/passkey.rb b/lib/action_pack/passkey.rb new file mode 100644 index 000000000..ace33176c --- /dev/null +++ b/lib/action_pack/passkey.rb @@ -0,0 +1,104 @@ +# ActionPack::Passkey provides WebAuthn passkey registration and authentication backed by Active Record. +# +# Passkeys are scoped to a polymorphic +holder+ (typically a User or Identity) and store the +# credential ID, public key, sign count, and transport hints needed for the WebAuthn ceremonies. +# +# == Registration +# +# Generate options for the browser's +navigator.credentials.create()+ call, then register the +# response: +# +# options = ActionPack::Passkey.creation_options(holder: current_user) +# # Pass options to the browser +# +# passkey = ActionPack::Passkey.register(params[:passkey], holder: current_user) +# +# == Authentication +# +# Generate options for the browser's +navigator.credentials.get()+ call, then authenticate the +# response: +# +# options = ActionPack::Passkey.request_options +# # Pass options to the browser +# +# passkey = ActionPack::Passkey.authenticate(params[:passkey]) +# +# == Holder integration +# +# Call +has_passkeys+ in your model to set up the association and configure ceremony options +# per-holder. See ActionPack::Passkey::Holder for details. +class ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class_name.constantize + self.table_name = "action_pack_passkeys" + belongs_to :holder, polymorphic: true + serialize :transports, coder: JSON, type: Array, default: [] + + 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 + # additional +options+ overrides. + def creation_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, + **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!+. + # + # 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) + + create!(**credential.to_h, **attributes) + end + + # Returns a RequestOptions object suitable for passing to the browser's + # +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) + ActionPack::WebAuthn::PublicKeyCredential.request_options( + **Rails.configuration.action_pack.web_authn.default_request_options.to_h, + **holder&.passkey_request_options.to_h, + **options + ) + end + + # 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) + 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) + credential = to_public_key_credential + credential.authenticate(passkey, challenge: challenge) + update!(sign_count: credential.sign_count, backed_up: credential.backed_up) + self + rescue ActionPack::WebAuthn::InvalidResponseError + nil + end + + # Returns an ActionPack::WebAuthn::PublicKeyCredential initialized from this record's stored + # credential data. + def to_public_key_credential + ActionPack::WebAuthn::PublicKeyCredential.new( + id: credential_id, + public_key: public_key, + sign_count: sign_count, + transports: transports + ) + end +end diff --git a/lib/action_pack/passkey/challenges_controller.rb b/lib/action_pack/passkey/challenges_controller.rb new file mode 100644 index 000000000..bec1770b0 --- /dev/null +++ b/lib/action_pack/passkey/challenges_controller.rb @@ -0,0 +1,37 @@ +# = Action Pack Passkey Challenges Controller +# +# Generates fresh WebAuthn challenges for passkey ceremonies. The companion +# JavaScript calls this endpoint before initiating a registration or +# 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. +# +# == Route +# +# By default mounted at +/rails/action_pack/passkey/challenge+ (configurable +# 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. + def create + challenge = create_passkey_challenge + + cookies.encrypted[COOKIE_NAME] = { value: challenge, httponly: true, same_site: :strict, secure: !request.local? && request.ssl? } + render json: { challenge: challenge } + end + + private + def create_passkey_challenge + ActionPack::WebAuthn::PublicKeyCredential::Options.new( + challenge_expiration: Rails.configuration.action_pack.web_authn.request_challenge_expiration + ).challenge + end +end diff --git a/lib/action_pack/passkey/form_helper.rb b/lib/action_pack/passkey/form_helper.rb new file mode 100644 index 000000000..4fc9a4444 --- /dev/null +++ b/lib/action_pack/passkey/form_helper.rb @@ -0,0 +1,87 @@ +# View helpers for rendering passkey forms and meta tags. +# +# 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. +module ActionPack::Passkey::FormHelper + # Renders ++ tags containing JSON-serialized creation options and the challenge endpoint + # URL for the WebAuthn registration ceremony. The companion JavaScript reads these tags to call + # +navigator.credentials.create()+. + def passkey_creation_options_meta_tag(creation_options, challenge_url: nil) + passkey_challenge_url_meta_tag(challenge_url: challenge_url) + + tag.meta(name: "passkey-creation-options", content: creation_options.to_json) + end + + # Renders ++ tags containing JSON-serialized request options and the challenge endpoint + # URL for the WebAuthn authentication ceremony. The companion JavaScript reads these tags to + # call +navigator.credentials.get()+. + def passkey_request_options_meta_tag(request_options, challenge_url: nil) + passkey_challenge_url_meta_tag(challenge_url: challenge_url) + + tag.meta(name: "passkey-request-options", content: request_options.to_json) + end + + # Renders a form with hidden fields for the passkey registration ceremony. The form POSTs to + # +url+ and includes hidden fields for +client_data_json+, +attestation_object+, and + # +transports+ — populated by the Stimulus controller after the browser credential API + # resolves. Accepts a +label+ string or a block for button content. + # + # Options: + # - +param+: the form parameter namespace (default: +:passkey+) + # - +form+: additional HTML attributes for the ++ tag + # - All other options are passed to the +
    Description