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 "rotp", "~> 6.3"
gem "rqrcode", "~> 2.0" gem "rqrcode", "~> 2.0"
gem "webauthn", "~> 3.1"
gem "bcrypt", "~> 3.1" gem "bcrypt", "~> 3.1"
gem "rack-attack", "~> 6.7" gem "rack-attack", "~> 6.7"

View file

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

View file

@ -1,16 +1,60 @@
class StepUpController < ApplicationController class StepUpController < ApplicationController
include WebauthnAuthenticatable
helper_method :step_up_cancel_path 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 def new
@action = params[:action_type] # e.g., "remove_totp", "disable_2fa", "oidc_reauth", "email_change" @action = params[:action_type] # e.g., "remove_totp", "disable_2fa", "oidc_reauth", "email_change"
@return_to = params[:return_to] @return_to = params[:return_to]
@available_methods = current_identity.available_step_up_methods @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? @code_sent = params[:code_sent].present?
end 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 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" 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]) redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
return return
@ -37,13 +81,12 @@ class StepUpController < ApplicationController
return return
end 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" 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]) redirect_to new_step_up_path(action_type: action_type, return_to: params[:return_to])
return return
end end
# Verify based on the method they chose
verified = case method verified = case method
when :totp when :totp
totp = current_identity.totp totp = current_identity.totp
@ -76,43 +119,7 @@ class StepUpController < ApplicationController
return return
end end
# Mark step-up as completed on the identity session, bound to the specific action complete_step_up(action_type, params[:return_to])
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
end end
def resend_email def resend_email
@ -134,6 +141,50 @@ class StepUpController < ApplicationController
private 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 def send_step_up_email_code
login_code = current_identity.v2_login_codes.create! login_code = current_identity.v2_login_codes.create!
IdentityMailer.v2_login_code(login_code).deliver_later IdentityMailer.v2_login_code(login_code).deliver_later
@ -148,20 +199,15 @@ class StepUpController < ApplicationController
end end
end end
# Prevent open redirect attacks - only allow internal paths
def safe_internal_redirect(return_to) def safe_internal_redirect(return_to)
return nil if return_to.blank? return nil if return_to.blank?
uri = URI.parse(return_to) rescue nil uri = URI.parse(return_to) rescue nil
return nil unless uri 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 return nil if uri.scheme || uri.host
# Must be a path starting with /
return nil unless uri.path&.start_with?("/") return nil unless uri.path&.start_with?("/")
# Return just the path + query string
[ uri.path, uri.query ].compact.join("?") [ uri.path, uri.query ].compact.join("?")
end end
end end

View file

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

View file

@ -1,3 +1,11 @@
import Alpine from 'alpinejs' 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 window.Alpine = Alpine
Alpine.start() 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; flex: 1;
padding: 2rem 1rem; padding: 2rem 1rem;
background: #f5f5f5; background: #f5f5f5;
@include dark-mode { @include dark-mode {
background: #1a1d23; background: #1a1d23;
} }
@ -71,24 +71,23 @@
padding: 2.5rem; padding: 2.5rem;
width: 100%; width: 100%;
max-width: 610px; max-width: 610px;
box-shadow: box-shadow:
4px 4px 16px rgba(0, 0, 0, 0.04), 4px 4px 16px rgba(0, 0, 0, 0.04),
2px 2px 8px rgba(0, 0, 0, 0.02), 2px 2px 8px rgba(0, 0, 0, 0.02),
inset 0 1px 0 rgba(255, 255, 255, 0.8); inset 0 1px 0 rgba(255, 255, 255, 0.8);
@include dark-mode { @include dark-mode {
background: linear-gradient(165deg, #252932 0%, #1f2329 100%); background: linear-gradient(165deg, #252932 0%, #1f2329 100%);
border-color: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.08);
box-shadow: box-shadow:
4px 4px 16px rgba(0, 0, 0, 0.3), 4px 4px 16px rgba(0, 0, 0, 0.3),
2px 2px 8px rgba(0, 0, 0, 0.2), 2px 2px 8px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.05); inset 0 1px 0 rgba(255, 255, 255, 0.05);
} }
header { header {
text-align: center; text-align: center;
margin-bottom: $space-7; margin-bottom: $space-7;
h1 { h1 {
font-size: 1.85rem; font-size: 1.85rem;
@ -97,19 +96,19 @@
color: var(--text-strong); color: var(--text-strong);
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
small { small {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--text-muted-strong); color: var(--text-muted-strong);
display: block; display: block;
line-height: 1.5; line-height: 1.5;
a { a {
color: var(--pico-primary); color: var(--pico-primary);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
@include transition-default(color); @include transition-default(color);
&:hover { &:hover {
color: var(--pico-primary-hover); color: var(--pico-primary-hover);
text-decoration: underline; text-decoration: underline;
@ -117,24 +116,52 @@
} }
} }
} }
fieldset { fieldset {
@include form-fieldset; @include form-fieldset;
margin: 0 0 1.75rem; margin: 0 0 1.75rem;
legend { font-size: 0.95rem; margin-bottom: $space-4; }
label { font-size: 0.9rem; margin-bottom: 0.625rem; } legend {
input, select, textarea { font-size: 0.95rem;
margin-bottom: $space-1; background: var(--surface-2); font-weight: 600;
@include transition-default(background); color: var(--pico-color);
&:focus { background: var(--surface-1); } 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 { .grid {
gap: 1rem; gap: 1rem;
} }
button[type="submit"], button[type="submit"],
input[type="submit"] { input[type="submit"] {
width: 100%; width: 100%;
@ -143,25 +170,25 @@
font-size: 1rem; font-size: 1rem;
border: none; border: none;
} }
footer { footer {
text-align: center; text-align: center;
margin-top: $space-7; margin-top: $space-7;
padding-top: $space-6; padding-top: $space-6;
border-top: 1px solid var(--surface-2-border); border-top: 1px solid var(--surface-2-border);
p { p {
margin: $space-1 0; margin: $space-1 0;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted-strong); color: var(--text-muted-strong);
} }
a { a {
color: var(--pico-primary); color: var(--pico-primary);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
@include transition-default(color); @include transition-default(color);
&:hover { &:hover {
color: var(--pico-primary-hover); color: var(--pico-primary-hover);
text-decoration: underline; text-decoration: underline;
@ -182,6 +209,7 @@
.auth-flash-wrapper { .auth-flash-wrapper {
flex-shrink: 0; flex-shrink: 0;
padding: 2rem 1rem 0; padding: 2rem 1rem 0;
pointer-events: none;
.banner { .banner {
max-width: 640px; 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 { .loading-text {
color: var(--text-muted-strong); color: var(--text-muted-strong);
font-size: 0.95rem; font-size: 0.95rem;
@ -8,7 +13,7 @@
.totp-disabled { .totp-disabled {
text-align: center; text-align: center;
padding: $space-6 $space-3; padding: $space-6 $space-3;
p { p {
color: var(--text-muted-strong); color: var(--text-muted-strong);
margin-bottom: $space-5; margin-bottom: $space-5;
@ -26,28 +31,28 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: $space-4; gap: $space-4;
.status-icon { .status-icon {
font-size: 2rem; font-size: 2rem;
flex-shrink: 0; flex-shrink: 0;
} }
.status-info { .status-info {
flex: 1; flex: 1;
strong { strong {
display: block; display: block;
font-size: 1rem; font-size: 1rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
p.status-detail { p.status-detail {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted-strong); color: var(--text-muted-strong);
} }
} }
button { button {
font-size: 0.875rem !important; font-size: 0.875rem !important;
padding: $space-1 $space-3 !important; padding: $space-1 $space-3 !important;
@ -65,13 +70,13 @@
.totp-setup-header { .totp-setup-header {
text-align: center; text-align: center;
margin-bottom: $space-5; margin-bottom: $space-5;
h3 { h3 {
margin: 0 0 $space-1; margin: 0 0 $space-1;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
} }
.step-indicator { .step-indicator {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 0.9rem;
@ -94,13 +99,13 @@
margin-bottom: $space-5; margin-bottom: $space-5;
background: var(--surface-2); background: var(--surface-2);
border-radius: $radius-lg; border-radius: $radius-lg;
p { p {
margin-bottom: $space-5; margin-bottom: $space-5;
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
} }
.qr { .qr {
display: block; display: block;
width: min(60vw, 200px) !important; width: min(60vw, 200px) !important;
@ -110,15 +115,15 @@
padding: $space-3; padding: $space-3;
border-radius: $radius-md; border-radius: $radius-md;
} }
details { details {
margin-top: $space-5; margin-top: $space-5;
summary { summary {
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted-strong); color: var(--text-muted-strong);
&:hover { &:hover {
color: var(--pico-primary); color: var(--pico-primary);
} }
@ -134,7 +139,7 @@
word-break: break-all; word-break: break-all;
font-family: $font-mono; font-family: $font-mono;
} }
.copy-secret-btn { .copy-secret-btn {
margin-top: $space-2; margin-top: $space-2;
font-size: 0.875rem !important; font-size: 0.875rem !important;
@ -157,11 +162,12 @@
display: flex; display: flex;
gap: $space-3; gap: $space-3;
margin-top: $space-5; margin-top: $space-5;
@media (max-width: 480px) { @media (max-width: 480px) {
flex-direction: column; flex-direction: column;
button, input[type="submit"] { button,
input[type="submit"] {
width: 100%; width: 100%;
} }
} }
@ -171,7 +177,7 @@
margin-top: $space-5; margin-top: $space-5;
padding-top: $space-5; padding-top: $space-5;
border-top: 1px solid var(--surface-2-border); border-top: 1px solid var(--surface-2-border);
details { details {
summary { summary {
cursor: pointer; cursor: pointer;
@ -180,12 +186,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: $space-2; gap: $space-2;
&:hover { &:hover {
color: var(--pico-primary); color: var(--pico-primary);
} }
} }
p { p {
margin-top: $space-3; margin-top: $space-3;
padding-left: $space-6; padding-left: $space-6;
@ -201,7 +207,7 @@
.sessions-actions { .sessions-actions {
margin-bottom: $space-4; margin-bottom: $space-4;
text-align: right; text-align: right;
button { button {
font-size: 0.875rem !important; font-size: 0.875rem !important;
padding: $space-1 $space-3 !important; padding: $space-1 $space-3 !important;
@ -215,19 +221,42 @@
gap: $space-3; 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 { .current-session {
background: linear-gradient(135deg, #f7fee7, #ecfccb); background: linear-gradient(135deg, #f7fee7, #ecfccb);
border-color: #84cc16; border-color: #84cc16;
border-width: 1px; border-width: 1px;
box-shadow: box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.08),
0 1px 4px rgba(0, 0, 0, 0.04), 0 1px 4px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.5); inset 0 1px 0 rgba(255, 255, 255, 0.5);
@include dark-mode { @include dark-mode {
background: linear-gradient(135deg, rgba(132, 204, 22, 0.15), rgba(132, 204, 22, 0.08)); background: linear-gradient(135deg, rgba(132, 204, 22, 0.15), rgba(132, 204, 22, 0.08));
border-color: #84cc16; border-color: #84cc16;
box-shadow: box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.3),
0 1px 4px rgba(0, 0, 0, 0.2); 0 1px 4px rgba(0, 0, 0, 0.2);
} }
@ -260,14 +289,14 @@
@include badge(#84cc16, #1a1d23, 4px); @include badge(#84cc16, #1a1d23, 4px);
margin-left: $space-1; margin-left: $space-1;
float: right; float: right;
box-shadow: box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.3); inset 0 1px 0 rgba(255, 255, 255, 0.3);
@include dark-mode { @include dark-mode {
background: #84cc16; background: #84cc16;
color: #1a1d23; color: #1a1d23;
box-shadow: box-shadow:
0 1px 3px rgba(132, 204, 22, 0.3), 0 1px 3px rgba(132, 204, 22, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.2); inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
@ -285,7 +314,7 @@
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted-strong); color: var(--text-muted-strong);
margin-bottom: $space-3; margin-bottom: $space-3;
div { div {
display: flex; display: flex;
gap: $space-1; gap: $space-1;
@ -300,6 +329,7 @@
.backup-code-method { .backup-code-method {
color: #dc2626; color: #dc2626;
background: linear-gradient(135deg, rgba(220, 38, 38, 0.15), rgba(153, 27, 27, 0.15)); background: linear-gradient(135deg, rgba(220, 38, 38, 0.15), rgba(153, 27, 27, 0.15));
@include dark-mode { @include dark-mode {
color: #fca5a5; color: #fca5a5;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25), rgba(220, 38, 38, 0.25)); background: linear-gradient(135deg, rgba(239, 68, 68, 0.25), rgba(220, 38, 38, 0.25));
@ -340,12 +370,12 @@
border-radius: $radius-md; border-radius: $radius-md;
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.5; line-height: 1.5;
@include dark-mode { @include dark-mode {
background: color-mix(in srgb, #d97706 15%, #1f2937 85%); background: color-mix(in srgb, #d97706 15%, #1f2937 85%);
color: #fbbf24; color: #fbbf24;
} }
strong { strong {
font-weight: 600; font-weight: 600;
} }
@ -381,16 +411,18 @@
gap: $space-3; gap: $space-3;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: $space-5; margin-bottom: $space-5;
button, a { button,
a {
flex: 1; flex: 1;
min-width: 140px; min-width: 140px;
} }
@media (max-width: 480px) { @media (max-width: 480px) {
flex-direction: column; flex-direction: column;
button, a { button,
a {
width: 100%; width: 100%;
min-width: unset; min-width: unset;
} }
@ -400,7 +432,7 @@
.backup-codes-confirmation { .backup-codes-confirmation {
padding-top: $space-5; padding-top: $space-5;
border-top: 1px solid var(--surface-2-border); border-top: 1px solid var(--surface-2-border);
label { label {
display: flex; display: flex;
align-items: center; align-items: center;
@ -409,15 +441,15 @@
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
input[type="checkbox"] { input[type="checkbox"] {
margin: 0; margin: 0;
} }
} }
button { button {
width: 100%; width: 100%;
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@ -429,23 +461,23 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: $space-4; gap: $space-4;
.status-info { .status-info {
flex: 1; flex: 1;
strong { strong {
display: block; display: block;
font-size: 1rem; font-size: 1rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.status-detail { .status-detail {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted-strong); color: var(--text-muted-strong);
} }
} }
button { button {
font-size: 0.875rem !important; font-size: 0.875rem !important;
padding: $space-1 $space-3 !important; padding: $space-1 $space-3 !important;
@ -460,3 +492,74 @@
color: var(--text-muted-strong); color: var(--text-muted-strong);
font-size: 0.95rem; 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 :v2_login_codes, class_name: "Identity::V2LoginCode", dependent: :destroy
has_many :totps, class_name: "Identity::TOTP", dependent: :destroy has_many :totps, class_name: "Identity::TOTP", dependent: :destroy
has_many :backup_codes, class_name: "Identity::BackupCode", 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_many :email_change_requests, class_name: "Identity::EmailChangeRequest", dependent: :destroy
has_one :backend_user, class_name: "Backend::User", dependent: :destroy has_one :backend_user, class_name: "Backend::User", dependent: :destroy
@ -62,10 +63,6 @@ class Identity < ApplicationRecord
backend_user&.active? backend_user&.active?
end end
has_many :documents, class_name: "Identity::Document", dependent: :destroy has_many :documents, class_name: "Identity::Document", dependent: :destroy
has_many :verifications, class_name: "Verification", dependent: :destroy has_many :verifications, class_name: "Verification", dependent: :destroy
has_many :document_verifications, class_name: "Verification::DocumentVerification", 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 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 def available_step_up_methods
methods = [] methods = []
methods << :totp if totp.present? methods << :totp if totp.present?
methods << :backup_code if backup_codes_enabled? methods << :backup_code if backup_codes_enabled?
methods << :webauthn if webauthn_enabled?
# Future: methods << :sms if sms_verified? # Future: methods << :sms if sms_verified?
methods methods
end end
@ -331,7 +338,8 @@ class Identity < ApplicationRecord
# Generic 2FA method helpers # Generic 2FA method helpers
def two_factor_methods def two_factor_methods
[ [
totps.verified totps.verified,
webauthn_credentials
# Future: sms_two_factors.verified, # Future: sms_two_factors.verified,
].flatten.compact ].flatten.compact
end 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 has_encrypted :browser_token
before_validation :ensure_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 EXPIRATION = 15.minutes
@ -72,9 +72,12 @@ class LoginAttempt < ApplicationRecord
def backup_code_available? = !authenticated_with_backup_code && identity.backup_codes_enabled? def backup_code_available? = !authenticated_with_backup_code && identity.backup_codes_enabled?
def webauthn_available? = !authenticated_with_webauthn && identity.webauthn_enabled?
def available_factors def available_factors
factors = [] factors = []
factors << :email if email_available? factors << :email if email_available?
factors << :webauthn if webauthn_available?
factors << :totp if totp_available? factors << :totp if totp_available?
factors << :backup_code if backup_code_available? factors << :backup_code if backup_code_available?
factors factors
@ -83,8 +86,12 @@ class LoginAttempt < ApplicationRecord
private private
def required_authentication_factors_count 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 # Require 2FA if enabled AND at least one 2FA method is configured
if identity.requires_two_factor? elsif identity.requires_two_factor?
2 2
else else
1 1

View file

@ -50,6 +50,7 @@
<% auth_methods = [] %> <% 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.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.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 << t("auth_type.backup_code") if identity_session.login_attempt.authenticated_with_backup_code %>
<%= auth_methods.join(", ") %> <%= auth_methods.join(", ") %>
</span> </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>
<section class="section-card"> <section class="section-card">
<h3><%= t ".applications" %></h3> <h3><%= t ".mfa" %></h3>
<%= render Components::BootlegTurbo.new(authorized_applications_path, id: "authorized-apps-container", hx_swap: "innerHTML") %> <%= render Components::BootlegTurbo.new(identity_totps_path, id: "totp-container", hx_swap: "innerHTML") %>
</section> </section>
<section class="section-card"> <section class="section-card">
<h3><%= t ".mfa" %></h3> <h3><%= t ".webauthn" %></h3>
<%= render Components::BootlegTurbo.new(identity_totps_path, id: "totp-container", hx_swap: "innerHTML") %> <%= render Components::BootlegTurbo.new(identity_webauthn_credentials_path, id: "webauthn-container", hx_swap: "innerHTML") %>
</section> </section>
<section class="section-card"> <section class="section-card">
@ -25,9 +25,14 @@
</section> </section>
<section class="section-card"> <section class="section-card">
<h3>Activity Log</h3> <h3><%= t ".applications" %></h3>
<p>View your recent account activity and security events.</p> <%= render Components::BootlegTurbo.new(authorized_applications_path, id: "authorized-apps-container", hx_swap: "innerHTML") %>
<%= link_to "View Activity Log", audit_logs_path, class: "button" %> </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> </section>
</div> </div>

View file

@ -13,7 +13,7 @@
<% if @available_methods.include?(:totp) %> <% if @available_methods.include?(:totp) %>
<%= link_to new_step_up_path(action_type: @action, method: :totp, return_to: @return_to), <%= link_to new_step_up_path(action_type: @action, method: :totp, return_to: @return_to),
class: "step-up-method-option" do %> 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="flex: 1;">
<div style="font-weight: 600;"><%= t(".methods.totp.title") %></div> <div style="font-weight: 600;"><%= t(".methods.totp.title") %></div>
<small><%= t(".methods.totp.description") %></small> <small><%= t(".methods.totp.description") %></small>
@ -24,7 +24,7 @@
<% if @available_methods.include?(:backup_code) %> <% if @available_methods.include?(:backup_code) %>
<%= link_to new_step_up_path(action_type: @action, method: :backup_code, return_to: @return_to), <%= link_to new_step_up_path(action_type: @action, method: :backup_code, return_to: @return_to),
class: "step-up-method-option" do %> 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="flex: 1;">
<div style="font-weight: 600;"><%= t(".methods.backup_code.title") %></div> <div style="font-weight: 600;"><%= t(".methods.backup_code.title") %></div>
<small><%= t(".methods.backup_code.description") %></small> <small><%= t(".methods.backup_code.description") %></small>
@ -32,13 +32,13 @@
<% end %> <% end %>
<% end %> <% end %>
<% if @available_methods.include?(:sms) %> <% if @available_methods.include?(:webauthn) %>
<%= link_to new_step_up_path(action_type: @action, method: :sms, return_to: @return_to), <%= link_to new_step_up_path(action_type: @action, method: :webauthn, return_to: @return_to),
class: "step-up-method-option" do %> 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="flex: 1;">
<div style="font-weight: 600;"><%= t(".methods.sms.title") %></div> <div style="font-weight: 600;"><%= t(".methods.webauthn.title", default: "Passkey") %></div>
<small><%= t(".methods.sms.description") %></small> <small><%= t(".methods.webauthn.description", default: "Use your passkey to verify") %></small>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
@ -79,6 +79,42 @@
<p><%= link_to t(".cancel"), step_up_cancel_path(@action) %></p> <p><%= link_to t(".cancel"), step_up_cancel_path(@action) %></p>
</footer> </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 %> <% else %>
<%# Show form for selected method %> <%# Show form for selected method %>
<header> <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 email: Email
totp: TOTP totp: TOTP
backup_code: Backup code backup_code: Backup code
webauthn: Passkey
logins: logins:
new: new:
title: Welcome back! title: Welcome back!
@ -371,6 +372,14 @@ en:
verify: Verify verify: Verify
lost_authenticator: Lost your authenticator? lost_authenticator: Lost your authenticator?
use_backup_code: Use a backup code instead 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: saml:
http_post: http_post:
title: SAMLing... title: SAMLing...
@ -384,10 +393,14 @@ en:
security_link: Security security_link: Security
security: security:
heading: Security heading: Security
sessions: Active sessions sessions: Active Sessions
applications: Linked apps mfa: Authenticator App
mfa: 2-Factor Authentication webauthn: Passkeys
backup_codes: Backup Codes 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: identity_backup_codes:
totp_heading: "Save your backup codes" totp_heading: "Save your backup codes"
save_warning_title: Save these codes now! 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. require_2fa_description: When enabled, you must use a second factor (authenticator app or backup code) to sign in.
disable_requirement: Disable Requirement disable_requirement: Disable Requirement
enable_requirement: Enable 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 setup_authenticator: Set up two-factor authentication
show: show:
heading: Set up two-factor authentication heading: Set up two-factor authentication
@ -451,6 +464,17 @@ en:
auth_type: Login method auth_type: Login method
terminate: Log out terminate: Log out
terminate_confirmation: Log out this session? 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: step_up:
new: new:
title: Gotta confirm it's you... title: Gotta confirm it's you...
@ -462,13 +486,18 @@ en:
backup_code: backup_code:
title: Backup Code title: Backup Code
description: Use one of your backup codes description: Use one of your backup codes
webauthn:
title: Passkey
description: Use your passkey to verify
cancel: Cancel cancel: Cancel
enter_code_labels: enter_code_labels:
totp: Enter authenticator code totp: Enter authenticator code
backup_code: Enter backup code backup_code: Enter backup code
webauthn: Verify with passkey
enter_code_descriptions: enter_code_descriptions:
totp: Enter the 6-digit code from your authenticator app totp: Enter the 6-digit code from your authenticator app
backup_code: Enter one of your backup codes backup_code: Enter one of your backup codes
webauthn: Use your passkey to confirm your identity
code_label: Enter code code_label: Enter code
backup_placeholder: XXXXXXXXXX backup_placeholder: XXXXXXXXXX
totp_placeholder: "000000" 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 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 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 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 delete "/logout", to: "sessions#logout", as: :logout
@ -330,11 +334,19 @@ Rails.application.routes.draw do
end end
end end
resources :identity_webauthn_credentials, only: [ :index, :new, :create, :destroy ] do
collection do
post :options
end
end
# Step-up authentication flow # Step-up authentication flow
get "/step_up", to: "step_up#new", as: :new_step_up 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/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/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/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 resources :identity_backup_codes, only: [ :index, :create ] do
patch :confirm, on: :collection 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 "saml_debug"
t.boolean "is_in_workspace", default: false, null: false t.boolean "is_in_workspace", default: false, null: false
t.string "slack_dm_channel_id" 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 "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 ["aadhaar_number_bidx"], name: "index_identities_on_aadhaar_number_bidx", unique: true
t.index ["deleted_at"], name: "index_identities_on_deleted_at" 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" t.index ["login_attempt_id"], name: "index_identity_v2_login_codes_on_login_attempt_id"
end 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| create_table "login_attempts", force: :cascade do |t|
t.bigint "identity_id", null: false t.bigint "identity_id", null: false
t.bigint "session_id" 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" t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
end 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_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "addresses", "identities" 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_totps", "identities"
add_foreign_key "identity_v2_login_codes", "identities" add_foreign_key "identity_v2_login_codes", "identities"
add_foreign_key "identity_v2_login_codes", "login_attempts" 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", "identities"
add_foreign_key "login_attempts", "identity_sessions", column: "session_id" add_foreign_key "login_attempts", "identity_sessions", column: "session_id"
add_foreign_key "oauth_access_grants", "identities", column: "resource_owner_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", "identities"
add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id" add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id"
add_foreign_key "verifications", "identity_documents" add_foreign_key "verifications", "identity_documents"
add_foreign_key "webauthn_credentials", "identities"
end end