From da690394765eb803c552ac8c858f6eb57afb722d Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 18 Feb 2026 11:24:10 +0100 Subject: [PATCH] Implement Passkey authentication --- app/assets/images/key.svg | 1 + app/assets/stylesheets/icons.css | 1 + app/controllers/concerns/current_request.rb | 2 + .../sessions/passkeys_controller.rb | 54 +++++++ app/controllers/sessions_controller.rb | 3 + .../users/credentials_controller.rb | 63 ++------ .../controllers/credential_controller.js | 2 +- .../controllers/passkey_controller.js | 82 ++++++++-- app/models/identity/credential.rb | 56 ++++++- app/views/sessions/new.html.erb | 14 +- .../users/credentials/_credential.html.erb | 3 +- app/views/users/credentials/index.html.erb | 5 +- app/views/users/credentials/new.html.erb | 10 +- app/views/users/show.html.erb | 2 +- config/routes.rb | 1 + .../public_key_credential/creation_options.rb | 1 + .../sessions/passkeys_controller_test.rb | 144 ++++++++++++++++++ .../creation_options_test.rb | 2 + 18 files changed, 368 insertions(+), 78 deletions(-) create mode 100644 app/assets/images/key.svg create mode 100644 app/controllers/sessions/passkeys_controller.rb create mode 100644 test/controllers/sessions/passkeys_controller_test.rb diff --git a/app/assets/images/key.svg b/app/assets/images/key.svg new file mode 100644 index 000000000..386e0b501 --- /dev/null +++ b/app/assets/images/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 42a3a21ec..35680e429 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -62,6 +62,7 @@ .icon--history { --svg: url("history.svg "); } .icon--home { --svg: url("home.svg "); } .icon--install-edge { --svg: url("install-edge.svg "); } + .icon--key { --svg: url("key.svg "); } .icon--lifebuoy { --svg: url("lifebuoy.svg "); } .icon--lock { --svg: url("lock.svg "); } .icon--logout { --svg: url("logout.svg "); } diff --git a/app/controllers/concerns/current_request.rb b/app/controllers/concerns/current_request.rb index 15f574ab0..193a2713b 100644 --- a/app/controllers/concerns/current_request.rb +++ b/app/controllers/concerns/current_request.rb @@ -8,6 +8,8 @@ module CurrentRequest Current.user_agent = request.user_agent Current.ip_address = request.ip Current.referrer = request.referrer + + ActionPack::WebAuthn::Current.host = request.host end end end diff --git a/app/controllers/sessions/passkeys_controller.rb b/app/controllers/sessions/passkeys_controller.rb new file mode 100644 index 000000000..5b997260b --- /dev/null +++ b/app/controllers/sessions/passkeys_controller.rb @@ -0,0 +1,54 @@ +class Sessions::PasskeysController < ApplicationController + 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( + id: params.expect(:credential_id), + client_data_json: response_params[:client_data_json], + authenticator_data: response_params[:authenticator_data], + signature: response_params[:signature], + challenge: session.delete(:webauthn_challenge), + origin: request.base_url + ) + + if credential + authentication_succeeded(credential.identity) + else + authentication_failed + end + end + + private + def response_params + params.expect(response: [ :client_data_json, :authenticator_data, :signature ]) + end + + def authentication_succeeded(identity) + start_new_session_for identity + + 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." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end + end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again later." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cabcfa143..eb7fa8309 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -6,6 +6,8 @@ class SessionsController < ApplicationController layout "public" def new + @request_options = Identity::Credential.request_options + session[:webauthn_challenge] = @request_options.challenge end def create @@ -67,4 +69,5 @@ class SessionsController < ApplicationController end end end + end diff --git a/app/controllers/users/credentials_controller.rb b/app/controllers/users/credentials_controller.rb index 63c623253..65ebe42ff 100644 --- a/app/controllers/users/credentials_controller.rb +++ b/app/controllers/users/credentials_controller.rb @@ -1,71 +1,40 @@ class Users::CredentialsController < ApplicationController - before_action :set_user - before_action :set_webauthn_host + before_action :set_credential, only: :destroy def index - @credentials = identity.credentials.order(created_at: :desc) + @credentials = Current.identity.credentials.order(name: :asc, created_at: :desc) end def new - @creation_options = creation_options + @creation_options = Identity::Credential.creation_options(identity: Current.identity, display_name: Current.user.name) session[:webauthn_challenge] = @creation_options.challenge end def create - public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create( - client_data_json: decode64(credential_response[:client_data_json]), - attestation_object: decode64(credential_response[:attestation_object]), + Identity::Credential.register( + identity: Current.identity, + name: credential_params[:name], + client_data_json: credential_params[:client_data_json], + attestation_object: credential_params[:attestation_object], challenge: session.delete(:webauthn_challenge), origin: request.base_url, - transports: Array(credential_response[:transports]) + transports: Array(credential_params[:transports]) ) - identity.credentials.create!( - name: params.dig(:credential, :name), - credential_id: public_key_credential.id, - public_key: public_key_credential.public_key.to_der, - sign_count: public_key_credential.sign_count, - transports: public_key_credential.transports - ) - - redirect_to user_credentials_path(@user) + redirect_to user_credentials_path(Current.user) end def destroy - identity.credentials.find(params[:id]).destroy! - redirect_to user_credentials_path(@user) + @credential.destroy! + redirect_to user_credentials_path(Current.user) end private - def set_user - @user = Current.identity.users.find(params[:user_id]) + def set_credential + @credential = Current.identity.credentials.find(params[:id]) end - def set_webauthn_host - ActionPack::WebAuthn::Current.host = request.host + def credential_params + params.expect(credential: [ :name, :client_data_json, :attestation_object, transports: [] ]) end - - def identity - @user.identity - end - - def creation_options - ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( - id: identity.id, - name: identity.email_address, - display_name: @user.name, - resident_key: :required, - exclude_credentials: identity.credentials.map { |c| ExcludeCredential.new(c.credential_id, c.transports) } - ) - end - - def credential_response - params.expect(credential: { response: [ :client_data_json, :attestation_object, transports: [] ] })[:response] - end - - def decode64(value) - Base64.urlsafe_decode64(value) - end - - ExcludeCredential = Struct.new(:id, :transports) end diff --git a/app/javascript/controllers/credential_controller.js b/app/javascript/controllers/credential_controller.js index 1b882d05e..bd9b0baee 100644 --- a/app/javascript/controllers/credential_controller.js +++ b/app/javascript/controllers/credential_controller.js @@ -25,7 +25,7 @@ export default class extends Controller { for (const transport of credential.response.getTransports?.() || []) { const input = document.createElement("input") input.type = "hidden" - input.name = "credential[response][transports][]" + input.name = "credential[transports][]" input.value = transport this.element.appendChild(input) } diff --git a/app/javascript/controllers/passkey_controller.js b/app/javascript/controllers/passkey_controller.js index a652d0c8f..87145db77 100644 --- a/app/javascript/controllers/passkey_controller.js +++ b/app/javascript/controllers/passkey_controller.js @@ -1,36 +1,92 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { + static values = { publicKey: Object, url: String, csrfToken: String } + + #abortController + connect() { - if (window.PublicKeyCredential?.isConditionalMediationAvailable) { - this.attemptConditionalMediation() - } + this.#attemptConditionalMediation() } disconnect() { - this.abortController?.abort() + this.#abortController?.abort() } - async attemptConditionalMediation() { - if (!await PublicKeyCredential.isConditionalMediationAvailable()) return + async #attemptConditionalMediation() { + if (!await PublicKeyCredential?.isConditionalMediationAvailable?.()) return - this.abortController = new AbortController() + this.#abortController = new AbortController() try { const credential = await navigator.credentials.get({ - publicKey: { - challenge: crypto.getRandomValues(new Uint8Array(32)), - rpId: window.location.hostname - }, + publicKey: this.#prepareOptions(this.publicKeyValue), mediation: "conditional", - signal: this.abortController.signal + signal: this.#abortController.signal }) - console.log("Passkey selected:", credential) + 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, + credential_id: credential.id, + "response[client_data_json]": new TextDecoder().decode(credential.response.clientDataJSON), + "response[authenticator_data]": this.#bufferToBase64url(credential.response.authenticatorData), + "response[signature]": this.#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: this.#base64urlToBuffer(options.challenge) + } + + if (options.allowCredentials?.length) { + prepared.allowCredentials = options.allowCredentials.map(cred => ({ + ...cred, + id: this.#base64urlToBuffer(cred.id) + })) + } else { + delete prepared.allowCredentials + } + + return prepared + } + + #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 + } + + #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/credential.rb b/app/models/identity/credential.rb index 14093c943..eb03add28 100644 --- a/app/models/identity/credential.rb +++ b/app/models/identity/credential.rb @@ -3,10 +3,64 @@ class Identity::Credential < ApplicationRecord 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 authenticate(id:, **params) + find_by(credential_id: id)&.authenticate(**params) + end + + def register(identity:, name:, client_data_json:, attestation_object:, challenge:, origin:, transports: []) + public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create( + client_data_json: Base64.urlsafe_decode64(client_data_json), + attestation_object: Base64.urlsafe_decode64(attestation_object), + challenge: challenge, + origin: origin, + transports: transports + ) + + identity.credentials.create!( + name: name, + credential_id: public_key_credential.id, + public_key: public_key_credential.public_key.to_der, + sign_count: public_key_credential.sign_count, + transports: public_key_credential.transports + ) + end + end + + def authenticate(client_data_json:, authenticator_data:, signature:, challenge:, origin:) + pkc = to_public_key_credential + pkc.authenticate( + client_data_json: client_data_json, + authenticator_data: Base64.urlsafe_decode64(authenticator_data), + signature: Base64.urlsafe_decode64(signature), + challenge: challenge, + origin: origin + ) + update!(sign_count: pkc.sign_count) + self + rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError + nil + end + def to_public_key_credential ActionPack::WebAuthn::PublicKeyCredential.new( id: credential_id, - public_key: public_key, + public_key: OpenSSL::PKey.read(public_key), + sign_count: sign_count, transports: transports ) end diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 10293b5a0..553236f1d 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,26 +1,30 @@ <% @page_title = "Enter your email" %> -
+

Get into Fizzy

<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
<% if Account.accepting_signups? %> -

New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we’ll get you signed in.

+

New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we'll get you signed in.

<% else %> -

Enter your email and we’ll get you signed in.

+

Enter your email and we'll get you signed in.

<% end %> <% end %> +
<% content_for :footer do %> diff --git a/app/views/users/credentials/_credential.html.erb b/app/views/users/credentials/_credential.html.erb index f532552c3..a240fc46f 100644 --- a/app/views/users/credentials/_credential.html.erb +++ b/app/views/users/credentials/_credential.html.erb @@ -1,8 +1,7 @@ <%= credential.name.presence || "Passkey" %> - <%= local_datetime_tag credential.created_at, style: :datetime %> - <%= button_to user_credential_path(@user, credential), method: :delete, + <%= button_to user_credential_path(Current.user, credential), method: :delete, class: "btn btn--circle txt-negative txt-xx-small borderless", 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/users/credentials/index.html.erb index 28febe77f..3b54e1e9f 100644 --- a/app/views/users/credentials/index.html.erb +++ b/app/views/users/credentials/index.html.erb @@ -2,7 +2,7 @@ <% content_for :header do %>
- <%= back_link_to "My profile", user_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> + <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

<%= @page_title %>

@@ -16,7 +16,6 @@ Passkey - Created @@ -26,7 +25,7 @@ <% end %> - <%= link_to new_user_credential_path(@user), class: "btn btn--link" do %> + <%= link_to new_user_credential_path(Current.user), class: "btn btn--link" do %> <%= icon_tag "add" %> Add a passkey <% end %> diff --git a/app/views/users/credentials/new.html.erb b/app/views/users/credentials/new.html.erb index 722a6f612..15ad75378 100644 --- a/app/views/users/credentials/new.html.erb +++ b/app/views/users/credentials/new.html.erb @@ -2,14 +2,14 @@ <% content_for :header do %>
- <%= back_link_to "Passkeys", user_credentials_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> + <%= back_link_to "Passkeys", user_credentials_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

<%= @page_title %>

<% end %>
- <%= form_with url: user_credentials_path(@user), + <%= form_with url: user_credentials_path(Current.user), data: { controller: "credential", credential_public_key_value: @creation_options.as_json }, html: { class: "flex flex-column gap" } do |form| %> @@ -18,8 +18,8 @@
- - + + <% end %> - <%= link_to "Cancel and go back", user_credentials_path(@user), hidden: true %> + <%= link_to "Cancel and go back", user_credentials_path(Current.user), hidden: true %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 24e742601..ad4ba3a59 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -6,7 +6,7 @@
<% if Current.user == @user %> <%= link_to user_credentials_path(@user), class: "user-edit-link btn", style: "inset: 0 auto auto 0", data: { controller: "tooltip" } do %> - 🔑 + <%= icon_tag "key" %> Manage passkeys <% end %> diff --git a/config/routes.rb b/config/routes.rb index 4e0106d82..d1ca55416 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -157,6 +157,7 @@ Rails.application.routes.draw do resources :transfers resource :magic_link resource :menu + resource :passkey, only: :create end end diff --git a/lib/action_pack/web_authn/public_key_credential/creation_options.rb b/lib/action_pack/web_authn/public_key_credential/creation_options.rb index 0497a2bad..46510ad62 100644 --- a/lib/action_pack/web_authn/public_key_credential/creation_options.rb +++ b/lib/action_pack/web_authn/public_key_credential/creation_options.rb @@ -95,6 +95,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W ], authenticatorSelection: { residentKey: @resident_key.to_s, + requireResidentKey: @resident_key == :required, userVerification: user_verification.to_s } } diff --git a/test/controllers/sessions/passkeys_controller_test.rb b/test/controllers/sessions/passkeys_controller_test.rb new file mode 100644 index 000000000..88512f2df --- /dev/null +++ b/test/controllers/sessions/passkeys_controller_test.rb @@ -0,0 +1,144 @@ +require "test_helper" + +class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest + setup do + @identity = identities(:kevin) + @private_key = OpenSSL::PKey::EC.generate("prime256v1") + + @credential = @identity.credentials.create!( + name: "Test Passkey", + credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false), + public_key: @private_key.public_to_der, + sign_count: 0, + transports: [ "internal" ] + ) + end + + test "successful authentication" do + untenanted do + get new_session_url + challenge = session[:webauthn_challenge] + + post session_passkey_url, params: assertion_params(challenge: challenge) + + assert_response :redirect + assert cookies[:session_token].present? + assert_redirected_to landing_path + end + end + + test "updates sign count" do + untenanted do + get new_session_url + challenge = session[:webauthn_challenge] + + post session_passkey_url, params: assertion_params(challenge: challenge, sign_count: 1) + + assert_equal 1, @credential.reload.sign_count + end + end + + test "rejects invalid signature" do + untenanted do + get new_session_url + challenge = session[:webauthn_challenge] + + params = assertion_params(challenge: challenge) + params[:response][:signature] = Base64.urlsafe_encode64("invalid", padding: false) + + post session_passkey_url, params: params + + assert_redirected_to new_session_path + assert_not cookies[:session_token].present? + assert_equal "That passkey didn't work. Try again.", flash[:alert] + end + end + + test "rejects unknown credential" do + untenanted do + get new_session_url + + post session_passkey_url, params: { + credential_id: "nonexistent", + response: { + client_data_json: Base64.urlsafe_encode64("{}", padding: false), + authenticator_data: Base64.urlsafe_encode64("x", padding: false), + signature: Base64.urlsafe_encode64("x", padding: false) + } + } + + assert_redirected_to new_session_path + assert_not cookies[:session_token].present? + end + end + + test "successful authentication via JSON" do + untenanted do + get new_session_url + challenge = session[:webauthn_challenge] + + post session_passkey_url(format: :json), params: assertion_params(challenge: challenge) + + assert_response :success + assert @response.parsed_body["session_token"].present? + end + end + + test "failed authentication via JSON" do + untenanted do + get new_session_url + + post session_passkey_url(format: :json), params: { + credential_id: "nonexistent", + response: { + client_data_json: Base64.urlsafe_encode64("{}", padding: false), + authenticator_data: Base64.urlsafe_encode64("x", padding: false), + signature: Base64.urlsafe_encode64("x", padding: false) + } + } + + assert_response :unauthorized + assert_equal "That passkey didn't work. Try again.", @response.parsed_body["message"] + end + end + + private + def assertion_params(challenge:, sign_count: 1) + origin = "http://www.example.com" + + client_data_json = { + challenge: challenge, + origin: origin, + type: "webauthn.get" + }.to_json + + authenticator_data = build_authenticator_data(sign_count: sign_count) + signature = sign(authenticator_data, client_data_json) + + { + credential_id: @credential.credential_id, + response: { + client_data_json: client_data_json, + authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false), + signature: Base64.urlsafe_encode64(signature, padding: false) + } + } + end + + def build_authenticator_data(sign_count:) + rp_id_hash = Digest::SHA256.digest("www.example.com") + flags = 0x01 | 0x04 # user present + user verified + + bytes = [] + bytes.concat(rp_id_hash.bytes) + bytes << flags + bytes.concat([ sign_count ].pack("N").bytes) + bytes.pack("C*") + end + + def sign(authenticator_data, client_data_json) + client_data_hash = Digest::SHA256.digest(client_data_json) + signed_data = authenticator_data + client_data_hash + @private_key.sign("SHA256", signed_data) + end +end diff --git a/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb b/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb index 010dd5464..2fc1acfbc 100644 --- a/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb +++ b/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb @@ -43,6 +43,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup ], @options.as_json[:pubKeyCredParams] assert_equal "preferred", @options.as_json[:authenticatorSelection][:residentKey] + assert_equal false, @options.as_json[:authenticatorSelection][:requireResidentKey] assert_equal "preferred", @options.as_json[:authenticatorSelection][:userVerification] end @@ -56,6 +57,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup ) assert_equal "required", options.as_json[:authenticatorSelection][:residentKey] + assert_equal true, options.as_json[:authenticatorSelection][:requireResidentKey] end test "as_json excludes excludeCredentials when empty" do