From 4bb0a2929ccd99feacc1716a27b280e62e892615 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 4 Feb 2026 12:39:50 +0100 Subject: [PATCH 01/12] Implement WebAuthn protocol - Add a CBOR decoder - Add public key credential options - Add COSE key decoder - Add authenticator attestation and assertion - Add credentials - Add exclude_credentials --- lib/action_pack/web_authn.rb | 7 + .../authenticator/assertion_response.rb | 80 +++++ .../web_authn/authenticator/attestation.rb | 58 ++++ .../authenticator/attestation_response.rb | 58 ++++ .../web_authn/authenticator/data.rb | 142 +++++++++ .../web_authn/authenticator/response.rb | 117 +++++++ lib/action_pack/web_authn/cbor_decoder.rb | 262 +++++++++++++++ lib/action_pack/web_authn/cose_key.rb | 141 +++++++++ lib/action_pack/web_authn/current.rb | 3 + .../web_authn/public_key_credential.rb | 41 +++ .../public_key_credential/creation_options.rb | 115 +++++++ .../public_key_credential/options.rb | 27 ++ .../public_key_credential/request_options.rb | 61 ++++ lib/action_pack/web_authn/relying_party.rb | 50 +++ .../authenticator/assertion_response_test.rb | 158 ++++++++++ .../attestation_response_test.rb | 195 ++++++++++++ .../authenticator/attestation_test.rb | 135 ++++++++ .../web_authn/authenticator/data_test.rb | 174 ++++++++++ .../web_authn/authenticator/response_test.rb | 141 +++++++++ .../web_authn/cbor_decoder_test.rb | 297 ++++++++++++++++++ .../action_pack/web_authn/cose_key_test.rb | 161 ++++++++++ .../creation_options_test.rb | 101 ++++++ .../request_options_test.rb | 72 +++++ .../web_authn/relying_party_test.rb | 30 ++ 24 files changed, 2626 insertions(+) create mode 100644 lib/action_pack/web_authn.rb create mode 100644 lib/action_pack/web_authn/authenticator/assertion_response.rb create mode 100644 lib/action_pack/web_authn/authenticator/attestation.rb create mode 100644 lib/action_pack/web_authn/authenticator/attestation_response.rb create mode 100644 lib/action_pack/web_authn/authenticator/data.rb create mode 100644 lib/action_pack/web_authn/authenticator/response.rb create mode 100644 lib/action_pack/web_authn/cbor_decoder.rb create mode 100644 lib/action_pack/web_authn/cose_key.rb create mode 100644 lib/action_pack/web_authn/current.rb create mode 100644 lib/action_pack/web_authn/public_key_credential.rb create mode 100644 lib/action_pack/web_authn/public_key_credential/creation_options.rb create mode 100644 lib/action_pack/web_authn/public_key_credential/options.rb create mode 100644 lib/action_pack/web_authn/public_key_credential/request_options.rb create mode 100644 lib/action_pack/web_authn/relying_party.rb create mode 100644 test/lib/action_pack/web_authn/authenticator/assertion_response_test.rb create mode 100644 test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb create mode 100644 test/lib/action_pack/web_authn/authenticator/attestation_test.rb create mode 100644 test/lib/action_pack/web_authn/authenticator/data_test.rb create mode 100644 test/lib/action_pack/web_authn/authenticator/response_test.rb create mode 100644 test/lib/action_pack/web_authn/cbor_decoder_test.rb create mode 100644 test/lib/action_pack/web_authn/cose_key_test.rb create mode 100644 test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb create mode 100644 test/lib/action_pack/web_authn/public_key_credential/request_options_test.rb create mode 100644 test/lib/action_pack/web_authn/relying_party_test.rb diff --git a/lib/action_pack/web_authn.rb b/lib/action_pack/web_authn.rb new file mode 100644 index 000000000..8ee72e6cb --- /dev/null +++ b/lib/action_pack/web_authn.rb @@ -0,0 +1,7 @@ +module ActionPack::WebAuthn + class << self + def relying_party + RelyingParty.new + end + end +end diff --git a/lib/action_pack/web_authn/authenticator/assertion_response.rb b/lib/action_pack/web_authn/authenticator/assertion_response.rb new file mode 100644 index 000000000..fa6aafcfd --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/assertion_response.rb @@ -0,0 +1,80 @@ +# = Action Pack WebAuthn Assertion Response +# +# Handles the authenticator response from a WebAuthn authentication ceremony. +# When a user authenticates with an existing credential, the authenticator +# returns an assertion response containing a signature that proves possession +# of the private key. +# +# == Usage +# +# # Look up the credential by ID +# credential = user.credentials.find_by!( +# credentail_id: params[:id] +# ) +# +# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new( +# client_data_json: params[:response][:clientDataJSON], +# authenticator_data: params[:response][:authenticatorData], +# signature: params[:response][:signature], +# credential: credential.to_public_key_credential +# ) +# +# response.validate!( +# challenge: session[:authentication_challenge], +# origin: "https://example.com" +# ) +# +# == Validation +# +# In addition to the base Response validations, this class verifies: +# +# * The client data type is "webauthn.get" +# * The signature is valid for the credential's public key +# +class ActionPack::WebAuthn::Authenticator::AssertionResponse < ActionPack::WebAuthn::Authenticator::Response + attr_reader :credential, :authenticator_data, :signature + + def initialize(credential:, authenticator_data:, signature:, **attributes) + super(**attributes) + @credential = credential + @signature = signature + @authenticator_data = ActionPack::WebAuthn::Authenticator::Data.wrap(authenticator_data) + end + + def validate!(**args) + super(**args) + + unless client_data["type"] == "webauthn.get" + raise InvalidResponseError, "Client data type is not webauthn.get" + end + + unless valid_signature? + raise InvalidResponseError, "Invalid signature" + end + + unless sign_count_increased? + raise InvalidResponseError, "Sign count did not increase" + end + end + + private + def valid_signature? + client_data_hash = Digest::SHA256.digest(client_data_json) + signed_data = authenticator_data.bytes.pack("C*") + client_data_hash + + credential.public_key.verify("SHA256", signature, signed_data) + rescue OpenSSL::PKey::PKeyError + false + end + + def sign_count_increased? + if authenticator_data.sign_count.zero? && credential.sign_count.zero? + # Some authenticators always return 0 for the sign count, even after multiple authentications. + # In that case, we have to check that both the stored and returned sign counts are 0, + # which indicates that the authenticator is likely not updating the sign count. + true + else + authenticator_data.sign_count > credential.sign_count + end + end +end diff --git a/lib/action_pack/web_authn/authenticator/attestation.rb b/lib/action_pack/web_authn/authenticator/attestation.rb new file mode 100644 index 000000000..66714fd6c --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/attestation.rb @@ -0,0 +1,58 @@ +# = Action Pack WebAuthn Attestation +# +# Decodes and represents the attestation object returned by an authenticator +# during registration. The attestation object is CBOR-encoded and contains +# the authenticator data along with an optional attestation statement. +# +# == Usage +# +# attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode( +# attestation_object_bytes +# ) +# +# attestation.credential_id # => "abc123..." +# attestation.public_key # => OpenSSL::PKey::EC +# attestation.sign_count # => 0 +# +# == Attributes +# +# [+authenticator_data+] +# The parsed Data containing credential information. +# +# [+format+] +# The attestation statement format (e.g., "none", "packed", "fido-u2f"). +# +# [+attestation_statement+] +# The attestation statement, which may contain a signature from the +# authenticator manufacturer. Empty for "none" format. +# +# == Delegated Methods +# +# The following methods are delegated to +authenticator_data+: +# +# * +credential_id+ - Base64URL-encoded credential identifier +# * +public_key+ - OpenSSL public key object +# * +public_key_bytes+ - Raw COSE key bytes +# * +sign_count+ - Signature counter for replay detection +# +class ActionPack::WebAuthn::Authenticator::Attestation + attr_reader :authenticator_data, :format, :attestation_statement + + delegate :credential_id, :public_key, :public_key_bytes, :sign_count, to: :authenticator_data + + def self.decode(bytes) + cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes) + + new( + authenticator_data: ActionPack::WebAuthn::Authenticator::Data.decode(cbor["authData"]), + format: cbor["fmt"], + attestation_statement: cbor["attStmt"] + ) + end + + def initialize(authenticator_data:, format:, attestation_statement:) + @authenticator_data = authenticator_data + @format = format + @attestation_statement = attestation_statement + end +end diff --git a/lib/action_pack/web_authn/authenticator/attestation_response.rb b/lib/action_pack/web_authn/authenticator/attestation_response.rb new file mode 100644 index 000000000..e20b4d7f7 --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/attestation_response.rb @@ -0,0 +1,58 @@ +# = Action Pack WebAuthn Attestation Response +# +# Handles the authenticator response from a WebAuthn registration ceremony. +# When a user registers a new credential, the authenticator returns an +# attestation response containing the new public key and credential ID. +# +# == Usage +# +# response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( +# client_data_json: params[:response][:clientDataJSON], +# attestation_object: params[:response][:attestationObject] +# ) +# +# response.validate!( +# challenge: session[:registration_challenge], +# origin: "https://example.com" +# ) +# +# # Store the credential +# credential_id = response.attestation.credential_id +# public_key = response.attestation.public_key +# +# == Validation +# +# In addition to the base Response validations, this class verifies: +# +# * The client data type is "webauthn.create" +# * The attestation format is "none" (other formats require certificate verification) +# +class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response + SUPPORTED_ATTESTATION_FORMATS = %w[ none ].freeze + attr_reader :attestation_object + + def initialize(attestation_object:, **attributes) + super(**attributes) + @attestation_object = attestation_object + end + + def validate!(**args) + super(**args) + + unless client_data["type"] == "webauthn.create" + raise InvalidResponseError, "Client data type is not webauthn.create" + end + + unless SUPPORTED_ATTESTATION_FORMATS.include?(attestation.format) + raise InvalidResponseError, "Unsupported attestation format: #{attestation.format}" + end + end + + def attestation + @attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.decode(attestation_object) + end + + def authenticator_data + attestation.authenticator_data + end +end diff --git a/lib/action_pack/web_authn/authenticator/data.rb b/lib/action_pack/web_authn/authenticator/data.rb new file mode 100644 index 000000000..a088179ef --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/data.rb @@ -0,0 +1,142 @@ +# = Action Pack WebAuthn Authenticator Data +# +# Decodes and represents the authenticator data structure from WebAuthn +# responses. This binary format contains information about the authenticator +# and, during registration, the newly created credential. +# +# == Structure +# +# The authenticator data consists of: +# +# * RP ID Hash (32 bytes) - SHA-256 hash of the relying party ID +# * Flags (1 byte) - Bit flags for user presence, verification, etc. +# * Sign Count (4 bytes) - Signature counter for replay detection +# * Attested Credential Data (variable) - Present only during registration +# +# == Usage +# +# data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes) +# +# data.user_present? # => true +# data.user_verified? # => true +# data.sign_count # => 42 +# data.credential_id # => "abc123..." (registration only) +# data.public_key # => OpenSSL::PKey::EC (registration only) +# +# == Flags +# +# [+user_present?+] +# Returns true if the user performed a test of user presence (e.g., touched +# the authenticator). +# +# [+user_verified?+] +# Returns true if the user was verified through biometrics, PIN, or other +# method. This is stronger than mere presence. +# +# [+backup_eligible?+] +# Returns true if the credential can be backed up (e.g., synced passkeys +# from Apple, Google, or Microsoft). Indicates multi-device credential support. +# +# [+backed_up?+] +# Returns true if the credential is currently backed up to cloud storage. +# Useful for risk assessment—backed-up credentials may be accessible from +# multiple devices. +# +class ActionPack::WebAuthn::Authenticator::Data + # Segment lengths + RELYING_PARTY_ID_HASH_LENGTH = 32 + FLAGS_LENGTH = 1 + SIGN_COUNT_LENGTH = 4 + AAGUID_LENGTH = 16 + CREDENTIAL_ID_LENGTH_BYTES = 2 + + # Flags + USER_PRESENT_FLAG = 0x01 + USER_VERIFIED_FLAG = 0x04 + BACKUP_ELIGIBLE_FLAG = 0x08 + BACKUP_STATE_FLAG = 0x10 + ATTESTED_CREDENTIAL_DATA_FLAG = 0x40 + + attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :credential_id, :public_key_bytes + + class << self + def wrap(data) + if data.is_a?(self) + data + else + decode(data) + end + end + + def decode(bytes) + bytes = bytes.bytes if bytes.is_a?(String) + position = 0 + + relying_party_id_hash = bytes[position, RELYING_PARTY_ID_HASH_LENGTH].pack("C*") + position += RELYING_PARTY_ID_HASH_LENGTH + + flags = bytes[position] + position += FLAGS_LENGTH + + sign_count = bytes[position, SIGN_COUNT_LENGTH].pack("C*").unpack1("N") + position += SIGN_COUNT_LENGTH + + credential_id = nil + public_key_bytes = nil + + if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0 + position += AAGUID_LENGTH + + credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n") + position += CREDENTIAL_ID_LENGTH_BYTES + + credential_id = Base64.urlsafe_encode64(bytes[position, credential_id_length].pack("C*"), padding: false) + position += credential_id_length + + public_key_bytes = bytes[position..].pack("C*") + end + + new( + bytes: bytes, + relying_party_id_hash: relying_party_id_hash, + flags: flags, + sign_count: sign_count, + credential_id: credential_id, + public_key_bytes: public_key_bytes + ) + end + end + + def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, credential_id:, public_key_bytes:) + @bytes = bytes + @relying_party_id_hash = relying_party_id_hash + @flags = flags + @sign_count = sign_count + @credential_id = credential_id + @public_key_bytes = public_key_bytes + end + + def user_present? + flags & USER_PRESENT_FLAG != 0 + end + + def user_verified? + flags & USER_VERIFIED_FLAG != 0 + end + + # Returns true if the credential is eligible for backup (e.g., synced passkey). + # This indicates the authenticator supports multi-device credentials. + def backup_eligible? + flags & BACKUP_ELIGIBLE_FLAG != 0 + end + + # Returns true if the credential is currently backed up to cloud storage. + # Only meaningful when +backup_eligible?+ is true. + def backed_up? + flags & BACKUP_STATE_FLAG != 0 + end + + def public_key + @public_key ||= ActionPack::WebAuthn::CoseKey.decode(public_key_bytes).to_openssl_key if public_key_bytes + end +end diff --git a/lib/action_pack/web_authn/authenticator/response.rb b/lib/action_pack/web_authn/authenticator/response.rb new file mode 100644 index 000000000..cdedc8411 --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/response.rb @@ -0,0 +1,117 @@ +# = Action Pack WebAuthn Authenticator Response +# +# Abstract base class for WebAuthn authenticator responses. Provides common +# validation logic for both registration (attestation) and authentication +# (assertion) ceremonies. +# +# This class should not be instantiated directly. Use AttestationResponse for +# registration or AssertionResponse for authentication. +# +# == Validation +# +# The +validate!+ method performs security checks required by the WebAuthn +# specification: +# +# * Challenge verification - ensures the response matches the server-generated challenge +# * Origin verification - ensures the response comes from the expected origin +# * User verification - optionally requires biometric or PIN verification +# +# == Example +# +# response.validate!( +# challenge: session[:webauthn_challenge], +# origin: "https://example.com", +# user_verification: :required +# ) +# +class ActionPack::WebAuthn::Authenticator::Response + # Raised when response validation fails. + class InvalidResponseError < StandardError; end + + attr_reader :client_data_json + + def initialize(client_data_json:) + @client_data_json = client_data_json + end + + def valid?(**args) + validate!(**args) + true + rescue InvalidResponseError + false + end + + def validate!(challenge:, origin:, user_verification: :preferred) + unless challenge_matches?(challenge) + raise InvalidResponseError, "Challenge does not match" + end + + unless origin_matches?(origin) + raise InvalidResponseError, "Origin does not match" + end + + if cross_origin? + raise InvalidResponseError, "Cross-origin requests are not supported" + end + + if token_binding_present? + raise InvalidResponseError, "Token binding is not supported" + end + + unless relying_party_id_matches? + raise InvalidResponseError, "Relying party ID does not match" + end + + unless user_present? + raise InvalidResponseError, "User presence is required" + end + + if user_verification == :required && !user_verified? + raise InvalidResponseError, "User verification is required" + end + end + + def relying_party + ActionPack::WebAuthn.relying_party + end + + def client_data + @client_data ||= JSON.parse(client_data_json) + end + + def authenticator_data + nil + end + + private + def challenge_matches?(expected_challenge) + ActiveSupport::SecurityUtils.secure_compare(expected_challenge, client_data["challenge"]) + end + + def origin_matches?(expected_origin) + ActiveSupport::SecurityUtils.secure_compare(expected_origin, client_data["origin"]) + end + + def relying_party_id_matches? + ActiveSupport::SecurityUtils.secure_compare( + Digest::SHA256.digest(relying_party.id), + authenticator_data&.relying_party_id_hash || "" + ) + end + + def cross_origin? + client_data["crossOrigin"] == true + end + + def token_binding_present? + client_data.dig("tokenBinding", "status") == "present" + end + + def user_present? + authenticator_data&.user_present? + end + + def user_verified? + authenticator_data&.user_verified? + end +end diff --git a/lib/action_pack/web_authn/cbor_decoder.rb b/lib/action_pack/web_authn/cbor_decoder.rb new file mode 100644 index 000000000..af8e284f7 --- /dev/null +++ b/lib/action_pack/web_authn/cbor_decoder.rb @@ -0,0 +1,262 @@ +# = Action Pack WebAuthn CBOR Decoder +# +# Decodes Concise Binary Object Representation (CBOR) data as specified in +# RFC 8949[https://tools.ietf.org/html/rfc8949]. CBOR is a binary data format +# used by WebAuthn for encoding authenticator data and attestation objects. +# +# == Usage +# +# The decoder accepts either a binary string or an array of bytes: +# +# # From binary string +# ActionPack::WebAuthn::CborDecoder.decode("\x83\x01\x02\x03") +# # => [1, 2, 3] +# +# # From byte array +# ActionPack::WebAuthn::CborDecoder.decode([0x83, 0x01, 0x02, 0x03]) +# # => [1, 2, 3] +# +# == Supported Types +# +# The decoder supports the following CBOR types: +# +# [Integers] +# Unsigned (major type 0) and negative (major type 1) integers of any size. +# +# [Byte strings] +# Binary data (major type 2), returned as ASCII-8BIT encoded strings. +# +# [Text strings] +# UTF-8 text (major type 3), returned as UTF-8 encoded strings. +# +# [Arrays] +# Ordered collections (major type 4) of any CBOR values. +# +# [Maps] +# Key-value pairs (major type 5) with any CBOR values as keys and values. +# +# [Floats] +# IEEE 754 half (16-bit), single (32-bit), and double (64-bit) precision. +# +# [Simple values] +# +false+, +true+, +null+, and +undefined+ (both decoded as +nil+). +# +# [Indefinite length] +# Streaming byte strings, text strings, arrays, and maps. +# +# Tags (major type 6) are recognized but their semantic meaning is ignored; +# the tagged value is returned directly. +# +# == Errors +# +# Raises +DecodeError+ when encountering malformed or unsupported CBOR data. +class ActionPack::WebAuthn::CborDecoder + # Raised when the decoder encounters invalid or unsupported CBOR data. + class DecodeError < StandardError; end + + # Major types + UNSIGNED_INTEGER_TYPE = 0 + NEGATIVE_INTEGER_TYPE = 1 + BYTE_STRING_TYPE = 2 + TEXT_STRING_TYPE = 3 + ARRAY_TYPE = 4 + MAP_TYPE = 5 + TAG_TYPE = 6 + FLOAT_OR_SIMPLE_TYPE = 7 + + # Additional information values + SIMPLE_VALUE_RANGE = 0..23 + SINGLE_BYTE_VALUE_FOLLOWS = 24 + TWO_BYTE_VALUE_FOLLOWS = 25 + FOUR_BYTE_VALUE_FOLLOWS = 26 + EIGHT_BYTE_VALUE_FOLLOWS = 27 + RESERVED_VALUE_RANGE = 28..30 + INDEFINITE_LENGTH_MAJOR_TYPE = 31 + + # Simple values + SIMPLE_FALSE_VALUE = 20 + SIMPLE_TRUE_VALUE = 21 + SIMPLE_NULL_VALUE = 22 + SIMPLE_UNDEFINED_VALUE = 23 + + # Flow control + BREAK_CODE = 0xFF + + # Limits + MAX_DEPTH = 16 + MAX_SIZE = 10.megabytes + + class << self + # Decodes a CBOR-encoded byte sequence into a Ruby object. + # + # ActionPack::WebAuthn::CborDecoder.decode("\xa2\x61a\x01\x61b\x02") + # # => {"a" => 1, "b" => 2} + def decode(bytes, **args) + bytes = bytes.bytes if bytes.respond_to?(:bytes) + new(bytes, **args).decode + end + end + + def initialize(bytes, max_depth: MAX_DEPTH, max_size: MAX_SIZE) # :nodoc: + raise DecodeError, "Input exceeds maximum size" if bytes.length > max_size + + @bytes = bytes + @max_depth = max_depth + @position = 0 + @depth = 0 + end + + # Decodes the next CBOR data item from the byte sequence. + def decode + raise DecodeError, "Unexpected end of input" if @position >= @bytes.length + raise DecodeError, "Maximum nesting depth exceeded" if @depth > @max_depth + + @depth += 1 + + result = case major_type + when UNSIGNED_INTEGER_TYPE then decode_unsigned_integer + when NEGATIVE_INTEGER_TYPE then decode_negative_integer + when BYTE_STRING_TYPE then decode_byte_string + when TEXT_STRING_TYPE then decode_text_string + when ARRAY_TYPE then decode_array + when MAP_TYPE then decode_map + when TAG_TYPE then decode_tag + when FLOAT_OR_SIMPLE_TYPE then decode_float_or_simple + end + + @depth -= 1 + result + end + + private + def major_type + peek >> 5 + end + + def peek + @bytes[@position] + end + + def decode_unsigned_integer + read_argument + end + + def decode_negative_integer + -1 - read_argument + end + + def decode_byte_string + if indefinite_length? + String.new(encoding: Encoding::ASCII_8BIT).tap { |str| str << decode_byte_string until break_code? } + else + read_bytes(read_argument).pack("C*") + end + end + + def decode_text_string + if indefinite_length? + String.new(encoding: Encoding::UTF_8).tap { |str| str << decode_text_string until break_code? } + else + read_bytes(read_argument).pack("C*").force_encoding(Encoding::UTF_8) + end + end + + def decode_array + if indefinite_length? + Array.new.tap { |arr| arr << decode until break_code? } + else + Array.new(read_argument) { decode } + end + end + + def decode_map + if indefinite_length? + Hash.new.tap { |hash| hash[decode] = decode until break_code? } + else + Hash.new.tap do |hash| + read_argument.times do + hash[decode] = decode + end + end + end + end + + def decode_float_or_simple + case info = additional_info + when SIMPLE_FALSE_VALUE then false + when SIMPLE_TRUE_VALUE then true + when SIMPLE_NULL_VALUE, SIMPLE_UNDEFINED_VALUE then nil + when TWO_BYTE_VALUE_FOLLOWS then decode_half_float + when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("g") + when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("G") + else + raise DecodeError, "Invalid simple value: #{info}" + end + end + + def decode_tag + read_argument + decode + end + + def decode_half_float + half = read_bytes(2).pack("C*").unpack1("n") + + sign = (half >> 15) & 0x1 + exponent = (half >> 10) & 0x1F + mantissa = half & 0x3FF + + value = if exponent == 0 + Math.ldexp(mantissa, -24) + elsif exponent == 31 + mantissa == 0 ? Float::INFINITY : Float::NAN + else + Math.ldexp(mantissa + 1024, exponent - 25) + end + + sign == 1 ? -value : value + end + + def read_argument + case info = additional_info + when SIMPLE_VALUE_RANGE then info + when SINGLE_BYTE_VALUE_FOLLOWS then read_byte + when TWO_BYTE_VALUE_FOLLOWS then read_bytes(2).pack("C*").unpack1("n") + when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("N") + when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("Q>") + when RESERVED_VALUE_RANGE + raise DecodeError, "Reserved additional info: #{info}" + else + raise DecodeError, "Invalid additional info: #{info}" + end + end + + def additional_info(consume: true) + byte = consume ? read_byte : peek + byte & 0b00011111 + end + + def indefinite_length? + read_byte if additional_info(consume: false) == INDEFINITE_LENGTH_MAJOR_TYPE + end + + def break_code? + read_byte if peek == BREAK_CODE + end + + def read_bytes(length) + raise DecodeError, "Unexpected end of input" if @position + length > @bytes.length + + bytes = @bytes[@position, length] + @position += length + bytes + end + + def read_byte + raise DecodeError, "Unexpected end of input" if @position >= @bytes.length + + byte = @bytes[@position] + @position += 1 + byte + end +end diff --git a/lib/action_pack/web_authn/cose_key.rb b/lib/action_pack/web_authn/cose_key.rb new file mode 100644 index 000000000..53ef3037c --- /dev/null +++ b/lib/action_pack/web_authn/cose_key.rb @@ -0,0 +1,141 @@ +# = Action Pack WebAuthn COSE Key +# +# Parses COSE (CBOR Object Signing and Encryption) public keys as specified in +# RFC 9053[https://datatracker.ietf.org/doc/html/rfc9053]. WebAuthn authenticators +# return public keys in COSE format, which must be converted to a standard format +# for signature verification. +# +# == Usage +# +# # Decode a COSE key from CBOR bytes (e.g., from authenticator data) +# cose_key = ActionPack::WebAuthn::CoseKey.decode(cbor_bytes) +# +# # Convert to OpenSSL key for signature verification +# openssl_key = cose_key.to_openssl_key +# openssl_key.verify("SHA256", signature, signed_data) +# +# == Supported Algorithms +# +# [ES256] +# ECDSA with P-256 curve and SHA-256. The most common algorithm for WebAuthn. +# +# [RS256] +# RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms. +# +# == Attributes +# +# [+key_type+] +# The COSE key type (2 for EC2, 3 for RSA). +# +# [+algorithm+] +# The COSE algorithm identifier (-7 for ES256, -257 for RS256). +# +# [+parameters+] +# The full COSE key parameters map, including curve and coordinate data. +class ActionPack::WebAuthn::CoseKey + # Raised when the key type, algorithm, or curve is not supported. + class UnsupportedKeyTypeError < StandardError; end + + # COSE key labels + KEY_TYPE_LABEL = 1 + ALGORITHM_LABEL = 3 + EC2_CURVE_LABEL = -1 + EC2_X_LABEL = -2 + EC2_Y_LABEL = -3 + RSA_N_LABEL = -1 + RSA_E_LABEL = -2 + + # COSE key types + EC2 = 2 + RSA = 3 + + # COSE algorithms + ES256 = -7 + RS256 = -257 + + # COSE EC2 curves + P256 = 1 + + # OpenSSL types + UNCOMPRESSED_POINT_MARKER = 0x04 + + attr_reader :key_type, :algorithm, :parameters + + class << self + # Decodes a COSE key from CBOR-encoded bytes. + # + # cose_key = ActionPack::WebAuthn::CoseKey.decode(cbor_bytes) + # cose_key.algorithm # => -7 (ES256) + def decode(bytes) + data = ActionPack::WebAuthn::CborDecoder.decode(bytes) + new( + key_type: data[KEY_TYPE_LABEL], + algorithm: data[ALGORITHM_LABEL], + parameters: data + ) + end + end + + def initialize(key_type:, algorithm:, parameters:) # :nodoc: + @key_type = key_type + @algorithm = algorithm + @parameters = parameters + end + + # Converts the COSE key to an OpenSSL public key object. + # + # Returns an +OpenSSL::PKey::EC+ for EC2 keys or +OpenSSL::PKey::RSA+ for + # RSA keys, suitable for use with +OpenSSL::PKey#verify+. + # + # Raises +UnsupportedKeyTypeError+ if the key type, algorithm, or curve + # is not supported. + def to_openssl_key + case [ key_type, algorithm ] + when [ EC2, ES256 ] then build_ec2_es256_key + when [ RSA, RS256 ] then build_rsa_rs256_key + else raise UnsupportedKeyTypeError, "Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}" + end + end + + private + def build_ec2_es256_key + curve = parameters[EC2_CURVE_LABEL] + raise UnsupportedKeyTypeError, "Unsupported EC curve: #{curve}" unless curve == P256 + + x = parameters[EC2_X_LABEL] + y = parameters[EC2_Y_LABEL] + + # Uncompressed point format: 0x04 || x || y + public_key_bytes = [ UNCOMPRESSED_POINT_MARKER, *x.bytes, *y.bytes ].pack("C*") + + asn1 = OpenSSL::ASN1::Sequence([ + OpenSSL::ASN1::Sequence([ + OpenSSL::ASN1::ObjectId("id-ecPublicKey"), + OpenSSL::ASN1::ObjectId("prime256v1") + ]), + OpenSSL::ASN1::BitString(public_key_bytes) + ]) + + OpenSSL::PKey::EC.new(asn1.to_der) + end + + def build_rsa_rs256_key + n = OpenSSL::BN.new(parameters[RSA_N_LABEL], 2) + e = OpenSSL::BN.new(parameters[RSA_E_LABEL], 2) + + asn1 = OpenSSL::ASN1::Sequence([ + OpenSSL::ASN1::Sequence([ + OpenSSL::ASN1::ObjectId("rsaEncryption"), + OpenSSL::ASN1::Null.new(nil) + ]), + OpenSSL::ASN1::BitString( + OpenSSL::ASN1::Sequence([ + OpenSSL::ASN1::Integer(n), + OpenSSL::ASN1::Integer(e) + ]).to_der + ) + ]) + + OpenSSL::PKey::RSA.new(asn1.to_der) + end +end diff --git a/lib/action_pack/web_authn/current.rb b/lib/action_pack/web_authn/current.rb new file mode 100644 index 000000000..ab9038a80 --- /dev/null +++ b/lib/action_pack/web_authn/current.rb @@ -0,0 +1,3 @@ +class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes + attribute :host +end diff --git a/lib/action_pack/web_authn/public_key_credential.rb b/lib/action_pack/web_authn/public_key_credential.rb new file mode 100644 index 000000000..1e00b74fb --- /dev/null +++ b/lib/action_pack/web_authn/public_key_credential.rb @@ -0,0 +1,41 @@ +class ActionPack::WebAuthn::PublicKeyCredential + attr_reader :id, :public_key, :sign_count, :owner + + class << self + def create(client_data_json:, attestation_object:, challenge:, origin:, owner: nil) + response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( + client_data_json: client_data_json, + attestation_object: attestation_object + ) + + response.validate!(challenge: challenge, origin: origin) + + new( + id: response.attestation.credential_id, + public_key: response.attestation.public_key, + sign_count: response.attestation.sign_count, + owner: owner + ) + end + end + + def initialize(id:, public_key:, sign_count:, owner: nil) + @id = id + @public_key = public_key + @sign_count = sign_count + @owner = owner + end + + def authenticate(client_data_json:, authenticator_data:, signature:, challenge:, origin:) + response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new( + client_data_json: client_data_json, + authenticator_data: authenticator_data, + signature: signature, + credential: self + ) + + response.validate!(challenge: challenge, origin: origin) + + @sign_count = response.authenticator_data.sign_count + 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 new file mode 100644 index 000000000..0497a2bad --- /dev/null +++ b/lib/action_pack/web_authn/public_key_credential/creation_options.rb @@ -0,0 +1,115 @@ +# = Action Pack WebAuthn Public Key Credential Creation Options +# +# Generates options for the WebAuthn registration ceremony (creating a new +# credential). These options are passed to +navigator.credentials.create()+ in +# the browser to prompt the user to register an authenticator. +# +# == Usage +# +# options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( +# id: current_user.id, +# name: current_user.email, +# display_name: current_user.name +# ) +# +# # In your controller, return as JSON for the JavaScript WebAuthn API +# render json: { publicKey: options.as_json } +# +# == Attributes +# +# [+id+] +# A unique identifier for the user account. Will be Base64URL-encoded in the +# output. This should be an opaque identifier (like a primary key), not +# personally identifiable information. +# +# [+name+] +# A human-readable identifier for the user account, typically an email +# address or username. Displayed by the authenticator. +# +# [+display_name+] +# A human-friendly name for the user, typically their full name. Displayed +# by the authenticator during registration. +# +# [+relying_party+] +# The relying party (your application) configuration. Defaults to +# +ActionPack::WebAuthn.relying_party+. +# +# == Supported Algorithms +# +# By default, supports ES256 (ECDSA with P-256 and SHA-256) and RS256 +# (RSASSA-PKCS1-v1_5 with SHA-256), which cover the vast majority of +# authenticators. +class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::WebAuthn::PublicKeyCredential::Options + ES256 = { type: "public-key", alg: -7 }.freeze + RS256 = { type: "public-key", alg: -257 }.freeze + + attr_reader :id, :name, :display_name + + # Creates a new set of credential creation options. + # + # ==== Options + # + # [+:id+] + # Required. The user's unique identifier. + # + # [+:name+] + # Required. The user's account name (e.g., email). + # + # [+:display_name+] + # Required. The user's display name. + # + # [+:relying_party+] + # Optional. The relying party configuration. + # + # [+:resident_key+] + # Optional. Resident key requirement. One of +:preferred+, +:required+, + # or +:discouraged+. Defaults to +:preferred+. + # + # [+:exclude_credentials+] + # Optional. Existing credentials to exclude from registration. Each must + # respond to +id+ and +transports+. + def initialize(id:, name:, display_name:, resident_key: :preferred, exclude_credentials: [], **attrs) + super(**attrs) + + @id = id + @name = name + @display_name = display_name + @resident_key = resident_key + @exclude_credentials = exclude_credentials + end + + # Returns a Hash suitable for JSON serialization and passing to the + # WebAuthn JavaScript API. + def as_json(*) + json = { + challenge: challenge, + rp: relying_party.as_json, + user: { + id: Base64.urlsafe_encode64(id.to_s, padding: false), + name: name, + displayName: display_name + }, + pubKeyCredParams: [ + ES256, + RS256 + ], + authenticatorSelection: { + residentKey: @resident_key.to_s, + userVerification: user_verification.to_s + } + } + + if @exclude_credentials.any? + json[:excludeCredentials] = @exclude_credentials.map { |credential| exclude_credential_json(credential) } + end + + json + end + + private + def exclude_credential_json(credential) + hash = { type: "public-key", id: credential.id } + hash[:transports] = credential.transports if credential.transports.any? + hash + end +end diff --git a/lib/action_pack/web_authn/public_key_credential/options.rb b/lib/action_pack/web_authn/public_key_credential/options.rb new file mode 100644 index 000000000..24c0fc29b --- /dev/null +++ b/lib/action_pack/web_authn/public_key_credential/options.rb @@ -0,0 +1,27 @@ +class ActionPack::WebAuthn::PublicKeyCredential::Options + CHALLENGE_LENGTH = 32 + USER_VERIFICATION_OPTIONS = [ :required, :preferred, :discouraged ].freeze + + attr_reader :user_verification, :relying_party + + def initialize(user_verification: :preferred, relying_party: ActionPack::WebAuthn.relying_party) + @user_verification = user_verification.to_sym + @relying_party = relying_party + + unless USER_VERIFICATION_OPTIONS.include?(user_verification) + raise ArgumentError, "Invalid user verification option: #{user_verification.inspect}" + end + end + + # Returns a Base64URL-encoded random challenge. The challenge is generated + # once and memoized for the lifetime of this object. + # + # The challenge must be stored server-side and verified when the client + # responds, to prevent replay attacks. + def challenge + @challenge ||= Base64.urlsafe_encode64( + SecureRandom.random_bytes(CHALLENGE_LENGTH), + padding: false + ) + end +end diff --git a/lib/action_pack/web_authn/public_key_credential/request_options.rb b/lib/action_pack/web_authn/public_key_credential/request_options.rb new file mode 100644 index 000000000..0b62ad532 --- /dev/null +++ b/lib/action_pack/web_authn/public_key_credential/request_options.rb @@ -0,0 +1,61 @@ +# = Action Pack WebAuthn Public Key Credential Request Options +# +# Generates options for the WebAuthn authentication ceremony (using an existing +# credential). These options are passed to +navigator.credentials.get()+ in +# the browser to prompt the user to authenticate with a registered authenticator. +# +# == Usage +# +# options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new( +# credentials: current_user.webauthn_credentials +# ) +# +# # In your controller, return as JSON for the JavaScript WebAuthn API +# render json: { publicKey: options.as_json } +# +# == Attributes +# +# [+credentials+] +# A collection of credential records for the user. Each credential must +# respond to +id+ returning the Base64URL-encoded credential ID, and +# +transports+ returning an array of transport strings. +# +# [+relying_party+] +# The relying party (your application) configuration. Defaults to +# +ActionPack::WebAuthn.relying_party+. +class ActionPack::WebAuthn::PublicKeyCredential::RequestOptions < ActionPack::WebAuthn::PublicKeyCredential::Options + attr_reader :credentials + + # Creates a new set of credential request options. + # + # ==== Options + # + # [+:credentials+] + # Required. The user's registered WebAuthn credentials. + # + # [+:relying_party+] + # Optional. The relying party configuration. + def initialize(credentials:, **attrs) + super(**attrs) + + @credentials = credentials + end + + # Returns a Hash suitable for JSON serialization and passing to the + # WebAuthn JavaScript API. + def as_json(*) + { + challenge: challenge, + rpId: relying_party.id, + allowCredentials: credentials.map { |credential| allow_credential_json(credential) }, + userVerification: user_verification.to_s + } + end + + private + def allow_credential_json(credential) + hash = { type: "public-key", id: credential.id } + hash[:transports] = credential.transports if credential.transports.any? + hash + end +end diff --git a/lib/action_pack/web_authn/relying_party.rb b/lib/action_pack/web_authn/relying_party.rb new file mode 100644 index 000000000..08dba0d36 --- /dev/null +++ b/lib/action_pack/web_authn/relying_party.rb @@ -0,0 +1,50 @@ +# = Action Pack WebAuthn Relying Party +# +# Represents the relying party (your application) in WebAuthn ceremonies. The +# relying party identity is sent to authenticators during registration and +# authentication to scope credentials to your application. +# +# == Usage +# +# # Using defaults (host from Current, name from Rails application) +# relying_party = ActionPack::WebAuthn::RelyingParty.new +# +# # With explicit values +# relying_party = ActionPack::WebAuthn::RelyingParty.new( +# id: "example.com", +# name: "Example Application" +# ) +# +# == Attributes +# +# [+id+] +# The relying party identifier, typically the application's domain name +# (e.g., "example.com"). This must match the origin's effective domain +# or be a registrable domain suffix of it. Credentials are scoped to this +# identifier. Defaults to +ActionPack::WebAuthn::Current.host+. +# +# [+name+] +# A human-readable name for your application, displayed by authenticators +# during ceremonies. Defaults to +Rails.application.name+. +class ActionPack::WebAuthn::RelyingParty + attr_reader :id, :name + + # Creates a new relying party configuration. + # + # ==== Options + # + # [+:id+] + # Optional. The relying party identifier (domain). + # + # [+:name+] + # Optional. The application display name. + def initialize(id: ActionPack::WebAuthn::Current.host, name: Rails.application.name) + @id = id + @name = name + end + + # Returns a Hash suitable for JSON serialization. + def as_json(*) + { id: id, name: name } + end +end diff --git a/test/lib/action_pack/web_authn/authenticator/assertion_response_test.rb b/test/lib/action_pack/web_authn/authenticator/assertion_response_test.rb new file mode 100644 index 000000000..68abaf2df --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/assertion_response_test.rb @@ -0,0 +1,158 @@ +require "test_helper" + +class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase + setup do + ActionPack::WebAuthn::Current.host = "example.com" + + @challenge = "test-challenge-123" + @origin = "https://example.com" + @client_data_json = { + challenge: @challenge, + origin: @origin, + type: "webauthn.get" + }.to_json + + # Generate a real key pair for signature verification + @private_key = OpenSSL::PKey::EC.generate("prime256v1") + @public_key = @private_key + @credential = Struct.new(:public_key, :sign_count).new(@public_key, 0) + + @authenticator_data = build_authenticator_data(user_verified: true) + @signature = sign(@authenticator_data, @client_data_json) + + @response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new( + client_data_json: @client_data_json, + authenticator_data: @authenticator_data, + signature: @signature, + credential: @credential + ) + end + + test "initializes with credential, authenticator data, and signature" do + assert_equal @credential, @response.credential + assert_instance_of ActionPack::WebAuthn::Authenticator::Data, @response.authenticator_data + assert_equal @signature, @response.signature + end + + test "validate! succeeds with valid challenge, origin, type, and signature" do + assert_nothing_raised do + @response.validate!(challenge: @challenge, origin: @origin) + end + end + + test "validate! raises when type is not webauthn.get" do + client_data_json = { + challenge: @challenge, + origin: @origin, + type: "webauthn.create" + }.to_json + + response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new( + client_data_json: client_data_json, + authenticator_data: @authenticator_data, + signature: sign(@authenticator_data, client_data_json), + credential: @credential + ) + + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + response.validate!(challenge: @challenge, origin: @origin) + end + + assert_equal "Client data type is not webauthn.get", error.message + end + + test "validate! raises when signature is invalid" do + response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new( + client_data_json: @client_data_json, + authenticator_data: @authenticator_data, + signature: "invalid-signature", + credential: @credential + ) + + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + response.validate!(challenge: @challenge, origin: @origin) + end + + assert_equal "Invalid signature", error.message + end + + test "validate! raises when challenge does not match" do + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + @response.validate!(challenge: "wrong-challenge", origin: @origin) + end + + assert_equal "Challenge does not match", error.message + end + + test "validate! raises when origin does not match" do + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + @response.validate!(challenge: @challenge, origin: "https://evil.com") + end + + assert_equal "Origin does not match", error.message + end + + test "validate! succeeds with user_verification preferred when not verified" do + authenticator_data = build_authenticator_data(user_verified: false) + response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new( + client_data_json: @client_data_json, + authenticator_data: authenticator_data, + signature: sign(authenticator_data, @client_data_json), + credential: @credential + ) + + assert_nothing_raised do + response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred) + end + end + + test "validate! succeeds with user_verification required when verified" do + authenticator_data = build_authenticator_data(user_verified: true) + response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new( + client_data_json: @client_data_json, + authenticator_data: authenticator_data, + signature: sign(authenticator_data, @client_data_json), + credential: @credential + ) + + assert_nothing_raised do + response.validate!(challenge: @challenge, origin: @origin, user_verification: :required) + end + end + + test "validate! raises with user_verification required when not verified" do + authenticator_data = build_authenticator_data(user_verified: false) + response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new( + client_data_json: @client_data_json, + authenticator_data: authenticator_data, + signature: sign(authenticator_data, @client_data_json), + credential: @credential + ) + + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + response.validate!(challenge: @challenge, origin: @origin, user_verification: :required) + end + + assert_equal "User verification is required", error.message + end + + private + def build_authenticator_data(user_verified:) + rp_id_hash = Digest::SHA256.digest("example.com") + flags = 0x01 # user present + flags |= 0x04 if user_verified + sign_count = 0 + + 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/authenticator/attestation_response_test.rb b/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb new file mode 100644 index 000000000..5d58a3ff4 --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb @@ -0,0 +1,195 @@ +require "test_helper" + +class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSupport::TestCase + setup do + ActionPack::WebAuthn::Current.host = "example.com" + + @challenge = "test-challenge-123" + @origin = "https://example.com" + @client_data_json = { + challenge: @challenge, + origin: @origin, + type: "webauthn.create" + }.to_json + + @response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( + client_data_json: @client_data_json, + attestation_object: build_attestation_object(user_verified: true) + ) + end + + test "initializes with attestation object" do + assert_not_nil @response.attestation_object + end + + test "validate! succeeds with valid challenge, origin, and type" do + assert_nothing_raised do + @response.validate!(challenge: @challenge, origin: @origin) + end + end + + test "validate! succeeds with user_verification preferred when not verified" do + response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( + client_data_json: @client_data_json, + attestation_object: build_attestation_object(user_verified: false) + ) + + assert_nothing_raised do + response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred) + end + end + + test "validate! succeeds with user_verification required when verified" do + response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( + client_data_json: @client_data_json, + attestation_object: build_attestation_object(user_verified: true) + ) + + assert_nothing_raised do + response.validate!(challenge: @challenge, origin: @origin, user_verification: :required) + end + end + + test "validate! raises with user_verification required when not verified" do + response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( + client_data_json: @client_data_json, + attestation_object: build_attestation_object(user_verified: false) + ) + + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + response.validate!(challenge: @challenge, origin: @origin, user_verification: :required) + end + + assert_equal "User verification is required", error.message + end + + test "validate! raises when type is not webauthn.create" do + client_data_json = { + challenge: @challenge, + origin: @origin, + type: "webauthn.get" + }.to_json + + response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( + client_data_json: client_data_json, + attestation_object: build_attestation_object(user_verified: true) + ) + + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + response.validate!(challenge: @challenge, origin: @origin) + end + + assert_equal "Client data type is not webauthn.create", error.message + end + + test "validate! raises when challenge does not match" do + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + @response.validate!(challenge: "wrong-challenge", origin: @origin) + end + + assert_equal "Challenge does not match", error.message + end + + test "validate! raises when origin does not match" do + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + @response.validate!(challenge: @challenge, origin: "https://evil.com") + end + + assert_equal "Origin does not match", error.message + end + + test "validate! raises when attestation format is not supported" do + response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( + client_data_json: @client_data_json, + attestation_object: build_attestation_object(user_verified: true, format: "packed") + ) + + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + response.validate!(challenge: @challenge, origin: @origin) + end + + assert_equal "Unsupported attestation format: packed", error.message + end + + private + def build_attestation_object(user_verified:, format: "none") + auth_data = build_authenticator_data(user_verified: user_verified) + encode_cbor_attestation_object(auth_data, format: format) + end + + def build_authenticator_data(user_verified:) + rp_id_hash = Digest::SHA256.digest("example.com") + flags = 0x41 # user present + attested credential + flags |= 0x04 if user_verified + sign_count = 0 + aaguid = SecureRandom.random_bytes(16) + credential_id = SecureRandom.random_bytes(32) + cose_key = build_cose_key + + bytes = [] + bytes.concat(rp_id_hash.bytes) + bytes << flags + bytes.concat([sign_count].pack("N").bytes) + bytes.concat(aaguid.bytes) + bytes.concat([credential_id.bytesize].pack("n").bytes) + bytes.concat(credential_id.bytes) + bytes.concat(cose_key.bytes) + bytes.pack("C*") + end + + def build_cose_key + ec_key = OpenSSL::PKey::EC.generate("prime256v1") + public_key_bn = ec_key.public_key.to_bn + public_key_point = public_key_bn.to_s(2) + x = public_key_point[1, 32] + y = public_key_point[33, 32] + + params = { 1 => 2, 3 => -7, -1 => 1, -2 => x, -3 => y } + encode_cbor_map(params) + end + + def encode_cbor_attestation_object(auth_data, format:) + bytes = [0xa3] + bytes.concat([0x63, *"fmt".bytes]) + bytes.concat([0x40 + format.bytesize, *format.bytes]) + bytes.concat([0x67, *"attStmt".bytes]) + bytes << 0xa0 + bytes.concat([0x68, *"authData".bytes]) + if auth_data.bytesize <= 255 + bytes.concat([0x58, auth_data.bytesize]) + else + bytes.concat([0x59, (auth_data.bytesize >> 8) & 0xff, auth_data.bytesize & 0xff]) + end + bytes.concat(auth_data.bytes) + bytes.pack("C*") + end + + def encode_cbor_map(hash) + bytes = [0xa0 + hash.size] + hash.each do |key, value| + bytes.concat(encode_cbor_integer(key)) + bytes.concat(encode_cbor_value(value)) + end + bytes.pack("C*") + end + + def encode_cbor_integer(int) + if int >= 0 && int <= 23 + [int] + elsif int >= -24 && int < 0 + [0x20 - int - 1] + else + raise "Integer encoding not implemented for #{int}" + end + end + + def encode_cbor_value(value) + case value + when Integer + encode_cbor_integer(value) + when String + length = value.bytesize + length <= 23 ? [0x40 + length, *value.bytes] : [0x58, length, *value.bytes] + end + end +end diff --git a/test/lib/action_pack/web_authn/authenticator/attestation_test.rb b/test/lib/action_pack/web_authn/authenticator/attestation_test.rb new file mode 100644 index 000000000..4531039e0 --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/attestation_test.rb @@ -0,0 +1,135 @@ +require "test_helper" + +class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::TestCase + setup do + @rp_id_hash = Digest::SHA256.digest("example.com") + @sign_count = 42 + @aaguid = SecureRandom.random_bytes(16) + @credential_id = SecureRandom.random_bytes(32) + + # Generate a real EC key + ec_key = OpenSSL::PKey::EC.generate("prime256v1") + public_key_bn = ec_key.public_key.to_bn + public_key_point = public_key_bn.to_s(2) + x_coord = public_key_point[1, 32] + y_coord = public_key_point[33, 32] + + @cose_key = build_cose_key(x_coord, y_coord) + @auth_data = build_authenticator_data + @attestation_object = build_attestation_object + end + + test "decodes attestation object" do + attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object) + + assert_equal "none", attestation.format + assert_equal({}, attestation.attestation_statement) + assert_instance_of ActionPack::WebAuthn::Authenticator::Data, attestation.authenticator_data + end + + test "delegates credential_id to authenticator_data" do + attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object) + + assert_equal Base64.urlsafe_encode64(@credential_id, padding: false), attestation.credential_id + end + + test "delegates sign_count to authenticator_data" do + attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object) + + assert_equal @sign_count, attestation.sign_count + end + + test "delegates public_key to authenticator_data" do + attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object) + + assert_instance_of OpenSSL::PKey::EC, attestation.public_key + end + + private + def build_authenticator_data + bytes = [] + bytes.concat(@rp_id_hash.bytes) + bytes << 0x41 # flags: user present + attested credential + bytes.concat([@sign_count].pack("N").bytes) + bytes.concat(@aaguid.bytes) + bytes.concat([@credential_id.bytesize].pack("n").bytes) + bytes.concat(@credential_id.bytes) + bytes.concat(@cose_key.bytes) + bytes.pack("C*") + end + + def build_attestation_object + # CBOR map: { "fmt": "none", "attStmt": {}, "authData": } + encode_cbor_attestation_object + end + + def encode_cbor_attestation_object + bytes = [0xa3] # map with 3 items + + # "fmt" => "none" + bytes.concat([0x63, *"fmt".bytes]) # text string "fmt" + bytes.concat([0x64, *"none".bytes]) # text string "none" + + # "attStmt" => {} + bytes.concat([0x67, *"attStmt".bytes]) # text string "attStmt" + bytes << 0xa0 # empty map + + # "authData" => + bytes.concat([0x68, *"authData".bytes]) # text string "authData" + auth_data_length = @auth_data.bytesize + if auth_data_length <= 23 + bytes << (0x40 + auth_data_length) + elsif auth_data_length <= 255 + bytes.concat([0x58, auth_data_length]) + else + bytes.concat([0x59, (auth_data_length >> 8) & 0xff, auth_data_length & 0xff]) + end + bytes.concat(@auth_data.bytes) + + bytes.pack("C*") + end + + def build_cose_key(x, y) + params = { + 1 => 2, # kty: EC2 + 3 => -7, # alg: ES256 + -1 => 1, # crv: P-256 + -2 => x, + -3 => y + } + encode_cbor_map(params) + end + + def encode_cbor_map(hash) + bytes = [0xa0 + hash.size] + hash.each do |key, value| + bytes.concat(encode_cbor_integer(key)) + bytes.concat(encode_cbor_value(value)) + end + bytes.pack("C*") + end + + def encode_cbor_integer(int) + if int >= 0 && int <= 23 + [int] + elsif int >= -24 && int < 0 + [0x20 - int - 1] + else + raise "Integer encoding not implemented for #{int}" + end + end + + def encode_cbor_value(value) + case value + when Integer + encode_cbor_integer(value) + when String + length = value.bytesize + if length <= 23 + [0x40 + length, *value.bytes] + else + [0x58, length, *value.bytes] + end + end + end +end diff --git a/test/lib/action_pack/web_authn/authenticator/data_test.rb b/test/lib/action_pack/web_authn/authenticator/data_test.rb new file mode 100644 index 000000000..3291585cc --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/data_test.rb @@ -0,0 +1,174 @@ +require "test_helper" + +class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase + setup do + @rp_id_hash = Digest::SHA256.digest("example.com") + @sign_count = 42 + @aaguid = SecureRandom.random_bytes(16) + @credential_id = SecureRandom.random_bytes(32) + + # Generate a real EC key for COSE encoding + ec_key = OpenSSL::PKey::EC.generate("prime256v1") + public_key_bn = ec_key.public_key.to_bn + public_key_point = public_key_bn.to_s(2) + x_coord = public_key_point[1, 32] + y_coord = public_key_point[33, 32] + + @cose_key = build_cose_key(x_coord, y_coord) + end + + test "decodes authenticator data without attested credential" do + flags = 0x01 # user present only + bytes = build_authenticator_data(flags: flags, include_credential: false) + + data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes) + + assert_equal @rp_id_hash, data.relying_party_id_hash + assert_equal flags, data.flags + assert_equal @sign_count, data.sign_count + assert_nil data.credential_id + assert_nil data.public_key_bytes + end + + test "decodes authenticator data with attested credential" do + flags = 0x41 # user present + attested credential data + bytes = build_authenticator_data(flags: flags, include_credential: true) + + data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes) + + assert_equal @rp_id_hash, data.relying_party_id_hash + assert_equal flags, data.flags + assert_equal @sign_count, data.sign_count + assert_equal Base64.urlsafe_encode64(@credential_id, padding: false), data.credential_id + assert_equal @cose_key, data.public_key_bytes + end + + test "user_present? returns true when flag is set" do + data = build_data_with_flags(0x01) + assert data.user_present? + end + + test "user_present? returns false when flag is not set" do + data = build_data_with_flags(0x00) + assert_not data.user_present? + end + + test "user_verified? returns true when flag is set" do + data = build_data_with_flags(0x04) + assert data.user_verified? + end + + test "user_verified? returns false when flag is not set" do + data = build_data_with_flags(0x00) + assert_not data.user_verified? + end + + test "backup_eligible? returns true when flag is set" do + data = build_data_with_flags(0x08) + assert data.backup_eligible? + end + + test "backup_eligible? returns false when flag is not set" do + data = build_data_with_flags(0x00) + assert_not data.backup_eligible? + end + + test "backed_up? returns true when flag is set" do + data = build_data_with_flags(0x10) + assert data.backed_up? + end + + test "backed_up? returns false when flag is not set" do + data = build_data_with_flags(0x00) + assert_not data.backed_up? + end + + test "public_key returns OpenSSL key when public_key_bytes present" do + flags = 0x41 + bytes = build_authenticator_data(flags: flags, include_credential: true) + data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes) + + assert_instance_of OpenSSL::PKey::EC, data.public_key + end + + test "public_key returns nil when public_key_bytes not present" do + flags = 0x01 + bytes = build_authenticator_data(flags: flags, include_credential: false) + data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes) + + assert_nil data.public_key + end + + private + def build_authenticator_data(flags:, include_credential:) + bytes = [] + bytes.concat(@rp_id_hash.bytes) + bytes << flags + bytes.concat([ @sign_count ].pack("N").bytes) + + if include_credential + bytes.concat(@aaguid.bytes) + bytes.concat([ @credential_id.bytesize ].pack("n").bytes) + bytes.concat(@credential_id.bytes) + bytes.concat(@cose_key.bytes) + end + + bytes.pack("C*") + end + + def build_data_with_flags(flags) + ActionPack::WebAuthn::Authenticator::Data.new( + bytes: [], + relying_party_id_hash: @rp_id_hash, + flags: flags, + sign_count: 0, + credential_id: nil, + public_key_bytes: nil + ) + end + + def build_cose_key(x, y) + # Simple CBOR map for EC2 key + params = { + 1 => 2, # kty: EC2 + 3 => -7, # alg: ES256 + -1 => 1, # crv: P-256 + -2 => x, + -3 => y + } + encode_cbor_map(params) + end + + def encode_cbor_map(hash) + bytes = [ 0xa0 + hash.size ] + hash.each do |key, value| + bytes.concat(encode_cbor_integer(key)) + bytes.concat(encode_cbor_value(value)) + end + bytes.pack("C*") + end + + def encode_cbor_integer(int) + if int >= 0 && int <= 23 + [ int ] + elsif int >= -24 && int < 0 + [ 0x20 - int - 1 ] + else + raise "Integer encoding not implemented for #{int}" + end + end + + def encode_cbor_value(value) + case value + when Integer + encode_cbor_integer(value) + when String + length = value.bytesize + if length <= 23 + [ 0x40 + length, *value.bytes ] + else + [ 0x58, length, *value.bytes ] + end + end + end +end diff --git a/test/lib/action_pack/web_authn/authenticator/response_test.rb b/test/lib/action_pack/web_authn/authenticator/response_test.rb new file mode 100644 index 000000000..8a172e561 --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/response_test.rb @@ -0,0 +1,141 @@ +require "test_helper" + +class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase + setup do + ActionPack::WebAuthn::Current.host = "example.com" + + @challenge = "test-challenge-123" + @origin = "https://example.com" + @client_data_json = { + challenge: @challenge, + origin: @origin, + type: "webauthn.create" + }.to_json + + @authenticator_data = build_authenticator_data + @response = TestableResponse.new( + client_data_json: @client_data_json, + authenticator_data: @authenticator_data + ) + end + + class TestableResponse < ActionPack::WebAuthn::Authenticator::Response + attr_reader :authenticator_data + + def initialize(authenticator_data:, **attrs) + super(**attrs) + @authenticator_data = authenticator_data + end + end + + test "parses client data JSON" do + assert_equal @challenge, @response.client_data["challenge"] + assert_equal @origin, @response.client_data["origin"] + end + + test "valid? returns true when challenge and origin match" do + assert @response.valid?(challenge: @challenge, origin: @origin) + end + + test "valid? returns false when challenge does not match" do + assert_not @response.valid?(challenge: "wrong-challenge", origin: @origin) + end + + test "valid? returns false when origin does not match" do + assert_not @response.valid?(challenge: @challenge, origin: "https://evil.com") + end + + test "validate! raises when challenge does not match" do + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + @response.validate!(challenge: "wrong-challenge", origin: @origin) + end + + assert_equal "Challenge does not match", error.message + end + + test "validate! raises when origin does not match" do + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + @response.validate!(challenge: @challenge, origin: "https://evil.com") + end + + assert_equal "Origin does not match", error.message + end + + test "validate! raises when crossOrigin is true" do + client_data_json = { + challenge: @challenge, + origin: @origin, + type: "webauthn.create", + crossOrigin: true + }.to_json + + response = TestableResponse.new( + client_data_json: client_data_json, + authenticator_data: @authenticator_data + ) + + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + response.validate!(challenge: @challenge, origin: @origin) + end + + assert_equal "Cross-origin requests are not supported", error.message + end + + test "validate! raises when relying party ID does not match" do + rp_id_hash = Digest::SHA256.digest("evil.com") + flags = 0x05 + sign_count = 0 + + bytes = [] + bytes.concat(rp_id_hash.bytes) + bytes << flags + bytes.concat([sign_count].pack("N").bytes) + + wrong_rp_data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*")) + + response = TestableResponse.new( + client_data_json: @client_data_json, + authenticator_data: wrong_rp_data + ) + + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + response.validate!(challenge: @challenge, origin: @origin) + end + + assert_equal "Relying party ID does not match", error.message + end + + test "validate! raises when tokenBinding status is present" do + client_data_json = { + challenge: @challenge, + origin: @origin, + type: "webauthn.create", + tokenBinding: { status: "present", id: "some-id" } + }.to_json + + response = TestableResponse.new( + client_data_json: client_data_json, + authenticator_data: @authenticator_data + ) + + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + response.validate!(challenge: @challenge, origin: @origin) + end + + assert_equal "Token binding is not supported", error.message + end + + private + def build_authenticator_data + rp_id_hash = Digest::SHA256.digest("example.com") + flags = 0x05 # user present + user verified + sign_count = 0 + + bytes = [] + bytes.concat(rp_id_hash.bytes) + bytes << flags + bytes.concat([sign_count].pack("N").bytes) + + ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*")) + end +end diff --git a/test/lib/action_pack/web_authn/cbor_decoder_test.rb b/test/lib/action_pack/web_authn/cbor_decoder_test.rb new file mode 100644 index 000000000..ea4a12984 --- /dev/null +++ b/test/lib/action_pack/web_authn/cbor_decoder_test.rb @@ -0,0 +1,297 @@ +require "test_helper" + +class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase + test "decodes unsigned integer 0" do + assert_equal 0, decode("00") + end + + test "decodes unsigned integer 1" do + assert_equal 1, decode("01") + end + + test "decodes unsigned integer 10" do + assert_equal 10, decode("0a") + end + + test "decodes unsigned integer 23" do + assert_equal 23, decode("17") + end + + test "decodes unsigned integer 24 (single byte follows)" do + assert_equal 24, decode("1818") + end + + test "decodes unsigned integer 25" do + assert_equal 25, decode("1819") + end + + test "decodes unsigned integer 100" do + assert_equal 100, decode("1864") + end + + test "decodes unsigned integer 1000 (two bytes follow)" do + assert_equal 1000, decode("1903e8") + end + + test "decodes unsigned integer 1000000 (four bytes follow)" do + assert_equal 1000000, decode("1a000f4240") + end + + test "decodes unsigned integer 1000000000000 (eight bytes follow)" do + assert_equal 1000000000000, decode("1b000000e8d4a51000") + end + + test "decodes negative integer -1" do + assert_equal(-1, decode("20")) + end + + test "decodes negative integer -10" do + assert_equal(-10, decode("29")) + end + + test "decodes negative integer -100" do + assert_equal(-100, decode("3863")) + end + + test "decodes negative integer -1000" do + assert_equal(-1000, decode("3903e7")) + end + + test "decodes empty byte string" do + assert_equal "", decode("40") + end + + test "decodes byte string with 4 bytes" do + assert_equal "\x01\x02\x03\x04".b, decode("4401020304") + end + + test "decodes empty text string" do + result = decode("60") + assert_equal "", result + assert_equal Encoding::UTF_8, result.encoding + end + + test "decodes text string 'a'" do + result = decode("6161") + assert_equal "a", result + assert_equal Encoding::UTF_8, result.encoding + end + + test "decodes text string 'IETF'" do + result = decode("6449455446") + assert_equal "IETF", result + assert_equal Encoding::UTF_8, result.encoding + end + + test "decodes text string with unicode" do + result = decode("62c3bc") + assert_equal "ü", result + assert_equal Encoding::UTF_8, result.encoding + end + + test "decodes empty array" do + assert_equal [], decode("80") + end + + test "decodes array [1, 2, 3]" do + assert_equal [ 1, 2, 3 ], decode("83010203") + end + + test "decodes nested array [1, [2, 3], [4, 5]]" do + assert_equal [ 1, [ 2, 3 ], [ 4, 5 ] ], decode("8301820203820405") + end + + test "decodes array with 25 elements" do + expected = (1..25).to_a + # 0x9819 = array with 25 elements (0x98 = type 4 + additional 24, 0x19 = 25) + # integers 1-23 encode as single bytes, 24 = 0x1818, 25 = 0x1819 + elements = (1..23).map { |n| format("%02x", n) }.join + "18181819" + assert_equal expected, decode("9819" + elements) + end + + test "decodes empty map" do + assert_equal({}, decode("a0")) + end + + test "decodes map {1: 2, 3: 4}" do + assert_equal({ 1 => 2, 3 => 4 }, decode("a201020304")) + end + + test "decodes map with string keys" do + assert_equal({ "a" => 1, "b" => 2 }, decode("a2616101616202")) + end + + test "decodes nested map" do + assert_equal({ "a" => { "b" => 1 } }, decode("a16161a1616201")) + end + + test "decodes false" do + assert_equal false, decode("f4") + end + + test "decodes true" do + assert_equal true, decode("f5") + end + + test "decodes null" do + assert_nil decode("f6") + end + + test "decodes undefined as nil" do + assert_nil decode("f7") + end + + test "decodes tagged value, ignoring tag" do + # 0xc0 = tag 0 (date/time string), followed by text "2013-03-21T20:04:00Z" + assert_equal "2013-03-21T20:04:00Z", decode("c074323031332d30332d32315432303a30343a30305a") + end + + test "decodes tagged integer" do + # 0xc1 = tag 1 (epoch time), followed by integer 1363896240 + assert_equal 1363896240, decode("c11a514b67b0") + end + + test "raises error for reserved additional info values" do + assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do + decode("1c") + end + end + + test "decodes indefinite length array" do + assert_equal [ 1, 2, 3 ], decode("9f010203ff") + end + + test "decodes empty indefinite length array" do + assert_equal [], decode("9fff") + end + + test "decodes indefinite length map" do + assert_equal({ "a" => 1, "b" => 2 }, decode("bf616101616202ff")) + end + + test "decodes indefinite length byte string" do + assert_equal "\x01\x02\x03".b, decode("5f4201024103ff") + end + + test "decodes indefinite length text string" do + result = decode("7f657374726561646d696e67ff") + assert_equal "streaming", result + assert_equal Encoding::UTF_8, result.encoding + end + + test "decodes half-precision float 0.0" do + assert_equal 0.0, decode("f90000") + end + + test "decodes half-precision float 1.0" do + assert_equal 1.0, decode("f93c00") + end + + test "decodes half-precision float 1.5" do + assert_equal 1.5, decode("f93e00") + end + + test "decodes half-precision float -4.0" do + assert_equal(-4.0, decode("f9c400")) + end + + test "decodes half-precision positive infinity" do + assert_equal Float::INFINITY, decode("f97c00") + end + + test "decodes half-precision NaN" do + assert_predicate decode("f97e00"), :nan? + end + + test "decodes single-precision float 100000.0" do + assert_equal 100000.0, decode("fa47c35000") + end + + test "decodes single-precision positive infinity" do + assert_equal Float::INFINITY, decode("fa7f800000") + end + + test "decodes double-precision float 1.1" do + assert_in_delta 1.1, decode("fb3ff199999999999a"), 0.0001 + end + + test "decodes double-precision float -4.1" do + assert_in_delta(-4.1, decode("fbc010666666666666"), 0.0001) + end + + test "decodes double-precision positive infinity" do + assert_equal Float::INFINITY, decode("fb7ff0000000000000") + end + + test "decodes double-precision negative infinity" do + assert_equal(-Float::INFINITY, decode("fbfff0000000000000")) + end + + test "raises error for unsupported simple value" do + assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do + decode("e0") + end + end + + test "decode accepts string input" do + bytes = [ 0x01 ].pack("C*") + assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode(bytes) + end + + test "decode accepts array input" do + assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ]) + end + + test "raises error for empty input" do + assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do + ActionPack::WebAuthn::CborDecoder.decode([]) + end + end + + test "raises error for truncated byte string" do + # 0x44 = byte string of length 4, but only 2 bytes follow + assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do + decode("440102") + end + end + + test "raises error for truncated integer" do + # 0x19 = 2-byte integer follows, but only 1 byte provided + assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do + decode("19ff") + end + end + + test "raises error for truncated array" do + # 0x82 = array of 2 items, but only 1 provided + assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do + decode("8201") + end + end + + test "raises error for deeply nested structure" do + # Build array nested 20 levels deep: [[[[...]]]] + # 0x81 = array of 1 item + deeply_nested = "81" * 20 + "01" + + error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do + decode(deeply_nested) + end + + assert_equal "Maximum nesting depth exceeded", error.message + end + + test "raises error for input exceeding max size" do + error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do + ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ], max_size: 0) + end + + assert_equal "Input exceeds maximum size", error.message + end + + private + def decode(hex) + bytes = [ hex ].pack("H*").bytes + ActionPack::WebAuthn::CborDecoder.decode(bytes) + end +end diff --git a/test/lib/action_pack/web_authn/cose_key_test.rb b/test/lib/action_pack/web_authn/cose_key_test.rb new file mode 100644 index 000000000..a3f9a33e9 --- /dev/null +++ b/test/lib/action_pack/web_authn/cose_key_test.rb @@ -0,0 +1,161 @@ +require "test_helper" + +class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase + setup do + # Generate a real EC key for valid test data + ec_key = OpenSSL::PKey::EC.generate("prime256v1") + public_key_bn = ec_key.public_key.to_bn + public_key_bytes = public_key_bn.to_s(2) + # Skip the 0x04 uncompressed point prefix + @ec2_x = public_key_bytes[1, 32] + @ec2_y = public_key_bytes[33, 32] + + @ec2_parameters = { + 1 => 2, # kty: EC2 + 3 => -7, # alg: ES256 + -1 => 1, # crv: P-256 + -2 => @ec2_x, + -3 => @ec2_y + } + + # Generate a real RSA key for valid test data + rsa_key = OpenSSL::PKey::RSA.new(2048) + @rsa_n = rsa_key.n.to_s(2) + @rsa_e = rsa_key.e.to_s(2) + + @rsa_parameters = { + 1 => 3, # kty: RSA + 3 => -257, # alg: RS256 + -1 => @rsa_n, + -2 => @rsa_e + } + end + + test "initializes with key type, algorithm, and parameters" do + key = ActionPack::WebAuthn::CoseKey.new( + key_type: 2, + algorithm: -7, + parameters: @ec2_parameters + ) + + assert_equal 2, key.key_type + assert_equal(-7, key.algorithm) + assert_equal @ec2_parameters, key.parameters + end + + test "decodes EC2/ES256 key from CBOR" do + cbor = encode_cbor(@ec2_parameters) + key = ActionPack::WebAuthn::CoseKey.decode(cbor) + + assert_equal 2, key.key_type + assert_equal(-7, key.algorithm) + end + + test "decodes RSA/RS256 key from CBOR" do + cbor = encode_cbor(@rsa_parameters) + key = ActionPack::WebAuthn::CoseKey.decode(cbor) + + assert_equal 3, key.key_type + assert_equal(-257, key.algorithm) + end + + test "converts EC2/ES256 key to OpenSSL EC key" do + key = ActionPack::WebAuthn::CoseKey.new( + key_type: 2, + algorithm: -7, + parameters: @ec2_parameters + ) + + openssl_key = key.to_openssl_key + + assert_instance_of OpenSSL::PKey::EC, openssl_key + assert_equal "prime256v1", openssl_key.group.curve_name + end + + test "converts RSA/RS256 key to OpenSSL RSA key" do + key = ActionPack::WebAuthn::CoseKey.new( + key_type: 3, + algorithm: -257, + parameters: @rsa_parameters + ) + + openssl_key = key.to_openssl_key + + assert_instance_of OpenSSL::PKey::RSA, openssl_key + assert_equal 65537, openssl_key.e.to_i + end + + test "raises error for unsupported key type/algorithm combination" do + key = ActionPack::WebAuthn::CoseKey.new( + key_type: 99, + algorithm: -7, + parameters: {} + ) + + error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do + key.to_openssl_key + end + + assert_match(/99\/-7/, error.message) + end + + test "raises error for unsupported EC curve" do + parameters = @ec2_parameters.merge(-1 => 2) # P-384 instead of P-256 + key = ActionPack::WebAuthn::CoseKey.new( + key_type: 2, + algorithm: -7, + parameters: parameters + ) + + error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do + key.to_openssl_key + end + + assert_match(/curve/, error.message.downcase) + end + + private + def encode_cbor(hash) + # CBOR map encoding + bytes = [ 0xa0 + hash.size ] # map with n items + + hash.each do |key, value| + bytes.concat(encode_cbor_integer(key)) + bytes.concat(encode_cbor_value(value)) + end + + bytes.pack("C*") + end + + def encode_cbor_integer(int) + if int >= 0 && int <= 23 + [ int ] + elsif int >= 0 && int <= 255 + [ 0x18, int ] + elsif int >= -24 && int < 0 + [ 0x20 - int - 1 ] + elsif int >= -256 && int < -24 + [ 0x38, -int - 1 ] + else + # 16-bit negative integer + val = -int - 1 + [ 0x39, (val >> 8) & 0xff, val & 0xff ] + end + end + + def encode_cbor_value(value) + case value + when Integer + encode_cbor_integer(value) + when String + length = value.bytesize + if length <= 23 + [ 0x40 + length, *value.bytes ] + elsif length <= 255 + [ 0x58, length, *value.bytes ] + else + [ 0x59, (length >> 8) & 0xff, length & 0xff, *value.bytes ] + end + end + 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 new file mode 100644 index 000000000..e4bd049c1 --- /dev/null +++ b/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb @@ -0,0 +1,101 @@ +require "test_helper" + +class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSupport::TestCase + setup do + @relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App") + @options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( + id: "user-123", + name: "user@example.com", + display_name: "Test User", + relying_party: @relying_party + ) + end + + test "initializes with required parameters" do + assert_equal "user-123", @options.id + assert_equal "user@example.com", @options.name + assert_equal "Test User", @options.display_name + assert_equal @relying_party, @options.relying_party + end + + test "generates base64url encoded challenge" do + assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge) + end + + test "generates challenge of correct length" do + decoded = Base64.urlsafe_decode64(@options.challenge) + assert_equal 32, decoded.bytesize + end + + test "as_json" do + assert_equal @options.challenge, @options.as_json[:challenge] + + assert_equal({ id: "example.com", name: "Example App" }, @options.as_json[:rp]) + + user = @options.as_json[:user] + assert_equal Base64.urlsafe_encode64("user-123", padding: false), user[:id] + assert_equal "user@example.com", user[:name] + assert_equal "Test User", user[:displayName] + + assert_equal [ + { type: "public-key", alg: -7 }, + { type: "public-key", alg: -257 } + ], @options.as_json[:pubKeyCredParams] + + assert_equal "preferred", @options.as_json[:authenticatorSelection][:residentKey] + assert_equal "preferred", @options.as_json[:authenticatorSelection][:userVerification] + end + + test "as_json includes residentKey in authenticatorSelection" do + options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( + id: "user-123", + name: "user@example.com", + display_name: "Test User", + resident_key: :required, + relying_party: @relying_party + ) + + assert_equal "required", options.as_json[:authenticatorSelection][:residentKey] + end + + test "as_json excludes excludeCredentials when empty" do + assert_nil @options.as_json[:excludeCredentials] + end + + test "as_json includes excludeCredentials" do + credential = Struct.new(:id, :transports) + credentials = [ + credential.new("cred-1", [ "usb", "nfc" ]), + credential.new("cred-2", [ "internal" ]) + ] + + options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( + id: "user-123", + name: "user@example.com", + display_name: "Test User", + exclude_credentials: credentials, + relying_party: @relying_party + ) + + assert_equal [ + { type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] }, + { type: "public-key", id: "cred-2", transports: [ "internal" ] } + ], options.as_json[:excludeCredentials] + end + + test "as_json 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 ], + relying_party: @relying_party + ) + + assert_equal [ + { type: "public-key", id: "cred-1" } + ], options.as_json[:excludeCredentials] + 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 new file mode 100644 index 000000000..4c6ee3baa --- /dev/null +++ b/test/lib/action_pack/web_authn/public_key_credential/request_options_test.rb @@ -0,0 +1,72 @@ +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", []) + ] + @options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new( + credentials: @credentials, + relying_party: @relying_party + ) + end + + test "initializes with required parameters" do + assert_equal @credentials, @options.credentials + assert_equal @relying_party, @options.relying_party + end + + test "generates base64url encoded challenge" do + assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge) + end + + test "generates challenge of correct length" do + decoded = Base64.urlsafe_decode64(@options.challenge) + assert_equal 32, decoded.bytesize + end + + test "as_json" do + assert_equal @options.challenge, @options.as_json[:challenge] + assert_equal "example.com", @options.as_json[:rpId] + assert_equal [ + { type: "public-key", id: "credential-1" }, + { type: "public-key", id: "credential-2" } + ], @options.as_json[:allowCredentials] + assert_equal "preferred", @options.as_json[:userVerification] + 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" ]) + ] + + options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new( + credentials: credentials, + relying_party: @relying_party + ) + + assert_equal [ + { type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] }, + { type: "public-key", id: "cred-2", transports: [ "internal" ] } + ], options.as_json[:allowCredentials] + end + + test "as_json omits transports when empty" do + credential = Struct.new(:id, :transports) + credentials = [ credential.new("cred-1", []) ] + + options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new( + credentials: credentials, + relying_party: @relying_party + ) + + assert_equal [ + { type: "public-key", id: "cred-1" } + ], options.as_json[:allowCredentials] + end +end diff --git a/test/lib/action_pack/web_authn/relying_party_test.rb b/test/lib/action_pack/web_authn/relying_party_test.rb new file mode 100644 index 000000000..1ea06c425 --- /dev/null +++ b/test/lib/action_pack/web_authn/relying_party_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class ActionPack::WebAuthn::RelyingPartyTest < ActiveSupport::TestCase + test "initializes with explicit id and name" do + relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App") + + assert_equal "example.com", relying_party.id + assert_equal "Example App", relying_party.name + end + + test "initializes with default id from Current.host" do + ActionPack::WebAuthn::Current.set(host: "default.example.com") do + relying_party = ActionPack::WebAuthn::RelyingParty.new(name: "Example App") + + assert_equal "default.example.com", relying_party.id + end + end + + test "initializes with default name from Rails application" do + relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com") + + assert_equal Rails.application.name, relying_party.name + end + + test "as_json returns id and name" do + relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App") + + assert_equal({ id: "example.com", name: "Example App" }, relying_party.as_json) + end +end From 6c58fd9bdd9d12445e1696cd6f2e2d0cefee1a6a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 13 Feb 2026 16:58:06 +0100 Subject: [PATCH 02/12] 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 From 70c19e488113bd745d52e5caeb11a7e2c44f5760 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 17 Feb 2026 15:44:10 -0600 Subject: [PATCH 03/12] Style polish --- app/assets/stylesheets/access-tokens.css | 26 ++++++++++++++----- .../users/credentials/_credential.html.erb | 4 +-- app/views/users/credentials/index.html.erb | 9 +++---- app/views/users/credentials/new.html.erb | 5 ++-- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/app/assets/stylesheets/access-tokens.css b/app/assets/stylesheets/access-tokens.css index a0f54f91c..1fcecb52e 100644 --- a/app/assets/stylesheets/access-tokens.css +++ b/app/assets/stylesheets/access-tokens.css @@ -1,19 +1,33 @@ -.access_tokens_table { +.access-tokens { border-collapse: collapse; + font-size: var(--text-small); inline-size: 100%; + margin-block-end: 2lh; td, th { - border-block-end: 1px solid var(--color-ink-light); - padding-inline: var(--inline-space); + border-block-end: 1px solid var(--color-ink-lighter); text-align: start; + + &:first-child { + inline-size: 100%; + } + + &:not(:first-child) { + padding-inline-start: var(--inline-space); + } + + &:not(:last-child) { + padding-inline-end: var(--inline-space); + } } th { + color: var(--color-ink-dark); font-size: var(--text-x-small); text-transform: uppercase; } - tr:nth-of-type(even) { - background-color: var(--color-ink-lightest); + td { + padding-block: 8px; } -} \ No newline at end of file +} diff --git a/app/views/users/credentials/_credential.html.erb b/app/views/users/credentials/_credential.html.erb index 27c2d199f..f532552c3 100644 --- a/app/views/users/credentials/_credential.html.erb +++ b/app/views/users/credentials/_credential.html.erb @@ -1,9 +1,9 @@ <%= credential.name.presence || "Passkey" %> - <%= local_datetime_tag credential.created_at, style: :datetime %> + <%= 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", + 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" %> Remove this passkey diff --git a/app/views/users/credentials/index.html.erb b/app/views/users/credentials/index.html.erb index a5521f617..28febe77f 100644 --- a/app/views/users/credentials/index.html.erb +++ b/app/views/users/credentials/index.html.erb @@ -8,10 +8,11 @@

<%= @page_title %>

<% end %> -
+
+

Passkeys let you sign in using your device’s biometrics, PIN, or security key—no email code needed.

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

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

- +
@@ -23,8 +24,6 @@ <%= render partial: "users/credentials/credential", collection: @credentials %>
Passkey
- <% 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 %> diff --git a/app/views/users/credentials/new.html.erb b/app/views/users/credentials/new.html.erb index 3e5e7abc0..722a6f612 100644 --- a/app/views/users/credentials/new.html.erb +++ b/app/views/users/credentials/new.html.erb @@ -8,11 +8,10 @@

<%= @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.

@@ -29,4 +28,4 @@ <% end %> <%= link_to "Cancel and go back", user_credentials_path(@user), hidden: true %> -
+
From da690394765eb803c552ac8c858f6eb57afb722d Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 18 Feb 2026 11:24:10 +0100 Subject: [PATCH 04/12] 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 From b23660d8974e8260f64ee5b77519f5fe00cd737f Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 19 Feb 2026 10:34:03 +0100 Subject: [PATCH 05/12] Fix --tailscale not printing hostname --- bin/dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/dev b/bin/dev index 24923069d..4c11aa3b0 100755 --- a/bin/dev +++ b/bin/dev @@ -41,7 +41,7 @@ if [ "$USE_TAILSCALE" = "1" ]; then exit 1 fi - TS_STATUS=$(tailscale status --self --json 2>&1) + TS_STATUS=$(tailscale status --self --json 2>/dev/null) if [ $? -ne 0 ]; then echo "Error: tailscale not logged in" >&2 exit 1 From fbe9252db1741f705bcea541ac6bddfb10ea10bb Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 19 Feb 2026 10:56:22 +0100 Subject: [PATCH 06/12] Auto-suggest name for new Passkeys - Use plain client data json everywhere - Expose AAGUID and backed_up on Credentials - Make attestation verifiers configurable - Auto-suggest names for passkeys and show icons --- app/assets/images/authentication.svg | 1 + app/assets/images/key.svg | 1 - app/assets/images/passkeys/1password_dark.svg | 3 + .../images/passkeys/1password_light.svg | 3 + .../images/passkeys/apple_passwords_dark.svg | 38 ++++++ .../images/passkeys/apple_passwords_light.svg | 38 ++++++ app/assets/images/passkeys/bitwarden_dark.svg | 11 ++ .../images/passkeys/bitwarden_light.svg | 11 ++ app/assets/images/passkeys/dashlane_dark.svg | 8 ++ app/assets/images/passkeys/dashlane_light.svg | 8 ++ app/assets/images/passkeys/generic_dark.svg | 1 + app/assets/images/passkeys/generic_light.svg | 1 + .../passkeys/google_password_manager_dark.svg | 1 + .../google_password_manager_light.svg | 1 + app/assets/images/passkeys/lastpass_dark.svg | 13 ++ app/assets/images/passkeys/lastpass_light.svg | 13 ++ .../images/passkeys/samsung_pass_dark.svg | 1 + .../images/passkeys/samsung_pass_light.svg | 1 + .../images/passkeys/windows_hello_dark.svg | 1 + .../images/passkeys/windows_hello_light.svg | 1 + app/assets/images/passkeys/yubikey_dark.svg | 1 + app/assets/images/passkeys/yubikey_light.svg | 1 + app/assets/stylesheets/credentials.css | 29 ++++ app/assets/stylesheets/icons.css | 2 +- app/controllers/concerns/current_request.rb | 1 + .../sessions/passkeys_controller.rb | 12 +- .../users/credentials_controller.rb | 32 +++-- .../controllers/credential_controller.js | 72 +++++----- .../controllers/passkey_controller.js | 26 +--- app/javascript/helpers/base64url_helpers.js | 12 ++ app/models/identity/credential.rb | 37 +++--- .../identity/credential/authenticator.rb | 12 ++ .../users/credentials/_credential.html.erb | 21 ++- app/views/users/credentials/edit.html.erb | 33 +++++ app/views/users/credentials/index.html.erb | 40 +++--- app/views/users/credentials/new.html.erb | 31 ----- app/views/users/show.html.erb | 2 +- config/passkey_aaguids.yml | 124 ++++++++++++++++++ config/routes.rb | 2 +- ...d_and_backed_up_to_identity_credentials.rb | 6 + db/schema.rb | 4 +- lib/action_pack/web_authn.rb | 10 ++ .../web_authn/authenticator/attestation.rb | 2 +- .../authenticator/attestation_response.rb | 10 +- .../attestation_verifiers/none.rb | 24 ++++ .../web_authn/authenticator/data.rb | 9 +- lib/action_pack/web_authn/current.rb | 2 +- .../web_authn/public_key_credential.rb | 9 +- .../public_key_credential/creation_options.rb | 21 ++- .../sessions/passkeys_controller_test.rb | 14 +- .../attestation_response_test.rb | 20 ++- .../attestation_verifiers/none_test.rb | 33 +++++ .../creation_options_test.rb | 28 ++++ 53 files changed, 666 insertions(+), 172 deletions(-) create mode 100644 app/assets/images/authentication.svg delete mode 100644 app/assets/images/key.svg create mode 100644 app/assets/images/passkeys/1password_dark.svg create mode 100644 app/assets/images/passkeys/1password_light.svg create mode 100644 app/assets/images/passkeys/apple_passwords_dark.svg create mode 100644 app/assets/images/passkeys/apple_passwords_light.svg create mode 100644 app/assets/images/passkeys/bitwarden_dark.svg create mode 100644 app/assets/images/passkeys/bitwarden_light.svg create mode 100644 app/assets/images/passkeys/dashlane_dark.svg create mode 100644 app/assets/images/passkeys/dashlane_light.svg create mode 100644 app/assets/images/passkeys/generic_dark.svg create mode 100644 app/assets/images/passkeys/generic_light.svg create mode 100644 app/assets/images/passkeys/google_password_manager_dark.svg create mode 100644 app/assets/images/passkeys/google_password_manager_light.svg create mode 100644 app/assets/images/passkeys/lastpass_dark.svg create mode 100644 app/assets/images/passkeys/lastpass_light.svg create mode 100644 app/assets/images/passkeys/samsung_pass_dark.svg create mode 100644 app/assets/images/passkeys/samsung_pass_light.svg create mode 100644 app/assets/images/passkeys/windows_hello_dark.svg create mode 100644 app/assets/images/passkeys/windows_hello_light.svg create mode 100644 app/assets/images/passkeys/yubikey_dark.svg create mode 100644 app/assets/images/passkeys/yubikey_light.svg create mode 100644 app/assets/stylesheets/credentials.css create mode 100644 app/javascript/helpers/base64url_helpers.js create mode 100644 app/models/identity/credential/authenticator.rb create mode 100644 app/views/users/credentials/edit.html.erb delete mode 100644 app/views/users/credentials/new.html.erb create mode 100644 config/passkey_aaguids.yml create mode 100644 db/migrate/20260219095815_add_aaguid_and_backed_up_to_identity_credentials.rb create mode 100644 lib/action_pack/web_authn/authenticator/attestation_verifiers/none.rb create mode 100644 test/lib/action_pack/web_authn/authenticator/attestation_verifiers/none_test.rb diff --git a/app/assets/images/authentication.svg b/app/assets/images/authentication.svg new file mode 100644 index 000000000..6f02cc18b --- /dev/null +++ b/app/assets/images/authentication.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/key.svg b/app/assets/images/key.svg deleted file mode 100644 index 386e0b501..000000000 --- a/app/assets/images/key.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/images/passkeys/1password_dark.svg b/app/assets/images/passkeys/1password_dark.svg new file mode 100644 index 000000000..ff3998898 --- /dev/null +++ b/app/assets/images/passkeys/1password_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/passkeys/1password_light.svg b/app/assets/images/passkeys/1password_light.svg new file mode 100644 index 000000000..dcd5b1ce0 --- /dev/null +++ b/app/assets/images/passkeys/1password_light.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/passkeys/apple_passwords_dark.svg b/app/assets/images/passkeys/apple_passwords_dark.svg new file mode 100644 index 000000000..bd1715076 --- /dev/null +++ b/app/assets/images/passkeys/apple_passwords_dark.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/passkeys/apple_passwords_light.svg b/app/assets/images/passkeys/apple_passwords_light.svg new file mode 100644 index 000000000..bd1715076 --- /dev/null +++ b/app/assets/images/passkeys/apple_passwords_light.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/passkeys/bitwarden_dark.svg b/app/assets/images/passkeys/bitwarden_dark.svg new file mode 100644 index 000000000..761c97ab7 --- /dev/null +++ b/app/assets/images/passkeys/bitwarden_dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/images/passkeys/bitwarden_light.svg b/app/assets/images/passkeys/bitwarden_light.svg new file mode 100644 index 000000000..761c97ab7 --- /dev/null +++ b/app/assets/images/passkeys/bitwarden_light.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/images/passkeys/dashlane_dark.svg b/app/assets/images/passkeys/dashlane_dark.svg new file mode 100644 index 000000000..f23ec9e3f --- /dev/null +++ b/app/assets/images/passkeys/dashlane_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/passkeys/dashlane_light.svg b/app/assets/images/passkeys/dashlane_light.svg new file mode 100644 index 000000000..49f658ffd --- /dev/null +++ b/app/assets/images/passkeys/dashlane_light.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/passkeys/generic_dark.svg b/app/assets/images/passkeys/generic_dark.svg new file mode 100644 index 000000000..e80a38065 --- /dev/null +++ b/app/assets/images/passkeys/generic_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/generic_light.svg b/app/assets/images/passkeys/generic_light.svg new file mode 100644 index 000000000..3f941f839 --- /dev/null +++ b/app/assets/images/passkeys/generic_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/google_password_manager_dark.svg b/app/assets/images/passkeys/google_password_manager_dark.svg new file mode 100644 index 000000000..b13ae8a5e --- /dev/null +++ b/app/assets/images/passkeys/google_password_manager_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/google_password_manager_light.svg b/app/assets/images/passkeys/google_password_manager_light.svg new file mode 100644 index 000000000..b13ae8a5e --- /dev/null +++ b/app/assets/images/passkeys/google_password_manager_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/lastpass_dark.svg b/app/assets/images/passkeys/lastpass_dark.svg new file mode 100644 index 000000000..764d273f5 --- /dev/null +++ b/app/assets/images/passkeys/lastpass_dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/assets/images/passkeys/lastpass_light.svg b/app/assets/images/passkeys/lastpass_light.svg new file mode 100644 index 000000000..aac8009ea --- /dev/null +++ b/app/assets/images/passkeys/lastpass_light.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/assets/images/passkeys/samsung_pass_dark.svg b/app/assets/images/passkeys/samsung_pass_dark.svg new file mode 100644 index 000000000..399033941 --- /dev/null +++ b/app/assets/images/passkeys/samsung_pass_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/samsung_pass_light.svg b/app/assets/images/passkeys/samsung_pass_light.svg new file mode 100644 index 000000000..399033941 --- /dev/null +++ b/app/assets/images/passkeys/samsung_pass_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/windows_hello_dark.svg b/app/assets/images/passkeys/windows_hello_dark.svg new file mode 100644 index 000000000..960a7af56 --- /dev/null +++ b/app/assets/images/passkeys/windows_hello_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/windows_hello_light.svg b/app/assets/images/passkeys/windows_hello_light.svg new file mode 100644 index 000000000..960a7af56 --- /dev/null +++ b/app/assets/images/passkeys/windows_hello_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/yubikey_dark.svg b/app/assets/images/passkeys/yubikey_dark.svg new file mode 100644 index 000000000..61bf17f54 --- /dev/null +++ b/app/assets/images/passkeys/yubikey_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/yubikey_light.svg b/app/assets/images/passkeys/yubikey_light.svg new file mode 100644 index 000000000..61bf17f54 --- /dev/null +++ b/app/assets/images/passkeys/yubikey_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/credentials.css b/app/assets/stylesheets/credentials.css new file mode 100644 index 000000000..64e39005b --- /dev/null +++ b/app/assets/stylesheets/credentials.css @@ -0,0 +1,29 @@ +@layer components { + .passkey-icon--dark { + display: none; + } + + .passkey-icon--light { + html[data-theme="dark"] & { + display: none; + } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + display: none; + } + } + } + + .passkey-icon--dark { + html[data-theme="dark"] & { + display: inline; + } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + display: inline; + } + } + } +} diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 35680e429..2f7fe8564 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -27,6 +27,7 @@ .icon--art { --svg: url("art.svg "); } .icon--assigned { --svg: url("assigned.svg "); } .icon--attachment { --svg: url("attachment.svg "); } + .icon--authentication { --svg: url("authentication.svg "); } .icon--bell-alert { --svg: url("bell-alert.svg "); } .icon--bell-off { --svg: url("bell-off.svg "); } .icon--bell { --svg: url("bell.svg "); } @@ -62,7 +63,6 @@ .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 193a2713b..689bc146a 100644 --- a/app/controllers/concerns/current_request.rb +++ b/app/controllers/concerns/current_request.rb @@ -10,6 +10,7 @@ module CurrentRequest 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/sessions/passkeys_controller.rb b/app/controllers/sessions/passkeys_controller.rb index 5b997260b..18917583d 100644 --- a/app/controllers/sessions/passkeys_controller.rb +++ b/app/controllers/sessions/passkeys_controller.rb @@ -5,12 +5,8 @@ class Sessions::PasskeysController < ApplicationController 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 + passkey: passkey_params, + challenge: session.delete(:webauthn_challenge) ) if credential @@ -21,8 +17,8 @@ class Sessions::PasskeysController < ApplicationController end private - def response_params - params.expect(response: [ :client_data_json, :authenticator_data, :signature ]) + def passkey_params + params.expect(passkey: [ :id, :client_data_json, :authenticator_data, :signature ]) end def authentication_succeeded(identity) diff --git a/app/controllers/users/credentials_controller.rb b/app/controllers/users/credentials_controller.rb index 65ebe42ff..aa0f3d8e9 100644 --- a/app/controllers/users/credentials_controller.rb +++ b/app/controllers/users/credentials_controller.rb @@ -1,26 +1,28 @@ class Users::CredentialsController < ApplicationController - before_action :set_credential, only: :destroy + before_action :set_credential, only: %i[ edit update destroy ] def index @credentials = Current.identity.credentials.order(name: :asc, created_at: :desc) - end - - def new @creation_options = Identity::Credential.creation_options(identity: Current.identity, display_name: Current.user.name) session[:webauthn_challenge] = @creation_options.challenge end def create - 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_params[:transports]) + 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 @@ -35,6 +37,10 @@ class Users::CredentialsController < ApplicationController end def credential_params - params.expect(credential: [ :name, :client_data_json, :attestation_object, transports: [] ]) + params.expect(credential: [ :name ]) + end + + def passkey_params + params.expect(passkey: [ :client_data_json, :attestation_object, transports: [] ]) end end diff --git a/app/javascript/controllers/credential_controller.js b/app/javascript/controllers/credential_controller.js index bd9b0baee..ddc76085d 100644 --- a/app/javascript/controllers/credential_controller.js +++ b/app/javascript/controllers/credential_controller.js @@ -1,60 +1,60 @@ 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 } - static targets = ["clientDataJSON", "attestationObject"] + static values = { publicKey: Object, registerUrl: String } + static targets = ["button", "error", "cancelled"] - async create(event) { - event.preventDefault() + async create() { + this.buttonTarget.disabled = true + this.errorTarget.hidden = true + this.cancelledTarget.hidden = true try { - const publicKey = this.prepareOptions(this.publicKeyValue) + const publicKey = this.#prepareOptions(this.publicKeyValue) const credential = await navigator.credentials.create({ publicKey }) - this.submitCredential(credential) + await this.#registerCredential(credential) } catch (error) { - if (error.name !== "AbortError" && error.name !== "NotAllowedError") { - console.error("Registration failed:", error) + if (error.name === "AbortError" || error.name === "NotAllowedError") { + this.cancelledTarget.hidden = false + } else { + this.errorTarget.hidden = false } + this.buttonTarget.disabled = false } } - submitCredential(credential) { - this.clientDataJSONTarget.value = this.bufferToBase64url(credential.response.clientDataJSON) - this.attestationObjectTarget.value = this.bufferToBase64url(credential.response.attestationObject) + 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" + }) - for (const transport of credential.response.getTransports?.() || []) { - const input = document.createElement("input") - input.type = "hidden" - input.name = "credential[transports][]" - input.value = transport - this.element.appendChild(input) + if (response.ok) { + const { location } = await response.json + Turbo.visit(location) + } else { + throw new Error("Registration failed") } - - this.element.requestSubmit() } - prepareOptions(options) { + #prepareOptions(options) { return { ...options, - challenge: this.base64urlToBuffer(options.challenge), - user: { ...options.user, id: this.base64urlToBuffer(options.user.id) }, + challenge: base64urlToBuffer(options.challenge), + user: { ...options.user, id: base64urlToBuffer(options.user.id) }, excludeCredentials: (options.excludeCredentials || []).map(cred => ({ ...cred, - id: this.base64urlToBuffer(cred.id) + id: 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 index 87145db77..566518bb9 100644 --- a/app/javascript/controllers/passkey_controller.js +++ b/app/javascript/controllers/passkey_controller.js @@ -1,4 +1,5 @@ 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 } @@ -41,10 +42,10 @@ export default class extends Controller { 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) + "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)) { @@ -62,13 +63,13 @@ export default class extends Controller { #prepareOptions(options) { const prepared = { ...options, - challenge: this.#base64urlToBuffer(options.challenge) + challenge: base64urlToBuffer(options.challenge) } if (options.allowCredentials?.length) { prepared.allowCredentials = options.allowCredentials.map(cred => ({ ...cred, - id: this.#base64urlToBuffer(cred.id) + id: base64urlToBuffer(cred.id) })) } else { delete prepared.allowCredentials @@ -76,17 +77,4 @@ export default class extends Controller { 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/javascript/helpers/base64url_helpers.js b/app/javascript/helpers/base64url_helpers.js new file mode 100644 index 000000000..945826f58 --- /dev/null +++ b/app/javascript/helpers/base64url_helpers.js @@ -0,0 +1,12 @@ +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/models/identity/credential.rb b/app/models/identity/credential.rb index eb03add28..d233fbcb6 100644 --- a/app/models/identity/credential.rb +++ b/app/models/identity/credential.rb @@ -18,44 +18,51 @@ class Identity::Credential < ApplicationRecord 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: []) + def register(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin, **attributes) public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create( - client_data_json: Base64.urlsafe_decode64(client_data_json), - attestation_object: Base64.urlsafe_decode64(attestation_object), + client_data_json: passkey[:client_data_json], + attestation_object: Base64.urlsafe_decode64(passkey[:attestation_object]), challenge: challenge, origin: origin, - transports: transports + transports: Array(passkey[:transports]) ) - identity.credentials.create!( - name: name, + 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(client_data_json:, authenticator_data:, signature:, challenge:, origin:) + def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.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), + 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) + 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, diff --git a/app/models/identity/credential/authenticator.rb b/app/models/identity/credential/authenticator.rb new file mode 100644 index 000000000..72ebece0f --- /dev/null +++ b/app/models/identity/credential/authenticator.rb @@ -0,0 +1,12 @@ +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/views/users/credentials/_credential.html.erb b/app/views/users/credentials/_credential.html.erb index a240fc46f..38fe5cb0e 100644 --- a/app/views/users/credentials/_credential.html.erb +++ b/app/views/users/credentials/_credential.html.erb @@ -1,11 +1,10 @@ - - <%= credential.name.presence || "Passkey" %> - - <%= 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" %> - Remove this passkey - <% end %> - - +
  • + <%= link_to edit_user_credential_path(Current.user, credential), class: "txt-ink flex align-center gap txt-medium full-width" do %> + <% authenticator = credential.authenticator %> + <%= image_tag authenticator&.icon&.[](:light) || "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink passkey-icon passkey-icon--light", aria: { hidden: true } %> + <%= image_tag authenticator&.icon&.[](:dark) || "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink passkey-icon passkey-icon--dark", aria: { hidden: true } %> + <%= credential.name.presence || "Passkey" %> + + <%= icon_tag "arrow-right", class: "txt-subtle txt-xx-small flex-item-no-shrink" %> + <% end %> +
  • diff --git a/app/views/users/credentials/edit.html.erb b/app/views/users/credentials/edit.html.erb new file mode 100644 index 000000000..c8e4ddfa4 --- /dev/null +++ b/app/views/users/credentials/edit.html.erb @@ -0,0 +1,33 @@ +<% @page_title = "Name your passkey" %> + +<% content_for :header do %> +
    + <%= back_link_to "Passkeys", user_credentials_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
    + +

    <%= @page_title %>

    +<% end %> + +
    + <% if params[:created] %> +

    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.label :name %> + <%= form.text_field :name, autofocus: true, class: "input", placeholder: "e.g. MacBook Pro, iPhone", data: { "1p-ignore": "" }, autocomplete: "off" %> +
    + + <%= form.submit "Save", class: "btn btn--primary" %> + <% end %> + +
    + <%= button_to user_credential_path(Current.user, @credential), 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" %> + Remove this passkey + <% end %> +
    +
    diff --git a/app/views/users/credentials/index.html.erb b/app/views/users/credentials/index.html.erb index 3b54e1e9f..3278f6d14 100644 --- a/app/views/users/credentials/index.html.erb +++ b/app/views/users/credentials/index.html.erb @@ -8,25 +8,33 @@

    <%= @page_title %>

    <% end %> -
    -

    Passkeys let you sign in using your device’s biometrics, PIN, or security key—no email code needed.

    +
    + +

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

    <% if @credentials.any? %> - - - - - - - - - <%= render partial: "users/credentials/credential", collection: @credentials %> - -
    Passkey
    +
      + <%= render partial: "users/credentials/credential", collection: @credentials %> +
    <% end %> - <%= link_to new_user_credential_path(Current.user), class: "btn btn--link" do %> +

    Your browser will prompt you to create a passkey + using your device's biometrics, PIN, or security key

    + + + + + +
    diff --git a/app/views/users/credentials/new.html.erb b/app/views/users/credentials/new.html.erb deleted file mode 100644 index 15ad75378..000000000 --- a/app/views/users/credentials/new.html.erb +++ /dev/null @@ -1,31 +0,0 @@ -<% @page_title = "Add a passkey" %> - -<% content_for :header do %> -
    - <%= 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(Current.user), - data: { controller: "credential", credential_public_key_value: @creation_options.as_json }, - html: { class: "flex flex-column gap" } do |form| %> - -
    - - -
    - - - - - - <% end %> - - <%= 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 ad4ba3a59..f75febf2a 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" %> + <%= icon_tag "authentication" %> Manage passkeys <% end %> diff --git a/config/passkey_aaguids.yml b/config/passkey_aaguids.yml new file mode 100644 index 000000000..537d99683 --- /dev/null +++ b/config/passkey_aaguids.yml @@ -0,0 +1,124 @@ +shared: + apple_passwords: + name: Apple Passwords + icon: + light: passkeys/apple_passwords_light.svg + dark: passkeys/apple_passwords_dark.svg + aaguids: + - dd4ec289-e01d-41c9-bb89-70fa845d4bf2 + - fbfc3007-154e-4ecc-8c0b-6e020557d7bd + + google_password_manager: + name: Google Password Manager + icon: + light: passkeys/google_password_manager_light.svg + dark: passkeys/google_password_manager_dark.svg + aaguids: + - ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4 + + windows_hello: + name: Windows Hello + icon: + light: passkeys/windows_hello_light.svg + dark: passkeys/windows_hello_dark.svg + aaguids: + - 08987058-cadc-4b81-b6e1-30de50dcbe96 + - 6028b017-b1d4-4c02-b4b3-afcdafc96bb2 + - 9ddd1817-af5a-4672-a2b9-3e3dd95000a9 + + 1password: + name: 1Password + icon: + light: passkeys/1password_light.svg + dark: passkeys/1password_dark.svg + aaguids: + - bada5566-a7aa-401f-bd96-45619a55120d + + bitwarden: + name: Bitwarden + icon: + light: passkeys/bitwarden_light.svg + dark: passkeys/bitwarden_dark.svg + aaguids: + - d548826e-79b4-db40-a3d8-11116f7e8349 + + samsung_pass: + name: Samsung Pass + icon: + light: passkeys/samsung_pass_light.svg + dark: passkeys/samsung_pass_dark.svg + aaguids: + - 53414d53-554e-4700-0000-000000000000 + + lastpass: + name: LastPass + icon: + light: passkeys/lastpass_light.svg + dark: passkeys/lastpass_dark.svg + aaguids: + - b78a0a55-6ef8-d246-a042-ba0f6d55050c + + dashlane: + name: Dashlane + icon: + light: passkeys/dashlane_light.svg + dark: passkeys/dashlane_dark.svg + aaguids: + - 531126d6-e717-415c-9320-3d9aa6981239 + + yubikey: + name: YubiKey + icon: + light: passkeys/yubikey_light.svg + dark: passkeys/yubikey_dark.svg + aaguids: + - 19083c3d-8383-4b18-bc03-8f1c9ab2fd1b + - 1ac71f64-468d-4fe0-bef1-0e5f2f551f18 + - 20ac7a17-c814-4833-93fe-539f0d5e3389 + - 24673149-6c86-42e7-98d9-433fb5b73296 + - 2fc0579f-8113-47ea-b116-bb5a8db9202a + - 3124e301-f14e-4e38-876d-fbeeb090e7bf + - 34744913-4f57-4e6e-a527-e9ec3c4b94e6 + - 34f5766d-1536-4a24-9033-0e294e510fb0 + - 3a662962-c6d4-4023-bebb-98ae92e78e20 + - 3aa78eb1-ddd8-46a8-a821-8f8ec57a7bd5 + - 3b24bf49-1d45-4484-a917-13175df0867b + - 4599062e-6926-4fe7-9566-9e8fb1aedaa0 + - 4fc84f16-2545-4e53-b8fc-7bf4d7282a10 + - 57f7de54-c807-4eab-b1c6-1c9be7984e92 + - 58276709-bb4b-4bb3-baf1-60eea99282a7 + - 5b0e46ba-db02-44ac-b979-ca9b84f5e335 + - 62e54e98-c209-4df3-b692-de71bb6a8528 + - 662ef48a-95e2-4aaa-a6c1-5b9c40375824 + - 6ab56fad-881f-4a43-acb2-0be065924522 + - 6ec5cff2-a0f9-4169-945b-f33b563f7b99 + - 73bb0cd4-e502-49b8-9c6f-b59445bf720b + - 7409272d-1ff9-4e10-9fc9-ac0019c124fd + - 79f3c8ba-9e35-484b-8f47-53a5a0f5c630 + - 7b96457d-e3cd-432b-9ceb-c9fdd7ef7432 + - 7d1351a6-e097-4852-b8bf-c9ac5c9ce4a3 + - 83c47309-aabb-4108-8470-8be838b573cb + - 85203421-48f9-4355-9bc8-8a53846e5083 + - 8c39ee86-7f9a-4a95-9ba3-f6b097e5c2ee + - 905b4cb4-ed6f-4da9-92fc-45e0d4e9b5c7 + - 90636e1f-ef82-43bf-bdcf-5255f139d12f + - 97e6a830-c952-4740-95fc-7c78dc97ce47 + - 9e66c661-e428-452a-a8fb-51f7ed088acf + - 9eb7eabc-9db5-49a1-b6c3-555a802093f4 + - a02167b9-ae71-4ac7-9a07-06432ebb6f1c + - a25342c0-3cdc-4414-8e46-f4807fca511c + - ad08c78a-4e41-49b9-86a2-ac15b06899e2 + - b2c1a50b-dad8-4dc7-ba4d-0ce9597904bc + - b90e7dc1-316e-4fee-a25a-56a666a670fe + - c1f9a0bc-1dd2-404a-b27f-8e29047a43fd + - c5ef55ff-ad9a-4b9f-b580-adebafe026d0 + - cb69481e-8ff7-4039-93ec-0a2729a154a8 + - ce6bf97f-9f69-4ba7-9032-97adc6ca5cf1 + - d2fbd093-ee62-488d-9dad-1e36389f8826 + - d7781e5d-e353-46aa-afe2-3ca49f13332a + - d8522d9f-575b-4866-88a9-ba99fa02f35b + - dd86a2da-86a0-4cbe-b462-4bd31f57bc6f + - ee882879-721c-4913-9775-3dfcce97072a + - fa2b99dc-9e39-4257-8f92-4a30d23c4118 + - fcc0118f-cd45-435b-8da1-9782b2da0715 + - ff4dac45-ede8-4ec2-aced-cf66103f4335 diff --git a/config/routes.rb b/config/routes.rb index d1ca55416..6353681d7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,7 +15,7 @@ Rails.application.routes.draw do resource :avatar resource :role resource :events - resources :credentials, only: [ :index, :new, :create, :destroy ] + resources :credentials, except: %i[ show new ] resources :push_subscriptions 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 new file mode 100644 index 000000000..172d88839 --- /dev/null +++ b/db/migrate/20260219095815_add_aaguid_and_backed_up_to_identity_credentials.rb @@ -0,0 +1,6 @@ +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 76ab5b9a0..07248185c 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_18_120000) do +ActiveRecord::Schema[8.2].define(version: 2026_02_19_095815) 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 @@ -345,6 +345,8 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do 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 diff --git a/lib/action_pack/web_authn.rb b/lib/action_pack/web_authn.rb index 8ee72e6cb..f80c13321 100644 --- a/lib/action_pack/web_authn.rb +++ b/lib/action_pack/web_authn.rb @@ -3,5 +3,15 @@ module ActionPack::WebAuthn def relying_party RelyingParty.new end + + def attestation_verifiers + @attestation_verifiers ||= { + "none" => Authenticator::AttestationVerifiers::None.new + } + end + + def register_attestation_verifier(format, verifier) + attestation_verifiers[format.to_s] = verifier + end end end diff --git a/lib/action_pack/web_authn/authenticator/attestation.rb b/lib/action_pack/web_authn/authenticator/attestation.rb index 66714fd6c..beacf0fac 100644 --- a/lib/action_pack/web_authn/authenticator/attestation.rb +++ b/lib/action_pack/web_authn/authenticator/attestation.rb @@ -38,7 +38,7 @@ class ActionPack::WebAuthn::Authenticator::Attestation attr_reader :authenticator_data, :format, :attestation_statement - delegate :credential_id, :public_key, :public_key_bytes, :sign_count, to: :authenticator_data + delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data def self.decode(bytes) cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes) diff --git a/lib/action_pack/web_authn/authenticator/attestation_response.rb b/lib/action_pack/web_authn/authenticator/attestation_response.rb index e20b4d7f7..d27cc499b 100644 --- a/lib/action_pack/web_authn/authenticator/attestation_response.rb +++ b/lib/action_pack/web_authn/authenticator/attestation_response.rb @@ -25,10 +25,10 @@ # In addition to the base Response validations, this class verifies: # # * The client data type is "webauthn.create" -# * The attestation format is "none" (other formats require certificate verification) +# * The attestation format has a registered verifier +# * The attestation statement passes format-specific verification # class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response - SUPPORTED_ATTESTATION_FORMATS = %w[ none ].freeze attr_reader :attestation_object def initialize(attestation_object:, **attributes) @@ -43,9 +43,13 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::Web raise InvalidResponseError, "Client data type is not webauthn.create" end - unless SUPPORTED_ATTESTATION_FORMATS.include?(attestation.format) + verifier = ActionPack::WebAuthn.attestation_verifiers[attestation.format] + + unless verifier raise InvalidResponseError, "Unsupported attestation format: #{attestation.format}" end + + verifier.verify!(attestation, client_data_json: client_data_json) end def attestation diff --git a/lib/action_pack/web_authn/authenticator/attestation_verifiers/none.rb b/lib/action_pack/web_authn/authenticator/attestation_verifiers/none.rb new file mode 100644 index 000000000..ebb039054 --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/attestation_verifiers/none.rb @@ -0,0 +1,24 @@ +# = Action Pack WebAuthn None Attestation Verifier +# +# Verifies attestation responses with the "none" format, which indicates the +# authenticator did not provide any attestation statement. This is the default +# format used by most consumer authenticators. +# +# == Implementing Custom Verifiers +# +# To support other attestation formats (e.g., "packed", "fido-u2f"), implement +# a class with the same +verify!+ interface and register it: +# +# ActionPack::WebAuthn.register_attestation_verifier("packed", MyPackedVerifier.new) +# +# The +verify!+ method receives the decoded +Attestation+ object and the raw +# +client_data_json+ bytes. Raise +InvalidResponseError+ if verification fails. +# +class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None + def verify!(attestation, client_data_json:) + if attestation.attestation_statement.present? + raise ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError, + "Attestation statement must be empty for 'none' format" + end + end +end diff --git a/lib/action_pack/web_authn/authenticator/data.rb b/lib/action_pack/web_authn/authenticator/data.rb index a088179ef..5d4e0f224 100644 --- a/lib/action_pack/web_authn/authenticator/data.rb +++ b/lib/action_pack/web_authn/authenticator/data.rb @@ -57,7 +57,7 @@ class ActionPack::WebAuthn::Authenticator::Data BACKUP_STATE_FLAG = 0x10 ATTESTED_CREDENTIAL_DATA_FLAG = 0x40 - attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :credential_id, :public_key_bytes + attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes class << self def wrap(data) @@ -81,10 +81,13 @@ class ActionPack::WebAuthn::Authenticator::Data sign_count = bytes[position, SIGN_COUNT_LENGTH].pack("C*").unpack1("N") position += SIGN_COUNT_LENGTH + aaguid = nil credential_id = nil public_key_bytes = nil if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0 + aaguid_bytes = bytes[position, AAGUID_LENGTH].pack("C*") + aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-") position += AAGUID_LENGTH credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n") @@ -101,17 +104,19 @@ class ActionPack::WebAuthn::Authenticator::Data relying_party_id_hash: relying_party_id_hash, flags: flags, sign_count: sign_count, + aaguid: aaguid, credential_id: credential_id, public_key_bytes: public_key_bytes ) end end - def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, credential_id:, public_key_bytes:) + def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, aaguid: nil, credential_id:, public_key_bytes:) @bytes = bytes @relying_party_id_hash = relying_party_id_hash @flags = flags @sign_count = sign_count + @aaguid = aaguid @credential_id = credential_id @public_key_bytes = public_key_bytes end diff --git a/lib/action_pack/web_authn/current.rb b/lib/action_pack/web_authn/current.rb index ab9038a80..88183af54 100644 --- a/lib/action_pack/web_authn/current.rb +++ b/lib/action_pack/web_authn/current.rb @@ -1,3 +1,3 @@ class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes - attribute :host + attribute :host, :origin end diff --git a/lib/action_pack/web_authn/public_key_credential.rb b/lib/action_pack/web_authn/public_key_credential.rb index 6ca8a99df..062efab77 100644 --- a/lib/action_pack/web_authn/public_key_credential.rb +++ b/lib/action_pack/web_authn/public_key_credential.rb @@ -1,5 +1,5 @@ class ActionPack::WebAuthn::PublicKeyCredential - attr_reader :id, :public_key, :sign_count, :transports, :owner + attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports, :owner class << self def create(client_data_json:, attestation_object:, challenge:, origin:, transports: [], owner: nil) @@ -14,16 +14,20 @@ class ActionPack::WebAuthn::PublicKeyCredential id: response.attestation.credential_id, public_key: response.attestation.public_key, sign_count: response.attestation.sign_count, + aaguid: response.attestation.aaguid, + backed_up: response.attestation.backed_up?, transports: transports, owner: owner ) end end - def initialize(id:, public_key:, sign_count:, transports: [], owner: nil) + def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [], owner: nil) @id = id @public_key = public_key @sign_count = sign_count + @aaguid = aaguid + @backed_up = backed_up @transports = transports @owner = owner end @@ -39,5 +43,6 @@ class ActionPack::WebAuthn::PublicKeyCredential response.validate!(challenge: challenge, origin: origin) @sign_count = response.authenticator_data.sign_count + @backed_up = response.authenticator_data.backed_up? 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 46510ad62..e68d8eba4 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 @@ -68,7 +68,11 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W # [+:exclude_credentials+] # Optional. Existing credentials to exclude from registration. Each must # respond to +id+ and +transports+. - def initialize(id:, name:, display_name:, resident_key: :preferred, exclude_credentials: [], **attrs) + # + # [+:attestation+] + # Optional. The attestation conveyance preference. One of +:none+, + # +:indirect+, +:direct+, or +:enterprise+. Defaults to +:none+. + def initialize(id:, name:, display_name:, resident_key: :preferred, exclude_credentials: [], attestation: :none, **attrs) super(**attrs) @id = id @@ -76,6 +80,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W @display_name = display_name @resident_key = resident_key @exclude_credentials = exclude_credentials + @attestation = validate_attestation(attestation) end # Returns a Hash suitable for JSON serialization and passing to the @@ -104,10 +109,24 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W json[:excludeCredentials] = @exclude_credentials.map { |credential| exclude_credential_json(credential) } end + if @attestation != :none + json[:attestation] = @attestation.to_s + end + json end private + ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze + + def validate_attestation(value) + if ATTESTATION_PREFERENCES.include?(value) + value + else + raise ArgumentError, "Invalid attestation preference: #{value.inspect}. Must be one of: #{ATTESTATION_PREFERENCES.map(&:inspect).join(", ")}" + end + end + def exclude_credential_json(credential) hash = { type: "public-key", id: credential.id } hash[:transports] = credential.transports if credential.transports.any? diff --git a/test/controllers/sessions/passkeys_controller_test.rb b/test/controllers/sessions/passkeys_controller_test.rb index 88512f2df..e8e8d315a 100644 --- a/test/controllers/sessions/passkeys_controller_test.rb +++ b/test/controllers/sessions/passkeys_controller_test.rb @@ -44,7 +44,7 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest challenge = session[:webauthn_challenge] params = assertion_params(challenge: challenge) - params[:response][:signature] = Base64.urlsafe_encode64("invalid", padding: false) + params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false) post session_passkey_url, params: params @@ -59,8 +59,8 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest get new_session_url post session_passkey_url, params: { - credential_id: "nonexistent", - response: { + passkey: { + id: "nonexistent", client_data_json: Base64.urlsafe_encode64("{}", padding: false), authenticator_data: Base64.urlsafe_encode64("x", padding: false), signature: Base64.urlsafe_encode64("x", padding: false) @@ -89,8 +89,8 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest get new_session_url post session_passkey_url(format: :json), params: { - credential_id: "nonexistent", - response: { + passkey: { + id: "nonexistent", client_data_json: Base64.urlsafe_encode64("{}", padding: false), authenticator_data: Base64.urlsafe_encode64("x", padding: false), signature: Base64.urlsafe_encode64("x", padding: false) @@ -116,8 +116,8 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest signature = sign(authenticator_data, client_data_json) { - credential_id: @credential.credential_id, - response: { + passkey: { + id: @credential.credential_id, client_data_json: client_data_json, authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false), signature: Base64.urlsafe_encode64(signature, padding: false) diff --git a/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb b/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb index 5d58a3ff4..bed10e4a4 100644 --- a/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb +++ b/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb @@ -98,7 +98,7 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo assert_equal "Origin does not match", error.message end - test "validate! raises when attestation format is not supported" do + test "validate! raises when attestation format is not registered" do response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( client_data_json: @client_data_json, attestation_object: build_attestation_object(user_verified: true, format: "packed") @@ -111,6 +111,24 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo assert_equal "Unsupported attestation format: packed", error.message end + test "validate! calls registered verifier for custom format" do + verified = false + custom_verifier = Object.new + custom_verifier.define_singleton_method(:verify!) { |_attestation, client_data_json:| verified = true } + + ActionPack::WebAuthn.register_attestation_verifier("packed", custom_verifier) + + response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( + client_data_json: @client_data_json, + attestation_object: build_attestation_object(user_verified: true, format: "packed") + ) + + response.validate!(challenge: @challenge, origin: @origin) + assert verified + ensure + ActionPack::WebAuthn.attestation_verifiers.delete("packed") + end + private def build_attestation_object(user_verified:, format: "none") auth_data = build_authenticator_data(user_verified: user_verified) diff --git a/test/lib/action_pack/web_authn/authenticator/attestation_verifiers/none_test.rb b/test/lib/action_pack/web_authn/authenticator/attestation_verifiers/none_test.rb new file mode 100644 index 000000000..5d3b71e23 --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/attestation_verifiers/none_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::NoneTest < ActiveSupport::TestCase + setup do + @verifier = ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None.new + end + + test "verify! passes with nil attestation statement" do + attestation = stub(attestation_statement: nil) + + assert_nothing_raised do + @verifier.verify!(attestation, client_data_json: "") + end + end + + test "verify! passes with empty attestation statement" do + attestation = stub(attestation_statement: {}) + + assert_nothing_raised do + @verifier.verify!(attestation, client_data_json: "") + end + end + + test "verify! raises with non-empty attestation statement" do + attestation = stub(attestation_statement: { "sig" => "abc" }) + + error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do + @verifier.verify!(attestation, client_data_json: "") + end + + assert_equal "Attestation statement must be empty for 'none' format", error.message + 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 2fc1acfbc..232cb74ba 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 @@ -84,6 +84,34 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup ], options.as_json[:excludeCredentials] end + test "as_json excludes attestation when none" do + assert_nil @options.as_json[:attestation] + end + + test "as_json includes attestation when not none" do + options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( + id: "user-123", + name: "user@example.com", + display_name: "Test User", + attestation: :direct, + relying_party: @relying_party + ) + + assert_equal "direct", options.as_json[:attestation] + end + + test "raises with invalid attestation preference" do + assert_raises(ArgumentError) do + ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( + id: "user-123", + name: "user@example.com", + display_name: "Test User", + attestation: :invalid, + relying_party: @relying_party + ) + end + end + test "as_json excludeCredentials omits transports when empty" do options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( id: "user-123", From ee7fc3495d3b5580ac1187713b0836caadde0c5d Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 20 Feb 2026 11:13:06 -0600 Subject: [PATCH 07/12] Move passkeys button below profile area --- app/views/users/show.html.erb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index f75febf2a..d96184e5f 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -5,11 +5,6 @@
    <% 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 "authentication" %> - Manage passkeys - <% end %> - <%= link_to edit_user_path(@user), class: "user-edit-link btn", data: { controller: "tooltip" } do %> <%= icon_tag "pencil" %> Edit profile @@ -46,6 +41,11 @@ <% if Current.user == @user %> + <%= link_to user_credentials_path(@user), class: "btn txt-x-small center" do %> + <%= icon_tag "authentication" %> + Manage passkeys + <% end %> + <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %> Sign out of Fizzy on this device <% end %> From f4fbe17b223e99b034dd91f26fc4585056ebecf4 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 20 Feb 2026 11:42:56 -0600 Subject: [PATCH 08/12] Nicer passkey styles --- app/assets/stylesheets/credentials.css | 43 +++++++++++-------- app/assets/stylesheets/utilities.css | 29 ++++++++++++- .../users/credentials/_credential.html.erb | 11 +++-- app/views/users/credentials/edit.html.erb | 6 +-- app/views/users/credentials/index.html.erb | 33 +++++++------- 5 files changed, 77 insertions(+), 45 deletions(-) diff --git a/app/assets/stylesheets/credentials.css b/app/assets/stylesheets/credentials.css index 64e39005b..bb6db0b37 100644 --- a/app/assets/stylesheets/credentials.css +++ b/app/assets/stylesheets/credentials.css @@ -1,29 +1,34 @@ @layer components { - .passkey-icon--dark { - display: none; + .credential { + border-block-start: var(--border); + list-style: none; + + &:last-child { + border-block-end: var(--border); + } } - .passkey-icon--light { - html[data-theme="dark"] & { - display: none; - } + .credential__link { + align-items: center; + block-size: 1.75lh; + color: currentcolor; + display: flex; + gap: 1ch; + padding-inline: 1ch; - @media (prefers-color-scheme: dark) { - html:not([data-theme]) & { - display: none; + @media (any-hover: hover) { + &:hover { + background: var(--color-ink-lightest); + + .credential__arrow { + opacity: 0.66; + } } } } - .passkey-icon--dark { - html[data-theme="dark"] & { - display: inline; - } - - @media (prefers-color-scheme: dark) { - html:not([data-theme]) & { - display: inline; - } - } + .credential__arrow { + margin-inline-start: auto; + opacity: 0; } } diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 53837034c..2aa07029c 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -31,9 +31,10 @@ .txt-capitalize-first-letter::first-letter { text-transform: capitalize; } .txt-link { color: var(--color-link); text-decoration: underline; } - .font-weight-normal { font-weight: normal; } + .font-weight-normal { font-weight: 400; } + .font-weight-medium { font-weight: 500; } .font-weight-semibold { font-weight: 600; } - .font-weight-bold { font-weight: bold; } + .font-weight-bold { font-weight: 700; } .font-weight-black { font-weight: 900; } /* Flexbox and Grid */ @@ -283,4 +284,28 @@ display: none; } } + + .hide-on-dark-mode { + html[data-theme="dark"] & { + display: none; + } + + html:not([data-theme]) & { + @media (prefers-color-scheme: dark) { + display: none; + } + } + } + + .hide-on-light-mode { + html[data-theme="light"] & { + display: none; + } + + html:not([data-theme]) & { + @media (prefers-color-scheme: light) { + display: none; + } + } + } } diff --git a/app/views/users/credentials/_credential.html.erb b/app/views/users/credentials/_credential.html.erb index 38fe5cb0e..fa1e733e6 100644 --- a/app/views/users/credentials/_credential.html.erb +++ b/app/views/users/credentials/_credential.html.erb @@ -1,10 +1,9 @@ -
  • - <%= link_to edit_user_credential_path(Current.user, credential), class: "txt-ink flex align-center gap txt-medium full-width" do %> +
  • + <%= 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 passkey-icon passkey-icon--light", aria: { hidden: true } %> - <%= image_tag authenticator&.icon&.[](:dark) || "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink passkey-icon passkey-icon--dark", aria: { hidden: true } %> + <%= 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" %> - - <%= icon_tag "arrow-right", class: "txt-subtle txt-xx-small flex-item-no-shrink" %> + <% end %>
  • diff --git a/app/views/users/credentials/edit.html.erb b/app/views/users/credentials/edit.html.erb index c8e4ddfa4..4beb0bba8 100644 --- a/app/views/users/credentials/edit.html.erb +++ b/app/views/users/credentials/edit.html.erb @@ -1,4 +1,4 @@ -<% @page_title = "Name your passkey" %> +<% @page_title = "Edit Passkey" %> <% content_for :header do %>
    @@ -15,11 +15,11 @@ <%= form_with model: @credential, scope: :credential, url: user_credential_path(Current.user, @credential), html: { class: "flex flex-column gap" } do |form| %>
    - <%= form.label :name %> + <%= form.label "Name your passkey" %> <%= form.text_field :name, autofocus: true, class: "input", placeholder: "e.g. MacBook Pro, iPhone", data: { "1p-ignore": "" }, autocomplete: "off" %>
    - <%= form.submit "Save", class: "btn btn--primary" %> + <%= form.submit "Save", class: "btn btn--link center" %> <% end %>
    diff --git a/app/views/users/credentials/index.html.erb b/app/views/users/credentials/index.html.erb index 3278f6d14..f37d8a373 100644 --- a/app/views/users/credentials/index.html.erb +++ b/app/views/users/credentials/index.html.erb @@ -13,28 +13,31 @@ data-credential-public-key-value="<%= @creation_options.as_json.to_json %>" data-credential-register-url-value="<%= user_credentials_path(Current.user) %>"> -

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

    +

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

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

      Your browser will prompt you to create a passkey - using your device's biometrics, PIN, or security key

      +
      + - +

      + Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key +

      - + - + +
    From 017bcc9ce1216b5e3379be94f97d4516d18add44 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 4 Mar 2026 14:12:40 +0100 Subject: [PATCH 09/12] Polish up Passkey interface - Fix indentation - Split awkward initializer into separate methods - Rename credentials to passkeys - Simplify authenticator registry loading - Flatten nested errors under ActionPack::WebAuthn - Set passkey current params only requests that use it - Inline anemic methods - Replace custom validation with ActiveModel::Validation - Rename identity to holder in Passkey - Rename credentials to passkeys in JS - Extract framework library out of controllers - Pass params hashes down to ActionPack - Attempt to simplify public interface - Push data decoding down to the classes representing the data - Introduce has_passkeys - Add CBOR bigint support - Rename ActionPack::WebAuthn::Passkey to ActionPack::Passkey - Add create_passkey_button helper - Rename public-key to creation-options - Add sign_in_with_passkey_button helper - Dispatch events for the whole Passkey lifecycle - Add ED25519 support - Prevent crash on missing meta tag - Validate resident key options - Don't clobber existing ActionPack config options - Validate cryptographic params - Move CurrentWebAuthnRequest into ActionPack::Passkey - Use ActiveModel::Attributes for Options objects - Implement expiring challanges - Add lifecycle events - Extract param helpers into Request - Add passkey_creation_options and passkey_request_options helpers - Add create_passkey_challenge to make it easier to override the create method if needed - Prefix all view helpers with passkey_ - Auto-include ActionPack::Passkey::Holder - Make the passkey challange url configurable - Add a reminder about Passkeys to the magic link email --- app/assets/stylesheets/credentials.css | 9 + app/controllers/concerns/current_request.rb | 3 - .../my/passkey_challenges_controller.rb | 7 + app/controllers/my/passkeys_controller.rb | 34 +++ .../sessions/passkeys_controller.rb | 35 +-- app/controllers/sessions_controller.rb | 6 +- .../users/credentials_controller.rb | 46 ---- app/javascript/application.js | 1 + .../controllers/credential_controller.js | 60 ----- .../controllers/passkey_controller.js | 80 ------ app/javascript/helpers/base64url_helpers.js | 12 - app/javascript/lib/action_pack/passkey.js | 246 ++++++++++++++++++ app/javascript/lib/action_pack/webauthn.js | 83 ++++++ app/models/identity.rb | 3 +- app/models/identity/credential.rb | 74 ------ .../identity/credential/authenticator.rb | 12 - app/models/passkey/authenticator.rb | 23 ++ .../sign_in_instructions.html.erb | 4 + .../sign_in_instructions.text.erb | 4 + app/views/my/access_tokens/index.html.erb | 2 +- app/views/my/passkeys/_passkey.html.erb | 13 + .../credentials => my/passkeys}/edit.html.erb | 6 +- .../passkeys}/index.html.erb | 22 +- app/views/sessions/new.html.erb | 13 +- .../users/credentials/_credential.html.erb | 9 - app/views/users/show.html.erb | 2 +- config/application.rb | 4 + config/importmap.rb | 1 + config/initializers/passkeys.rb | 3 + config/routes.rb | 4 +- ...60213154740_create_action_pack_passkeys.rb | 20 ++ ...60213154740_create_identity_credentials.rb | 17 -- ...d_and_backed_up_to_identity_credentials.rb | 6 - db/schema.rb | 33 +-- db/schema_sqlite.rb | 16 ++ lib/action_pack/passkey.rb | 104 ++++++++ .../passkey/challenges_controller.rb | 37 +++ lib/action_pack/passkey/form_helper.rb | 87 +++++++ lib/action_pack/passkey/holder.rb | 143 ++++++++++ lib/action_pack/passkey/request.rb | 81 ++++++ lib/action_pack/railtie.rb | 70 +++++ lib/action_pack/web_authn.rb | 47 ++++ .../authenticator/assertion_response.rb | 55 ++-- .../web_authn/authenticator/attestation.rb | 15 ++ .../authenticator/attestation_response.rb | 50 ++-- .../attestation_verifiers/none.rb | 2 +- .../web_authn/authenticator/data.rb | 27 ++ .../web_authn/authenticator/response.rb | 142 +++++----- lib/action_pack/web_authn/cbor_decoder.rb | 35 ++- lib/action_pack/web_authn/cose_key.rb | 62 ++++- lib/action_pack/web_authn/current.rb | 22 +- .../web_authn/public_key_credential.rb | 138 ++++++++-- .../public_key_credential/creation_options.rb | 83 ++---- .../public_key_credential/options.rb | 77 +++++- .../public_key_credential/request_options.rb | 19 +- ...ion_pack_passkey_infer_name_from_aaguid.rb | 15 ++ .../my/passkey_challenges_controller_test.rb | 33 +++ .../my/passkeys_controller_test.rb | 26 ++ .../sessions/passkeys_controller_test.rb | 71 +---- test/lib/action_pack/passkey_test.rb | 92 +++++++ .../authenticator/assertion_response_test.rb | 65 +++-- .../attestation_response_test.rb | 180 ++++++------- .../authenticator/attestation_test.rb | 132 ++-------- .../attestation_verifiers/none_test.rb | 2 +- .../web_authn/authenticator/data_test.rb | 163 +++++------- .../web_authn/authenticator/response_test.rb | 56 ++-- .../web_authn/cbor_decoder_test.rb | 20 +- .../action_pack/web_authn/cose_key_test.rb | 216 ++++++++++----- .../creation_options_test.rb | 12 +- .../request_options_test.rb | 9 +- ...ack_passkey_infer_name_from_aaguid_test.rb | 32 +++ .../previews/magic_link_mailer_preview.rb | 2 +- test/test_helpers/webauthn_test_helper.rb | 94 +++++++ 73 files changed, 2326 insertions(+), 1103 deletions(-) create mode 100644 app/controllers/my/passkey_challenges_controller.rb create mode 100644 app/controllers/my/passkeys_controller.rb delete mode 100644 app/controllers/users/credentials_controller.rb delete mode 100644 app/javascript/controllers/credential_controller.js delete mode 100644 app/javascript/controllers/passkey_controller.js delete mode 100644 app/javascript/helpers/base64url_helpers.js create mode 100644 app/javascript/lib/action_pack/passkey.js create mode 100644 app/javascript/lib/action_pack/webauthn.js delete mode 100644 app/models/identity/credential.rb delete mode 100644 app/models/identity/credential/authenticator.rb create mode 100644 app/models/passkey/authenticator.rb create mode 100644 app/views/my/passkeys/_passkey.html.erb rename app/views/{users/credentials => my/passkeys}/edit.html.erb (73%) rename app/views/{users/credentials => my/passkeys}/index.html.erb (61%) delete mode 100644 app/views/users/credentials/_credential.html.erb create mode 100644 config/initializers/passkeys.rb create mode 100644 db/migrate/20260213154740_create_action_pack_passkeys.rb delete mode 100644 db/migrate/20260213154740_create_identity_credentials.rb delete mode 100644 db/migrate/20260219095815_add_aaguid_and_backed_up_to_identity_credentials.rb create mode 100644 lib/action_pack/passkey.rb create mode 100644 lib/action_pack/passkey/challenges_controller.rb create mode 100644 lib/action_pack/passkey/form_helper.rb create mode 100644 lib/action_pack/passkey/holder.rb create mode 100644 lib/action_pack/passkey/request.rb create mode 100644 lib/action_pack/railtie.rb create mode 100644 lib/rails_ext/action_pack_passkey_infer_name_from_aaguid.rb create mode 100644 test/controllers/my/passkey_challenges_controller_test.rb create mode 100644 test/controllers/my/passkeys_controller_test.rb create mode 100644 test/lib/action_pack/passkey_test.rb create mode 100644 test/lib/rails_ext/action_pack_passkey_infer_name_from_aaguid_test.rb create mode 100644 test/test_helpers/webauthn_test_helper.rb 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