diff --git a/Gemfile b/Gemfile index 7f7a463bbb..f0de870a1c 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,7 @@ gem "image_processing", "~> 1.14" gem "platform_agent" gem "aws-sdk-s3", require: false gem "web-push" +gem "webauthn", "~> 3.0" gem "net-http-persistent" gem "rubyzip", require: "zip" gem "mittens" diff --git a/Gemfile.lock b/Gemfile.lock index e4f364c1c9..d2bdd69353 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -112,6 +112,7 @@ GEM railties addressable (2.8.8) public_suffix (>= 2.0.2, < 8.0) + android_key_attestation (0.3.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) @@ -138,6 +139,7 @@ GEM bcrypt_pbkdf (1.1.1) benchmark (0.5.0) bigdecimal (3.3.1) + bindata (2.5.1) bindex (0.8.1) bootsnap (1.19.0) msgpack (~> 1.2) @@ -156,11 +158,15 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cbor (0.5.10.1) childprocess (5.1.0) logger (~> 1.5) chunky_png (1.4.0) concurrent-ruby (1.3.5) connection_pool (2.5.5) + cose (1.3.1) + cbor (~> 0.5.9) + openssl-signature_algorithm (~> 1.0) crack (1.0.1) bigdecimal rexml @@ -308,6 +314,8 @@ GEM nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) openssl (3.3.2) + openssl-signature_algorithm (1.3.0) + openssl (> 2.0) ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.0) @@ -399,6 +407,8 @@ GEM logger ruby2_keywords (0.0.5) rubyzip (3.2.2) + safety_net_attestation (0.5.0) + jwt (>= 2.0, < 4.0) securerandom (0.4.1) selenium-webdriver (4.38.0) base64 (~> 0.2) @@ -451,6 +461,10 @@ GEM thruster (0.1.16-x86_64-darwin) thruster (0.1.16-x86_64-linux) timeout (0.4.4) + tpm-key_attestation (0.14.1) + bindata (~> 2.4) + openssl (> 2.0) + openssl-signature_algorithm (~> 1.0) trilogy (2.9.0) tsort (0.2.0) turbo-rails (2.0.20) @@ -472,6 +486,14 @@ GEM web-push (3.0.2) jwt (~> 3.0) openssl (~> 3.0) + webauthn (3.4.3) + android_key_attestation (~> 0.3.0) + bindata (~> 2.4) + cbor (~> 0.5.9) + cose (~> 1.1) + openssl (>= 2.2) + safety_net_attestation (~> 0.5.0) + tpm-key_attestation (~> 0.14.0) webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -546,6 +568,7 @@ DEPENDENCIES vcr web-console web-push + webauthn (~> 3.0) webmock BUNDLED WITH diff --git a/app/controllers/concerns/webauthn_relying_party.rb b/app/controllers/concerns/webauthn_relying_party.rb new file mode 100644 index 0000000000..458ee711be --- /dev/null +++ b/app/controllers/concerns/webauthn_relying_party.rb @@ -0,0 +1,52 @@ +module WebauthnRelyingParty + extend ActiveSupport::Concern + + private + def webauthn_relying_party + @webauthn_relying_party ||= WebAuthn::RelyingParty.new( + allowed_origins: webauthn_allowed_origins, + id: webauthn_rp_id, + name: "Fizzy" + ) + end + + def webauthn_rp_id + configured_rp_id = Rails.application.config.x.webauthn&.rp_id + configured_rp_id.presence || default_webauthn_rp_id + end + + def webauthn_allowed_origins + configured_origins = Rails.application.config.x.webauthn&.allowed_origins + configured_origins.presence || default_webauthn_allowed_origins + end + + def default_webauthn_rp_id + if Rails.env.development? + "fizzy.localhost" + elsif Rails.env.test? + "localhost" + else + request.host + end + end + + def default_webauthn_allowed_origins + if Rails.env.development? + # Allow both HTTP dev server and HTTPS (used by some password managers) + [ + "http://fizzy.localhost:3006", + "https://fizzy.localhost" + ] + elsif Rails.env.test? + [ "http://localhost" ] + else + [ "#{request.protocol}#{request.host_with_port}" ] + end + end + + # Encode a user ID as base64url for WebAuthn JSON serialization + # The WebAuthn spec requires user.id to be transmitted as base64url + def encode_webauthn_user_id(user_id) + Base64.urlsafe_encode64(user_id.to_s, padding: false) + end +end diff --git a/app/controllers/identity/passkeys_controller.rb b/app/controllers/identity/passkeys_controller.rb new file mode 100644 index 0000000000..cb0b156b62 --- /dev/null +++ b/app/controllers/identity/passkeys_controller.rb @@ -0,0 +1,64 @@ +class Identity::PasskeysController < ApplicationController + include WebauthnRelyingParty + + disallow_account_scope + + layout "public" + + def index + @passkeys = Current.identity.passkeys.order(created_at: :desc) + end + + def new + @options = passkey_registration_options + session[:webauthn_challenge] = @options.challenge + end + + def create + webauthn_credential = webauthn_relying_party.verify_registration( + credential_params, + session.delete(:webauthn_challenge) + ) + + Current.identity.passkeys.create!( + external_id: webauthn_credential.id, + public_key: webauthn_credential.public_key, + sign_count: webauthn_credential.sign_count, + name: passkey_name + ) + + redirect_to identity_passkeys_path, notice: "Passkey added successfully." + rescue WebAuthn::Error, JSON::ParserError, KeyError, NoMethodError + redirect_to new_identity_passkey_path, alert: "Could not register passkey." + end + + def destroy + passkey = Current.identity.passkeys.find(params[:id]) + passkey.destroy + redirect_to identity_passkeys_path, notice: "Passkey removed." + end + + private + def passkey_registration_options + webauthn_relying_party.options_for_registration( + user: { + id: encode_webauthn_user_id(Current.identity.id), + name: Current.identity.email_address, + display_name: Current.identity.email_address + }, + authenticator_selection: { + resident_key: "required", + user_verification: "required" + }, + exclude: Current.identity.passkeys.pluck(:external_id) + ) + end + + def credential_params + JSON.parse(params.require(:credential)) + end + + def passkey_name + params[:name].presence || PlatformAgent.new(request.user_agent).os || "Passkey" + end +end diff --git a/app/controllers/sessions/choices_controller.rb b/app/controllers/sessions/choices_controller.rb new file mode 100644 index 0000000000..3082231b2e --- /dev/null +++ b/app/controllers/sessions/choices_controller.rb @@ -0,0 +1,67 @@ +class Sessions::ChoicesController < ApplicationController + include WebauthnRelyingParty + + disallow_account_scope + require_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + + layout "public" + + def new + @identity = Identity.find_by_email_address(email_address) + + if @identity.nil? || @identity.passkeys.none? + redirect_to new_session_path + return + end + + @passkey_options = passkey_authentication_options + session[:webauthn_challenge] = @passkey_options.challenge + end + + def create + if params[:method] == "magic_link" + send_magic_link + else + authenticate_with_passkey + end + end + + private + def email_address + params[:email] + end + + def passkey_authentication_options + webauthn_relying_party.options_for_authentication( + user_verification: "required" + ) + end + + def send_magic_link + identity = Identity.find_by_email_address!(email_address) + redirect_to_session_magic_link identity.send_magic_link + end + + def authenticate_with_passkey + identity = Identity.find_by_email_address!(email_address) + webauthn_credential = WebAuthn::Credential.from_get(credential_params, relying_party: webauthn_relying_party) + passkey = identity.passkeys.find_by!(external_id: webauthn_credential.id) + + webauthn_credential.verify( + session.delete(:webauthn_challenge), + public_key: passkey.public_key, + sign_count: passkey.sign_count + ) + + passkey.update!(sign_count: webauthn_credential.sign_count) + start_new_session_for identity + redirect_to after_authentication_url + rescue WebAuthn::Error, ActiveRecord::RecordNotFound, JSON::ParserError, KeyError, NoMethodError + redirect_to new_session_choice_path(email: email_address), alert: "Authentication failed. Try again." + end + + def credential_params + JSON.parse(params.require(:credential)) + end +end diff --git a/app/controllers/sessions/passkeys_controller.rb b/app/controllers/sessions/passkeys_controller.rb new file mode 100644 index 0000000000..f89353715c --- /dev/null +++ b/app/controllers/sessions/passkeys_controller.rb @@ -0,0 +1,29 @@ +class Sessions::PasskeysController < ApplicationController + include WebauthnRelyingParty + + disallow_account_scope + require_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + + def create + webauthn_credential = WebAuthn::Credential.from_get(credential_params, relying_party: webauthn_relying_party) + passkey = Passkey.find_by!(external_id: webauthn_credential.id) + + webauthn_credential.verify( + session.delete(:webauthn_challenge), + public_key: passkey.public_key, + sign_count: passkey.sign_count + ) + + passkey.update!(sign_count: webauthn_credential.sign_count) + start_new_session_for passkey.identity + redirect_to after_authentication_url + rescue WebAuthn::Error, ActiveRecord::RecordNotFound, JSON::ParserError, KeyError, NoMethodError + redirect_to new_session_path, alert: "Authentication failed. Try again or use email." + end + + private + def credential_params + JSON.parse(params.require(:credential)) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index fb9fe0617e..afa10a91ef 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,4 +1,6 @@ class SessionsController < ApplicationController + include WebauthnRelyingParty + disallow_account_scope require_unauthenticated_access except: :destroy rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } @@ -6,15 +8,21 @@ class SessionsController < ApplicationController layout "public" def new + @passkey_options = passkey_authentication_options + session[:webauthn_challenge] = @passkey_options.challenge end def create if identity = Identity.find_by_email_address(email_address) - redirect_to_session_magic_link identity.send_magic_link + if identity.passkeys.any? + redirect_to new_session_choice_path(email: email_address) + else + redirect_to_session_magic_link identity.send_magic_link + end else signup = Signup.new(email_address: email_address) if signup.valid?(:identity_creation) - redirect_to_session_magic_link signup.create_identity + redirect_to new_signup_path else head :unprocessable_entity end @@ -30,4 +38,8 @@ def destroy def email_address params.expect(:email_address) end + + def passkey_authentication_options + webauthn_relying_party.options_for_authentication(user_verification: "required") + end end diff --git a/app/controllers/signups/passkeys_controller.rb b/app/controllers/signups/passkeys_controller.rb new file mode 100644 index 0000000000..fbd1588146 --- /dev/null +++ b/app/controllers/signups/passkeys_controller.rb @@ -0,0 +1,48 @@ +class Signups::PasskeysController < ApplicationController + include WebauthnRelyingParty + + disallow_account_scope + allow_unauthenticated_access only: :create + + def create + signup = Signup.new(email_address: email_param) + + if signup.valid?(:identity_creation) + webauthn_credential = webauthn_relying_party.verify_registration( + credential_params, + session.delete(:webauthn_challenge) + ) + + identity = Identity.find_or_create_by!(email_address: email_param) + + identity.passkeys.create!( + external_id: webauthn_credential.id, + public_key: webauthn_credential.public_key, + sign_count: webauthn_credential.sign_count, + name: passkey_name + ) + + start_new_session_for identity + redirect_to new_signup_completion_path + else + redirect_to new_signup_path, alert: "Please enter a valid email address." + end + rescue WebAuthn::Error, JSON::ParserError, KeyError, NoMethodError => e + Rails.logger.error "Passkey registration failed: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.first(10).join("\n") + redirect_to new_signup_path, alert: "Could not register passkey. Please try again." + end + + private + def email_param + params[:email_address] + end + + def credential_params + JSON.parse(params.require(:credential)) + end + + def passkey_name + params[:name].presence || PlatformAgent.new(request.user_agent).os || "Passkey" + end +end diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb index 776421201d..ff99fdd8f1 100644 --- a/app/controllers/signups_controller.rb +++ b/app/controllers/signups_controller.rb @@ -1,4 +1,6 @@ class SignupsController < ApplicationController + include WebauthnRelyingParty + disallow_account_scope allow_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." } @@ -8,12 +10,15 @@ class SignupsController < ApplicationController def new @signup = Signup.new + @passkey_options = passkey_registration_options + session[:webauthn_challenge] = @passkey_options.challenge end def create signup = Signup.new(signup_params) if signup.valid?(:identity_creation) - redirect_to_session_magic_link signup.create_identity + identity = Identity.find_or_create_by!(email_address: signup_params[:email_address]) + redirect_to_session_magic_link identity.send_magic_link(for: :sign_up) else head :unprocessable_entity end @@ -27,4 +32,18 @@ def redirect_authenticated_user def signup_params params.expect signup: :email_address end + + def passkey_registration_options + webauthn_relying_party.options_for_registration( + user: { + id: WebAuthn.generate_user_id, + name: "user@example.com", + display_name: "New User" + }, + authenticator_selection: { + resident_key: "required", + user_verification: "required" + } + ) + end end diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js index beca1f08f3..015337e720 100644 --- a/app/javascript/controllers/form_controller.js +++ b/app/javascript/controllers/form_controller.js @@ -2,10 +2,11 @@ import { Controller } from "@hotwired/stimulus" import { debounce, nextFrame } from "helpers/timing_helpers"; export default class extends Controller { - static targets = [ "cancel", "submit", "input" ] + static targets = [ "cancel", "submit", "input", "hiddenEmail" ] static values = { - debounceTimeout: { type: Number, default: 300 } + debounceTimeout: { type: Number, default: 300 }, + emailSelector: String } #isComposing = false @@ -87,4 +88,13 @@ export default class extends Controller { blurActiveInput() { document.activeElement?.blur() } + + copyEmailToHidden(event) { + if (this.hasHiddenEmailTarget && this.emailSelectorValue) { + const emailInput = document.querySelector(this.emailSelectorValue) + if (emailInput) { + this.hiddenEmailTarget.value = emailInput.value + } + } + } } diff --git a/app/javascript/controllers/passkey_controller.js b/app/javascript/controllers/passkey_controller.js new file mode 100644 index 0000000000..adba257aae --- /dev/null +++ b/app/javascript/controllers/passkey_controller.js @@ -0,0 +1,143 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["credential", "email"] + static values = { options: Object, emailSelector: String } + + async register(event) { + event.preventDefault() + + // Copy email from external input if emailSelector is provided + if (this.hasEmailTarget && this.emailSelectorValue) { + const emailInput = document.querySelector(this.emailSelectorValue) + if (emailInput) { + this.emailTarget.value = emailInput.value + } + } + + try { + const options = this.#prepareCreateOptions(this.optionsValue) + const credential = await navigator.credentials.create({ publicKey: options }) + + this.credentialTarget.value = JSON.stringify(this.#serializeCreate(credential)) + this.element.requestSubmit() + } catch (error) { + if (error.name !== "AbortError") { + console.error("Passkey registration failed:", error) + } + } + } + + async authenticate(event) { + event.preventDefault() + + try { + const credential = await navigator.credentials.get({ + publicKey: this.#prepareGetOptions(this.optionsValue) + }) + + this.credentialTarget.value = JSON.stringify(this.#serializeGet(credential)) + this.element.requestSubmit() + } catch (error) { + if (error.name !== "AbortError") { + console.error("Passkey authentication failed:", error) + } + } + } + + #prepareCreateOptions(options) { + // Remove empty extensions object - some authenticators don't handle it well + const { extensions, ...restOptions } = options + const preparedOptions = { + ...restOptions, + challenge: this.#base64urlToBuffer(options.challenge), + user: { + ...options.user, + id: this.#base64urlToBuffer(options.user.id) + }, + excludeCredentials: (options.excludeCredentials || []).map(c => ({ + ...c, + id: this.#base64urlToBuffer(c.id) + })) + } + + // Only include extensions if non-empty + if (extensions && Object.keys(extensions).length > 0) { + preparedOptions.extensions = extensions + } + + return preparedOptions + } + + #prepareGetOptions(options) { + // Remove empty extensions object - some authenticators don't handle it well + const { extensions, ...restOptions } = options + const preparedOptions = { + ...restOptions, + challenge: this.#base64urlToBuffer(options.challenge), + allowCredentials: (options.allowCredentials || []).map(c => ({ + ...c, + id: this.#base64urlToBuffer(c.id) + })) + } + + // Only include extensions if non-empty + if (extensions && Object.keys(extensions).length > 0) { + preparedOptions.extensions = extensions + } + + return preparedOptions + } + + #serializeCreate(credential) { + return { + id: credential.id, + rawId: this.#bufferToBase64url(credential.rawId), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {}, + authenticatorAttachment: credential.authenticatorAttachment || null, + response: { + clientDataJSON: this.#bufferToBase64url(credential.response.clientDataJSON), + attestationObject: this.#bufferToBase64url(credential.response.attestationObject), + transports: credential.response.getTransports ? credential.response.getTransports() : [] + } + } + } + + #serializeGet(credential) { + return { + id: credential.id, + rawId: this.#bufferToBase64url(credential.rawId), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {}, + authenticatorAttachment: credential.authenticatorAttachment || null, + response: { + clientDataJSON: this.#bufferToBase64url(credential.response.clientDataJSON), + authenticatorData: this.#bufferToBase64url(credential.response.authenticatorData), + signature: this.#bufferToBase64url(credential.response.signature), + userHandle: credential.response.userHandle + ? this.#bufferToBase64url(credential.response.userHandle) + : null + } + } + } + + #base64urlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/") + const padded = base64 + "=".repeat((4 - base64.length % 4) % 4) + const binary = atob(padded) + return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer + } + + #bufferToBase64url(buffer) { + // Handle ArrayBuffer, Uint8Array, or already-encoded string + if (typeof buffer === "string") { + // Already a string, assume it's base64url encoded + return buffer + } + + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) + const binary = String.fromCharCode(...bytes) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") + } +} diff --git a/app/models/identity.rb b/app/models/identity.rb index bb69734b4e..4c3c83ab79 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -2,6 +2,7 @@ class Identity < ApplicationRecord include Joinable, Transferable has_many :magic_links, dependent: :destroy + has_many :passkeys, dependent: :destroy has_many :sessions, dependent: :destroy has_many :users, dependent: :nullify has_many :accounts, through: :users diff --git a/app/models/passkey.rb b/app/models/passkey.rb new file mode 100644 index 0000000000..b6e2cf57f5 --- /dev/null +++ b/app/models/passkey.rb @@ -0,0 +1,6 @@ +class Passkey < ApplicationRecord + belongs_to :identity + + validates :external_id, presence: true, uniqueness: true + validates :public_key, presence: true +end diff --git a/app/views/identity/passkeys/index.html.erb b/app/views/identity/passkeys/index.html.erb new file mode 100644 index 0000000000..755ea510d7 --- /dev/null +++ b/app/views/identity/passkeys/index.html.erb @@ -0,0 +1,45 @@ +<% @page_title = "Passkeys" %> + +
+
+

Passkeys

+ <%= link_to new_identity_passkey_path, class: "btn btn--link txt-small" do %> + <%= icon_tag "plus" %> + Add passkey + <% end %> +
+ + <% if @passkeys.any? %> + + <% else %> +

+ No passkeys yet. Add one to sign in without email. +

+ <% end %> + +
+ <%= link_to session_menu_path(script_name: nil), class: "btn btn--plain txt-link txt-small" do %> + <%= icon_tag "arrow-left" %> + Back to accounts + <% end %> +
+
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/identity/passkeys/new.html.erb b/app/views/identity/passkeys/new.html.erb new file mode 100644 index 0000000000..9d7a307c80 --- /dev/null +++ b/app/views/identity/passkeys/new.html.erb @@ -0,0 +1,31 @@ +<% @page_title = "Add passkey" %> + +
"> +

Add a passkey

+ + <%= form_with url: identity_passkeys_path, + data: { controller: "passkey", passkey_options_value: @options.as_json } do |form| %> + <%= form.hidden_field :credential, data: { passkey_target: "credential" } %> + +
+ <%= form.text_field :name, + class: "input txt-medium", + placeholder: "Name this passkey (optional)", + autocomplete: "off" %> + + +
+ <% end %> + +

+ <%= link_to "Cancel", identity_passkeys_path, class: "txt-link" %> +

+
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb index 150b6794a3..5e21a8f42d 100644 --- a/app/views/my/menus/_settings.html.erb +++ b/app/views/my/menus/_settings.html.erb @@ -1,6 +1,7 @@ <%= collapsible_nav_section "Settings" do %> <%= filter_place_menu_item account_settings_path, "Account Settings", "settings" %> <%= filter_place_menu_item user_path(Current.user), "My Profile", "person" %> + <%= filter_place_menu_item identity_passkeys_path(script_name: nil), "Passkeys", "password" %> <%= filter_place_menu_item notifications_path, "All notifications", "bell" %> <%= filter_place_menu_item notifications_settings_path, "Notification Settings", "settings" %> diff --git a/app/views/sessions/choices/new.html.erb b/app/views/sessions/choices/new.html.erb new file mode 100644 index 0000000000..b9ac95d515 --- /dev/null +++ b/app/views/sessions/choices/new.html.erb @@ -0,0 +1,44 @@ +<% @page_title = "Sign in" %> + +
"> +

Welcome back

+ +

Choose how to sign in as <%= @identity.email_address %>

+ + <%= form_with url: session_choice_path, + data: { controller: "passkey", passkey_options_value: @passkey_options.as_json } do |form| %> + <%= form.hidden_field :email, value: @identity.email_address %> + <%= form.hidden_field :credential, data: { passkey_target: "credential" } %> + <%= form.hidden_field :method, value: "passkey", data: { passkey_target: "method" } %> + +
+ +
+ <% end %> + +
+

or

+
+ + <%= form_with url: session_choice_path, class: "flex flex-column gap" do |form| %> + <%= form.hidden_field :email, value: @identity.email_address %> + <%= form.hidden_field :method, value: "magic_link" %> + + + <% end %> + +

+ Not you? <%= link_to "Use a different email", new_session_path %> +

+
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index fc89bf6818..e020e6146e 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -17,6 +17,19 @@ <%= icon_tag "arrow-right" %> <% end %> + +

or

+ + <%= form_with url: session_passkey_path, + data: { controller: "passkey", passkey_options_value: @passkey_options.as_json } do |form| %> + <%= form.hidden_field :credential, data: { passkey_target: "credential" } %> + + + <% end %> <% content_for :footer do %> diff --git a/app/views/signups/new.html.erb b/app/views/signups/new.html.erb index 4578d020c1..51cd004781 100644 --- a/app/views/signups/new.html.erb +++ b/app/views/signups/new.html.erb @@ -3,18 +3,34 @@

Sign up

- <%= form_with model: @signup, url: signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %> -
- -
- -

Enter your email to create an account.

- - + <% end %> + +

or

+ + <%= form_with url: signup_passkey_path, + data: { controller: "passkey", passkey_options_value: @passkey_options.as_json, passkey_email_selector_value: "#signup_email" } do |form| %> + <%= form.hidden_field :credential, data: { passkey_target: "credential" } %> + <%= form.hidden_field :email_address, data: { passkey_target: "email" } %> + + <% end %>
diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb new file mode 100644 index 0000000000..aca33394e9 --- /dev/null +++ b/config/initializers/webauthn.rb @@ -0,0 +1,5 @@ +WebAuthn.configure do |config| + config.rp_name = "Fizzy" + # rp_id and origin are configured per-request via WebauthnRelyingParty concern + # to support dynamic domain configuration +end diff --git a/config/routes.rb b/config/routes.rb index e67e4ef7c7..78eb8a0ebb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -146,15 +146,22 @@ resources :transfers resource :magic_link resource :menu + resource :passkey, only: :create + resource :choice, only: %i[new create] end end + scope module: :identity, as: :identity, path: "identity" do + resources :passkeys, only: %i[index new create destroy] + end + get "/signup", to: redirect("/signup/new") resource :signup, only: %i[ new create ] do collection do scope module: :signups, as: :signup do resource :completion, only: %i[ new create ] + resource :passkey, only: %i[ create ] end end end diff --git a/db/migrate/20251209050324_create_passkeys.rb b/db/migrate/20251209050324_create_passkeys.rb new file mode 100644 index 0000000000..141d0a4159 --- /dev/null +++ b/db/migrate/20251209050324_create_passkeys.rb @@ -0,0 +1,14 @@ +class CreatePasskeys < ActiveRecord::Migration[8.2] + def change + create_table :passkeys, id: :uuid do |t| + t.references :identity, null: false, foreign_key: true, type: :uuid + t.string :external_id, null: false + t.binary :public_key, null: false + t.bigint :sign_count, default: 0, null: false + t.string :name + t.timestamps + end + + add_index :passkeys, :external_id, unique: true + end +end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 1dd2b3e00f..5ebd553614 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.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: 2025_12_05_010536) do +ActiveRecord::Schema[8.2].define(version: 2025_12_09_050324) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -377,6 +377,18 @@ t.index ["user_id"], name: "index_notifications_on_user_id" end + create_table "passkeys", id: :uuid, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "external_id", limit: 255, null: false + t.uuid "identity_id", null: false + t.string "name", limit: 255 + t.binary "public_key", null: false + t.bigint "sign_count", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["external_id"], name: "index_passkeys_on_external_id", unique: true + t.index ["identity_id"], name: "index_passkeys_on_identity_id" + end + create_table "pins", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false @@ -556,6 +568,8 @@ t.index ["account_id"], name: "index_webhooks_on_account_id" t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions" end + + add_foreign_key "passkeys", "identities" execute "CREATE VIRTUAL TABLE search_records_fts USING fts5(\n title,\n content,\n tokenize='porter'\n )" end diff --git a/test/controllers/identity/passkeys_controller_test.rb b/test/controllers/identity/passkeys_controller_test.rb new file mode 100644 index 0000000000..1821bb4e87 --- /dev/null +++ b/test/controllers/identity/passkeys_controller_test.rb @@ -0,0 +1,94 @@ +require "test_helper" + +class Identity::PasskeysControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "index lists passkeys for current identity" do + untenanted do + get identity_passkeys_url + end + + assert_response :success + assert_select "li", text: /iPhone/ + end + + test "index shows empty state when no passkeys" do + # Use an identity without passkeys + logout_and_sign_in_as :jz + + untenanted do + get identity_passkeys_url + end + + assert_response :success + assert_match /No passkeys yet/, response.body + end + + test "new shows passkey registration form" do + untenanted do + get new_identity_passkey_url + end + + assert_response :success + assert_select "button[data-action*='passkey#register']" + end + + test "new sets webauthn challenge in session" do + untenanted do + get new_identity_passkey_url + end + + assert_response :success + assert session[:webauthn_challenge].present? + end + + test "create with invalid credential redirects with error" do + untenanted do + get new_identity_passkey_url + + post identity_passkeys_url, params: { credential: '{"id": "invalid"}' } + + assert_redirected_to new_identity_passkey_url(script_name: nil) + assert_equal "Could not register passkey.", flash[:alert] + end + end + + test "destroy removes passkey" do + passkey = passkeys(:kevin_iphone) + + untenanted do + assert_difference -> { Passkey.count }, -1 do + delete identity_passkey_url(passkey) + end + + assert_redirected_to identity_passkeys_url(script_name: nil) + assert_equal "Passkey removed.", flash[:notice] + end + + assert_not Passkey.exists?(passkey.id) + end + + test "destroy only allows removing own passkeys" do + # Kevin is signed in, trying to delete David's passkey + passkey = passkeys(:david_macbook) + + untenanted do + delete identity_passkey_url(passkey) + # Should redirect but not find the passkey (returns 404 or similar behavior) + # Since find is scoped to Current.identity.passkeys, it won't find david's passkey + assert_response :not_found + end + end + + test "requires authentication" do + sign_out + + untenanted do + get identity_passkeys_url + end + + assert_response :redirect + end +end diff --git a/test/controllers/sessions/choices_controller_test.rb b/test/controllers/sessions/choices_controller_test.rb new file mode 100644 index 0000000000..b36a2f5448 --- /dev/null +++ b/test/controllers/sessions/choices_controller_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class Sessions::ChoicesControllerTest < ActionDispatch::IntegrationTest + test "new shows choice page for user with passkeys" do + identity = identities(:kevin) # has passkeys + + untenanted do + get new_session_choice_path(email: identity.email_address) + end + + assert_response :success + assert_select "button[data-action*='passkey#authenticate']" + assert_match /Use passkey/, response.body + assert_match /Send me a magic link/, response.body + end + + test "new redirects to session page if no passkeys" do + identity = identities(:jz) # no passkeys + + untenanted do + get new_session_choice_path(email: identity.email_address) + + assert_redirected_to new_session_url(script_name: nil) + end + end + + test "new redirects to session page if identity not found" do + untenanted do + get new_session_choice_path(email: "nonexistent@example.com") + + assert_redirected_to new_session_url(script_name: nil) + end + end + + test "new sets webauthn challenge in session" do + identity = identities(:kevin) + + untenanted do + get new_session_choice_path(email: identity.email_address) + end + + assert_response :success + assert session[:webauthn_challenge].present? + end + + test "create with magic_link method sends magic link" do + identity = identities(:kevin) + + untenanted do + get new_session_choice_path(email: identity.email_address) + + assert_difference -> { MagicLink.count }, 1 do + post session_choice_path, params: { email: identity.email_address, method: "magic_link" } + end + + assert_redirected_to session_magic_link_path + end + end + + test "create with invalid passkey credential shows error" do + identity = identities(:kevin) + + untenanted do + get new_session_choice_path(email: identity.email_address) + + post session_choice_path, params: { + email: identity.email_address, + method: "passkey", + credential: '{"id": "invalid"}' + } + + assert_redirected_to new_session_choice_path(email: identity.email_address) + assert_equal "Authentication failed. Try again.", flash[:alert] + end + end +end diff --git a/test/controllers/sessions/passkeys_controller_test.rb b/test/controllers/sessions/passkeys_controller_test.rb new file mode 100644 index 0000000000..978cc74132 --- /dev/null +++ b/test/controllers/sessions/passkeys_controller_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest + test "create with invalid credential redirects with error" do + untenanted do + # First visit login page to get the challenge + get new_session_url + + # Then try to authenticate with invalid credential + post session_passkey_url, params: { credential: '{"id": "invalid"}' } + + assert_redirected_to new_session_url(script_name: nil) + assert_equal "Authentication failed. Try again or use email.", flash[:alert] + end + end + + test "create with non-existent passkey redirects with error" do + untenanted do + get new_session_url + + # Simulate a credential that doesn't exist in our database + post session_passkey_url, params: { + credential: JSON.generate({ + id: "non-existent-passkey-id", + rawId: Base64.urlsafe_encode64("non-existent-passkey-id", padding: false), + type: "public-key", + response: { + clientDataJSON: Base64.urlsafe_encode64("{}", padding: false), + authenticatorData: Base64.urlsafe_encode64("fake-auth-data", padding: false), + signature: Base64.urlsafe_encode64("fake-signature", padding: false) + } + }) + } + + assert_redirected_to new_session_url(script_name: nil) + assert_equal "Authentication failed. Try again or use email.", flash[:alert] + end + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 7ebe4d5188..ebe3c74ec2 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -9,8 +9,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "create" do - identity = identities(:kevin) + test "create for existing user without passkeys sends magic link" do + identity = identities(:jz) # jz has no passkeys in fixtures untenanted do assert_difference -> { MagicLink.count }, 1 do @@ -18,28 +18,35 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to session_magic_link_path - assert_nil flash[:magic_link_code] end end - test "create for a new user" do + test "create for existing user with passkeys redirects to choice" do + identity = identities(:kevin) # kevin has a passkey in fixtures + untenanted do - assert_difference -> { MagicLink.count }, +1 do - assert_difference -> { Identity.count }, +1 do + assert_no_difference -> { MagicLink.count } do + post session_path, params: { email_address: identity.email_address } + end + + assert_redirected_to new_session_choice_path(email: identity.email_address) + end + end + + test "create for a new user redirects to signup" do + untenanted do + assert_no_difference -> { Identity.count } do + assert_no_difference -> { MagicLink.count } do post session_path, - params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" } + params: { email_address: "newuser-#{SecureRandom.hex(6)}@example.com" } end end - assert_redirected_to session_magic_link_path - assert MagicLink.last.for_sign_up? + assert_redirected_to new_signup_path end end test "create with invalid email address" do - # Avoid Sentry exceptions when attackers try to stuff invalid emails. The browser performs form - # field validation that should normally prevent this from occurring, so I'm not worried about - # returning proper validation errors. without_action_dispatch_exception_handling do untenanted do assert_no_difference -> { Identity.count } do diff --git a/test/controllers/signup/passkeys_controller_test.rb b/test/controllers/signup/passkeys_controller_test.rb new file mode 100644 index 0000000000..a088e0ec85 --- /dev/null +++ b/test/controllers/signup/passkeys_controller_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class Signup::PasskeysControllerTest < ActionDispatch::IntegrationTest + test "create with invalid credential shows error" do + untenanted do + # Visit signup page to get the challenge + get new_signup_url + + post signup_passkey_path, params: { + email_address: "test@example.com", + credential: '{"id": "invalid"}' + } + + assert_redirected_to new_signup_url(script_name: nil) + assert_equal "Could not register passkey. Please try again.", flash[:alert] + end + end + + test "create with invalid email shows error" do + untenanted do + get new_signup_url + + post signup_passkey_path, params: { + email_address: "not-an-email", + credential: '{"id": "test"}' + } + + assert_redirected_to new_signup_url(script_name: nil) + assert_equal "Please enter a valid email address.", flash[:alert] + end + end +end diff --git a/test/controllers/signups_controller_test.rb b/test/controllers/signups_controller_test.rb index 17314ee904..0467f6c1ad 100644 --- a/test/controllers/signups_controller_test.rb +++ b/test/controllers/signups_controller_test.rb @@ -20,12 +20,12 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest end end - test "create" do + test "create sends magic link for email verification" do email_address = "newuser-#{SecureRandom.hex(6)}@example.com" untenanted do - assert_difference -> { Identity.count }, +1 do - assert_difference -> { MagicLink.count }, +1 do + assert_difference -> { Identity.count }, 1 do + assert_difference -> { MagicLink.count }, 1 do post signup_path, params: { signup: { email_address: email_address } } end end diff --git a/test/fixtures/passkeys.yml b/test/fixtures/passkeys.yml new file mode 100644 index 0000000000..b545998ff9 --- /dev/null +++ b/test/fixtures/passkeys.yml @@ -0,0 +1,13 @@ +david_macbook: + identity: david + external_id: david-macbook-external-id + public_key: <%= Base64.strict_encode64("fake-public-key-david-macbook") %> + sign_count: 5 + name: MacBook Pro + +kevin_iphone: + identity: kevin + external_id: kevin-iphone-external-id + public_key: <%= Base64.strict_encode64("fake-public-key-kevin-iphone") %> + sign_count: 10 + name: iPhone diff --git a/test/models/passkey_test.rb b/test/models/passkey_test.rb new file mode 100644 index 0000000000..02ea7b6455 --- /dev/null +++ b/test/models/passkey_test.rb @@ -0,0 +1,96 @@ +require "test_helper" + +class PasskeyTest < ActiveSupport::TestCase + test "belongs to identity" do + passkey = Passkey.new( + identity: identities(:kevin), + external_id: "test-external-id", + public_key: "test-public-key" + ) + + assert passkey.valid? + assert_equal identities(:kevin), passkey.identity + end + + test "requires external_id" do + passkey = Passkey.new( + identity: identities(:kevin), + public_key: "test-public-key" + ) + + assert_not passkey.valid? + assert_includes passkey.errors[:external_id], "can't be blank" + end + + test "requires public_key" do + passkey = Passkey.new( + identity: identities(:kevin), + external_id: "test-external-id" + ) + + assert_not passkey.valid? + assert_includes passkey.errors[:public_key], "can't be blank" + end + + test "external_id must be unique" do + Passkey.create!( + identity: identities(:kevin), + external_id: "unique-external-id", + public_key: "test-public-key" + ) + + duplicate = Passkey.new( + identity: identities(:david), + external_id: "unique-external-id", + public_key: "different-public-key" + ) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:external_id], "has already been taken" + end + + test "sign_count defaults to zero" do + passkey = Passkey.create!( + identity: identities(:kevin), + external_id: "test-external-id", + public_key: "test-public-key" + ) + + assert_equal 0, passkey.sign_count + end + + test "identity has many passkeys" do + identity = identities(:kevin) + + passkey1 = Passkey.create!( + identity: identity, + external_id: "external-id-1", + public_key: "public-key-1", + name: "iPhone" + ) + + passkey2 = Passkey.create!( + identity: identity, + external_id: "external-id-2", + public_key: "public-key-2", + name: "MacBook" + ) + + assert_includes identity.passkeys, passkey1 + assert_includes identity.passkeys, passkey2 + end + + test "destroying identity destroys passkeys" do + identity = Identity.create!(email_address: "passkey-test@example.com") + + passkey = Passkey.create!( + identity: identity, + external_id: "test-external-id", + public_key: "test-public-key" + ) + + identity.destroy + + assert_not Passkey.exists?(passkey.id) + end +end