WebAuthn/passkeys support (#53)

god i hope any of this works
This commit is contained in:
Mahad Kalam 2026-01-14 02:48:21 +06:00 committed by GitHub
parent 820983b4fc
commit 5562fe2c06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1493 additions and 327 deletions

View file

@ -140,6 +140,8 @@ gem "geocoder", "~> 1.8"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 2.0"
gem "webauthn", "~> 3.1"
gem "bcrypt", "~> 3.1"
gem "rack-attack", "~> 6.7"

View file

@ -9,31 +9,31 @@ GIT
GEM
remote: https://rubygems.org/
specs:
aasm (5.5.0)
aasm (5.5.2)
concurrent-ruby (~> 1.0)
actioncable (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
actioncable (8.0.4)
actionpack (= 8.0.4)
activesupport (= 8.0.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
actionmailbox (8.0.4)
actionpack (= 8.0.4)
activejob (= 8.0.4)
activerecord (= 8.0.4)
activestorage (= 8.0.4)
activesupport (= 8.0.4)
mail (>= 2.8.0)
actionmailer (8.0.2)
actionpack (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activesupport (= 8.0.2)
actionmailer (8.0.4)
actionpack (= 8.0.4)
actionview (= 8.0.4)
activejob (= 8.0.4)
activesupport (= 8.0.4)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2)
actionview (= 8.0.2)
activesupport (= 8.0.2)
actionpack (8.0.4)
actionview (= 8.0.4)
activesupport (= 8.0.4)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@ -41,15 +41,15 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.2)
actionpack (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
actiontext (8.0.4)
actionpack (= 8.0.4)
activerecord (= 8.0.4)
activestorage (= 8.0.4)
activesupport (= 8.0.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2)
activesupport (= 8.0.2)
actionview (8.0.4)
activesupport (= 8.0.4)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
@ -59,22 +59,22 @@ GEM
block_cipher_kit (>= 0.0.4)
rails (>= 7.2.2.1)
serve_byte_range (~> 1.0)
activejob (8.0.2)
activesupport (= 8.0.2)
activejob (8.0.4)
activesupport (= 8.0.4)
globalid (>= 0.3.6)
activemodel (8.0.2)
activesupport (= 8.0.2)
activerecord (8.0.2)
activemodel (= 8.0.2)
activesupport (= 8.0.2)
activemodel (8.0.4)
activesupport (= 8.0.4)
activerecord (8.0.4)
activemodel (= 8.0.4)
activesupport (= 8.0.4)
timeout (>= 0.4.0)
activestorage (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activesupport (= 8.0.2)
activestorage (8.0.4)
actionpack (= 8.0.4)
activejob (= 8.0.4)
activerecord (= 8.0.4)
activesupport (= 8.0.4)
marcel (~> 1.0)
activesupport (8.0.2)
activesupport (8.0.4)
base64
benchmark (>= 0.3)
bigdecimal
@ -90,13 +90,14 @@ GEM
acts_as_paranoid (0.10.3)
activerecord (>= 6.1, < 8.1)
activesupport (>= 6.1, < 8.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
ahoy_matey (5.4.1)
activesupport (>= 7.1)
device_detector (>= 1)
safely_block (>= 0.4)
annotaterb (4.19.0)
android_key_attestation (0.3.0)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
argon2-kdf (0.3.1)
@ -109,45 +110,48 @@ GEM
turbo-rails
awesome_print (1.9.2)
aws-eventstream (1.4.0)
aws-partitions (1.1110.0)
aws-sdk-core (3.225.0)
aws-partitions (1.1203.0)
aws-sdk-core (3.241.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.102.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.189.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.0)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.0)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
base64 (0.3.0)
bcrypt (3.1.21)
benchmark (0.5.0)
better_html (2.2.0)
actionview (>= 7.0)
activesupport (>= 7.0)
ast (~> 2.0)
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.1.9)
bigdecimal (4.0.1)
bindata (2.5.1)
bindex (0.8.1)
blind_index (2.7.0)
activesupport (>= 7.1)
argon2-kdf (>= 0.2)
block_cipher_kit (0.0.4)
bootsnap (1.18.6)
bootsnap (1.21.0)
msgpack (~> 1.2)
brakeman (7.1.2)
racc
browser (6.2.0)
builder (3.3.0)
cbor (0.5.10.1)
chartkick (5.2.1)
childprocess (5.1.0)
logger (~> 1.5)
@ -157,21 +161,24 @@ GEM
activesupport (>= 7.2.0, < 8.2.0)
railties (>= 7.2.0, < 8.2.0)
zeitwerk (>= 2.5.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
console1984 (0.2.2)
irb (~> 1.13)
parser
rails (>= 7.0)
rainbow
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
countries (7.1.1)
unaccent (~> 0.3)
crass (1.0.6)
css_parser (1.21.1)
addressable
csv (3.3.5)
date (3.4.1)
debug (1.10.0)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
device_detector (1.1.3)
@ -183,10 +190,10 @@ GEM
doorkeeper (>= 5.5, < 5.9)
jwt (>= 2.5)
ostruct (>= 0.5)
dotenv (3.1.8)
dotenv (3.2.0)
drb (2.2.3)
dry-cli (1.2.0)
erb (5.0.1)
dry-cli (1.4.0)
erb (6.0.1)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
@ -195,60 +202,60 @@ GEM
rubocop (>= 1)
smart_properties
erubi (1.13.1)
et-orbi (1.2.11)
et-orbi (1.4.0)
tzinfo
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 6.1.0)
faraday (2.13.1)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-mashify (1.0.0)
faraday-mashify (1.0.2)
faraday (~> 2.0)
hashie
faraday-multipart (1.1.0)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
faraday-net_http (3.4.2)
net-http (~> 0.5)
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl)
ffi-compiler (1.3.2)
ffi (>= 1.15.5)
rake
fiddle (1.1.8)
flipper (1.3.5)
flipper (1.3.6)
concurrent-ruby (< 2)
flipper-active_record (1.3.5)
flipper-active_record (1.3.6)
activerecord (>= 4.2, < 9)
flipper (~> 1.3.5)
flipper-ui (1.3.5)
flipper (~> 1.3.6)
flipper-ui (1.3.6)
erubi (>= 1.0.0, < 2.0.0)
flipper (~> 1.3.5)
flipper (~> 1.3.6)
rack (>= 1.4, < 4)
rack-protection (>= 1.5.3, < 5.0.0)
rack-session (>= 1.0.2, < 3.0.0)
sanitize (< 8)
front_matter_parser (1.0.1)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
geocoder (1.8.6)
base64 (>= 0.1.0)
csv (>= 3.0.0)
gli (2.22.2)
ostruct
globalid (1.2.1)
globalid (1.3.0)
activesupport (>= 6.1)
good_job (4.10.2)
good_job (4.13.1)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
@ -261,30 +268,30 @@ GEM
activerecord (>= 4.0)
hashids (~> 1.0)
hashids (1.0.6)
hashie (5.0.0)
hashie (5.1.0)
logger
htmlentities (4.4.2)
http (5.2.0)
http (5.3.1)
addressable (~> 2.8)
base64 (~> 0.1)
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.5.0)
http-cookie (1.0.8)
http-cookie (1.1.0)
domain_name (~> 0.5)
http-form_data (2.3.0)
i18n (1.14.7)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.0)
irb (1.15.2)
io-console (0.8.2)
irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jb (0.8.2)
jmespath (1.6.2)
json (2.12.0)
json (2.18.0)
jwt (3.1.2)
base64
kaminari (1.2.2)
@ -312,35 +319,36 @@ GEM
railties (>= 6.1)
rexml
lint_roller (1.1.0)
literal (1.7.1)
literal (1.8.1)
zeitwerk
llhttp-ffi (0.5.1)
ffi-compiler (~> 1.0)
rake (~> 13.0)
lockbox (2.0.1)
lockbox (2.1.0)
logger (1.7.0)
loofah (2.24.1)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
lz_string (0.3.0)
mail (2.8.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
marcel (1.1.0)
mini-levenshtein (0.1.2)
mini_magick (5.2.0)
benchmark
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (5.25.5)
minitest (6.0.1)
prism (~> 1.5)
msgpack (1.8.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
net-http (0.6.0)
uri
net-imap (0.5.8)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
@ -350,40 +358,49 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.18.8-aarch64-linux-gnu)
nokogiri (1.19.0-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-aarch64-linux-musl)
nokogiri (1.19.0-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.8-arm-linux-gnu)
nokogiri (1.19.0-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-arm-linux-musl)
nokogiri (1.19.0-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin)
nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin)
nokogiri (1.19.0-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu)
nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
nokogiri-xmlsec-instructure (0.12.0)
nokogiri (~> 1.13)
openssl (3.3.2)
ostruct (0.6.1)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
ostruct (0.6.3)
paper_trail (16.0.0)
activerecord (>= 6.1)
request_store (~> 1.4)
parallel (1.27.0)
parser (3.3.8.0)
parser (3.3.10.0)
ast (~> 2.4.1)
racc
pg (1.5.9)
phlex (2.2.1)
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
phlex (2.3.1)
zeitwerk (~> 2.7)
phlex-rails (2.2.0)
phlex (~> 2.2.1)
phlex-rails (2.3.1)
phlex (~> 2.3.0)
railties (>= 7.1, < 9)
pp (0.6.2)
zeitwerk (~> 2.7)
pp (0.6.3)
prettyprint
premailer (1.27.0)
addressable
@ -394,31 +411,30 @@ GEM
net-smtp
premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0)
prism (1.4.0)
propshaft (1.1.0)
prism (1.8.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.2.6)
psych (5.3.1)
date
stringio
public_activity (3.0.1)
actionpack (>= 6.1.0)
public_activity (3.0.2)
actionpack (>= 6.1)
activerecord (>= 6.1)
i18n (>= 0.5.0)
railties (>= 6.1.0)
public_suffix (6.0.2)
railties (>= 6.1)
public_suffix (7.0.2)
puma (7.1.0)
nio4r (~> 2.0)
pundit (2.5.0)
pundit (2.5.2)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.15)
rack-attack (6.7.0)
rack (3.2.4)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-protection (4.1.1)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
@ -429,22 +445,22 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails (8.0.2)
actioncable (= 8.0.2)
actionmailbox (= 8.0.2)
actionmailer (= 8.0.2)
actionpack (= 8.0.2)
actiontext (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activemodel (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
rails (8.0.4)
actioncable (= 8.0.4)
actionmailbox (= 8.0.4)
actionmailer (= 8.0.4)
actionpack (= 8.0.4)
actiontext (= 8.0.4)
actionview (= 8.0.4)
activejob (= 8.0.4)
activemodel (= 8.0.4)
activerecord (= 8.0.4)
activestorage (= 8.0.4)
activesupport (= 8.0.4)
bundler (>= 1.15.0)
railties (= 8.0.2)
railties (= 8.0.4)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@ -452,33 +468,35 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails_semantic_logger (4.17.0)
rails_semantic_logger (4.19.0)
rack
railties (>= 5.1)
semantic_logger (~> 4.16)
railties (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
railties (8.0.4)
actionpack (= 8.0.4)
activesupport (= 8.0.4)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rdoc (6.14.0)
rake (13.3.1)
rdoc (7.1.0)
erb
psych (>= 4.0.0)
tsort
redcarpet (3.6.1)
regexp_parser (2.10.0)
reline (0.6.1)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
rexml (3.4.1)
rexml (3.4.4)
rinku (2.0.6)
rotp (6.3.0)
rouge (4.5.2)
rouge (4.7.0)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
@ -488,7 +506,7 @@ GEM
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.6)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.1)
@ -500,7 +518,7 @@ GEM
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.6)
rubocop (1.75.7)
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -508,17 +526,17 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.1)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.25.0)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.32.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@ -529,11 +547,13 @@ GEM
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
ruby-vips (2.2.5)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
safely_block (0.5.0)
saml2 (3.2.3)
safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0)
saml2 (3.3.0)
activesupport (>= 3.2, < 8.2)
nokogiri (>= 1.5.8, < 2.0)
nokogiri-xmlsec-instructure (~> 0.9, >= 0.9.5)
@ -541,7 +561,7 @@ GEM
crass (~> 1.0.2)
nokogiri (>= 1.16.8)
securerandom (0.4.1)
semantic_logger (4.16.1)
semantic_logger (4.17.0)
concurrent-ruby (~> 1.0)
sentry-rails (5.28.1)
railties (>= 5.0)
@ -551,8 +571,8 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.2)
serve_byte_range (1.0.0)
rack (>= 1.0)
slack-ruby-client (2.6.0)
faraday (>= 2.0)
slack-ruby-client (2.7.0)
faraday (>= 2.0.1)
faraday-mashify
faraday-multipart
gli
@ -562,29 +582,34 @@ GEM
actionview (>= 6.0)
activesupport (>= 6.0)
smart_properties (1.17.0)
stringio (3.1.7)
stringio (3.2.0)
superform (0.5.1)
phlex-rails (>= 1.0, < 3.0)
zeitwerk (~> 2.6)
thor (1.3.2)
thruster (0.1.13)
thruster (0.1.13-aarch64-linux)
thruster (0.1.13-arm64-darwin)
thruster (0.1.13-x86_64-darwin)
thruster (0.1.13-x86_64-linux)
timeout (0.4.3)
turbo-rails (2.0.16)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-darwin)
thruster (0.1.17-x86_64-linux)
timeout (0.6.0)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
tsort (0.2.0)
turbo-rails (2.0.20)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unaccent (0.4.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
vite_rails (3.0.19)
vite_rails (3.0.20)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.9.2)
@ -598,13 +623,21 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
websocket-driver (0.7.7)
webauthn (3.4.3)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.1)
openssl (>= 2.2)
safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wicked (2.0.0)
railties (>= 3.0.7)
zeitwerk (2.7.3)
zeitwerk (2.7.4)
PLATFORMS
aarch64-linux
@ -691,6 +724,7 @@ DEPENDENCIES
valid_email2!
vite_rails
web-console
webauthn (~> 3.1)
wicked (~> 2.0)
BUNDLED WITH

View file

@ -20,8 +20,9 @@ so is the onboarding controller, she should really be ripped out and replaced.
- make sure you have working installations of ruby ≥ 3.4.4 & nodejs
- clone repo
- create .env.development, populate `DATABASE_URL` w/ a local postgres instance
- create .env.development, populate `DATABASE_URL` w/ a local postgres instance and `LOCKBOX_MASTER_KEY` with the value of `openssl rand -hex 32`
- if you want to use docker, you can run `docker compose -f docker-compose-dbonly.yml up` to spin up a database and plug `postgresql://postgres@localhost:5432/identity_vault_development` in as your `DATABASE_URL`
- if you don't have docker and are on macOS, [orbstack](https://orbstack.dev) may be helpful
- run `bundle install`
- run `bin/rails db:prepare`
- console in (`bin/rails console`)

View file

@ -0,0 +1,62 @@
module WebauthnAuthenticatable
extend ActiveSupport::Concern
private
def generate_webauthn_authentication_options(identity, session_key:, user_verification: "required")
credentials = identity.webauthn_credentials.active.pluck(:external_id).map { |id| Base64.urlsafe_decode64(id) }
options = WebAuthn::Credential.options_for_get(
allow: credentials,
user_verification: user_verification
)
session[session_key] = options.challenge
Rails.logger.info "WebAuthn options generated: session_key=#{session_key}"
options
end
def verify_webauthn_credential(identity, credential_data:, session_key:)
webauthn_credential = WebAuthn::Credential.from_get(credential_data)
# Delete challenge immediately to prevent replay attacks (single-use)
stored_challenge = session.delete(session_key)
Rails.logger.info "WebAuthn verify: session_key=#{session_key}, challenge_present=#{stored_challenge.present?}"
return nil unless stored_challenge.present?
Identity::WebauthnCredential.transaction do
credential = identity.webauthn_credentials.active.lock.find_by(
external_id: Base64.urlsafe_encode64(webauthn_credential.id, padding: false)
)
return nil unless credential
begin
webauthn_credential.verify(
stored_challenge,
public_key: credential.webauthn_public_key,
sign_count: credential.sign_count
)
credential.update!(sign_count: webauthn_credential.sign_count)
credential.touch unless credential.saved_change_to_sign_count?
credential
rescue WebAuthn::SignCountVerificationError => e
Rails.logger.warn "WebAuthn sign count anomaly detected: credential_id=#{credential.id}, identity_id=#{identity.id}"
credential.mark_as_compromised!
raise WebauthnCredentialCompromisedError.new(credential)
end
end
end
end
class WebauthnCredentialCompromisedError < StandardError
attr_reader :credential
def initialize(credential)
@credential = credential
super("Passkey may be compromised due to sign count anomaly")
end
end

View file

@ -0,0 +1,92 @@
class IdentityWebauthnCredentialsController < ApplicationController
include WebauthnAuthenticatable
before_action :require_step_up_for_destroy, only: [ :destroy ]
def index
@webauthn_credentials = current_identity.webauthn_credentials.order(created_at: :desc)
render layout: request.headers["HX-Request"] ? "htmx" : false
end
def new
render layout: request.headers["HX-Request"] ? "htmx" : false
end
def options
challenge = WebAuthn::Credential.options_for_create(
user: {
id: current_identity.webauthn_user_id,
name: current_identity.primary_email,
display_name: "#{current_identity.first_name} #{current_identity.last_name}"
},
exclude: current_identity.webauthn_credentials.raw_credential_ids,
authenticator_selection: {
user_verification: "required",
resident_key: "preferred"
}
)
session[:webauthn_registration_challenge] = challenge.challenge
render json: challenge
end
def create
# Delete challenge immediately to prevent replay attacks (single-use)
stored_challenge = session.delete(:webauthn_registration_challenge)
unless stored_challenge.present?
flash[:error] = "Registration session expired. Please try again."
redirect_to security_path and return
end
begin
credential_data = JSON.parse(params[:credential_data])
nickname = params[:nickname]
webauthn_credential = WebAuthn::Credential.from_create(credential_data)
webauthn_credential.verify(stored_challenge)
credential = current_identity.webauthn_credentials.create!(
webauthn_id: webauthn_credential.id,
webauthn_public_key: webauthn_credential.public_key,
nickname: nickname.presence,
sign_count: webauthn_credential.sign_count
)
flash[:success] = t(".successfully_added")
redirect_to security_path
rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn registration error: credential creation failed"
flash[:error] = "Passkey registration failed. Please try again."
render :new, status: :unprocessable_entity
rescue => e
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class.name}"
flash[:error] = "An unexpected error occurred. Please try again."
render :new, status: :unprocessable_entity
end
end
def destroy
credential = current_identity.webauthn_credentials.find(params[:id])
credential.destroy
consume_step_up!
flash[:success] = t(".successfully_removed")
redirect_to security_path
end
private
def require_step_up_for_destroy
return if current_session.step_up_valid_for?(action: "remove_passkey")
session[:pending_destroy_credential_id] = params[:id]
redirect_to new_step_up_path(
action_type: "remove_passkey",
return_to: identity_webauthn_credential_path(params[:id])
)
end
end

View file

@ -3,6 +3,9 @@ class LoginsController < ApplicationController
include SAMLHelper
include SafeUrlValidation
include AhoyAnalytics
include WebauthnAuthenticatable
WEBAUTHN_SESSION_KEY = :webauthn_authentication_challenge
skip_before_action :authenticate_identity!
before_action :set_return_to, only: [ :new, :create ]
@ -44,9 +47,13 @@ class LoginsController < ApplicationController
same_site: :lax
}
send_v2_login_code(identity, attempt)
track_event("login.code_sent", is_signup: attempt.provenance == "signup", scenario: analytics_scenario_from_return_to(@return_to))
redirect_to login_attempt_path(id: attempt.to_param), status: :see_other
if identity.webauthn_enabled?
redirect_to webauthn_login_attempt_path(id: attempt.to_param), status: :see_other
else
send_v2_login_code(identity, attempt)
track_event("login.code_sent", is_signup: attempt.provenance == "signup", scenario: analytics_scenario_from_return_to(@return_to))
redirect_to login_attempt_path(id: attempt.to_param), status: :see_other
end
rescue => e
flash[:error] = e.message
redirect_to login_path(return_to: @return_to)
@ -167,6 +174,57 @@ class LoginsController < ApplicationController
handle_post_verification_redirect
end
def webauthn
render status: :unprocessable_entity
end
def skip_webauthn
# the user wants to skip using a passkey, use email code instead
send_v2_login_code(@identity, @attempt)
redirect_to login_attempt_path(id: @attempt.to_param), status: :see_other
end
def webauthn_options
options = generate_webauthn_authentication_options(
@identity,
session_key: WEBAUTHN_SESSION_KEY,
user_verification: "preferred"
)
render json: options
end
def verify_webauthn
flash.clear
credential_data = JSON.parse(params[:credential_data])
credential = verify_webauthn_credential(
@identity,
credential_data: credential_data,
session_key: WEBAUTHN_SESSION_KEY
)
unless credential
flash.now[:error] = "Passkey not found"
render :webauthn, status: :unprocessable_entity
return
end
factors = (@attempt.authentication_factors || {}).dup
factors[:webauthn] = true
@attempt.update!(authentication_factors: factors)
handle_post_verification_redirect
rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn authentication error: #{e.message}"
flash.now[:error] = "Passkey verification failed. Please try again or use email code."
render :webauthn, status: :unprocessable_entity
rescue => e
Rails.logger.error "Unexpected WebAuthn error: #{e.message}"
flash.now[:error] = "An unexpected error occurred. Please try again."
render :webauthn, status: :unprocessable_entity
end
private
def set_attempt
@ -331,6 +389,8 @@ class LoginsController < ApplicationController
if available.include?(:totp)
redirect_to totp_login_attempt_path(id: @attempt.to_param), status: :see_other
elsif available.include?(:webauthn)
redirect_to webauthn_login_attempt_path(id: @attempt.to_param), status: :see_other
elsif available.include?(:backup_code)
redirect_to backup_code_login_attempt_path(id: @attempt.to_param), status: :see_other
else

View file

@ -1,16 +1,60 @@
class StepUpController < ApplicationController
include WebauthnAuthenticatable
helper_method :step_up_cancel_path
WEBAUTHN_SESSION_KEY = :step_up_webauthn_challenge
ACTIONS_WITHOUT_EMAIL_FALLBACK = %w[email_change disable_2fa remove_passkey].freeze
def new
@action = params[:action_type] # e.g., "remove_totp", "disable_2fa", "oidc_reauth", "email_change"
@return_to = params[:return_to]
@available_methods = current_identity.available_step_up_methods
@available_methods << :email unless @action == "email_change" # Email fallback not available for email change (already verifying old email)
@available_methods << :email unless @action.in?(ACTIONS_WITHOUT_EMAIL_FALLBACK)
@code_sent = params[:code_sent].present?
end
def webauthn_options
options = generate_webauthn_authentication_options(
current_identity,
session_key: WEBAUTHN_SESSION_KEY,
user_verification: "required"
)
render json: options
end
def verify_webauthn
credential_data = JSON.parse(params[:credential_data])
credential = verify_webauthn_credential(
current_identity,
credential_data: credential_data,
session_key: WEBAUTHN_SESSION_KEY
)
unless credential
flash[:error] = "Passkey not found"
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
return
end
complete_step_up(params[:action_type], params[:return_to])
rescue WebauthnCredentialCompromisedError => e
Rails.logger.warn "Step-up blocked: compromised credential detected for identity #{current_identity.id}"
flash[:error] = "Security issue detected with your passkey. It has been disabled for your protection. Please use another verification method or register a new passkey."
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
rescue WebAuthn::Error => e
Rails.logger.error "Step-up WebAuthn error: verification failed"
flash[:error] = "Passkey verification failed. Please try again."
redirect_to new_step_up_path(action_type: params[:action_type], method: :webauthn, return_to: params[:return_to])
rescue => e
Rails.logger.error "Unexpected step-up WebAuthn error: #{e.class.name}"
flash[:error] = "An unexpected error occurred. Please try again."
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
end
def send_email_code
if params[:action_type] == "email_change"
if params[:action_type].in?(ACTIONS_WITHOUT_EMAIL_FALLBACK)
flash[:error] = "Email verification is not available for this action"
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
return
@ -37,13 +81,12 @@ class StepUpController < ApplicationController
return
end
if action_type == "email_change" && method == :email
if action_type.in?(ACTIONS_WITHOUT_EMAIL_FALLBACK) && method == :email
flash[:error] = "Email verification is not available for this action"
redirect_to new_step_up_path(action_type: action_type, return_to: params[:return_to])
return
end
# Verify based on the method they chose
verified = case method
when :totp
totp = current_identity.totp
@ -76,43 +119,7 @@ class StepUpController < ApplicationController
return
end
# Mark step-up as completed on the identity session, bound to the specific action
current_session.record_step_up!(action: action_type)
# Execute the verified action
case action_type
when "remove_totp"
totp = current_identity.totp
totp&.destroy
TwoFactorMailer.authentication_method_disabled(current_identity).deliver_later
if current_identity.two_factor_methods.empty?
current_identity.update!(use_two_factor_authentication: false)
current_identity.backup_codes.active.each(&:mark_discarded!)
end
consume_step_up!
redirect_to security_path, notice: "Two-factor authentication disabled"
when "disable_2fa"
current_identity.update!(use_two_factor_authentication: false)
TwoFactorMailer.required_authentication_disabled(current_identity).deliver_later
consume_step_up!
redirect_to security_path, notice: "2FA requirement disabled"
when "oidc_reauth"
# OIDC re-authentication completed, redirect back to OAuth flow
safe_path = safe_internal_redirect(params[:return_to])
redirect_to safe_path || root_path
when "email_change"
# Email change step-up completed, redirect to the email change form
safe_path = safe_internal_redirect(params[:return_to])
redirect_to safe_path || new_email_change_path
else
redirect_to security_path, alert: "Unknown action"
end
complete_step_up(action_type, params[:return_to])
end
def resend_email
@ -134,6 +141,50 @@ class StepUpController < ApplicationController
private
def complete_step_up(action_type, return_to)
current_session.record_step_up!(action: action_type)
case action_type
when "remove_totp"
totp = current_identity.totp
totp&.destroy
TwoFactorMailer.authentication_method_disabled(current_identity).deliver_later
if current_identity.two_factor_methods.empty?
current_identity.update!(use_two_factor_authentication: false)
current_identity.backup_codes.active.each(&:mark_discarded!)
end
consume_step_up!
redirect_to security_path, notice: "Two-factor authentication disabled"
when "disable_2fa"
current_identity.update!(use_two_factor_authentication: false)
TwoFactorMailer.required_authentication_disabled(current_identity).deliver_later
consume_step_up!
redirect_to security_path, notice: "2FA requirement disabled"
when "oidc_reauth"
safe_path = safe_internal_redirect(return_to)
redirect_to safe_path || root_path
when "email_change"
safe_path = safe_internal_redirect(return_to)
redirect_to safe_path || new_email_change_path
when "remove_passkey"
credential_id = session.delete(:pending_destroy_credential_id)
if credential_id
redirect_to identity_webauthn_credential_path(credential_id), method: :delete
else
redirect_to security_path
end
else
redirect_to security_path, alert: "Unknown action"
end
end
def send_step_up_email_code
login_code = current_identity.v2_login_codes.create!
IdentityMailer.v2_login_code(login_code).deliver_later
@ -148,20 +199,15 @@ class StepUpController < ApplicationController
end
end
# Prevent open redirect attacks - only allow internal paths
def safe_internal_redirect(return_to)
return nil if return_to.blank?
uri = URI.parse(return_to) rescue nil
return nil unless uri
# Reject if it has a scheme or host (absolute URL or protocol-relative like //evil.com)
return nil if uri.scheme || uri.host
# Must be a path starting with /
return nil unless uri.path&.start_with?("/")
# Return just the path + query string
[ uri.path, uri.query ].compact.join("?")
end
end

View file

@ -34,4 +34,4 @@ window.copyErrorId = function(element) {
}).catch(err => {
console.error('Failed to copy:', err);
});
};
};

View file

@ -1,3 +1,11 @@
import Alpine from 'alpinejs'
import { webauthnRegister } from './webauthn-registration.js'
import { webauthnAuth } from './webauthn-authentication.js'
import { stepUpWebauthn } from './webauthn-step-up.js'
Alpine.data('webauthnRegister', webauthnRegister)
Alpine.data('webauthnAuth', webauthnAuth)
Alpine.data('stepUpWebauthn', stepUpWebauthn)
window.Alpine = Alpine
Alpine.start()

View file

@ -0,0 +1,101 @@
export function webauthnAuth() {
return {
loading: false,
error: null,
browserSupported: true,
init() {
this.browserSupported = !!(
globalThis.PublicKeyCredential?.parseRequestOptionsFromJSON &&
navigator.credentials?.get
);
if (this.browserSupported) {
this.authenticate();
}
},
getLoginAttemptId() {
const pathParts = window.location.pathname.split('/');
const loginIndex = pathParts.indexOf('login');
if (loginIndex >= 0 && pathParts.length > loginIndex + 1) {
return pathParts[loginIndex + 1];
}
throw new Error('Could not determine login attempt ID');
},
async getAuthenticationOptions() {
const loginAttemptId = this.getLoginAttemptId();
const response = await fetch(`/login/${loginAttemptId}/webauthn/options`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
}
});
if (!response.ok) {
throw new Error('Failed to get authentication options from server');
}
return await response.json();
},
async authenticate() {
this.loading = true;
this.error = null;
try {
const options = await this.getAuthenticationOptions();
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options);
const credential = await navigator.credentials.get({ publicKey });
if (!credential) {
throw new Error('Authentication failed - no credential returned');
}
const response = credential.response;
const toBase64Url = (buffer) => {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
const credentialJSON = {
id: credential.id,
rawId: toBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: toBase64Url(response.clientDataJSON),
authenticatorData: toBase64Url(response.authenticatorData),
signature: toBase64Url(response.signature),
userHandle: response.userHandle ? toBase64Url(response.userHandle) : null,
},
clientExtensionResults: credential.getClientExtensionResults(),
};
const credentialDataField = document.getElementById('credential-data');
const form = document.getElementById('webauthn-form');
if (!credentialDataField || !form) {
throw new Error('Form elements not found');
}
credentialDataField.value = JSON.stringify(credentialJSON);
form.submit();
} catch (error) {
console.error('Passkey authentication error:', error);
if (error.name === 'NotAllowedError') {
this.error = 'Authentication was cancelled or not allowed';
} else if (error.name === 'InvalidStateError') {
this.error = 'No passkey found for this account';
} else if (error.name === 'NotSupportedError') {
this.error = 'Passkeys are not supported on this device';
} else {
this.error = error.message || 'An unexpected error occurred';
}
this.loading = false;
}
}
};
}

View file

@ -0,0 +1,88 @@
export function webauthnRegister() {
return {
nickname: '',
loading: false,
error: null,
browserSupported: true,
init() {
this.browserSupported = !!(
globalThis.PublicKeyCredential?.parseCreationOptionsFromJSON &&
navigator.credentials?.create
);
},
async getRegistrationOptions() {
const response = await fetch('/identity_webauthn_credentials/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
}
});
if (!response.ok) {
throw new Error('Failed to get registration options from server');
}
return await response.json();
},
async register() {
if (!this.nickname.trim()) {
this.error = 'Please enter a nickname for your passkey';
return;
}
this.loading = true;
this.error = null;
try {
const options = await this.getRegistrationOptions();
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options);
const credential = await navigator.credentials.create({ publicKey });
if (!credential) {
throw new Error('Credential creation failed');
}
const response = credential.response;
const toBase64Url = (buffer) => {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
const credentialJSON = {
id: credential.id,
rawId: toBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: toBase64Url(response.clientDataJSON),
attestationObject: toBase64Url(response.attestationObject),
},
clientExtensionResults: credential.getClientExtensionResults(),
};
const credentialDataField = document.getElementById('registration-credential-data');
const nicknameField = document.getElementById('registration-nickname');
const form = document.getElementById('webauthn-registration-form');
credentialDataField.value = JSON.stringify(credentialJSON);
nicknameField.value = this.nickname;
form.submit();
} catch (error) {
console.error('Passkey registration error:', error);
if (error.name === 'NotAllowedError') {
this.error = 'Registration was cancelled or not allowed';
} else if (error.name === 'InvalidStateError') {
this.error = 'This passkey is already registered';
} else if (error.name === 'NotSupportedError') {
this.error = 'Passkeys are not supported on this device';
} else {
this.error = error.message || 'An unexpected error occurred';
}
this.loading = false;
}
}
};
}

View file

@ -0,0 +1,86 @@
export function stepUpWebauthn() {
return {
loading: false,
error: null,
browserSupported: true,
initialized: false,
init() {
if (this.initialized) return;
this.initialized = true;
this.browserSupported = !!(
globalThis.PublicKeyCredential?.parseRequestOptionsFromJSON &&
navigator.credentials?.get
);
if (this.browserSupported) {
this.authenticate();
}
},
async authenticate() {
if (this.loading) return;
this.loading = true;
this.error = null;
try {
const response = await fetch('/step_up/webauthn/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
}
});
if (!response.ok) {
throw new Error('Failed to get authentication options from server');
}
const options = await response.json();
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options);
const credential = await navigator.credentials.get({ publicKey });
if (!credential) {
throw new Error('Authentication failed - no credential returned');
}
const credResponse = credential.response;
const toBase64Url = (buffer) => {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
const credentialJSON = {
id: credential.id,
rawId: toBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: toBase64Url(credResponse.clientDataJSON),
authenticatorData: toBase64Url(credResponse.authenticatorData),
signature: toBase64Url(credResponse.signature),
userHandle: credResponse.userHandle ? toBase64Url(credResponse.userHandle) : null,
},
clientExtensionResults: credential.getClientExtensionResults(),
};
document.getElementById('step-up-credential-data').value = JSON.stringify(credentialJSON);
document.getElementById('step-up-webauthn-form').submit();
} catch (error) {
console.error('Step-up passkey error:', error);
if (error.name === 'NotAllowedError') {
this.error = 'Authentication was cancelled or not allowed';
} else if (error.name === 'InvalidStateError') {
this.error = 'No passkey found for this account';
} else if (error.name === 'NotSupportedError') {
this.error = 'Passkeys are not supported on this device';
} else {
this.error = error.message || 'An unexpected error occurred';
}
this.loading = false;
}
}
};
}

View file

@ -8,7 +8,7 @@
flex: 1;
padding: 2rem 1rem;
background: #f5f5f5;
@include dark-mode {
background: #1a1d23;
}
@ -71,24 +71,23 @@
padding: 2.5rem;
width: 100%;
max-width: 610px;
box-shadow:
box-shadow:
4px 4px 16px rgba(0, 0, 0, 0.04),
2px 2px 8px rgba(0, 0, 0, 0.02),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
@include dark-mode {
background: linear-gradient(165deg, #252932 0%, #1f2329 100%);
border-color: rgba(255, 255, 255, 0.08);
box-shadow:
box-shadow:
4px 4px 16px rgba(0, 0, 0, 0.3),
2px 2px 8px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
header {
text-align: center;
margin-bottom: $space-7;
h1 {
font-size: 1.85rem;
@ -97,19 +96,19 @@
color: var(--text-strong);
letter-spacing: -0.02em;
}
small {
font-size: 0.95rem;
color: var(--text-muted-strong);
display: block;
line-height: 1.5;
a {
color: var(--pico-primary);
text-decoration: none;
font-weight: 500;
@include transition-default(color);
&:hover {
color: var(--pico-primary-hover);
text-decoration: underline;
@ -117,24 +116,52 @@
}
}
}
fieldset {
@include form-fieldset;
margin: 0 0 1.75rem;
legend { font-size: 0.95rem; margin-bottom: $space-4; }
label { font-size: 0.9rem; margin-bottom: 0.625rem; }
input, select, textarea {
margin-bottom: $space-1; background: var(--surface-2);
@include transition-default(background);
&:focus { background: var(--surface-1); }
legend {
font-size: 0.95rem;
font-weight: 600;
color: var(--pico-color);
margin-bottom: $space-4;
padding: 0;
}
label {
font-size: 0.9rem;
font-weight: 500;
margin-bottom: 0.625rem;
display: block;
color: var(--pico-color);
}
input,
select,
textarea {
margin-bottom: $space-1;
border-radius: $radius-md;
background: var(--surface-2);
@include transition-default(background);
&:focus {
background: var(--surface-1);
}
}
small {
font-size: 0.85rem;
color: var(--text-muted-strong);
margin-top: 0.375rem;
display: block;
}
small { font-size: 0.85rem; color: var(--text-muted-strong); margin-top: 0.375rem; display: block; }
}
.grid {
gap: 1rem;
}
button[type="submit"],
input[type="submit"] {
width: 100%;
@ -143,25 +170,25 @@
font-size: 1rem;
border: none;
}
footer {
text-align: center;
margin-top: $space-7;
padding-top: $space-6;
border-top: 1px solid var(--surface-2-border);
p {
margin: $space-1 0;
font-size: 0.9rem;
color: var(--text-muted-strong);
}
a {
color: var(--pico-primary);
text-decoration: none;
font-weight: 500;
@include transition-default(color);
&:hover {
color: var(--pico-primary-hover);
text-decoration: underline;
@ -182,6 +209,7 @@
.auth-flash-wrapper {
flex-shrink: 0;
padding: 2rem 1rem 0;
pointer-events: none;
.banner {
max-width: 640px;
@ -190,3 +218,28 @@
}
}
}
// WebAuthn
.webauthn-button {
width: 100%;
padding: 0.7rem 1rem;
border: none;
border-radius: 8px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
.webauthn-icon {
display: inline-flex;
align-items: center;
line-height: 1;
svg {
display: block;
}
}
}

View file

@ -1,3 +1,8 @@
.security-sections {
max-width: 900px;
margin: 2rem auto 0;
}
.loading-text {
color: var(--text-muted-strong);
font-size: 0.95rem;
@ -8,7 +13,7 @@
.totp-disabled {
text-align: center;
padding: $space-6 $space-3;
p {
color: var(--text-muted-strong);
margin-bottom: $space-5;
@ -26,28 +31,28 @@
display: flex;
align-items: center;
gap: $space-4;
.status-icon {
font-size: 2rem;
flex-shrink: 0;
}
.status-info {
flex: 1;
strong {
display: block;
font-size: 1rem;
margin-bottom: 0.25rem;
}
p.status-detail {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted-strong);
}
}
button {
font-size: 0.875rem !important;
padding: $space-1 $space-3 !important;
@ -65,13 +70,13 @@
.totp-setup-header {
text-align: center;
margin-bottom: $space-5;
h3 {
margin: 0 0 $space-1;
font-size: 1.5rem;
font-weight: 700;
}
.step-indicator {
margin: 0;
font-size: 0.9rem;
@ -94,13 +99,13 @@
margin-bottom: $space-5;
background: var(--surface-2);
border-radius: $radius-lg;
p {
margin-bottom: $space-5;
font-weight: 600;
font-size: 0.95rem;
}
.qr {
display: block;
width: min(60vw, 200px) !important;
@ -110,15 +115,15 @@
padding: $space-3;
border-radius: $radius-md;
}
details {
margin-top: $space-5;
summary {
cursor: pointer;
font-size: 0.9rem;
color: var(--text-muted-strong);
&:hover {
color: var(--pico-primary);
}
@ -134,7 +139,7 @@
word-break: break-all;
font-family: $font-mono;
}
.copy-secret-btn {
margin-top: $space-2;
font-size: 0.875rem !important;
@ -157,11 +162,12 @@
display: flex;
gap: $space-3;
margin-top: $space-5;
@media (max-width: 480px) {
flex-direction: column;
button, input[type="submit"] {
button,
input[type="submit"] {
width: 100%;
}
}
@ -171,7 +177,7 @@
margin-top: $space-5;
padding-top: $space-5;
border-top: 1px solid var(--surface-2-border);
details {
summary {
cursor: pointer;
@ -180,12 +186,12 @@
display: flex;
align-items: center;
gap: $space-2;
&:hover {
color: var(--pico-primary);
}
}
p {
margin-top: $space-3;
padding-left: $space-6;
@ -201,7 +207,7 @@
.sessions-actions {
margin-bottom: $space-4;
text-align: right;
button {
font-size: 0.875rem !important;
padding: $space-1 $space-3 !important;
@ -215,19 +221,42 @@
gap: $space-3;
}
.session-card {
@include card($padding: $space-4, $radius: $radius-lg);
@include transition-default();
@include dark-mode {
background: #1f2937;
border-color: #374151;
}
&:hover {
transform: translateY(-1px);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.1),
0 2px 6px rgba(0, 0, 0, 0.06);
@include dark-mode {
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.4),
0 2px 6px rgba(0, 0, 0, 0.3);
}
}
}
.current-session {
background: linear-gradient(135deg, #f7fee7, #ecfccb);
border-color: #84cc16;
border-width: 1px;
box-shadow:
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 1px 4px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
@include dark-mode {
background: linear-gradient(135deg, rgba(132, 204, 22, 0.15), rgba(132, 204, 22, 0.08));
border-color: #84cc16;
box-shadow:
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3),
0 1px 4px rgba(0, 0, 0, 0.2);
}
@ -260,14 +289,14 @@
@include badge(#84cc16, #1a1d23, 4px);
margin-left: $space-1;
float: right;
box-shadow:
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
@include dark-mode {
background: #84cc16;
color: #1a1d23;
box-shadow:
box-shadow:
0 1px 3px rgba(132, 204, 22, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
@ -285,7 +314,7 @@
font-size: 0.85rem;
color: var(--text-muted-strong);
margin-bottom: $space-3;
div {
display: flex;
gap: $space-1;
@ -300,6 +329,7 @@
.backup-code-method {
color: #dc2626;
background: linear-gradient(135deg, rgba(220, 38, 38, 0.15), rgba(153, 27, 27, 0.15));
@include dark-mode {
color: #fca5a5;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25), rgba(220, 38, 38, 0.25));
@ -340,12 +370,12 @@
border-radius: $radius-md;
font-size: 0.9rem;
line-height: 1.5;
@include dark-mode {
background: color-mix(in srgb, #d97706 15%, #1f2937 85%);
color: #fbbf24;
}
strong {
font-weight: 600;
}
@ -381,16 +411,18 @@
gap: $space-3;
flex-wrap: wrap;
margin-bottom: $space-5;
button, a {
button,
a {
flex: 1;
min-width: 140px;
}
@media (max-width: 480px) {
flex-direction: column;
button, a {
button,
a {
width: 100%;
min-width: unset;
}
@ -400,7 +432,7 @@
.backup-codes-confirmation {
padding-top: $space-5;
border-top: 1px solid var(--surface-2-border);
label {
display: flex;
align-items: center;
@ -409,15 +441,15 @@
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
input[type="checkbox"] {
margin: 0;
}
}
button {
width: 100%;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
@ -429,23 +461,23 @@
display: flex;
align-items: center;
gap: $space-4;
.status-info {
flex: 1;
strong {
display: block;
font-size: 1rem;
margin-bottom: 0.25rem;
}
.status-detail {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted-strong);
}
}
button {
font-size: 0.875rem !important;
padding: $space-1 $space-3 !important;
@ -460,3 +492,74 @@
color: var(--text-muted-strong);
font-size: 0.95rem;
}
// WebAuthn!
.webauthn-disabled {
text-align: center;
padding: $space-6 $space-3;
p {
color: var(--text-muted-strong);
margin-bottom: $space-5;
font-size: 0.95rem;
}
}
.webauthn-setup-description {
margin-bottom: $space-5;
font-size: 0.95rem;
font-style: italic;
color: var(--text-muted-strong);
}
// Inline confirmation for delete actions
.confirmation-inline {
padding: $space-4;
background: var(--surface-2);
border-radius: $radius-md;
border: 1px solid var(--surface-2-border);
[data-theme="dark"] & {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
.confirmation-text {
margin: 0 0 $space-4;
font-size: 0.9rem;
color: var(--text-strong);
line-height: 1.5;
}
.confirmation-actions {
display: flex;
gap: $space-2;
justify-content: flex-end;
@media (max-width: 480px) {
flex-direction: column;
button,
form {
width: 100%;
}
}
button,
form button {
font-size: 0.875rem !important;
padding: $space-1 $space-3 !important;
width: auto !important;
margin: 0 !important;
min-width: 80px;
@media (max-width: 480px) {
width: 100% !important;
}
}
form {
display: inline-block;
}
}
}

View file

@ -54,6 +54,7 @@ class Identity < ApplicationRecord
has_many :v2_login_codes, class_name: "Identity::V2LoginCode", dependent: :destroy
has_many :totps, class_name: "Identity::TOTP", dependent: :destroy
has_many :backup_codes, class_name: "Identity::BackupCode", dependent: :destroy
has_many :webauthn_credentials, class_name: "Identity::WebauthnCredential", dependent: :destroy
has_many :email_change_requests, class_name: "Identity::EmailChangeRequest", dependent: :destroy
has_one :backend_user, class_name: "Backend::User", dependent: :destroy
@ -62,10 +63,6 @@ class Identity < ApplicationRecord
backend_user&.active?
end
has_many :documents, class_name: "Identity::Document", dependent: :destroy
has_many :verifications, class_name: "Verification", dependent: :destroy
has_many :document_verifications, class_name: "Verification::DocumentVerification", dependent: :destroy
@ -320,10 +317,20 @@ class Identity < ApplicationRecord
def backup_codes_enabled? = backup_codes.active.any?
def webauthn_enabled? = webauthn_credentials.any?
# Encode identity ID as base64url for WebAuthn user.id
# Uses 64-bit unsigned big-endian binary format
def webauthn_user_id
user_id_binary = [ id ].pack("Q>")
Base64.urlsafe_encode64(user_id_binary, padding: false)
end
def available_step_up_methods
methods = []
methods << :totp if totp.present?
methods << :backup_code if backup_codes_enabled?
methods << :webauthn if webauthn_enabled?
# Future: methods << :sms if sms_verified?
methods
end
@ -331,7 +338,8 @@ class Identity < ApplicationRecord
# Generic 2FA method helpers
def two_factor_methods
[
totps.verified
totps.verified,
webauthn_credentials
# Future: sms_two_factors.verified,
].flatten.compact
end

View file

@ -0,0 +1,73 @@
class Identity::WebauthnCredential < ApplicationRecord
belongs_to :identity
has_paper_trail
include PublicActivity::Model
tracked owner: proc { |controller, record| record.identity }, recipient: proc { |controller, record| record.identity }, only: [ :create, :destroy ]
scope :active, -> { where(compromised_at: nil) }
scope :compromised, -> { where.not(compromised_at: nil) }
validates :external_id, presence: true, uniqueness: true
validates :public_key, presence: true
validates :sign_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :nickname, length: { maximum: 50 }, allow_blank: true
before_validation :set_initial_sign_count, on: :create
# WebAuthn credential IDs and public keys are binary data that need to be
# base64url encoded for storage and transmission
def webauthn_id
Base64.urlsafe_decode64(external_id)
end
def webauthn_id=(value)
self.external_id = Base64.urlsafe_encode64(value, padding: false)
end
def webauthn_public_key
Base64.urlsafe_decode64(public_key)
end
def webauthn_public_key=(value)
self.public_key = Base64.urlsafe_encode64(value, padding: false)
end
# Increment the sign count after successful authentication
# This helps detect credential cloning attacks
def increment_sign_count!
increment!(:sign_count)
end
# Mark credential as potentially compromised (e.g., sign count anomaly)
def mark_as_compromised!
update!(compromised_at: Time.current)
Rails.logger.warn "WebAuthn credential marked as compromised: id=#{id}, identity_id=#{identity_id}"
end
def compromised?
compromised_at.present?
end
def active?
!compromised?
end
# Human-readable display for the credential
def display_name
nickname.presence || "Passkey created #{created_at.strftime('%b %d, %Y')}"
end
# Class method to get all decoded credential IDs for a collection
# Useful for building WebAuthn allow/exclude lists
def self.raw_credential_ids
pluck(:external_id).map { |id| Base64.urlsafe_decode64(id) }
end
private
def set_initial_sign_count
self.sign_count ||= 0
end
end

View file

@ -8,7 +8,7 @@ class LoginAttempt < ApplicationRecord
has_encrypted :browser_token
before_validation :ensure_browser_token
store_accessor :authentication_factors, :email, :totp, :backup_code, :legacy_email, prefix: :authenticated_with
store_accessor :authentication_factors, :email, :totp, :backup_code, :webauthn, :legacy_email, prefix: :authenticated_with
EXPIRATION = 15.minutes
@ -72,9 +72,12 @@ class LoginAttempt < ApplicationRecord
def backup_code_available? = !authenticated_with_backup_code && identity.backup_codes_enabled?
def webauthn_available? = !authenticated_with_webauthn && identity.webauthn_enabled?
def available_factors
factors = []
factors << :email if email_available?
factors << :webauthn if webauthn_available?
factors << :totp if totp_available?
factors << :backup_code if backup_code_available?
factors
@ -83,8 +86,12 @@ class LoginAttempt < ApplicationRecord
private
def required_authentication_factors_count
# WebAuthn inherently provides 2FA (possession + biometric/PIN)
# So if WebAuthn is used, we only need 1 factor
if authenticated_with_webauthn
1
# Require 2FA if enabled AND at least one 2FA method is configured
if identity.requires_two_factor?
elsif identity.requires_two_factor?
2
else
1

View file

@ -50,6 +50,7 @@
<% auth_methods = [] %>
<% auth_methods << t("auth_type.email") if identity_session.login_attempt.authenticated_with_email || identity_session.login_attempt.authenticated_with_legacy_email %>
<% auth_methods << t("auth_type.totp") if identity_session.login_attempt.authenticated_with_totp %>
<% auth_methods << t("auth_type.webauthn") if identity_session.login_attempt.authenticated_with_webauthn %>
<% auth_methods << t("auth_type.backup_code") if identity_session.login_attempt.authenticated_with_backup_code %>
<%= auth_methods.join(", ") %>
</span>

View file

@ -0,0 +1,43 @@
<div class="session-card">
<div class="session-header">
<div class="session-icon">
<%= inline_icon("fingerprint", size: 32) %>
</div>
<div class="session-info">
<div class="session-device">
<%= identity_webauthn_credential.display_name %>
</div>
</div>
</div>
<div class="session-details">
<div>
<span class="detail-label"><%= t "identity_webauthn_credentials.created_at" %></span>
<span><%= identity_webauthn_credential.created_at.strftime("%b %d, %Y at %l:%M %p") %></span>
</div>
<div>
<span class="detail-label"><%= t "identity_webauthn_credentials.last_used" %></span>
<span><%= time_ago_in_words(identity_webauthn_credential.updated_at) %> ago</span>
</div>
</div>
<div x-data="{ showConfirm: false }">
<div x-show="!showConfirm">
<button x-on:click="showConfirm = true" class="danger small-btn" type="button">
<%= t("identity_webauthn_credentials.remove") %> <%= inline_icon("delete", size: 16) %>
</button>
</div>
<div x-show="showConfirm" x-cloak class="confirmation-inline">
<p class="confirmation-text"><%= t("identity_webauthn_credentials.remove_confirmation") %></p>
<div class="confirmation-actions">
<button x-on:click="showConfirm = false" class="button" type="button">Cancel</button>
<%= button_to identity_webauthn_credential_path(identity_webauthn_credential),
method: :delete,
class: "button danger" do %>
Confirm
<% end %>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,30 @@
<div id="webauthn-credentials-container">
<% if @webauthn_credentials.empty? %>
<div class="webauthn-disabled">
<p class="webauthn-description"><%= t(".setup_description") %></p>
<%= link_to t(".setup_webauthn"),
new_identity_webauthn_credential_path,
class: "webauthn-enable-btn",
data: {
"hx-get": new_identity_webauthn_credential_path,
"hx-target": "#webauthn-credentials-container",
"hx-swap": "innerHTML"
} %>
</div>
<% else %>
<div class="sessions-list">
<%= render partial: "identity_webauthn_credential", collection: @webauthn_credentials %>
</div>
<div style="margin-top: 1rem;">
<%= link_to "Add another passkey",
new_identity_webauthn_credential_path,
class: "btn btn-secondary",
data: {
"hx-get": new_identity_webauthn_credential_path,
"hx-target": "#webauthn-credentials-container",
"hx-swap": "innerHTML"
} %>
</div>
<% end %>
</div>

View file

@ -0,0 +1,52 @@
<div class="passkey-setup" x-data="webauthnRegister()">
<p class="webauthn-setup-description">
A passwordless way to sign in using your device's biometric authentication or PIN.
</p>
<div x-show="!browserSupported" x-cloak class="alert alert-warning">
<strong>Browser not supported</strong>
<p>Your browser doesn't support the latest WebAuthn features. Use something modern!</p>
</div>
<%= form_with url: identity_webauthn_credentials_path, method: :post, local: true, id: "webauthn-registration-form", style: "display: none;" do |form| %>
<%= form.hidden_field :credential_data, id: "registration-credential-data" %>
<%= form.hidden_field :nickname, id: "registration-nickname" %>
<% end %>
<div x-show="browserSupported">
<form x-on:submit.prevent="register()">
<div class="form-group">
<label for="webauthn-nickname">Passkey Nickname</label>
<input
type="text"
id="webauthn-nickname"
x-model="nickname"
placeholder="e.g., Heidi's iPhone, MacBook Air"
required
maxlength="50"
autocomplete="off">
<small class="form-hint">Give this passkey a memorable name to identify it later!</small>
</div>
<div x-show="error" x-cloak class="alert alert-error" role="alert">
<strong>Registration Failed</strong>
<p x-text="error"></p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="loading">
<span x-show="!loading">Register Passkey</span>
<span x-show="loading" x-cloak>Registering...</span>
</button>
<%= link_to "Cancel",
identity_webauthn_credentials_path,
class: "btn btn-secondary",
data: {
"hx-get": identity_webauthn_credentials_path,
"hx-target": "#webauthn-credentials-container",
"hx-swap": "innerHTML"
} %>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,46 @@
<div class="auth-container" x-data="webauthnAuth()">
<div class="auth-card passkey-auth" x-show="browserSupported">
<header>
<h1><%= t(".title") %></h1>
<small><%= t(".subtitle") %></small>
</header>
<div x-show="error" x-cloak role="alert">
<strong>Authentication Failed</strong>
<p x-text="error"></p>
</div>
<%= form_with url: verify_webauthn_login_attempt_path(@attempt), method: :post, local: true, id: "webauthn-form" do |form| %>
<%= form.hidden_field :credential_data, id: "credential-data" %>
<% end %>
<div class="passkey-prompt">
<button
type="button"
class="webauthn-button"
x-on:click="authenticate()"
x-bind:disabled="loading">
<span class="webauthn-icon"><%= inline_icon("fingerprint", size: 16) %></span>
<span x-show="!loading"><%= t(".authenticate") %></span>
<span x-show="loading" x-cloak><%= t(".authenticating") %>...</span>
</button>
</div>
<footer>
<p>
<%= t(".prefer_email") %>
<%= button_to t(".use_email_code"),
skip_webauthn_login_attempt_path(@attempt),
method: :post,
class: "secondary small-btn" %>
</p>
</footer>
</div>
<div x-show="!browserSupported" x-cloak class="auth-card">
<div class="alert alert-warning">
<strong>Browser Not Supported</strong>
<p><%= t(".browser_not_supported") %></p>
</div>
</div>
</div>

View file

@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity, owner: activity.trackable&.identity) do %>
added a passkey: <%= activity.trackable&.display_name %>.
<% end %>

View file

@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity, owner: activity.trackable&.identity) do %>
removed a passkey: <%= activity.trackable&.display_name %>.
<% end %>

View file

@ -10,13 +10,13 @@
</section>
<section class="section-card">
<h3><%= t ".applications" %></h3>
<%= render Components::BootlegTurbo.new(authorized_applications_path, id: "authorized-apps-container", hx_swap: "innerHTML") %>
<h3><%= t ".mfa" %></h3>
<%= render Components::BootlegTurbo.new(identity_totps_path, id: "totp-container", hx_swap: "innerHTML") %>
</section>
<section class="section-card">
<h3><%= t ".mfa" %></h3>
<%= render Components::BootlegTurbo.new(identity_totps_path, id: "totp-container", hx_swap: "innerHTML") %>
<h3><%= t ".webauthn" %></h3>
<%= render Components::BootlegTurbo.new(identity_webauthn_credentials_path, id: "webauthn-container", hx_swap: "innerHTML") %>
</section>
<section class="section-card">
@ -25,9 +25,14 @@
</section>
<section class="section-card">
<h3>Activity Log</h3>
<p>View your recent account activity and security events.</p>
<%= link_to "View Activity Log", audit_logs_path, class: "button" %>
<h3><%= t ".applications" %></h3>
<%= render Components::BootlegTurbo.new(authorized_applications_path, id: "authorized-apps-container", hx_swap: "innerHTML") %>
</section>
<section class="section-card">
<h3><%= t ".activity" %></h3>
<p><%= t ".activity_description" %></p>
<%= link_to t(".view_activity_log"), audit_logs_path, class: "button" %>
</section>
</div>

View file

@ -13,7 +13,7 @@
<% if @available_methods.include?(:totp) %>
<%= link_to new_step_up_path(action_type: @action, method: :totp, return_to: @return_to),
class: "step-up-method-option" do %>
<div><%= inline_icon("private", size: 32) %></div>
<div><%= inline_icon("clock", size: 32) %></div>
<div style="flex: 1;">
<div style="font-weight: 600;"><%= t(".methods.totp.title") %></div>
<small><%= t(".methods.totp.description") %></small>
@ -24,7 +24,7 @@
<% if @available_methods.include?(:backup_code) %>
<%= link_to new_step_up_path(action_type: @action, method: :backup_code, return_to: @return_to),
class: "step-up-method-option" do %>
<div><%= inline_icon("private", size: 32) %></div>
<div><%= inline_icon("docs", size: 32) %></div>
<div style="flex: 1;">
<div style="font-weight: 600;"><%= t(".methods.backup_code.title") %></div>
<small><%= t(".methods.backup_code.description") %></small>
@ -32,13 +32,13 @@
<% end %>
<% end %>
<% if @available_methods.include?(:sms) %>
<%= link_to new_step_up_path(action_type: @action, method: :sms, return_to: @return_to),
<% if @available_methods.include?(:webauthn) %>
<%= link_to new_step_up_path(action_type: @action, method: :webauthn, return_to: @return_to),
class: "step-up-method-option" do %>
<div><%= inline_icon("message", size: 32) %></div>
<div><%= inline_icon("fingerprint", size: 32) %></div>
<div style="flex: 1;">
<div style="font-weight: 600;"><%= t(".methods.sms.title") %></div>
<small><%= t(".methods.sms.description") %></small>
<div style="font-weight: 600;"><%= t(".methods.webauthn.title", default: "Passkey") %></div>
<small><%= t(".methods.webauthn.description", default: "Use your passkey to verify") %></small>
</div>
<% end %>
<% end %>
@ -79,6 +79,42 @@
<p><%= link_to t(".cancel"), step_up_cancel_path(@action) %></p>
</footer>
<% elsif method == :webauthn %>
<%# Show WebAuthn/Passkey authentication %>
<header>
<h1><%= t(".enter_code_labels.webauthn", default: "Verify with passkey") %></h1>
<small><%= t(".enter_code_descriptions.webauthn", default: "Use your passkey to confirm your identity") %></small>
</header>
<div x-data="stepUpWebauthn()" x-init="init()">
<div x-show="loading" style="text-align: center; padding: 2rem;">
<p>Waiting for passkey...</p>
</div>
<div x-show="error" x-cloak style="margin: 1rem 0;">
<div class="alert alert-error" x-text="error"></div>
<button @click="authenticate()" class="btn" style="width: 100%; margin-top: 1rem;">
Try again
</button>
</div>
<div x-show="!browserSupported" x-cloak>
<div class="alert alert-error">
Your browser doesn't support passkeys. Please use a different verification method.
</div>
</div>
<%= form_with url: verify_step_up_webauthn_path, method: :post, local: true, id: "step-up-webauthn-form" do |f| %>
<%= f.hidden_field :action_type, value: @action %>
<%= f.hidden_field :return_to, value: @return_to %>
<%= f.hidden_field :credential_data, id: "step-up-credential-data" %>
<% end %>
</div>
<footer>
<p><%= link_to t(".use_different_method"), new_step_up_path(action_type: @action, return_to: @return_to) %></p>
</footer>
<% else %>
<%# Show form for selected method %>
<header>

View file

@ -0,0 +1,29 @@
# Configure WebAuthn for passkey authentication
WebAuthn.configure do |config|
# The allowed origins - where WebAuthn requests can come from
config.allowed_origins = if Rails.env.production?
[ "https://auth.hackclub.com" ]
elsif Rails.env.development?
[ "http://localhost:3000" ]
else
# For test environment or other environments
[ "http://localhost:3000" ]
end
# The Relying Party name - shown in authenticator UI
config.rp_name = "Hack Club Auth"
# Explicitly set the Relying Party ID to prevent misconfiguration
# This must match the domain where WebAuthn is used
config.rp_id = if Rails.env.production?
"auth.hackclub.com"
else
"localhost"
end
# Credential options (optional - these are the defaults)
# Algorithms we support for credential public keys
# ES256 is ECDSA with SHA-256, the most widely supported algorithm
# RS256 is RSA with SHA-256, supported by some older authenticators
config.algorithms = [ "ES256", "RS256" ]
end

View file

@ -335,6 +335,7 @@ en:
email: Email
totp: TOTP
backup_code: Backup code
webauthn: Passkey
logins:
new:
title: Welcome back!
@ -371,6 +372,14 @@ en:
verify: Verify
lost_authenticator: Lost your authenticator?
use_backup_code: Use a backup code instead
webauthn:
title: Welcome back!
subtitle: Use the passkey saved on this device or another nearby device
authenticate: Use passkey
authenticating: Authenticating
browser_not_supported: Your browser doesn't support passkeys. Please use a modern browser or use email code instead.
prefer_email: Don't have a passkey?
use_email_code: Use email code instead
saml:
http_post:
title: SAMLing...
@ -384,10 +393,14 @@ en:
security_link: Security
security:
heading: Security
sessions: Active sessions
applications: Linked apps
mfa: 2-Factor Authentication
sessions: Active Sessions
mfa: Authenticator App
webauthn: Passkeys
backup_codes: Backup Codes
applications: Connected Apps
activity: Activity Log
activity_description: View your recent account activity and security events.
view_activity_log: View Activity Log
identity_backup_codes:
totp_heading: "Save your backup codes"
save_warning_title: Save these codes now!
@ -415,7 +428,7 @@ en:
require_2fa_description: When enabled, you must use a second factor (authenticator app or backup code) to sign in.
disable_requirement: Disable Requirement
enable_requirement: Enable Requirement
setup_description: Add an extra layer of security by requiring a second factor in addition to your email.
setup_description: Add an extra layer of security to email sign-ins by requiring a code from an authenticator app.
setup_authenticator: Set up two-factor authentication
show:
heading: Set up two-factor authentication
@ -451,6 +464,17 @@ en:
auth_type: Login method
terminate: Log out
terminate_confirmation: Log out this session?
identity_webauthn_credentials:
index:
setup_description: Sign in securely using a passkey stored on your device. Passkeys don't require two-factor authentication.
setup_webauthn: Set up passkey
added: Added
created_at: Created
last_used: Last used
remove: Remove
remove_confirmation: Are you sure you want to remove this passkey? You won't be able to use it to sign in anymore.
successfully_added: passkey added!
successfully_removed: passkey removed!
step_up:
new:
title: Gotta confirm it's you...
@ -462,13 +486,18 @@ en:
backup_code:
title: Backup Code
description: Use one of your backup codes
webauthn:
title: Passkey
description: Use your passkey to verify
cancel: Cancel
enter_code_labels:
totp: Enter authenticator code
backup_code: Enter backup code
webauthn: Verify with passkey
enter_code_descriptions:
totp: Enter the 6-digit code from your authenticator app
backup_code: Enter one of your backup codes
webauthn: Use your passkey to confirm your identity
code_label: Enter code
backup_placeholder: XXXXXXXXXX
totp_placeholder: "000000"

View file

@ -291,6 +291,10 @@ Rails.application.routes.draw do
post "/login/:id/totp", to: "logins#verify_totp", as: :verify_totp_login_attempt
get "/login/:id/backup_code", to: "logins#backup_code", as: :backup_code_login_attempt
post "/login/:id/backup_code", to: "logins#verify_backup_code", as: :verify_backup_code_login_attempt
get "/login/:id/webauthn", to: "logins#webauthn", as: :webauthn_login_attempt
post "/login/:id/webauthn/options", to: "logins#webauthn_options", as: :webauthn_options_login_attempt
post "/login/:id/webauthn/verify", to: "logins#verify_webauthn", as: :verify_webauthn_login_attempt
post "/login/:id/webauthn/skip", to: "logins#skip_webauthn", as: :skip_webauthn_login_attempt
delete "/logout", to: "sessions#logout", as: :logout
@ -330,11 +334,19 @@ Rails.application.routes.draw do
end
end
resources :identity_webauthn_credentials, only: [ :index, :new, :create, :destroy ] do
collection do
post :options
end
end
# Step-up authentication flow
get "/step_up", to: "step_up#new", as: :new_step_up
post "/step_up/verify", to: "step_up#verify", as: :verify_step_up
post "/step_up/send_email_code", to: "step_up#send_email_code", as: :send_step_up_email_code
post "/step_up/resend_email", to: "step_up#resend_email", as: :resend_step_up_email
post "/step_up/webauthn/options", to: "step_up#webauthn_options", as: :step_up_webauthn_options
post "/step_up/webauthn/verify", to: "step_up#verify_webauthn", as: :verify_step_up_webauthn
resources :identity_backup_codes, only: [ :index, :create ] do
patch :confirm, on: :collection

View file

@ -0,0 +1,13 @@
class CreateIdentityWebauthnCredentials < ActiveRecord::Migration[8.0]
def change
create_table :identity_webauthn_credentials do |t|
t.references :identity, null: false, foreign_key: true
t.string :external_id
t.string :public_key
t.string :nickname
t.integer :sign_count
t.timestamps
end
end
end

View file

@ -0,0 +1,8 @@
class ImproveWebauthnCredentialsConstraints < ActiveRecord::Migration[8.0]
def change
change_column_null :identity_webauthn_credentials, :external_id, false
change_column_null :identity_webauthn_credentials, :public_key, false
add_index :identity_webauthn_credentials, :external_id, unique: true, if_not_exists: true
add_index :identity_webauthn_credentials, :identity_id, if_not_exists: true
end
end

View file

@ -0,0 +1,5 @@
class AddCompromisedAtToIdentityWebauthnCredentials < ActiveRecord::Migration[8.0]
def change
add_column :identity_webauthn_credentials, :compromised_at, :datetime
end
end

27
db/schema.rb generated
View file

@ -301,6 +301,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
t.boolean "saml_debug"
t.boolean "is_in_workspace", default: false, null: false
t.string "slack_dm_channel_id"
t.string "webauthn_id"
t.index "lower((primary_email)::text)", name: "idx_identities_unique_primary_email", unique: true, where: "(deleted_at IS NULL)"
t.index ["aadhaar_number_bidx"], name: "index_identities_on_aadhaar_number_bidx", unique: true
t.index ["deleted_at"], name: "index_identities_on_deleted_at"
@ -436,6 +437,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
t.index ["login_attempt_id"], name: "index_identity_v2_login_codes_on_login_attempt_id"
end
create_table "identity_webauthn_credentials", force: :cascade do |t|
t.bigint "identity_id", null: false
t.string "external_id", null: false
t.string "public_key", null: false
t.string "nickname"
t.integer "sign_count"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["external_id"], name: "index_identity_webauthn_credentials_on_external_id", unique: true
t.index ["identity_id"], name: "index_identity_webauthn_credentials_on_identity_id"
end
create_table "login_attempts", force: :cascade do |t|
t.bigint "identity_id", null: false
t.bigint "session_id"
@ -573,6 +586,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
end
create_table "webauthn_credentials", force: :cascade do |t|
t.bigint "identity_id", null: false
t.string "external_id", null: false
t.string "public_key", null: false
t.string "nickname", null: false
t.integer "sign_count", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true
t.index ["identity_id"], name: "index_webauthn_credentials_on_identity_id"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "addresses", "identities"
@ -594,6 +619,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
add_foreign_key "identity_totps", "identities"
add_foreign_key "identity_v2_login_codes", "identities"
add_foreign_key "identity_v2_login_codes", "login_attempts"
add_foreign_key "identity_webauthn_credentials", "identities"
add_foreign_key "login_attempts", "identities"
add_foreign_key "login_attempts", "identity_sessions", column: "session_id"
add_foreign_key "oauth_access_grants", "identities", column: "resource_owner_id"
@ -604,4 +630,5 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
add_foreign_key "verifications", "identities"
add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id"
add_foreign_key "verifications", "identity_documents"
add_foreign_key "webauthn_credentials", "identities"
end