From 6c58fd9bdd9d12445e1696cd6f2e2d0cefee1a6a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 13 Feb 2026 16:58:06 +0100 Subject: [PATCH] Implement Passkey registration - Create Identity credentials - Add management UI and registration flow --- .../users/credentials_controller.rb | 71 +++++++++++++++++++ .../controllers/credential_controller.js | 60 ++++++++++++++++ .../controllers/passkey_controller.js | 36 ++++++++++ app/models/identity.rb | 1 + app/models/identity/credential.rb | 13 ++++ app/views/sessions/new.html.erb | 4 +- .../users/credentials/_credential.html.erb | 12 ++++ app/views/users/credentials/index.html.erb | 34 +++++++++ app/views/users/credentials/new.html.erb | 32 +++++++++ app/views/users/show.html.erb | 5 ++ config/routes.rb | 1 + ...60213154740_create_identity_credentials.rb | 17 +++++ db/schema.rb | 13 ++++ .../web_authn/public_key_credential.rb | 8 ++- .../creation_options_test.rb | 19 +++-- .../request_options_test.rb | 23 +++--- 16 files changed, 330 insertions(+), 19 deletions(-) create mode 100644 app/controllers/users/credentials_controller.rb create mode 100644 app/javascript/controllers/credential_controller.js create mode 100644 app/javascript/controllers/passkey_controller.js create mode 100644 app/models/identity/credential.rb create mode 100644 app/views/users/credentials/_credential.html.erb create mode 100644 app/views/users/credentials/index.html.erb create mode 100644 app/views/users/credentials/new.html.erb create mode 100644 db/migrate/20260213154740_create_identity_credentials.rb diff --git a/app/controllers/users/credentials_controller.rb b/app/controllers/users/credentials_controller.rb new file mode 100644 index 000000000..63c623253 --- /dev/null +++ b/app/controllers/users/credentials_controller.rb @@ -0,0 +1,71 @@ +class Users::CredentialsController < ApplicationController + before_action :set_user + before_action :set_webauthn_host + + def index + @credentials = identity.credentials.order(created_at: :desc) + end + + def new + @creation_options = creation_options + 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]), + challenge: session.delete(:webauthn_challenge), + origin: request.base_url, + transports: Array(credential_response[: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) + end + + def destroy + identity.credentials.find(params[:id]).destroy! + redirect_to user_credentials_path(@user) + end + + private + def set_user + @user = Current.identity.users.find(params[:user_id]) + end + + def set_webauthn_host + ActionPack::WebAuthn::Current.host = request.host + 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 new file mode 100644 index 000000000..1b882d05e --- /dev/null +++ b/app/javascript/controllers/credential_controller.js @@ -0,0 +1,60 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { publicKey: Object } + static targets = ["clientDataJSON", "attestationObject"] + + async create(event) { + event.preventDefault() + + try { + const publicKey = this.prepareOptions(this.publicKeyValue) + const credential = await navigator.credentials.create({ publicKey }) + this.submitCredential(credential) + } catch (error) { + if (error.name !== "AbortError" && error.name !== "NotAllowedError") { + console.error("Registration failed:", error) + } + } + } + + submitCredential(credential) { + this.clientDataJSONTarget.value = this.bufferToBase64url(credential.response.clientDataJSON) + this.attestationObjectTarget.value = this.bufferToBase64url(credential.response.attestationObject) + + for (const transport of credential.response.getTransports?.() || []) { + const input = document.createElement("input") + input.type = "hidden" + input.name = "credential[response][transports][]" + input.value = transport + this.element.appendChild(input) + } + + this.element.requestSubmit() + } + + prepareOptions(options) { + return { + ...options, + challenge: this.base64urlToBuffer(options.challenge), + user: { ...options.user, id: this.base64urlToBuffer(options.user.id) }, + excludeCredentials: (options.excludeCredentials || []).map(cred => ({ + ...cred, + id: this.base64urlToBuffer(cred.id) + })) + } + } + + 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/javascript/controllers/passkey_controller.js b/app/javascript/controllers/passkey_controller.js new file mode 100644 index 000000000..a652d0c8f --- /dev/null +++ b/app/javascript/controllers/passkey_controller.js @@ -0,0 +1,36 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + if (window.PublicKeyCredential?.isConditionalMediationAvailable) { + 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: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rpId: window.location.hostname + }, + mediation: "conditional", + signal: this.abortController.signal + }) + + console.log("Passkey selected:", credential) + } catch (error) { + if (error.name !== "AbortError") { + console.error("Passkey error:", error) + } + } + } +} diff --git a/app/models/identity.rb b/app/models/identity.rb index 18f4e3163..8e56ce196 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -2,6 +2,7 @@ class Identity < ApplicationRecord include Joinable, Transferable 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 new file mode 100644 index 000000000..14093c943 --- /dev/null +++ b/app/models/identity/credential.rb @@ -0,0 +1,13 @@ +class Identity::Credential < ApplicationRecord + belongs_to :identity + + serialize :transports, coder: JSON, type: Array, default: [] + + def to_public_key_credential + ActionPack::WebAuthn::PublicKeyCredential.new( + id: credential_id, + public_key: public_key, + transports: transports + ) + end +end diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 4e82c0b70..10293b5a0 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,12 +1,12 @@ <% @page_title = "Enter your email" %> -
+

Get into Fizzy

<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
diff --git a/app/views/users/credentials/_credential.html.erb b/app/views/users/credentials/_credential.html.erb new file mode 100644 index 000000000..27c2d199f --- /dev/null +++ b/app/views/users/credentials/_credential.html.erb @@ -0,0 +1,12 @@ + + <%= credential.name.presence || "Passkey" %> + <%= local_datetime_tag credential.created_at, style: :datetime %> + + <%= button_to user_credential_path(@user, credential), method: :delete, + class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent", + data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %> + <%= icon_tag "trash" %> + Remove this passkey + <% end %> + + diff --git a/app/views/users/credentials/index.html.erb b/app/views/users/credentials/index.html.erb new file mode 100644 index 000000000..a5521f617 --- /dev/null +++ b/app/views/users/credentials/index.html.erb @@ -0,0 +1,34 @@ +<% @page_title = "Passkeys" %> + +<% content_for :header do %> +
+ <%= back_link_to "My profile", user_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <% if @credentials.any? %> +

Passkeys let you sign in without email codes. They use your device's biometrics, PIN, or security key.

+ + + + + + + + + + <%= render partial: "users/credentials/credential", collection: @credentials %> + +
PasskeyCreated
+ <% else %> +

Passkeys let you sign in without email codes. They use your device's biometrics, PIN, or security key.

+ <% end %> + + <%= link_to new_user_credential_path(@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 new file mode 100644 index 000000000..3e5e7abc0 --- /dev/null +++ b/app/views/users/credentials/new.html.erb @@ -0,0 +1,32 @@ +<% @page_title = "Add a passkey" %> + +<% content_for :header do %> +
+ <%= back_link_to "Passkeys", user_credentials_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <%= form_with url: user_credentials_path(@user), + data: { controller: "credential", credential_public_key_value: @creation_options.as_json }, + html: { class: "flex flex-column gap" } do |form| %> +

A passkey lets you sign in using your device's biometrics, PIN, or security key — no email code needed.

+ +
+ + +
+ + + + + + <% end %> + + <%= link_to "Cancel and go back", user_credentials_path(@user), hidden: true %> +
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index eb3e40c64..24e742601 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -5,6 +5,11 @@
<% 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 %> + 🔑 + Manage passkeys + <% end %> + <%= link_to edit_user_path(@user), class: "user-edit-link btn", data: { controller: "tooltip" } do %> <%= icon_tag "pencil" %> Edit profile diff --git a/config/routes.rb b/config/routes.rb index 0ca383dda..4e0106d82 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,6 +15,7 @@ Rails.application.routes.draw do resource :avatar resource :role resource :events + resources :credentials, only: [ :index, :new, :create, :destroy ] resources :push_subscriptions diff --git a/db/migrate/20260213154740_create_identity_credentials.rb b/db/migrate/20260213154740_create_identity_credentials.rb new file mode 100644 index 000000000..b7bf70568 --- /dev/null +++ b/db/migrate/20260213154740_create_identity_credentials.rb @@ -0,0 +1,17 @@ +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/schema.rb b/db/schema.rb index 20cb0d161..76ab5b9a0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -344,6 +344,19 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) 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.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/lib/action_pack/web_authn/public_key_credential.rb b/lib/action_pack/web_authn/public_key_credential.rb index 1e00b74fb..6ca8a99df 100644 --- a/lib/action_pack/web_authn/public_key_credential.rb +++ b/lib/action_pack/web_authn/public_key_credential.rb @@ -1,8 +1,8 @@ class ActionPack::WebAuthn::PublicKeyCredential - attr_reader :id, :public_key, :sign_count, :owner + attr_reader :id, :public_key, :sign_count, :transports, :owner class << self - def create(client_data_json:, attestation_object:, challenge:, origin:, owner: nil) + def create(client_data_json:, attestation_object:, challenge:, origin:, transports: [], owner: nil) response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( client_data_json: client_data_json, attestation_object: attestation_object @@ -14,15 +14,17 @@ class ActionPack::WebAuthn::PublicKeyCredential id: response.attestation.credential_id, public_key: response.attestation.public_key, sign_count: response.attestation.sign_count, + transports: transports, owner: owner ) end end - def initialize(id:, public_key:, sign_count:, owner: nil) + def initialize(id:, public_key:, sign_count:, transports: [], owner: nil) @id = id @public_key = public_key @sign_count = sign_count + @transports = transports @owner = owner 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 e4bd049c1..010dd5464 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 @@ -63,10 +63,9 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup end test "as_json includes excludeCredentials" do - credential = Struct.new(:id, :transports) credentials = [ - credential.new("cred-1", [ "usb", "nfc" ]), - credential.new("cred-2", [ "internal" ]) + build_credential(id: "cred-1", transports: [ "usb", "nfc" ]), + build_credential(id: "cred-2", transports: [ "internal" ]) ] options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( @@ -84,13 +83,11 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup end test "as_json excludeCredentials omits transports when empty" do - credential = Struct.new(:id, :transports).new("cred-1", []) - options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( id: "user-123", name: "user@example.com", display_name: "Test User", - exclude_credentials: [ credential ], + exclude_credentials: [ build_credential(id: "cred-1") ], relying_party: @relying_party ) @@ -98,4 +95,14 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup { type: "public-key", id: "cred-1" } ], options.as_json[:excludeCredentials] end + + private + def build_credential(id:, transports: []) + ActionPack::WebAuthn::PublicKeyCredential.new( + id: id, + public_key: OpenSSL::PKey::EC.generate("prime256v1"), + sign_count: 0, + transports: transports + ) + end end diff --git a/test/lib/action_pack/web_authn/public_key_credential/request_options_test.rb b/test/lib/action_pack/web_authn/public_key_credential/request_options_test.rb index 4c6ee3baa..96c064ffc 100644 --- a/test/lib/action_pack/web_authn/public_key_credential/request_options_test.rb +++ b/test/lib/action_pack/web_authn/public_key_credential/request_options_test.rb @@ -3,10 +3,9 @@ require "test_helper" class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupport::TestCase setup do @relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App") - credential = Struct.new(:id, :transports) @credentials = [ - credential.new("credential-1", []), - credential.new("credential-2", []) + build_credential(id: "credential-1"), + build_credential(id: "credential-2") ] @options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new( credentials: @credentials, @@ -39,10 +38,9 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp end test "as_json includes transports when present" do - credential = Struct.new(:id, :transports) credentials = [ - credential.new("cred-1", [ "usb", "nfc" ]), - credential.new("cred-2", [ "internal" ]) + build_credential(id: "cred-1", transports: [ "usb", "nfc" ]), + build_credential(id: "cred-2", transports: [ "internal" ]) ] options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new( @@ -57,8 +55,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp end test "as_json omits transports when empty" do - credential = Struct.new(:id, :transports) - credentials = [ credential.new("cred-1", []) ] + credentials = [ build_credential(id: "cred-1") ] options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new( credentials: credentials, @@ -69,4 +66,14 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp { type: "public-key", id: "cred-1" } ], options.as_json[:allowCredentials] end + + private + def build_credential(id:, transports: []) + ActionPack::WebAuthn::PublicKeyCredential.new( + id: id, + public_key: OpenSSL::PKey::EC.generate("prime256v1"), + sign_count: 0, + transports: transports + ) + end end