From 5753b59cc2d15b053e61ed56a2593980d68f454e Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 26 Nov 2025 21:19:56 +0000 Subject: [PATCH 01/23] Step 1 --- Gemfile | 6 +- Gemfile.lock | 26 +++ ...dentity_webauthn_credentials_controller.rb | 10 + app/frontend/entrypoints/application.js | 11 ++ app/frontend/js/webauthn-registration.js | 173 ++++++++++++++++++ .../stylesheets/snippets/security.scss | 120 +++++++----- .../_webauthn_credential.html.erb | 68 +++++++ .../index.html.erb | 19 ++ .../new.html.erb | 53 ++++++ app/views/saml/error.html.erb | 2 +- app/views/static_pages/security.html.erb | 5 + config/locales/en.yml | 5 + config/routes.rb | 2 + ...42_create_identity_webauthn_credentials.rb | 13 ++ 14 files changed, 461 insertions(+), 52 deletions(-) create mode 100644 app/controllers/identity_webauthn_credentials_controller.rb create mode 100644 app/frontend/js/webauthn-registration.js create mode 100644 app/views/identity_webauthn_credentials/_webauthn_credential.html.erb create mode 100644 app/views/identity_webauthn_credentials/index.html.erb create mode 100644 app/views/identity_webauthn_credentials/new.html.erb create mode 100644 db/migrate/20251126211842_create_identity_webauthn_credentials.rb diff --git a/Gemfile b/Gemfile index 87365d9..7a59f53 100644 --- a/Gemfile +++ b/Gemfile @@ -133,6 +133,8 @@ gem "geocoder", "~> 1.8" gem "rotp", "~> 6.3" gem "rqrcode", "~> 2.0" +gem "webauthn", "~> 3.1" + gem "bcrypt", "~> 3.1" gem "rack-attack", "~> 6.7" @@ -144,5 +146,5 @@ gem "slocks", "~> 0.1.0" gem "factory_bot_rails", "~> 6.4" group :production do - gem 'cloudflare-rails' -end \ No newline at end of file + gem "cloudflare-rails" +end diff --git a/Gemfile.lock b/Gemfile.lock index 85e5212..8d3f05c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -84,6 +84,7 @@ GEM activesupport (>= 6.1, < 8.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + android_key_attestation (0.3.0) annotaterb (4.19.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) @@ -125,6 +126,7 @@ GEM parser (>= 2.4) smart_properties bigdecimal (3.1.9) + bindata (2.5.1) bindex (0.8.1) blind_index (2.7.0) activesupport (>= 7.1) @@ -136,6 +138,7 @@ GEM racc browser (6.2.0) builder (3.3.0) + cbor (0.5.10.1) childprocess (5.1.0) logger (~> 1.5) chunky_png (1.4.0) @@ -151,6 +154,9 @@ GEM parser rails (>= 7.0) rainbow + cose (1.3.1) + cbor (~> 0.5.9) + openssl-signature_algorithm (~> 1.0) countries (7.1.1) unaccent (~> 0.3) crass (1.0.6) @@ -265,6 +271,8 @@ GEM jb (0.8.2) jmespath (1.6.2) json (2.12.0) + jwt (3.1.2) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -346,6 +354,9 @@ GEM racc (~> 1.4) nokogiri-xmlsec-instructure (0.12.0) nokogiri (~> 1.13) + openssl (3.3.2) + openssl-signature_algorithm (1.3.0) + openssl (> 2.0) ostruct (0.6.1) paper_trail (16.0.0) activerecord (>= 6.1) @@ -501,6 +512,8 @@ GEM ruby-vips (2.2.4) ffi (~> 1.12) logger + safety_net_attestation (0.5.0) + jwt (>= 2.0, < 4.0) saml2 (3.2.3) activesupport (>= 3.2, < 8.2) nokogiri (>= 1.5.8, < 2.0) @@ -535,6 +548,10 @@ GEM thruster (0.1.13-x86_64-darwin) thruster (0.1.13-x86_64-linux) timeout (0.4.3) + tpm-key_attestation (0.14.1) + bindata (~> 2.4) + openssl (> 2.0) + openssl-signature_algorithm (~> 1.0) turbo-rails (2.0.16) actionpack (>= 7.1.0) railties (>= 7.1.0) @@ -563,6 +580,14 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + 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.7.7) base64 websocket-extensions (>= 0.1.0) @@ -648,6 +673,7 @@ DEPENDENCIES valid_email2 (~> 7.0) vite_rails web-console + webauthn (~> 3.1) wicked (~> 2.0) BUNDLED WITH diff --git a/app/controllers/identity_webauthn_credentials_controller.rb b/app/controllers/identity_webauthn_credentials_controller.rb new file mode 100644 index 0000000..ddd8c0b --- /dev/null +++ b/app/controllers/identity_webauthn_credentials_controller.rb @@ -0,0 +1,10 @@ +class IdentityWebauthnCredentialsController < ApplicationController + def index + @webauthn_credentials = [] + render layout: request.headers["HX-Request"] ? "htmx" : false + end + + def new + render layout: request.headers["HX-Request"] ? "htmx" : false + end +end diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index c415eff..1ad201d 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -2,13 +2,24 @@ import "../js/alpine.js"; import "../js/lightswitch.js"; import "../js/click-to-copy"; import "../js/otp-input.js"; +import { registerWebauthn } from "../js/webauthn-registration.js"; import htmx from "htmx.org" window.htmx = htmx +window.registerWebauthn = registerWebauthn; + // Add CSRF token to all HTMX requests document.addEventListener('htmx:configRequest', (event) => { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; if (csrfToken) { event.detail.headers['X-CSRF-Token'] = csrfToken; } +}); + +document.addEventListener('DOMContentLoaded', () => { + registerWebauthn.init(); +}); + +document.addEventListener('htmx:afterSwap', () => { + registerWebauthn.init(); }); \ No newline at end of file diff --git a/app/frontend/js/webauthn-registration.js b/app/frontend/js/webauthn-registration.js new file mode 100644 index 0000000..edd5b51 --- /dev/null +++ b/app/frontend/js/webauthn-registration.js @@ -0,0 +1,173 @@ +const registerWebauthn = { + checkBrowserSupport() { + const hasJsonSupport = !!globalThis.PublicKeyCredential?.parseCreationOptionsFromJSON; + + if (!hasJsonSupport) { + this.showBrowserWarning(); + return false; + } + + return true; + }, + + showBrowserWarning() { + const warning = document.getElementById('browser-support-warning'); + const formContainer = document.querySelector('.passkey-setup'); + + if (warning) warning.style.display = 'block'; + if (formContainer) formContainer.style.display = 'none'; + }, + + toBase64url(buffer) { + const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + }, + + async getRegistrationOptions(nickname) { + const userId = new Uint8Array(16); + crypto.getRandomValues(userId); + + const challenge = new Uint8Array(32); + crypto.getRandomValues(challenge); + + return { + challenge: this.toBase64url(challenge), + rp: { + name: "Hack Club Account", + id: window.location.hostname + }, + user: { + id: this.toBase64url(userId), + name: "user@example.com", + displayName: nickname + }, + pubKeyCredParams: [ + { type: "public-key", alg: -7 }, + { type: "public-key", alg: -257 } + ], + authenticatorSelection: { + authenticatorAttachment: "platform", + requireResidentKey: false, + residentKey: "preferred", + userVerification: "preferred" + }, + timeout: 60000, + attestation: "none" + }; + }, + + async register(nickname) { + try { + const options = await this.getRegistrationOptions(nickname); + console.log('Registration options:', options); + + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options); + console.log('Parsed creation options:', publicKey); + + const credential = await navigator.credentials.create({ publicKey }); + + if (!credential) { + throw new Error('Credential creation failed'); + } + + console.log('Credential created:', credential); + + const credentialJSON = credential.toJSON(); + console.log('Credential JSON:', credentialJSON); + + const registrationData = { + nickname: nickname, + credential: credentialJSON, + timestamp: new Date().toISOString() + }; + + console.log('Registration data:', registrationData); + + return { + success: true, + data: registrationData + }; + } catch (error) { + console.error('Passkey registration error:', error); + + let errorMessage = 'An unexpected error occurred'; + + if (error.name === 'NotAllowedError') { + errorMessage = 'Registration was cancelled or not allowed'; + } else if (error.name === 'InvalidStateError') { + errorMessage = 'This passkey is already registered'; + } else if (error.name === 'NotSupportedError') { + errorMessage = 'Passkeys are not supported on this device'; + } else if (error.message) { + errorMessage = error.message; + } + + return { + success: false, + error: errorMessage + }; + } + }, + + async handleSubmit(event) { + event.preventDefault(); + + const nicknameInput = document.getElementById('webauthn-nickname'); + const registerBtn = document.getElementById('register-btn'); + const btnText = registerBtn?.querySelector('.btn-text'); + const btnSpinner = registerBtn?.querySelector('.btn-spinner'); + const successAlert = document.getElementById('webauthn-registration-success'); + const errorAlert = document.getElementById('webauthn-registration-error'); + const errorMessage = document.getElementById('error-message'); + + const nickname = nicknameInput.value.trim(); + if (!nickname) { + this.showError('Please enter a nickname for your passkey'); + return; + } + + registerBtn.disabled = true; + btnText.style.display = 'none'; + btnSpinner.style.display = 'inline'; + successAlert.style.display = 'none'; + errorAlert.style.display = 'none'; + + const result = await this.register(nickname); + + registerBtn.disabled = false; + btnText.style.display = 'inline'; + btnSpinner.style.display = 'none'; + + if (result.success) { + successAlert.style.display = 'block'; + nicknameInput.value = ''; + } else { + errorMessage.textContent = result.error; + errorAlert.style.display = 'block'; + } + }, + + showError(message) { + const errorAlert = document.getElementById('webauthn-registration-error'); + const errorMessage = document.getElementById('error-message'); + + if (errorMessage) errorMessage.textContent = message; + if (errorAlert) errorAlert.style.display = 'block'; + }, + + init() { + if (!this.checkBrowserSupport()) { + return; + } + + // Find the form within the passkey registration container + const container = document.getElementById('passkey-registration-container'); + const form = container?.querySelector('form'); + if (form && !form.dataset.webauthnInitialized) { + form.addEventListener('submit', (e) => this.handleSubmit(e)); + form.dataset.webauthnInitialized = 'true'; + } + } +}; + +export { registerWebauthn }; diff --git a/app/frontend/stylesheets/snippets/security.scss b/app/frontend/stylesheets/snippets/security.scss index 6593315..ca8656b 100644 --- a/app/frontend/stylesheets/snippets/security.scss +++ b/app/frontend/stylesheets/snippets/security.scss @@ -1,4 +1,3 @@ - .security-sections { max-width: 900px; margin: 2rem auto 0; @@ -16,7 +15,7 @@ .totp-disabled { text-align: center; padding: $space-6 $space-3; - + p { color: var(--text-muted-strong); margin-bottom: $space-5; @@ -34,28 +33,28 @@ display: flex; align-items: center; gap: $space-4; - + .status-icon { font-size: 2rem; flex-shrink: 0; } - + .status-info { flex: 1; - + strong { display: block; font-size: 1rem; margin-bottom: 0.25rem; } - + p.status-detail { margin: 0; font-size: 0.9rem; color: var(--text-muted-strong); } } - + button { font-size: 0.875rem !important; padding: $space-1 $space-3 !important; @@ -73,13 +72,13 @@ .totp-setup-header { text-align: center; margin-bottom: $space-5; - + h3 { margin: 0 0 $space-1; font-size: 1.5rem; font-weight: 700; } - + .step-indicator { margin: 0; font-size: 0.9rem; @@ -102,13 +101,13 @@ margin-bottom: $space-5; background: var(--surface-2); border-radius: $radius-lg; - + p { margin-bottom: $space-5; font-weight: 600; font-size: 0.95rem; } - + .qr { display: block; width: min(60vw, 200px) !important; @@ -118,15 +117,15 @@ padding: $space-3; border-radius: $radius-md; } - + details { margin-top: $space-5; - + summary { cursor: pointer; font-size: 0.9rem; color: var(--text-muted-strong); - + &:hover { color: var(--pico-primary); } @@ -142,7 +141,7 @@ word-break: break-all; font-family: $font-mono; } - + .copy-secret-btn { margin-top: $space-2; font-size: 0.875rem !important; @@ -165,11 +164,12 @@ display: flex; gap: $space-3; margin-top: $space-5; - + @media (max-width: 480px) { flex-direction: column; - - button, input[type="submit"] { + + button, + input[type="submit"] { width: 100%; } } @@ -179,7 +179,7 @@ margin-top: $space-5; padding-top: $space-5; border-top: 1px solid var(--surface-2-border); - + details { summary { cursor: pointer; @@ -188,12 +188,12 @@ display: flex; align-items: center; gap: $space-2; - + &:hover { color: var(--pico-primary); } } - + p { margin-top: $space-3; padding-left: $space-6; @@ -209,7 +209,7 @@ .sessions-actions { margin-bottom: $space-4; text-align: right; - + button { font-size: 0.875rem !important; padding: $space-1 $space-3 !important; @@ -226,20 +226,20 @@ .session-card { @include card($padding: $space-4, $radius: $radius-lg); @include transition-default(); - + [data-theme="dark"] & { background: #1f2937; border-color: #374151; } - + &:hover { transform: translateY(-1px); - box-shadow: + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.06); - + [data-theme="dark"] & { - box-shadow: + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.3); } @@ -250,15 +250,15 @@ background: linear-gradient(135deg, #f7fee7, #ecfccb); border-color: #84cc16; border-width: 1px; - box-shadow: + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.5); - + [data-theme="dark"] & { background: linear-gradient(135deg, rgba(132, 204, 22, 0.15), rgba(132, 204, 22, 0.08)); border-color: #84cc16; - box-shadow: + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2); } @@ -291,14 +291,14 @@ @include badge(#84cc16, #1a1d23, 4px); margin-left: $space-1; float: right; - box-shadow: + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.3); - + [data-theme="dark"] & { background: #84cc16; color: #1a1d23; - box-shadow: + box-shadow: 0 1px 3px rgba(132, 204, 22, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2); } @@ -316,7 +316,7 @@ font-size: 0.85rem; color: var(--text-muted-strong); margin-bottom: $space-3; - + div { display: flex; gap: $space-1; @@ -331,6 +331,7 @@ .backup-code-method { color: #dc2626; background: linear-gradient(135deg, rgba(220, 38, 38, 0.15), rgba(153, 27, 27, 0.15)); + [data-theme="dark"] & { color: #fca5a5; background: linear-gradient(135deg, rgba(239, 68, 68, 0.25), rgba(220, 38, 38, 0.25)); @@ -371,12 +372,12 @@ border-radius: $radius-md; font-size: 0.9rem; line-height: 1.5; - + [data-theme="dark"] & { background: color-mix(in srgb, #d97706 15%, #1f2937 85%); color: #fbbf24; } - + strong { font-weight: 600; } @@ -412,16 +413,18 @@ gap: $space-3; flex-wrap: wrap; margin-bottom: $space-5; - - button, a { + + button, + a { flex: 1; min-width: 140px; } - + @media (max-width: 480px) { flex-direction: column; - - button, a { + + button, + a { width: 100%; min-width: unset; } @@ -431,7 +434,7 @@ .backup-codes-confirmation { padding-top: $space-5; border-top: 1px solid var(--surface-2-border); - + label { display: flex; align-items: center; @@ -440,15 +443,15 @@ font-size: 0.95rem; font-weight: 500; cursor: pointer; - + input[type="checkbox"] { margin: 0; } } - + button { width: 100%; - + &:disabled { opacity: 0.5; cursor: not-allowed; @@ -460,23 +463,23 @@ display: flex; align-items: center; gap: $space-4; - + .status-info { flex: 1; - + strong { display: block; font-size: 1rem; margin-bottom: 0.25rem; } - + .status-detail { margin: 0; font-size: 0.9rem; color: var(--text-muted-strong); } } - + button { font-size: 0.875rem !important; padding: $space-1 $space-3 !important; @@ -491,3 +494,22 @@ color: var(--text-muted-strong); font-size: 0.95rem; } + +// WebAuthn! +.webauthn-disabled { + text-align: center; + padding: $space-6 $space-3; + + p { + color: var(--text-muted-strong); + margin-bottom: $space-5; + font-size: 0.95rem; + } +} + +.webauthn-setup-description { + margin-bottom: $space-5; + font-size: 0.95rem; + font-style: italic; + color: var(--text-muted-strong); +} \ No newline at end of file diff --git a/app/views/identity_webauthn_credentials/_webauthn_credential.html.erb b/app/views/identity_webauthn_credentials/_webauthn_credential.html.erb new file mode 100644 index 0000000..2170dee --- /dev/null +++ b/app/views/identity_webauthn_credentials/_webauthn_credential.html.erb @@ -0,0 +1,68 @@ +
+
+
+ <% if identity_session.os_info&.include?("Mac") || identity_session.os_info&.include?("macOS") %> + <%= inline_icon("apple", size: 32) %> + <% elsif identity_session.os_info&.include?("iOS") || identity_session.os_info&.include?("iPhone") || identity_session.os_info&.include?("iPad") %> + <%= inline_icon("apple", size: 32) %> + <% elsif identity_session.os_info&.include?("Windows") %> + <%= inline_icon("windows", size: 32) %> + <% elsif identity_session.os_info&.include?("Android") %> + <%= inline_icon("google", size: 32) %> + <% elsif identity_session.os_info&.include?("Linux") %> + <%= inline_icon("terminal", size: 32) %> + <% else %> + <%= inline_icon("web", size: 32) %> + <% end %> +
+
+
+ <%= identity_session.device_info || t("identity_sessions.unknown_device") %> + <% if identity_session.id == @current_session&.id %> + <%= t "identity_sessions.current" %> + <% end %> +
+
<%= identity_session.os_info || t("identity_sessions.unknown_os") %>
+
+
+ +
+
+ <%= t "identity_sessions.ip" %> + <%= identity_session.ip || t("identity_sessions.unknown") %> +
+
+ <%= t "identity_sessions.created_at" %> + <%= identity_session.created_at.strftime("%b %d, %Y at %l:%M %p") %> +
+
+ <%= t "identity_sessions.last_seen" %> + <%= time_ago_in_words(identity_session.last_seen || identity_session.created_at) %> ago +
+
+ <%= t "identity_sessions.expires_at" %> + <%= identity_session.expires_at.strftime("%b %d, %Y") %> +
+ <% if identity_session.login_attempt %> +
+ <%= t "identity_sessions.auth_type" %> + + <% auth_methods = [] %> + <% auth_methods << t("auth_type.email") if identity_session.login_attempt.authenticated_with_email || identity_session.login_attempt.authenticated_with_legacy_email %> + <% auth_methods << t("auth_type.totp") if identity_session.login_attempt.authenticated_with_totp %> + <% auth_methods << t("auth_type.backup_code") if identity_session.login_attempt.authenticated_with_backup_code %> + <%= auth_methods.join(", ") %> + +
+ <% end %> +
+ + <% if identity_session.id != @current_session&.id %> + + <% end %> +
diff --git a/app/views/identity_webauthn_credentials/index.html.erb b/app/views/identity_webauthn_credentials/index.html.erb new file mode 100644 index 0000000..395edf3 --- /dev/null +++ b/app/views/identity_webauthn_credentials/index.html.erb @@ -0,0 +1,19 @@ +
+ <% if @webauthn_credentials.empty? %> +
+

<%= t(".setup_description") %>

+ <%= 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" + } %> +
+ <% else %> +
+ <%= render partial: "identity_webauthn_credential", collection: @webauthn_credentials %> +
+ <% end %> +
diff --git a/app/views/identity_webauthn_credentials/new.html.erb b/app/views/identity_webauthn_credentials/new.html.erb new file mode 100644 index 0000000..8216580 --- /dev/null +++ b/app/views/identity_webauthn_credentials/new.html.erb @@ -0,0 +1,53 @@ +
+

+ A passwordless way to sign in using your device's biometric authentication or PIN. +

+ + + +
+
+
+ + + Give this passkey a memorable name to identify it later! +
+ +
+ + <%= 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" + } %> +
+
+
+ + + + +
\ No newline at end of file diff --git a/app/views/saml/error.html.erb b/app/views/saml/error.html.erb index 75b50dd..c5bb868 100644 --- a/app/views/saml/error.html.erb +++ b/app/views/saml/error.html.erb @@ -1,7 +1,7 @@ <% if Flipper.enabled?(:are_we_enterprise_yet, current_identity) && !@missing_message %><%= @error %> <%= @error %> <% else %> - Hack Club is currently migrating to Slack Enterprise Grid.
+ Hack Club is currently migrating to Slack Enterprise Grid.

The Slack will be unavailable for up to 2 days (counting from Monday morning).

Thanks for your patience! If you like, you can join us on IRC at irc.hackclub.com?

diff --git a/app/views/static_pages/security.html.erb b/app/views/static_pages/security.html.erb index 013d9f3..67a7e4e 100644 --- a/app/views/static_pages/security.html.erb +++ b/app/views/static_pages/security.html.erb @@ -19,6 +19,11 @@ <%= render Components::BootlegTurbo.new(identity_totps_path, id: "totp-container", hx_swap: "innerHTML") %> +
+

<%= t ".webauthn" %>

+ <%= render Components::BootlegTurbo.new(identity_webauthn_credentials_path, id: "webauthn-container", hx_swap: "innerHTML") %> +
+

<%= t ".backup_codes" %>

<%= render Components::BootlegTurbo.new(identity_backup_codes_path, id: "backup-codes-container", hx_swap: "innerHTML") %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 11ef932..fa074aa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -267,6 +267,7 @@ en: applications: Linked apps mfa: 2-Factor Authentication backup_codes: Backup Codes + webauthn: Passkeys identity_backup_codes: totp_heading: "Save your backup codes" save_warning_title: Save these codes now! @@ -330,6 +331,10 @@ en: auth_type: Login method terminate: Log out terminate_confirmation: Log out this session? + identity_webauthn_credentials: + index: + setup_description: Sign in securely using a passkey stored on your device - like your phone, laptop, or hardware security key. + setup_webauthn: Set up passkey step_up: new: title: Gotta confirm it's you... diff --git a/config/routes.rb b/config/routes.rb index 82a38b2..451301a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -299,6 +299,8 @@ def self.matches?(request) end end + resources :identity_webauthn_credentials, only: [ :index, :new, :destroy ] + # Step-up authentication flow get "/step_up", to: "step_up#new", as: :new_step_up post "/step_up/verify", to: "step_up#verify", as: :verify_step_up diff --git a/db/migrate/20251126211842_create_identity_webauthn_credentials.rb b/db/migrate/20251126211842_create_identity_webauthn_credentials.rb new file mode 100644 index 0000000..fd64606 --- /dev/null +++ b/db/migrate/20251126211842_create_identity_webauthn_credentials.rb @@ -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 From ac051dabae8e7c108a7bbece2226ed3e4b6abe6c Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 26 Nov 2025 22:14:33 +0000 Subject: [PATCH 02/23] Step 2 --- ...dentity_webauthn_credentials_controller.rb | 67 +++++++- app/controllers/logins_controller.rb | 81 ++++++++- app/frontend/entrypoints/application.js | 4 + app/frontend/js/webauthn-authentication.js | 156 ++++++++++++++++++ app/frontend/js/webauthn-registration.js | 87 +++++----- app/models/identity.rb | 7 +- app/models/identity/webauthn_credential.rb | 44 +++++ app/models/login_attempt.rb | 11 +- .../_identity_webauthn_credential.html.erb | 22 +++ .../_webauthn_credential.html.erb | 68 -------- app/views/logins/webauthn.html.erb | 50 ++++++ config/initializers/webauthn.rb | 21 +++ config/locales/en.yml | 14 ++ config/routes.rb | 10 +- db/schema.rb | 28 +++- 15 files changed, 547 insertions(+), 123 deletions(-) create mode 100644 app/frontend/js/webauthn-authentication.js create mode 100644 app/models/identity/webauthn_credential.rb create mode 100644 app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb delete mode 100644 app/views/identity_webauthn_credentials/_webauthn_credential.html.erb create mode 100644 app/views/logins/webauthn.html.erb create mode 100644 config/initializers/webauthn.rb diff --git a/app/controllers/identity_webauthn_credentials_controller.rb b/app/controllers/identity_webauthn_credentials_controller.rb index ddd8c0b..a38a0b8 100644 --- a/app/controllers/identity_webauthn_credentials_controller.rb +++ b/app/controllers/identity_webauthn_credentials_controller.rb @@ -1,10 +1,75 @@ class IdentityWebauthnCredentialsController < ApplicationController def index - @webauthn_credentials = [] + @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 + + # Generate registration options (challenge) for WebAuthn credential creation + def options + user_id_binary = [current_identity.id].pack("Q>") # 64-bit unsigned big-endian + user_id_base64 = Base64.urlsafe_encode64(user_id_binary, padding: false) + + challenge = WebAuthn::Credential.options_for_create( + user: { + id: user_id_base64, + name: current_identity.primary_email, + display_name: "#{current_identity.first_name} #{current_identity.last_name}" + }, + exclude: current_identity.webauthn_credentials.pluck(:external_id).map { |id| Base64.urlsafe_decode64(id) }, + authenticator_selection: { + user_verification: "preferred", + resident_key: "preferred" + } + ) + + # store the challenge in the session to verify it later! + session[:webauthn_registration_challenge] = challenge.challenge + + render json: challenge + end + + def create + begin + # Parse the JSON request body manually since Rails doesn't auto-parse for non-API controllers + # (is this wrong? probably...) + request_body = request.body.read + request.body.rewind + body_params = JSON.parse(request_body) + + nickname = body_params["nickname"] + credential_data = body_params.except("nickname") + + webauthn_credential = WebAuthn::Credential.from_create(credential_data) + + webauthn_credential.verify(session[:webauthn_registration_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 + ) + + session.delete(:webauthn_registration_challenge) + + render json: { success: true, credential_id: credential.id } + rescue WebAuthn::Error => e + Rails.logger.error "WebAuthn registration error: #{e.message}" + render json: { success: false, error: e.message }, status: :unprocessable_entity + end + end + + def destroy + credential = current_identity.webauthn_credentials.find(params[:id]) + credential.destroy + + respond_to do |format| + format.html { redirect_to security_path, notice: "Passkey removed successfully" } + format.json { render json: { success: true } } + end + end end diff --git a/app/controllers/logins_controller.rb b/app/controllers/logins_controller.rb index 7dcc0cf..6f182a8 100644 --- a/app/controllers/logins_controller.rb +++ b/app/controllers/logins_controller.rb @@ -43,8 +43,12 @@ def create same_site: :lax } - send_v2_login_code(identity, attempt) - redirect_to login_attempt_path(id: attempt.to_param), status: :see_other + if identity.webauthn_enabled? + redirect_to webauthn_login_attempt_path(id: attempt.to_param), status: :see_other + else + send_v2_login_code(identity, attempt) + redirect_to login_attempt_path(id: attempt.to_param), status: :see_other + end rescue => e flash[:error] = e.message redirect_to login_path(return_to: @return_to) @@ -157,6 +161,75 @@ def verify_backup_code handle_post_verification_redirect end + def webauthn + render status: :unprocessable_entity + end + + def skip_webauthn + # User wants to skip passkey and 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 + credentials = @identity.webauthn_credentials.pluck(:external_id).map { |id| Base64.urlsafe_decode64(id) } + + options = WebAuthn::Credential.options_for_get( + allow: credentials, + user_verification: "preferred" + ) + + session[:webauthn_authentication_challenge] = options.challenge + + render json: options + end + + def verify_webauthn + flash.clear + + begin + # Parse the JSON request body manually since Rails doesn't auto-parse for non-API controllers + request_body = request.body.read + request.body.rewind + credential_data = JSON.parse(request_body) + + webauthn_credential = WebAuthn::Credential.from_get(credential_data) + + credential = @identity.webauthn_credentials.find_by( + external_id: Base64.urlsafe_encode64(webauthn_credential.id, padding: false) + ) + + unless credential + flash.now[:error] = "Passkey not found" + render :webauthn, status: :unprocessable_entity + return + end + + webauthn_credential.verify( + session[:webauthn_authentication_challenge], + public_key: credential.webauthn_public_key, + sign_count: credential.sign_count + ) + + credential.update!(sign_count: webauthn_credential.sign_count) + + session.delete(:webauthn_authentication_challenge) + 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 + end + private def set_attempt @@ -313,7 +386,9 @@ def provision_slack_on_first_login(scenario) def redirect_to_next_factor available = @attempt.available_factors - if available.include?(:totp) + if available.include?(:webauthn) + redirect_to webauthn_login_attempt_path(id: @attempt.to_param), status: :see_other + elsif available.include?(:totp) redirect_to totp_login_attempt_path(id: @attempt.to_param), status: :see_other elsif available.include?(:backup_code) redirect_to backup_code_login_attempt_path(id: @attempt.to_param), status: :see_other diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index 1ad201d..b3070f9 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -3,10 +3,12 @@ import "../js/lightswitch.js"; import "../js/click-to-copy"; import "../js/otp-input.js"; import { registerWebauthn } from "../js/webauthn-registration.js"; +import { authenticateWebauthn } from "../js/webauthn-authentication.js"; import htmx from "htmx.org" window.htmx = htmx window.registerWebauthn = registerWebauthn; +window.authenticateWebauthn = authenticateWebauthn; // Add CSRF token to all HTMX requests document.addEventListener('htmx:configRequest', (event) => { @@ -18,8 +20,10 @@ document.addEventListener('htmx:configRequest', (event) => { document.addEventListener('DOMContentLoaded', () => { registerWebauthn.init(); + authenticateWebauthn.init(); }); document.addEventListener('htmx:afterSwap', () => { registerWebauthn.init(); + authenticateWebauthn.init(); }); \ No newline at end of file diff --git a/app/frontend/js/webauthn-authentication.js b/app/frontend/js/webauthn-authentication.js new file mode 100644 index 0000000..e45e35b --- /dev/null +++ b/app/frontend/js/webauthn-authentication.js @@ -0,0 +1,156 @@ +const authenticateWebauthn = { + checkBrowserSupport() { + const hasJsonSupport = !!globalThis.PublicKeyCredential?.parseRequestOptionsFromJSON; + + if (!hasJsonSupport) { + this.showBrowserWarning(); + return false; + } + + return true; + }, + + showBrowserWarning() { + const warning = document.getElementById('browser-support-warning'); + const authContainer = document.querySelector('.passkey-auth'); + + if (warning) warning.style.display = 'block'; + if (authContainer) authContainer.style.display = 'none'; + }, + + async getAuthenticationOptions() { + // Fetch authentication options from the server + // The server will generate a cryptographic challenge + 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() { + try { + const options = await this.getAuthenticationOptions(); // fetch our options + challenge + console.log('Authentication options:', options); + + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options); + console.log('Parsed request options:', publicKey); + + const credential = await navigator.credentials.get({ publicKey }); + + if (!credential) { + throw new Error('Authentication failed - no credential returned'); + } + + const credentialJSON = credential.toJSON(); + const loginAttemptId = this.getLoginAttemptId(); + const response = await fetch(`/login/${loginAttemptId}/webauthn/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content + }, + body: JSON.stringify(credentialJSON) + }); + + if (!response.ok) { + const result = await response.json(); + throw new Error(result.error || 'Authentication failed'); + } + + console.log('Authentication successful'); + + window.location.reload(); // TODO + + return { + success: true + }; + } catch (error) { + console.error('Passkey authentication error:', error); + + let errorMessage = 'An unexpected error occurred'; + + if (error.name === 'NotAllowedError') { + errorMessage = 'Authentication was cancelled or not allowed'; + } else if (error.name === 'InvalidStateError') { + errorMessage = 'No passkey found for this account'; + } else if (error.name === 'NotSupportedError') { + errorMessage = 'Passkeys are not supported on this device'; + } else if (error.message) { + errorMessage = error.message; + } + + return { + success: false, + error: errorMessage + }; + } + }, + + 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 handleAuthenticate() { + const authBtn = document.getElementById('authenticate-btn'); + const btnText = authBtn?.querySelector('.btn-text'); + const btnSpinner = authBtn?.querySelector('.btn-spinner'); + const errorAlert = document.getElementById('webauthn-auth-error'); + const errorMessage = document.getElementById('error-message'); + + if (authBtn) { + authBtn.disabled = true; + if (btnText) btnText.style.display = 'none'; + if (btnSpinner) btnSpinner.style.display = 'inline'; + } + + if (errorAlert) errorAlert.style.display = 'none'; + + const result = await this.authenticate(); + + if (authBtn) { + authBtn.disabled = false; + if (btnText) btnText.style.display = 'inline'; + if (btnSpinner) btnSpinner.style.display = 'none'; + } + + if (!result.success) { + if (errorMessage) errorMessage.textContent = result.error; + if (errorAlert) errorAlert.style.display = 'block'; + } + }, + + init() { + if (!this.checkBrowserSupport()) { + return; + } + + const container = document.getElementById('passkey-auth-container'); + if (container && !container.dataset.webauthnInitialized) { + this.handleAuthenticate(); + container.dataset.webauthnInitialized = 'true'; + } + + const authBtn = document.getElementById('authenticate-btn'); + if (authBtn && !authBtn.dataset.webauthnInitialized) { + authBtn.addEventListener('click', () => this.handleAuthenticate()); + authBtn.dataset.webauthnInitialized = 'true'; + } + } +}; + +export { authenticateWebauthn }; diff --git a/app/frontend/js/webauthn-registration.js b/app/frontend/js/webauthn-registration.js index edd5b51..e475c9c 100644 --- a/app/frontend/js/webauthn-registration.js +++ b/app/frontend/js/webauthn-registration.js @@ -18,49 +18,31 @@ const registerWebauthn = { if (formContainer) formContainer.style.display = 'none'; }, - toBase64url(buffer) { - const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); - }, + async getRegistrationOptions() { + // Fetch registration options from the server + // The server will generate a cryptographic challenge and return user info + 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'); + } - async getRegistrationOptions(nickname) { - const userId = new Uint8Array(16); - crypto.getRandomValues(userId); - - const challenge = new Uint8Array(32); - crypto.getRandomValues(challenge); - - return { - challenge: this.toBase64url(challenge), - rp: { - name: "Hack Club Account", - id: window.location.hostname - }, - user: { - id: this.toBase64url(userId), - name: "user@example.com", - displayName: nickname - }, - pubKeyCredParams: [ - { type: "public-key", alg: -7 }, - { type: "public-key", alg: -257 } - ], - authenticatorSelection: { - authenticatorAttachment: "platform", - requireResidentKey: false, - residentKey: "preferred", - userVerification: "preferred" - }, - timeout: 60000, - attestation: "none" - }; + return await response.json(); }, async register(nickname) { try { - const options = await this.getRegistrationOptions(nickname); + // Get registration options from server (includes challenge and user info) + const options = await this.getRegistrationOptions(); console.log('Registration options:', options); + // Parse the options and create the credential const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options); console.log('Parsed creation options:', publicKey); @@ -72,20 +54,34 @@ const registerWebauthn = { console.log('Credential created:', credential); + // Convert credential to JSON for transmission const credentialJSON = credential.toJSON(); console.log('Credential JSON:', credentialJSON); - const registrationData = { - nickname: nickname, - credential: credentialJSON, - timestamp: new Date().toISOString() - }; + // Send the credential to the server for verification and storage + const response = await fetch('/identity_webauthn_credentials', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content + }, + body: JSON.stringify({ + nickname: nickname, + ...credentialJSON + }) + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || 'Failed to register passkey'); + } - console.log('Registration data:', registrationData); + console.log('Registration successful:', result); return { success: true, - data: registrationData + data: result }; } catch (error) { console.error('Passkey registration error:', error); @@ -139,8 +135,8 @@ const registerWebauthn = { btnSpinner.style.display = 'none'; if (result.success) { - successAlert.style.display = 'block'; - nicknameInput.value = ''; + // Redirect to the credentials index page to show the updated list + window.location.href = '/identity_webauthn_credentials'; } else { errorMessage.textContent = result.error; errorAlert.style.display = 'block'; @@ -160,7 +156,6 @@ const registerWebauthn = { return; } - // Find the form within the passkey registration container const container = document.getElementById('passkey-registration-container'); const form = container?.querySelector('form'); if (form && !form.dataset.webauthnInitialized) { diff --git a/app/models/identity.rb b/app/models/identity.rb index 94b828b..e966025 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -54,6 +54,7 @@ class Identity < ApplicationRecord has_many :v2_login_codes, class_name: "Identity::V2LoginCode", dependent: :destroy has_many :totps, class_name: "Identity::TOTP", dependent: :destroy has_many :backup_codes, class_name: "Identity::BackupCode", dependent: :destroy + has_many :webauthn_credentials, class_name: "Identity::WebauthnCredential", dependent: :destroy has_many :documents, class_name: "Identity::Document", dependent: :destroy has_many :verifications, class_name: "Verification", dependent: :destroy @@ -290,10 +291,13 @@ def totp = totps.verified.first def backup_codes_enabled? = backup_codes.active.any? + def webauthn_enabled? = webauthn_credentials.any? + def available_step_up_methods methods = [] methods << :totp if totp.present? methods << :backup_code if backup_codes_enabled? + methods << :webauthn if webauthn_enabled? # Future: methods << :sms if sms_verified? methods end @@ -301,7 +305,8 @@ def available_step_up_methods # Generic 2FA method helpers def two_factor_methods [ - totps.verified + totps.verified, + webauthn_credentials # Future: sms_two_factors.verified, ].flatten.compact end diff --git a/app/models/identity/webauthn_credential.rb b/app/models/identity/webauthn_credential.rb new file mode 100644 index 0000000..abfb383 --- /dev/null +++ b/app/models/identity/webauthn_credential.rb @@ -0,0 +1,44 @@ +class Identity::WebauthnCredential < ApplicationRecord + belongs_to :identity + + 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 } + + 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 + + # Human-readable display for the credential + def display_name + nickname.presence || "Passkey created #{created_at.strftime('%b %d, %Y')}" + end + + private + + def set_initial_sign_count + self.sign_count ||= 0 + end +end diff --git a/app/models/login_attempt.rb b/app/models/login_attempt.rb index a99e996..c86dc16 100644 --- a/app/models/login_attempt.rb +++ b/app/models/login_attempt.rb @@ -8,7 +8,7 @@ class LoginAttempt < ApplicationRecord has_encrypted :browser_token before_validation :ensure_browser_token - store_accessor :authentication_factors, :email, :totp, :backup_code, :legacy_email, prefix: :authenticated_with + store_accessor :authentication_factors, :email, :totp, :backup_code, :webauthn, :legacy_email, prefix: :authenticated_with EXPIRATION = 15.minutes @@ -72,9 +72,12 @@ def totp_available? = !authenticated_with_totp && identity.totp.present? def backup_code_available? = !authenticated_with_backup_code && identity.backup_codes_enabled? + def webauthn_available? = !authenticated_with_webauthn && identity.webauthn_enabled? + def available_factors factors = [] factors << :email if email_available? + factors << :webauthn if webauthn_available? factors << :totp if totp_available? factors << :backup_code if backup_code_available? factors @@ -83,8 +86,12 @@ def available_factors private def required_authentication_factors_count + # WebAuthn inherently provides 2FA (possession + biometric/PIN) + # So if WebAuthn is used, we only need 1 factor + if authenticated_with_webauthn + 1 # Require 2FA if enabled AND at least one 2FA method is configured - if identity.requires_two_factor? + elsif identity.requires_two_factor? 2 else 1 diff --git a/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb b/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb new file mode 100644 index 0000000..84f061c --- /dev/null +++ b/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb @@ -0,0 +1,22 @@ +
+
+
+ <%= inline_icon("fingerprint", size: 32) %> +
+
+
+ <%= identity_webauthn_credential.display_name %> +
+
+ <%= t("identity_webauthn_credentials.added") %> <%= time_ago_in_words(identity_webauthn_credential.created_at) %> ago +
+
+
+ + +
diff --git a/app/views/identity_webauthn_credentials/_webauthn_credential.html.erb b/app/views/identity_webauthn_credentials/_webauthn_credential.html.erb deleted file mode 100644 index 2170dee..0000000 --- a/app/views/identity_webauthn_credentials/_webauthn_credential.html.erb +++ /dev/null @@ -1,68 +0,0 @@ -
-
-
- <% if identity_session.os_info&.include?("Mac") || identity_session.os_info&.include?("macOS") %> - <%= inline_icon("apple", size: 32) %> - <% elsif identity_session.os_info&.include?("iOS") || identity_session.os_info&.include?("iPhone") || identity_session.os_info&.include?("iPad") %> - <%= inline_icon("apple", size: 32) %> - <% elsif identity_session.os_info&.include?("Windows") %> - <%= inline_icon("windows", size: 32) %> - <% elsif identity_session.os_info&.include?("Android") %> - <%= inline_icon("google", size: 32) %> - <% elsif identity_session.os_info&.include?("Linux") %> - <%= inline_icon("terminal", size: 32) %> - <% else %> - <%= inline_icon("web", size: 32) %> - <% end %> -
-
-
- <%= identity_session.device_info || t("identity_sessions.unknown_device") %> - <% if identity_session.id == @current_session&.id %> - <%= t "identity_sessions.current" %> - <% end %> -
-
<%= identity_session.os_info || t("identity_sessions.unknown_os") %>
-
-
- -
-
- <%= t "identity_sessions.ip" %> - <%= identity_session.ip || t("identity_sessions.unknown") %> -
-
- <%= t "identity_sessions.created_at" %> - <%= identity_session.created_at.strftime("%b %d, %Y at %l:%M %p") %> -
-
- <%= t "identity_sessions.last_seen" %> - <%= time_ago_in_words(identity_session.last_seen || identity_session.created_at) %> ago -
-
- <%= t "identity_sessions.expires_at" %> - <%= identity_session.expires_at.strftime("%b %d, %Y") %> -
- <% if identity_session.login_attempt %> -
- <%= t "identity_sessions.auth_type" %> - - <% auth_methods = [] %> - <% auth_methods << t("auth_type.email") if identity_session.login_attempt.authenticated_with_email || identity_session.login_attempt.authenticated_with_legacy_email %> - <% auth_methods << t("auth_type.totp") if identity_session.login_attempt.authenticated_with_totp %> - <% auth_methods << t("auth_type.backup_code") if identity_session.login_attempt.authenticated_with_backup_code %> - <%= auth_methods.join(", ") %> - -
- <% end %> -
- - <% if identity_session.id != @current_session&.id %> - - <% end %> -
diff --git a/app/views/logins/webauthn.html.erb b/app/views/logins/webauthn.html.erb new file mode 100644 index 0000000..37b27d6 --- /dev/null +++ b/app/views/logins/webauthn.html.erb @@ -0,0 +1,50 @@ +
+
+
+

<%= t(".title") %>

+ <%= t(".subtitle") %> +
+ + + + + +
+
+ <%= inline_icon("fingerprint", size: 64) if defined?(inline_icon) %> +
+ + + +

<%= t(".hint") %>

+
+ +
+

+ <%= t(".prefer_email") %> + <%= button_to t(".use_email_code"), + skip_webauthn_login_attempt_path(@attempt), + method: :post, + class: "secondary small-btn" %> +

+ + <% if @attempt.backup_code_available? %> +

+ <%= t(".lost_passkey") %> + <%= link_to t(".use_backup_code"), backup_code_login_attempt_path(@attempt) %> +

+ <% end %> +
+
+
diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb new file mode 100644 index 0000000..df39fc8 --- /dev/null +++ b/config/initializers/webauthn.rb @@ -0,0 +1,21 @@ +# 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://account.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 Account" + + # 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 diff --git a/config/locales/en.yml b/config/locales/en.yml index fa074aa..1747abb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -250,6 +250,17 @@ en: verify: Verify lost_authenticator: Lost your authenticator? use_backup_code: Use a backup code instead + webauthn: + title: Use your passkey + subtitle: Use the passkey saved on this device or another nearby device + authenticate: Use passkey + authenticating: Authenticating + hint: Follow your browser's prompt to authenticate with your passkey + 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 + lost_passkey: Lost your passkey? + use_backup_code: Use a backup code instead saml: http_post: title: SAMLing... @@ -335,6 +346,9 @@ en: index: setup_description: Sign in securely using a passkey stored on your device - like your phone, laptop, or hardware security key. setup_webauthn: Set up passkey + added: Added + 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. step_up: new: title: Gotta confirm it's you... diff --git a/config/routes.rb b/config/routes.rb index 451301a..bd38103 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -273,6 +273,10 @@ def self.matches?(request) post "/login/:id/totp", to: "logins#verify_totp", as: :verify_totp_login_attempt get "/login/:id/backup_code", to: "logins#backup_code", as: :backup_code_login_attempt post "/login/:id/backup_code", to: "logins#verify_backup_code", as: :verify_backup_code_login_attempt + get "/login/:id/webauthn", to: "logins#webauthn", as: :webauthn_login_attempt + post "/login/:id/webauthn/options", to: "logins#webauthn_options", as: :webauthn_options_login_attempt + post "/login/:id/webauthn/verify", to: "logins#verify_webauthn", as: :verify_webauthn_login_attempt + post "/login/:id/webauthn/skip", to: "logins#skip_webauthn", as: :skip_webauthn_login_attempt delete "/logout", to: "sessions#logout", as: :logout @@ -299,7 +303,11 @@ def self.matches?(request) end end - resources :identity_webauthn_credentials, only: [ :index, :new, :destroy ] + resources :identity_webauthn_credentials, only: [ :index, :new, :create, :destroy ] do + collection do + post :options + end + end # Step-up authentication flow get "/step_up", to: "step_up#new", as: :new_step_up diff --git a/db/schema.rb b/db/schema.rb index 9757d02..e17be8b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_29_180141) do +ActiveRecord::Schema[8.0].define(version: 2025_11_26_211842) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -298,6 +298,7 @@ t.boolean "developer_mode", default: false, null: false t.boolean "saml_debug" t.boolean "is_in_workspace", default: false, null: false + t.string "webauthn_id" 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 ["legacy_migrated_at"], name: "index_identities_on_legacy_migrated_at" @@ -405,6 +406,17 @@ t.index ["login_attempt_id"], name: "index_identity_v2_login_codes_on_login_attempt_id" end + create_table "identity_webauthn_credentials", force: :cascade do |t| + t.bigint "identity_id", null: false + t.string "external_id" + t.string "public_key" + t.string "nickname" + t.integer "sign_count" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["identity_id"], name: "index_identity_webauthn_credentials_on_identity_id" + end + create_table "login_attempts", force: :cascade do |t| t.bigint "identity_id", null: false t.bigint "session_id" @@ -524,6 +536,18 @@ t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end + create_table "webauthn_credentials", force: :cascade do |t| + t.bigint "identity_id", null: false + t.string "external_id", null: false + t.string "public_key", null: false + t.string "nickname", null: false + t.integer "sign_count", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true + t.index ["identity_id"], name: "index_webauthn_credentials_on_identity_id" + end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "addresses", "identities" @@ -543,6 +567,7 @@ add_foreign_key "identity_totps", "identities" add_foreign_key "identity_v2_login_codes", "identities" add_foreign_key "identity_v2_login_codes", "login_attempts" + add_foreign_key "identity_webauthn_credentials", "identities" add_foreign_key "login_attempts", "identities" add_foreign_key "login_attempts", "identity_sessions", column: "session_id" add_foreign_key "oauth_access_grants", "identities", column: "resource_owner_id" @@ -552,4 +577,5 @@ add_foreign_key "verifications", "identities" add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id" add_foreign_key "verifications", "identity_documents" + add_foreign_key "webauthn_credentials", "identities" end From 75cee1f7d25362573a108f1c811a7745b6bfa306 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 26 Nov 2025 22:47:38 +0000 Subject: [PATCH 03/23] Step 3 --- ...dentity_webauthn_credentials_controller.rb | 9 +-- app/frontend/js/webauthn-registration.js | 6 +- app/frontend/stylesheets/snippets/auth.scss | 75 +++++++++++++------ .../_identity_webauthn_credential.html.erb | 34 +++++---- .../index.html.erb | 2 +- app/views/logins/webauthn.html.erb | 9 +-- config/locales/en.yml | 7 +- 7 files changed, 86 insertions(+), 56 deletions(-) diff --git a/app/controllers/identity_webauthn_credentials_controller.rb b/app/controllers/identity_webauthn_credentials_controller.rb index a38a0b8..e527eb5 100644 --- a/app/controllers/identity_webauthn_credentials_controller.rb +++ b/app/controllers/identity_webauthn_credentials_controller.rb @@ -56,7 +56,8 @@ def create session.delete(:webauthn_registration_challenge) - render json: { success: true, credential_id: credential.id } + flash[:success] = t(".successfully_added") + render json: { success: true, redirect_url: security_path } rescue WebAuthn::Error => e Rails.logger.error "WebAuthn registration error: #{e.message}" render json: { success: false, error: e.message }, status: :unprocessable_entity @@ -67,9 +68,7 @@ def destroy credential = current_identity.webauthn_credentials.find(params[:id]) credential.destroy - respond_to do |format| - format.html { redirect_to security_path, notice: "Passkey removed successfully" } - format.json { render json: { success: true } } - end + flash[:success] = t(".successfully_removed") + redirect_to security_path end end diff --git a/app/frontend/js/webauthn-registration.js b/app/frontend/js/webauthn-registration.js index e475c9c..520249d 100644 --- a/app/frontend/js/webauthn-registration.js +++ b/app/frontend/js/webauthn-registration.js @@ -54,11 +54,9 @@ const registerWebauthn = { console.log('Credential created:', credential); - // Convert credential to JSON for transmission const credentialJSON = credential.toJSON(); console.log('Credential JSON:', credentialJSON); - // Send the credential to the server for verification and storage const response = await fetch('/identity_webauthn_credentials', { method: 'POST', headers: { @@ -135,8 +133,8 @@ const registerWebauthn = { btnSpinner.style.display = 'none'; if (result.success) { - // Redirect to the credentials index page to show the updated list - window.location.href = '/identity_webauthn_credentials'; + // Redirect to the URL provided by the server (flash message will be displayed) + window.location.href = result.data.redirect_url || '/identity_webauthn_credentials'; } else { errorMessage.textContent = result.error; errorAlert.style.display = 'block'; diff --git a/app/frontend/stylesheets/snippets/auth.scss b/app/frontend/stylesheets/snippets/auth.scss index e416e4a..5ed66a3 100644 --- a/app/frontend/stylesheets/snippets/auth.scss +++ b/app/frontend/stylesheets/snippets/auth.scss @@ -7,7 +7,7 @@ min-height: 100vh; padding: 2rem 1rem; background: #f5f5f5; - + [data-theme="dark"] & { background: #1a1d23; } @@ -20,24 +20,24 @@ padding: 2.5rem; width: 100%; max-width: 610px; - box-shadow: + box-shadow: 4px 4px 16px rgba(0, 0, 0, 0.04), 2px 2px 8px rgba(0, 0, 0, 0.02), inset 0 1px 0 rgba(255, 255, 255, 0.8); - + [data-theme="dark"] & { background: linear-gradient(165deg, #252932 0%, #1f2329 100%); border-color: rgba(255, 255, 255, 0.08); - box-shadow: + box-shadow: 4px 4px 16px rgba(0, 0, 0, 0.3), 2px 2px 8px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05); } - + header { text-align: center; margin-bottom: $space-7; - + h1 { font-size: 1.85rem; font-weight: 600; @@ -45,19 +45,19 @@ color: var(--text-strong); letter-spacing: -0.02em; } - + small { font-size: 0.95rem; color: var(--text-muted-strong); display: block; line-height: 1.5; - + a { color: var(--pico-primary); text-decoration: none; font-weight: 500; @include transition-default(color); - + &:hover { color: var(--pico-primary-hover); text-decoration: underline; @@ -65,12 +65,12 @@ } } } - + fieldset { border: none; padding: 0; margin: 0 0 1.75rem; - + legend { font-size: 0.95rem; font-weight: 600; @@ -78,7 +78,7 @@ margin-bottom: $space-4; padding: 0; } - + label { font-size: 0.9rem; font-weight: 500; @@ -86,18 +86,20 @@ display: block; color: var(--pico-color); } - - input, select, textarea { + + 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); @@ -105,11 +107,11 @@ display: block; } } - + .grid { gap: 1rem; } - + button[type="submit"], input[type="submit"] { width: 100%; @@ -118,25 +120,25 @@ font-size: 1rem; border: none; } - + footer { text-align: center; margin-top: $space-7; padding-top: $space-6; border-top: 1px solid var(--surface-2-border); - + p { margin: $space-1 0; font-size: 0.9rem; color: var(--text-muted-strong); } - + a { color: var(--pico-primary); text-decoration: none; font-weight: 500; @include transition-default(color); - + &:hover { color: var(--pico-primary-hover); text-decoration: underline; @@ -150,7 +152,7 @@ .brand { display: none; } - + // Flash wrapper for auth layout .auth-flash-wrapper { position: absolute; @@ -159,7 +161,7 @@ right: 0; padding: 2rem 1rem 0; pointer-events: none; - + .banner { max-width: 640px; margin-left: auto; @@ -168,3 +170,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; + } + } +} \ No newline at end of file diff --git a/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb b/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb index 84f061c..2fcc7e1 100644 --- a/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb +++ b/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb @@ -1,22 +1,30 @@ -
-
-
+
+
+
<%= inline_icon("fingerprint", size: 32) %>
-
-
+
+
<%= identity_webauthn_credential.display_name %>
-
- <%= t("identity_webauthn_credentials.added") %> <%= time_ago_in_words(identity_webauthn_credential.created_at) %> ago -
- + <% end %>
diff --git a/app/views/identity_webauthn_credentials/index.html.erb b/app/views/identity_webauthn_credentials/index.html.erb index 395edf3..5147b4c 100644 --- a/app/views/identity_webauthn_credentials/index.html.erb +++ b/app/views/identity_webauthn_credentials/index.html.erb @@ -12,7 +12,7 @@ } %>
<% else %> -
+
<%= render partial: "identity_webauthn_credential", collection: @webauthn_credentials %>
<% end %> diff --git a/app/views/logins/webauthn.html.erb b/app/views/logins/webauthn.html.erb index 37b27d6..16c5b07 100644 --- a/app/views/logins/webauthn.html.erb +++ b/app/views/logins/webauthn.html.erb @@ -18,16 +18,11 @@
-
- <%= inline_icon("fingerprint", size: 64) if defined?(inline_icon) %> -
- - - -

<%= t(".hint") %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index 1747abb..166afb7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -251,11 +251,10 @@ en: lost_authenticator: Lost your authenticator? use_backup_code: Use a backup code instead webauthn: - title: Use your passkey + title: Welcome back! subtitle: Use the passkey saved on this device or another nearby device authenticate: Use passkey authenticating: Authenticating - hint: Follow your browser's prompt to authenticate with your passkey 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 @@ -347,8 +346,12 @@ en: setup_description: Sign in securely using a passkey stored on your device - like your phone, laptop, or hardware security key. setup_webauthn: Set up passkey added: Added + created_at: Created + last_used: Last used remove: Remove remove_confirmation: Are you sure you want to remove this passkey? You won't be able to use it to sign in anymore. + successfully_added: passkey added! + successfully_removed: passkey removed! step_up: new: title: Gotta confirm it's you... From a576372d2624d1de585e0a88bf3679241436a581 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 26 Nov 2025 23:02:53 +0000 Subject: [PATCH 04/23] bin/lint --- .../identity_webauthn_credentials_controller.rb | 2 +- app/services/scim_service.rb | 14 +++++++------- .../identity_webauthn_credentials/new.html.erb | 15 +++++++-------- config/initializers/webauthn.rb | 8 ++++---- lib/middleware/domain_redirect.rb | 8 ++++---- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/app/controllers/identity_webauthn_credentials_controller.rb b/app/controllers/identity_webauthn_credentials_controller.rb index e527eb5..0e4bbc8 100644 --- a/app/controllers/identity_webauthn_credentials_controller.rb +++ b/app/controllers/identity_webauthn_credentials_controller.rb @@ -10,7 +10,7 @@ def new # Generate registration options (challenge) for WebAuthn credential creation def options - user_id_binary = [current_identity.id].pack("Q>") # 64-bit unsigned big-endian + user_id_binary = [ current_identity.id ].pack("Q>") # 64-bit unsigned big-endian user_id_base64 = Base64.urlsafe_encode64(user_id_binary, padding: false) challenge = WebAuthn::Credential.options_for_create( diff --git a/app/services/scim_service.rb b/app/services/scim_service.rb index 1976c27..49bde08 100644 --- a/app/services/scim_service.rb +++ b/app/services/scim_service.rb @@ -69,14 +69,14 @@ def create_user(identity:, scenario:) end error_msg = if response.body.is_a?(Hash) - response.body.dig("Errors", 0, "description") || - response.body["detail"] || + response.body.dig("Errors", 0, "description") || + response.body["detail"] || response.body["message"] || response.body["error"] - end - + end + error_msg ||= "Unknown error (Status #{response.status}): #{response.body.inspect}" - + Rails.logger.error "Failed to create Slack user: #{error_msg}" # Check for email_taken error with existing_user ID @@ -95,10 +95,10 @@ def create_user(identity:, scenario:) if error_msg.include?("already") || error_msg.include?("duplicate") || error_msg.include?("exists") || error_msg.include?("email_taken") || error_msg.include?("conflict") # Try to find the existing user by email using SCIM existing_user = find_existing_user_by_email(identity.primary_email) - + # Fallback to Web API lookup if SCIM failed to find the user existing_user ||= SlackService.find_by_email(identity.primary_email) - + if existing_user Rails.logger.info "Found existing Slack user for #{identity.primary_email}: #{existing_user}" return { diff --git a/app/views/identity_webauthn_credentials/new.html.erb b/app/views/identity_webauthn_credentials/new.html.erb index 8216580..b80bdd1 100644 --- a/app/views/identity_webauthn_credentials/new.html.erb +++ b/app/views/identity_webauthn_credentials/new.html.erb @@ -12,15 +12,14 @@
- + autocomplete="off"> Give this passkey a memorable name to identify it later!
@@ -29,7 +28,7 @@ Register Passkey - <%= link_to "Cancel", + <%= link_to "Cancel", identity_webauthn_credentials_path, class: "btn btn-secondary", data: { @@ -50,4 +49,4 @@ Registration Failed

-
\ No newline at end of file +
diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb index df39fc8..03d408a 100644 --- a/config/initializers/webauthn.rb +++ b/config/initializers/webauthn.rb @@ -2,12 +2,12 @@ WebAuthn.configure do |config| # The allowed origins - where WebAuthn requests can come from config.allowed_origins = if Rails.env.production? - ["https://account.hackclub.com"] + [ "https://account.hackclub.com" ] elsif Rails.env.development? - ["http://localhost:3000"] + [ "http://localhost:3000" ] else # For test environment or other environments - ["http://localhost:3000"] + [ "http://localhost:3000" ] end # The Relying Party name - shown in authenticator UI @@ -17,5 +17,5 @@ # 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"] + config.algorithms = [ "ES256", "RS256" ] end diff --git a/lib/middleware/domain_redirect.rb b/lib/middleware/domain_redirect.rb index 732ec19..66bc16e 100644 --- a/lib/middleware/domain_redirect.rb +++ b/lib/middleware/domain_redirect.rb @@ -5,12 +5,12 @@ def initialize(app) def call(env) request = Rack::Request.new(env) - - if request.path.start_with?('/api') || request.host == 'account.hackclub.com' + + if request.path.start_with?("/api") || request.host == "account.hackclub.com" return @app.call(env) end - + # Redirect to account.hackclub.com - [301, { 'Location' => "https://account.hackclub.com#{request.fullpath}", 'Content-Type' => 'text/html' }, []] + [ 301, { "Location" => "https://account.hackclub.com#{request.fullpath}", "Content-Type" => "text/html" }, [] ] end end From 01b4417a36e3d39021c119381e3d5e9fbff289e3 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 26 Nov 2025 23:06:49 +0000 Subject: [PATCH 05/23] Upgrade deps + fixes CI --- Gemfile.lock | 343 ++++++++++++++++++++++++++------------------------- 1 file changed, 176 insertions(+), 167 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8d3f05c..f6ff7d8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,31 +1,31 @@ GEM remote: https://rubygems.org/ specs: - aasm (5.5.0) + aasm (5.5.2) concurrent-ruby (~> 1.0) - actioncable (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + actioncable (8.0.4) + actionpack (= 8.0.4) + activesupport (= 8.0.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actionmailbox (8.0.4) + actionpack (= 8.0.4) + activejob (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) mail (>= 2.8.0) - actionmailer (8.0.2) - actionpack (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activesupport (= 8.0.2) + actionmailer (8.0.4) + actionpack (= 8.0.4) + actionview (= 8.0.4) + activejob (= 8.0.4) + activesupport (= 8.0.4) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2) - actionview (= 8.0.2) - activesupport (= 8.0.2) + actionpack (8.0.4) + actionview (= 8.0.4) + activesupport (= 8.0.4) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -33,15 +33,15 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2) - actionpack (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actiontext (8.0.4) + actionpack (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2) - activesupport (= 8.0.2) + actionview (8.0.4) + activesupport (= 8.0.4) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -51,22 +51,22 @@ GEM block_cipher_kit (>= 0.0.4) rails (>= 7.2.2.1) serve_byte_range (~> 1.0) - activejob (8.0.2) - activesupport (= 8.0.2) + activejob (8.0.4) + activesupport (= 8.0.4) globalid (>= 0.3.6) - activemodel (8.0.2) - activesupport (= 8.0.2) - activerecord (8.0.2) - activemodel (= 8.0.2) - activesupport (= 8.0.2) + activemodel (8.0.4) + activesupport (= 8.0.4) + activerecord (8.0.4) + activemodel (= 8.0.4) + activesupport (= 8.0.4) timeout (>= 0.4.0) - activestorage (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activesupport (= 8.0.2) + activestorage (8.0.4) + actionpack (= 8.0.4) + activejob (= 8.0.4) + activerecord (= 8.0.4) + activesupport (= 8.0.4) marcel (~> 1.0) - activesupport (8.0.2) + activesupport (8.0.4) base64 benchmark (>= 0.3) bigdecimal @@ -82,10 +82,10 @@ GEM acts_as_paranoid (0.10.3) activerecord (>= 6.1, < 8.1) activesupport (>= 6.1, < 8.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) android_key_attestation (0.3.0) - annotaterb (4.19.0) + annotaterb (4.20.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) argon2-kdf (0.3.1) @@ -98,43 +98,44 @@ GEM turbo-rails awesome_print (1.9.2) aws-eventstream (1.4.0) - aws-partitions (1.1110.0) - aws-sdk-core (3.225.0) + aws-partitions (1.1188.0) + aws-sdk-core (3.239.2) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 + bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.102.0) - aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.189.0) - aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-s3 (1.205.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.12.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.0) - better_html (2.1.1) - actionview (>= 6.0) - activesupport (>= 6.0) + benchmark (0.5.0) + better_html (2.2.0) + actionview (>= 7.0) + activesupport (>= 7.0) ast (~> 2.0) erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.1.9) + bigdecimal (3.3.1) bindata (2.5.1) bindex (0.8.1) blind_index (2.7.0) activesupport (>= 7.1) argon2-kdf (>= 0.2) block_cipher_kit (0.0.4) - bootsnap (1.18.6) + bootsnap (1.19.0) msgpack (~> 1.2) - brakeman (7.1.0) + brakeman (7.1.1) racc browser (6.2.0) builder (3.3.0) @@ -148,7 +149,7 @@ GEM railties (>= 7.2.0, < 8.2.0) zeitwerk (>= 2.5.0) concurrent-ruby (1.3.5) - connection_pool (2.5.3) + connection_pool (2.5.5) console1984 (0.2.2) irb (~> 1.13) parser @@ -161,8 +162,8 @@ GEM unaccent (~> 0.3) crass (1.0.6) csv (3.3.5) - date (3.4.1) - debug (1.10.0) + date (3.5.0) + debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) diff-lcs (1.6.2) @@ -171,8 +172,8 @@ GEM railties (>= 5) dotenv (3.1.8) drb (2.2.3) - dry-cli (1.2.0) - erb (5.0.1) + dry-cli (1.3.0) + erb (6.0.0) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) @@ -181,24 +182,24 @@ GEM rubocop (>= 1) smart_properties erubi (1.13.1) - et-orbi (1.2.11) + et-orbi (1.4.0) tzinfo factory_bot (6.5.6) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) - faraday (2.13.1) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger faraday-mashify (1.0.0) faraday (~> 2.0) hashie - faraday-multipart (1.1.0) + faraday-multipart (1.1.1) multipart-post (~> 2.0) - faraday-net_http (3.4.0) - net-http (>= 0.5.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) @@ -211,30 +212,30 @@ GEM ffi (>= 1.15.5) rake fiddle (1.1.8) - flipper (1.3.5) + flipper (1.3.6) concurrent-ruby (< 2) - flipper-active_record (1.3.5) + flipper-active_record (1.3.6) activerecord (>= 4.2, < 9) - flipper (~> 1.3.5) - flipper-ui (1.3.5) + flipper (~> 1.3.6) + flipper-ui (1.3.6) erubi (>= 1.0.0, < 2.0.0) - flipper (~> 1.3.5) + flipper (~> 1.3.6) rack (>= 1.4, < 4) rack-protection (>= 1.5.3, < 5.0.0) rack-session (>= 1.0.2, < 3.0.0) sanitize (< 8) front_matter_parser (1.0.1) - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) + fugit (1.12.1) + et-orbi (~> 1.4) raabro (~> 1.4) geocoder (1.8.6) base64 (>= 0.1.0) csv (>= 3.0.0) gli (2.22.2) ostruct - globalid (1.2.1) + globalid (1.3.0) activesupport (>= 6.1) - good_job (4.10.2) + good_job (4.12.1) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) @@ -246,16 +247,15 @@ GEM hashids (~> 1.0) hashids (1.0.6) hashie (5.0.0) - honeybadger (5.28.0) + honeybadger (5.29.1) logger ostruct - http (5.2.0) + http (5.3.1) addressable (~> 2.8) - base64 (~> 0.1) http-cookie (~> 1.0) http-form_data (~> 2.2) llhttp-ffi (~> 0.5.0) - http-cookie (1.0.8) + http-cookie (1.1.0) domain_name (~> 0.5) http-form_data (2.3.0) i18n (1.14.7) @@ -263,14 +263,14 @@ GEM image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) - io-console (0.8.0) - irb (1.15.2) + io-console (0.8.1) + irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jb (0.8.2) jmespath (1.6.2) - json (2.12.0) + json (2.16.0) jwt (3.1.2) base64 kaminari (1.2.2) @@ -298,35 +298,35 @@ GEM railties (>= 6.1) rexml lint_roller (1.1.0) - literal (1.7.1) + literal (1.8.1) zeitwerk llhttp-ffi (0.5.1) ffi-compiler (~> 1.0) rake (~> 13.0) - lockbox (2.0.1) + lockbox (2.1.0) logger (1.7.0) loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) lz_string (0.3.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) mini-levenshtein (0.1.2) - mini_magick (5.2.0) - benchmark + mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (5.25.5) + minitest (5.26.2) msgpack (1.8.0) multipart-post (2.4.1) mutex_m (0.3.0) - net-http (0.6.0) - uri - net-imap (0.5.8) + net-http (0.8.0) + uri (>= 0.11.1) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -335,51 +335,57 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) - nokogiri (1.18.8-aarch64-linux-gnu) + nio4r (2.7.5) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.8-aarch64-linux-musl) + nokogiri (1.18.10-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.8-arm-linux-gnu) + nokogiri (1.18.10-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.8-arm-linux-musl) + nokogiri (1.18.10-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.8-arm64-darwin) + nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.8-x86_64-darwin) + nokogiri (1.18.10-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-gnu) + nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-musl) + nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) nokogiri-xmlsec-instructure (0.12.0) nokogiri (~> 1.13) openssl (3.3.2) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - ostruct (0.6.1) + ostruct (0.6.3) paper_trail (16.0.0) activerecord (>= 6.1) request_store (~> 1.4) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.10.0) ast (~> 2.4.1) racc - pg (1.5.9) - phlex (2.2.1) + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-aarch64-linux-musl) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-darwin) + pg (1.6.2-x86_64-linux) + pg (1.6.2-x86_64-linux-musl) + phlex (2.3.1) zeitwerk (~> 2.7) - phlex-rails (2.2.0) - phlex (~> 2.2.1) + phlex-rails (2.3.1) + phlex (~> 2.3.0) railties (>= 7.1, < 9) - pp (0.6.2) + zeitwerk (~> 2.7) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.4.0) - propshaft (1.1.0) + prism (1.6.0) + propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - railties (>= 7.0.0) psych (5.2.6) date stringio @@ -388,17 +394,17 @@ GEM activerecord (>= 6.1) i18n (>= 0.5.0) railties (>= 6.1.0) - public_suffix (6.0.2) - puma (6.6.0) + public_suffix (7.0.0) + puma (7.1.0) nio4r (~> 2.0) - pundit (2.5.0) + pundit (2.5.2) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.15) - rack-attack (6.7.0) + rack (3.2.4) + rack-attack (6.8.0) rack (>= 1.0, < 4) - rack-protection (4.1.1) + rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) @@ -411,20 +417,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.2) - actioncable (= 8.0.2) - actionmailbox (= 8.0.2) - actionmailer (= 8.0.2) - actionpack (= 8.0.2) - actiontext (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activemodel (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + rails (8.0.4) + actioncable (= 8.0.4) + actionmailbox (= 8.0.4) + actionmailer (= 8.0.4) + actionpack (= 8.0.4) + actiontext (= 8.0.4) + actionview (= 8.0.4) + activejob (= 8.0.4) + activemodel (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) bundler (>= 1.15.0) - railties (= 8.0.2) + railties (= 8.0.4) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -432,33 +438,35 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails_semantic_logger (4.17.0) + rails_semantic_logger (4.18.0) rack railties (>= 5.1) semantic_logger (~> 4.16) - railties (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + railties (8.0.4) + actionpack (= 8.0.4) + activesupport (= 8.0.4) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.2.1) - rdoc (6.14.0) + rake (13.3.1) + rdoc (6.16.0) erb psych (>= 4.0.0) + tsort redcarpet (3.6.1) - regexp_parser (2.10.0) - reline (0.6.1) + regexp_parser (2.11.3) + reline (0.6.3) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) - rexml (3.4.1) + rexml (3.4.4) rinku (2.0.6) rotp (6.3.0) - rouge (4.5.2) + rouge (4.6.1) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) @@ -468,7 +476,7 @@ GEM rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.6) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (7.1.1) @@ -480,7 +488,7 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.6) - rubocop (1.75.7) + rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -488,17 +496,17 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.44.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.44.1) + rubocop-ast (1.48.0) parser (>= 3.3.7.2) prism (~> 1.4) - rubocop-performance (1.25.0) + rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.32.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.1) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -509,12 +517,12 @@ GEM rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) - ruby-vips (2.2.4) + ruby-vips (2.2.5) ffi (~> 1.12) logger safety_net_attestation (0.5.0) jwt (>= 2.0, < 4.0) - saml2 (3.2.3) + saml2 (3.3.0) activesupport (>= 3.2, < 8.2) nokogiri (>= 1.5.8, < 2.0) nokogiri-xmlsec-instructure (~> 0.9, >= 0.9.5) @@ -522,12 +530,12 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.16.8) securerandom (0.4.1) - semantic_logger (4.16.1) + semantic_logger (4.17.0) concurrent-ruby (~> 1.0) serve_byte_range (1.0.0) rack (>= 1.0) - slack-ruby-client (2.6.0) - faraday (>= 2.0) + slack-ruby-client (2.7.0) + faraday (>= 2.0.1) faraday-mashify faraday-multipart gli @@ -537,31 +545,32 @@ GEM actionview (>= 6.0) activesupport (>= 6.0) smart_properties (1.17.0) - stringio (3.1.7) + stringio (3.1.8) superform (0.5.1) phlex-rails (>= 1.0, < 3.0) zeitwerk (~> 2.6) - thor (1.3.2) - thruster (0.1.13) - thruster (0.1.13-aarch64-linux) - thruster (0.1.13-arm64-darwin) - thruster (0.1.13-x86_64-darwin) - thruster (0.1.13-x86_64-linux) - timeout (0.4.3) + thor (1.4.0) + thruster (0.1.16) + thruster (0.1.16-aarch64-linux) + thruster (0.1.16-arm64-darwin) + thruster (0.1.16-x86_64-darwin) + thruster (0.1.16-x86_64-linux) + timeout (0.4.4) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) - turbo-rails (2.0.16) + tsort (0.2.0) + turbo-rails (2.0.20) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unaccent (0.4.0) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - uri (1.0.3) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.1.1) useragent (0.16.11) valid_email2 (7.0.13) activemodel (>= 6.0) @@ -588,7 +597,7 @@ GEM openssl (>= 2.2) safety_net_attestation (~> 0.5.0) tpm-key_attestation (~> 0.14.0) - websocket-driver (0.7.7) + websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -677,4 +686,4 @@ DEPENDENCIES wicked (~> 2.0) BUNDLED WITH - 2.6.9 + 2.7.2 From 29206e4d27e15d1cbb92f100fcc60d097d5634fe Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 26 Nov 2025 23:08:42 +0000 Subject: [PATCH 06/23] Fix sign count vuln --- app/controllers/logins_controller.rb | 32 ++++++++++++++++------------ 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/app/controllers/logins_controller.rb b/app/controllers/logins_controller.rb index 6f182a8..22d253d 100644 --- a/app/controllers/logins_controller.rb +++ b/app/controllers/logins_controller.rb @@ -195,23 +195,27 @@ def verify_webauthn webauthn_credential = WebAuthn::Credential.from_get(credential_data) - credential = @identity.webauthn_credentials.find_by( - external_id: Base64.urlsafe_encode64(webauthn_credential.id, padding: false) - ) + # wrap in a transaction with pessimistic locking to prevent race conditions on sign_count + Identity::WebauthnCredential.transaction do + credential = @identity.webauthn_credentials.lock.find_by( + external_id: Base64.urlsafe_encode64(webauthn_credential.id, padding: false) + ) - unless credential - flash.now[:error] = "Passkey not found" - render :webauthn, status: :unprocessable_entity - return - end + unless credential + flash.now[:error] = "Passkey not found" + render :webauthn, status: :unprocessable_entity + return + end - webauthn_credential.verify( - session[:webauthn_authentication_challenge], - public_key: credential.webauthn_public_key, - sign_count: credential.sign_count - ) + webauthn_credential.verify( + session[:webauthn_authentication_challenge], + public_key: credential.webauthn_public_key, + sign_count: credential.sign_count + ) - credential.update!(sign_count: webauthn_credential.sign_count) + # Update sign_count within the locked transaction + credential.update!(sign_count: webauthn_credential.sign_count) + end session.delete(:webauthn_authentication_challenge) factors = (@attempt.authentication_factors || {}).dup From beb8cc75c5f45f719381b9618b9571d825300aae Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 26 Nov 2025 23:15:19 +0000 Subject: [PATCH 07/23] Revert "Upgrade deps + fixes CI" This reverts commit 01b4417a36e3d39021c119381e3d5e9fbff289e3. --- Gemfile.lock | 343 +++++++++++++++++++++++++-------------------------- 1 file changed, 167 insertions(+), 176 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f6ff7d8..8d3f05c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,31 +1,31 @@ GEM remote: https://rubygems.org/ specs: - aasm (5.5.2) + aasm (5.5.0) concurrent-ruby (~> 1.0) - actioncable (8.0.4) - actionpack (= 8.0.4) - activesupport (= 8.0.4) + actioncable (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.4) - actionpack (= 8.0.4) - activejob (= 8.0.4) - activerecord (= 8.0.4) - activestorage (= 8.0.4) - activesupport (= 8.0.4) + actionmailbox (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) mail (>= 2.8.0) - actionmailer (8.0.4) - actionpack (= 8.0.4) - actionview (= 8.0.4) - activejob (= 8.0.4) - activesupport (= 8.0.4) + actionmailer (8.0.2) + actionpack (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activesupport (= 8.0.2) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.4) - actionview (= 8.0.4) - activesupport (= 8.0.4) + actionpack (8.0.2) + actionview (= 8.0.2) + activesupport (= 8.0.2) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -33,15 +33,15 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.4) - actionpack (= 8.0.4) - activerecord (= 8.0.4) - activestorage (= 8.0.4) - activesupport (= 8.0.4) + actiontext (8.0.2) + actionpack (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.4) - activesupport (= 8.0.4) + actionview (8.0.2) + activesupport (= 8.0.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -51,22 +51,22 @@ GEM block_cipher_kit (>= 0.0.4) rails (>= 7.2.2.1) serve_byte_range (~> 1.0) - activejob (8.0.4) - activesupport (= 8.0.4) + activejob (8.0.2) + activesupport (= 8.0.2) globalid (>= 0.3.6) - activemodel (8.0.4) - activesupport (= 8.0.4) - activerecord (8.0.4) - activemodel (= 8.0.4) - activesupport (= 8.0.4) + activemodel (8.0.2) + activesupport (= 8.0.2) + activerecord (8.0.2) + activemodel (= 8.0.2) + activesupport (= 8.0.2) timeout (>= 0.4.0) - activestorage (8.0.4) - actionpack (= 8.0.4) - activejob (= 8.0.4) - activerecord (= 8.0.4) - activesupport (= 8.0.4) + activestorage (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activesupport (= 8.0.2) marcel (~> 1.0) - activesupport (8.0.4) + activesupport (8.0.2) base64 benchmark (>= 0.3) bigdecimal @@ -82,10 +82,10 @@ GEM acts_as_paranoid (0.10.3) activerecord (>= 6.1, < 8.1) activesupport (>= 6.1, < 8.1) - addressable (2.8.8) - public_suffix (>= 2.0.2, < 8.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) android_key_attestation (0.3.0) - annotaterb (4.20.0) + annotaterb (4.19.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) argon2-kdf (0.3.1) @@ -98,44 +98,43 @@ GEM turbo-rails awesome_print (1.9.2) aws-eventstream (1.4.0) - aws-partitions (1.1188.0) - aws-sdk-core (3.239.2) + aws-partitions (1.1110.0) + aws-sdk-core (3.225.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 - bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.102.0) + aws-sdk-core (~> 3, >= 3.225.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.205.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.189.0) + aws-sdk-core (~> 3, >= 3.225.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.12.1) + aws-sigv4 (1.12.0) aws-eventstream (~> 1, >= 1.0.2) - base64 (0.3.0) + base64 (0.2.0) bcrypt (3.1.20) - benchmark (0.5.0) - better_html (2.2.0) - actionview (>= 7.0) - activesupport (>= 7.0) + benchmark (0.4.0) + better_html (2.1.1) + actionview (>= 6.0) + activesupport (>= 6.0) ast (~> 2.0) erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.3.1) + bigdecimal (3.1.9) bindata (2.5.1) bindex (0.8.1) blind_index (2.7.0) activesupport (>= 7.1) argon2-kdf (>= 0.2) block_cipher_kit (0.0.4) - bootsnap (1.19.0) + bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.1.1) + brakeman (7.1.0) racc browser (6.2.0) builder (3.3.0) @@ -149,7 +148,7 @@ GEM railties (>= 7.2.0, < 8.2.0) zeitwerk (>= 2.5.0) concurrent-ruby (1.3.5) - connection_pool (2.5.5) + connection_pool (2.5.3) console1984 (0.2.2) irb (~> 1.13) parser @@ -162,8 +161,8 @@ GEM unaccent (~> 0.3) crass (1.0.6) csv (3.3.5) - date (3.5.0) - debug (1.11.0) + date (3.4.1) + debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) diff-lcs (1.6.2) @@ -172,8 +171,8 @@ GEM railties (>= 5) dotenv (3.1.8) drb (2.2.3) - dry-cli (1.3.0) - erb (6.0.0) + dry-cli (1.2.0) + erb (5.0.1) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) @@ -182,24 +181,24 @@ GEM rubocop (>= 1) smart_properties erubi (1.13.1) - et-orbi (1.4.0) + et-orbi (1.2.11) tzinfo factory_bot (6.5.6) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) - faraday (2.14.0) + faraday (2.13.1) faraday-net_http (>= 2.0, < 3.5) json logger faraday-mashify (1.0.0) faraday (~> 2.0) hashie - faraday-multipart (1.1.1) + faraday-multipart (1.1.0) multipart-post (~> 2.0) - faraday-net_http (3.4.2) - net-http (~> 0.5) + faraday-net_http (3.4.0) + net-http (>= 0.5.0) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) @@ -212,30 +211,30 @@ GEM ffi (>= 1.15.5) rake fiddle (1.1.8) - flipper (1.3.6) + flipper (1.3.5) concurrent-ruby (< 2) - flipper-active_record (1.3.6) + flipper-active_record (1.3.5) activerecord (>= 4.2, < 9) - flipper (~> 1.3.6) - flipper-ui (1.3.6) + flipper (~> 1.3.5) + flipper-ui (1.3.5) erubi (>= 1.0.0, < 2.0.0) - flipper (~> 1.3.6) + flipper (~> 1.3.5) rack (>= 1.4, < 4) rack-protection (>= 1.5.3, < 5.0.0) rack-session (>= 1.0.2, < 3.0.0) sanitize (< 8) front_matter_parser (1.0.1) - fugit (1.12.1) - et-orbi (~> 1.4) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) geocoder (1.8.6) base64 (>= 0.1.0) csv (>= 3.0.0) gli (2.22.2) ostruct - globalid (1.3.0) + globalid (1.2.1) activesupport (>= 6.1) - good_job (4.12.1) + good_job (4.10.2) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) @@ -247,15 +246,16 @@ GEM hashids (~> 1.0) hashids (1.0.6) hashie (5.0.0) - honeybadger (5.29.1) + honeybadger (5.28.0) logger ostruct - http (5.3.1) + http (5.2.0) addressable (~> 2.8) + base64 (~> 0.1) http-cookie (~> 1.0) http-form_data (~> 2.2) llhttp-ffi (~> 0.5.0) - http-cookie (1.1.0) + http-cookie (1.0.8) domain_name (~> 0.5) http-form_data (2.3.0) i18n (1.14.7) @@ -263,14 +263,14 @@ GEM image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) - io-console (0.8.1) - irb (1.15.3) + io-console (0.8.0) + irb (1.15.2) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jb (0.8.2) jmespath (1.6.2) - json (2.16.0) + json (2.12.0) jwt (3.1.2) base64 kaminari (1.2.2) @@ -298,35 +298,35 @@ GEM railties (>= 6.1) rexml lint_roller (1.1.0) - literal (1.8.1) + literal (1.7.1) zeitwerk llhttp-ffi (0.5.1) ffi-compiler (~> 1.0) rake (~> 13.0) - lockbox (2.1.0) + lockbox (2.0.1) logger (1.7.0) loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) lz_string (0.3.0) - mail (2.9.0) - logger + mail (2.8.1) mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.0.4) mini-levenshtein (0.1.2) - mini_magick (5.3.1) + mini_magick (5.2.0) + benchmark logger mini_mime (1.1.5) - minitest (5.26.2) + minitest (5.25.5) msgpack (1.8.0) multipart-post (2.4.1) mutex_m (0.3.0) - net-http (0.8.0) - uri (>= 0.11.1) - net-imap (0.5.12) + net-http (0.6.0) + uri + net-imap (0.5.8) date net-protocol net-pop (0.1.2) @@ -335,57 +335,51 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.5) - nokogiri (1.18.10-aarch64-linux-gnu) + nio4r (2.7.4) + nokogiri (1.18.8-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.10-aarch64-linux-musl) + nokogiri (1.18.8-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.10-arm-linux-gnu) + nokogiri (1.18.8-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.10-arm-linux-musl) + nokogiri (1.18.8-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.10-arm64-darwin) + nokogiri (1.18.8-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.10-x86_64-darwin) + nokogiri (1.18.8-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-gnu) + nokogiri (1.18.8-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-musl) + nokogiri (1.18.8-x86_64-linux-musl) racc (~> 1.4) nokogiri-xmlsec-instructure (0.12.0) nokogiri (~> 1.13) openssl (3.3.2) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - ostruct (0.6.3) + ostruct (0.6.1) paper_trail (16.0.0) activerecord (>= 6.1) request_store (~> 1.4) parallel (1.27.0) - parser (3.3.10.0) + parser (3.3.8.0) ast (~> 2.4.1) racc - pg (1.6.2) - pg (1.6.2-aarch64-linux) - pg (1.6.2-aarch64-linux-musl) - pg (1.6.2-arm64-darwin) - pg (1.6.2-x86_64-darwin) - pg (1.6.2-x86_64-linux) - pg (1.6.2-x86_64-linux-musl) - phlex (2.3.1) + pg (1.5.9) + phlex (2.2.1) zeitwerk (~> 2.7) - phlex-rails (2.3.1) - phlex (~> 2.3.0) + phlex-rails (2.2.0) + phlex (~> 2.2.1) railties (>= 7.1, < 9) - zeitwerk (~> 2.7) - pp (0.6.3) + pp (0.6.2) prettyprint prettyprint (0.2.0) - prism (1.6.0) - propshaft (1.3.1) + prism (1.4.0) + propshaft (1.1.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack + railties (>= 7.0.0) psych (5.2.6) date stringio @@ -394,17 +388,17 @@ GEM activerecord (>= 6.1) i18n (>= 0.5.0) railties (>= 6.1.0) - public_suffix (7.0.0) - puma (7.1.0) + public_suffix (6.0.2) + puma (6.6.0) nio4r (~> 2.0) - pundit (2.5.2) + pundit (2.5.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.4) - rack-attack (6.8.0) + rack (3.1.15) + rack-attack (6.7.0) rack (>= 1.0, < 4) - rack-protection (4.2.1) + rack-protection (4.1.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) @@ -417,20 +411,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.4) - actioncable (= 8.0.4) - actionmailbox (= 8.0.4) - actionmailer (= 8.0.4) - actionpack (= 8.0.4) - actiontext (= 8.0.4) - actionview (= 8.0.4) - activejob (= 8.0.4) - activemodel (= 8.0.4) - activerecord (= 8.0.4) - activestorage (= 8.0.4) - activesupport (= 8.0.4) + rails (8.0.2) + actioncable (= 8.0.2) + actionmailbox (= 8.0.2) + actionmailer (= 8.0.2) + actionpack (= 8.0.2) + actiontext (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activemodel (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) bundler (>= 1.15.0) - railties (= 8.0.4) + railties (= 8.0.2) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -438,35 +432,33 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails_semantic_logger (4.18.0) + rails_semantic_logger (4.17.0) rack railties (>= 5.1) semantic_logger (~> 4.16) - railties (8.0.4) - actionpack (= 8.0.4) - activesupport (= 8.0.4) + railties (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) - tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.1) - rdoc (6.16.0) + rake (13.2.1) + rdoc (6.14.0) erb psych (>= 4.0.0) - tsort redcarpet (3.6.1) - regexp_parser (2.11.3) - reline (0.6.3) + regexp_parser (2.10.0) + reline (0.6.1) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) - rexml (3.4.4) + rexml (3.4.1) rinku (2.0.6) rotp (6.3.0) - rouge (4.6.1) + rouge (4.5.2) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) @@ -476,7 +468,7 @@ GEM rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.7) + rspec-mocks (3.13.6) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (7.1.1) @@ -488,7 +480,7 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.6) - rubocop (1.81.7) + rubocop (1.75.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -496,17 +488,17 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.47.1, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.48.0) + rubocop-ast (1.44.1) parser (>= 3.3.7.2) prism (~> 1.4) - rubocop-performance (1.26.1) + rubocop-performance (1.25.0) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.34.1) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.32.0) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -517,12 +509,12 @@ GEM rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) - ruby-vips (2.2.5) + ruby-vips (2.2.4) ffi (~> 1.12) logger safety_net_attestation (0.5.0) jwt (>= 2.0, < 4.0) - saml2 (3.3.0) + saml2 (3.2.3) activesupport (>= 3.2, < 8.2) nokogiri (>= 1.5.8, < 2.0) nokogiri-xmlsec-instructure (~> 0.9, >= 0.9.5) @@ -530,12 +522,12 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.16.8) securerandom (0.4.1) - semantic_logger (4.17.0) + semantic_logger (4.16.1) concurrent-ruby (~> 1.0) serve_byte_range (1.0.0) rack (>= 1.0) - slack-ruby-client (2.7.0) - faraday (>= 2.0.1) + slack-ruby-client (2.6.0) + faraday (>= 2.0) faraday-mashify faraday-multipart gli @@ -545,32 +537,31 @@ GEM actionview (>= 6.0) activesupport (>= 6.0) smart_properties (1.17.0) - stringio (3.1.8) + stringio (3.1.7) superform (0.5.1) phlex-rails (>= 1.0, < 3.0) zeitwerk (~> 2.6) - thor (1.4.0) - thruster (0.1.16) - thruster (0.1.16-aarch64-linux) - thruster (0.1.16-arm64-darwin) - thruster (0.1.16-x86_64-darwin) - thruster (0.1.16-x86_64-linux) - timeout (0.4.4) + thor (1.3.2) + thruster (0.1.13) + thruster (0.1.13-aarch64-linux) + thruster (0.1.13-arm64-darwin) + thruster (0.1.13-x86_64-darwin) + thruster (0.1.13-x86_64-linux) + timeout (0.4.3) 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) + turbo-rails (2.0.16) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unaccent (0.4.0) - unicode-display_width (3.2.0) - unicode-emoji (~> 4.1) - unicode-emoji (4.1.0) - uri (1.1.1) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.3) useragent (0.16.11) valid_email2 (7.0.13) activemodel (>= 6.0) @@ -597,7 +588,7 @@ GEM openssl (>= 2.2) safety_net_attestation (~> 0.5.0) tpm-key_attestation (~> 0.14.0) - websocket-driver (0.8.0) + websocket-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -686,4 +677,4 @@ DEPENDENCIES wicked (~> 2.0) BUNDLED WITH - 2.7.2 + 2.6.9 From 8dd45c81d6585114ff42b8c503be57e116fe0dfe Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 26 Nov 2025 23:23:50 +0000 Subject: [PATCH 08/23] More updates --- app/controllers/logins_controller.rb | 2 +- app/frontend/entrypoints/application.js | 15 -- app/frontend/js/alpine.js | 7 + app/frontend/js/webauthn-authentication.js | 218 ++++++----------- app/frontend/js/webauthn-registration.js | 230 ++++++------------ .../index.html.erb | 11 + .../new.html.erb | 39 +-- app/views/logins/webauthn.html.erb | 34 +-- 8 files changed, 196 insertions(+), 360 deletions(-) diff --git a/app/controllers/logins_controller.rb b/app/controllers/logins_controller.rb index 22d253d..504f37a 100644 --- a/app/controllers/logins_controller.rb +++ b/app/controllers/logins_controller.rb @@ -7,7 +7,7 @@ class LoginsController < ApplicationController before_action :set_return_to, only: [ :new, :create ] before_action :set_attempt, except: [ :new, :create ] before_action :validate_browser_token, except: [ :new, :create ] - before_action :ensure_no_user! + before_action :ensure_no_user!, except: [ :verify, :verify_totp, :verify_backup_code, :verify_webauthn, :webauthn_options ] def new @prefill_email = params[:email] if params[:email].present? diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index b3070f9..c415eff 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -2,28 +2,13 @@ import "../js/alpine.js"; import "../js/lightswitch.js"; import "../js/click-to-copy"; import "../js/otp-input.js"; -import { registerWebauthn } from "../js/webauthn-registration.js"; -import { authenticateWebauthn } from "../js/webauthn-authentication.js"; import htmx from "htmx.org" window.htmx = htmx -window.registerWebauthn = registerWebauthn; -window.authenticateWebauthn = authenticateWebauthn; - // Add CSRF token to all HTMX requests document.addEventListener('htmx:configRequest', (event) => { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; if (csrfToken) { event.detail.headers['X-CSRF-Token'] = csrfToken; } -}); - -document.addEventListener('DOMContentLoaded', () => { - registerWebauthn.init(); - authenticateWebauthn.init(); -}); - -document.addEventListener('htmx:afterSwap', () => { - registerWebauthn.init(); - authenticateWebauthn.init(); }); \ No newline at end of file diff --git a/app/frontend/js/alpine.js b/app/frontend/js/alpine.js index 73757b1..a34dbfe 100644 --- a/app/frontend/js/alpine.js +++ b/app/frontend/js/alpine.js @@ -1,3 +1,10 @@ import Alpine from 'alpinejs' +import { webauthnRegister } from './webauthn-registration.js' +import { webauthnAuth } from './webauthn-authentication.js' + +// Register Alpine components +Alpine.data('webauthnRegister', webauthnRegister) +Alpine.data('webauthnAuth', webauthnAuth) + window.Alpine = Alpine Alpine.start() \ No newline at end of file diff --git a/app/frontend/js/webauthn-authentication.js b/app/frontend/js/webauthn-authentication.js index e45e35b..e58986b 100644 --- a/app/frontend/js/webauthn-authentication.js +++ b/app/frontend/js/webauthn-authentication.js @@ -1,156 +1,94 @@ -const authenticateWebauthn = { - checkBrowserSupport() { - const hasJsonSupport = !!globalThis.PublicKeyCredential?.parseRequestOptionsFromJSON; - - if (!hasJsonSupport) { - this.showBrowserWarning(); - return false; - } - - return true; - }, - - showBrowserWarning() { - const warning = document.getElementById('browser-support-warning'); - const authContainer = document.querySelector('.passkey-auth'); - - if (warning) warning.style.display = 'block'; - if (authContainer) authContainer.style.display = 'none'; - }, - - async getAuthenticationOptions() { - // Fetch authentication options from the server - // The server will generate a cryptographic challenge - 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 +// Alpine.js component for WebAuthn authentication +export function webauthnAuth() { + return { + loading: false, + error: null, + browserSupported: true, + + init() { + // Check browser support on initialization + const hasJsonSupport = !!globalThis.PublicKeyCredential?.parseRequestOptionsFromJSON; + this.browserSupported = hasJsonSupport; + + // Auto-trigger authentication on page load if browser is supported + if (this.browserSupported) { + this.authenticate(); } - }); - - if (!response.ok) { - throw new Error('Failed to get authentication options from server'); - } - - return await response.json(); - }, + }, - async authenticate() { - try { - const options = await this.getAuthenticationOptions(); // fetch our options + challenge - console.log('Authentication options:', options); - - const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options); - console.log('Parsed request options:', publicKey); - - const credential = await navigator.credentials.get({ publicKey }); - - if (!credential) { - throw new Error('Authentication failed - no credential returned'); + 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'); + }, - const credentialJSON = credential.toJSON(); + async getAuthenticationOptions() { const loginAttemptId = this.getLoginAttemptId(); - const response = await fetch(`/login/${loginAttemptId}/webauthn/verify`, { + 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 - }, - body: JSON.stringify(credentialJSON) + } }); if (!response.ok) { - const result = await response.json(); - throw new Error(result.error || 'Authentication failed'); + throw new Error('Failed to get authentication options from server'); } - console.log('Authentication successful'); - - window.location.reload(); // TODO - - return { - success: true - }; - } catch (error) { - console.error('Passkey authentication error:', error); - - let errorMessage = 'An unexpected error occurred'; - - if (error.name === 'NotAllowedError') { - errorMessage = 'Authentication was cancelled or not allowed'; - } else if (error.name === 'InvalidStateError') { - errorMessage = 'No passkey found for this account'; - } else if (error.name === 'NotSupportedError') { - errorMessage = 'Passkeys are not supported on this device'; - } else if (error.message) { - errorMessage = error.message; + 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 credentialJSON = credential.toJSON(); + const loginAttemptId = this.getLoginAttemptId(); + const response = await fetch(`/login/${loginAttemptId}/webauthn/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content + }, + body: JSON.stringify(credentialJSON) + }); + + if (!response.ok) { + const result = await response.json(); + throw new Error(result.error || 'Authentication failed'); + } + + // Success! Redirect to the next page (server will handle this via htmx or full page load) + window.location.reload(); + } catch (error) { + console.error('Passkey authentication error:', error); + + // Translate error codes to user-friendly messages + 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; } - - return { - success: false, - error: errorMessage - }; } - }, - - 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 handleAuthenticate() { - const authBtn = document.getElementById('authenticate-btn'); - const btnText = authBtn?.querySelector('.btn-text'); - const btnSpinner = authBtn?.querySelector('.btn-spinner'); - const errorAlert = document.getElementById('webauthn-auth-error'); - const errorMessage = document.getElementById('error-message'); - - if (authBtn) { - authBtn.disabled = true; - if (btnText) btnText.style.display = 'none'; - if (btnSpinner) btnSpinner.style.display = 'inline'; - } - - if (errorAlert) errorAlert.style.display = 'none'; - - const result = await this.authenticate(); - - if (authBtn) { - authBtn.disabled = false; - if (btnText) btnText.style.display = 'inline'; - if (btnSpinner) btnSpinner.style.display = 'none'; - } - - if (!result.success) { - if (errorMessage) errorMessage.textContent = result.error; - if (errorAlert) errorAlert.style.display = 'block'; - } - }, - - init() { - if (!this.checkBrowserSupport()) { - return; - } - - const container = document.getElementById('passkey-auth-container'); - if (container && !container.dataset.webauthnInitialized) { - this.handleAuthenticate(); - container.dataset.webauthnInitialized = 'true'; - } - - const authBtn = document.getElementById('authenticate-btn'); - if (authBtn && !authBtn.dataset.webauthnInitialized) { - authBtn.addEventListener('click', () => this.handleAuthenticate()); - authBtn.dataset.webauthnInitialized = 'true'; - } - } -}; - -export { authenticateWebauthn }; + }; +} diff --git a/app/frontend/js/webauthn-registration.js b/app/frontend/js/webauthn-registration.js index 520249d..73e7f75 100644 --- a/app/frontend/js/webauthn-registration.js +++ b/app/frontend/js/webauthn-registration.js @@ -1,166 +1,88 @@ -const registerWebauthn = { - checkBrowserSupport() { - const hasJsonSupport = !!globalThis.PublicKeyCredential?.parseCreationOptionsFromJSON; - - if (!hasJsonSupport) { - this.showBrowserWarning(); - return false; - } - - return true; - }, - - showBrowserWarning() { - const warning = document.getElementById('browser-support-warning'); - const formContainer = document.querySelector('.passkey-setup'); - - if (warning) warning.style.display = 'block'; - if (formContainer) formContainer.style.display = 'none'; - }, - - async getRegistrationOptions() { - // Fetch registration options from the server - // The server will generate a cryptographic challenge and return user info - 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(nickname) { - try { - // Get registration options from server (includes challenge and user info) - const options = await this.getRegistrationOptions(); - console.log('Registration options:', options); - - // Parse the options and create the credential - const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options); - console.log('Parsed creation options:', publicKey); - - const credential = await navigator.credentials.create({ publicKey }); - - if (!credential) { - throw new Error('Credential creation failed'); - } - - console.log('Credential created:', credential); - - const credentialJSON = credential.toJSON(); - console.log('Credential JSON:', credentialJSON); - - const response = await fetch('/identity_webauthn_credentials', { +// Alpine.js component for WebAuthn registration +export function webauthnRegister() { + return { + nickname: '', + loading: false, + error: null, + browserSupported: true, + + init() { + // Check browser support on initialization + const hasJsonSupport = !!globalThis.PublicKeyCredential?.parseCreationOptionsFromJSON; + this.browserSupported = hasJsonSupport; + }, + + 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 - }, - body: JSON.stringify({ - nickname: nickname, - ...credentialJSON - }) + } }); - const result = await response.json(); - - if (!response.ok || !result.success) { - throw new Error(result.error || 'Failed to register passkey'); + if (!response.ok) { + throw new Error('Failed to get registration options from server'); } - console.log('Registration successful:', result); + return await response.json(); + }, - return { - success: true, - data: result - }; - } catch (error) { - console.error('Passkey registration error:', error); - - let errorMessage = 'An unexpected error occurred'; - - if (error.name === 'NotAllowedError') { - errorMessage = 'Registration was cancelled or not allowed'; - } else if (error.name === 'InvalidStateError') { - errorMessage = 'This passkey is already registered'; - } else if (error.name === 'NotSupportedError') { - errorMessage = 'Passkeys are not supported on this device'; - } else if (error.message) { - errorMessage = error.message; + async register() { + if (!this.nickname.trim()) { + this.error = 'Please enter a nickname for your passkey'; + return; } - return { - success: false, - error: errorMessage - }; - } - }, - - async handleSubmit(event) { - event.preventDefault(); - - const nicknameInput = document.getElementById('webauthn-nickname'); - const registerBtn = document.getElementById('register-btn'); - const btnText = registerBtn?.querySelector('.btn-text'); - const btnSpinner = registerBtn?.querySelector('.btn-spinner'); - const successAlert = document.getElementById('webauthn-registration-success'); - const errorAlert = document.getElementById('webauthn-registration-error'); - const errorMessage = document.getElementById('error-message'); - - const nickname = nicknameInput.value.trim(); - if (!nickname) { - this.showError('Please enter a nickname for your passkey'); - return; - } - - registerBtn.disabled = true; - btnText.style.display = 'none'; - btnSpinner.style.display = 'inline'; - successAlert.style.display = 'none'; - errorAlert.style.display = 'none'; - - const result = await this.register(nickname); - - registerBtn.disabled = false; - btnText.style.display = 'inline'; - btnSpinner.style.display = 'none'; - - if (result.success) { - // Redirect to the URL provided by the server (flash message will be displayed) - window.location.href = result.data.redirect_url || '/identity_webauthn_credentials'; - } else { - errorMessage.textContent = result.error; - errorAlert.style.display = 'block'; - } - }, - - showError(message) { - const errorAlert = document.getElementById('webauthn-registration-error'); - const errorMessage = document.getElementById('error-message'); - - if (errorMessage) errorMessage.textContent = message; - if (errorAlert) errorAlert.style.display = 'block'; - }, - - init() { - if (!this.checkBrowserSupport()) { - return; - } - - const container = document.getElementById('passkey-registration-container'); - const form = container?.querySelector('form'); - if (form && !form.dataset.webauthnInitialized) { - form.addEventListener('submit', (e) => this.handleSubmit(e)); - form.dataset.webauthnInitialized = 'true'; + 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 credentialJSON = credential.toJSON(); + const response = await fetch('/identity_webauthn_credentials', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content + }, + body: JSON.stringify({ + nickname: this.nickname, + ...credentialJSON + }) + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || 'Failed to register passkey'); + } + + // Success! Redirect to the security page + window.location.href = result.redirect_url || '/identity_webauthn_credentials'; + } catch (error) { + console.error('Passkey registration error:', error); + + // Translate error codes to user-friendly messages + 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; + } } - } -}; - -export { registerWebauthn }; + }; +} diff --git a/app/views/identity_webauthn_credentials/index.html.erb b/app/views/identity_webauthn_credentials/index.html.erb index 5147b4c..58dc13a 100644 --- a/app/views/identity_webauthn_credentials/index.html.erb +++ b/app/views/identity_webauthn_credentials/index.html.erb @@ -15,5 +15,16 @@
<%= render partial: "identity_webauthn_credential", collection: @webauthn_credentials %>
+ +
+ <%= 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" + } %> +
<% end %>
diff --git a/app/views/identity_webauthn_credentials/new.html.erb b/app/views/identity_webauthn_credentials/new.html.erb index b80bdd1..2f8cd7d 100644 --- a/app/views/identity_webauthn_credentials/new.html.erb +++ b/app/views/identity_webauthn_credentials/new.html.erb @@ -1,33 +1,15 @@ -
+

A passwordless way to sign in using your device's biometric authentication or PIN.

-
-
-

<%= t("identity_webauthn_credentials.remove_confirmation") %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index 862997f..0afd023 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -215,6 +215,7 @@ en: email: Email totp: TOTP backup_code: Backup code + webauthn: Passkey logins: new: title: Welcome back! From 46b9ffe78bbb3d403080b9128b98574754481c9f Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 27 Nov 2025 11:02:54 +0000 Subject: [PATCH 22/23] fix 'last used at' for macOS passkeys --- app/controllers/logins_controller.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/controllers/logins_controller.rb b/app/controllers/logins_controller.rb index d6138e7..d52bf9f 100644 --- a/app/controllers/logins_controller.rb +++ b/app/controllers/logins_controller.rb @@ -210,6 +210,9 @@ def verify_webauthn ) credential.update!(sign_count: webauthn_credential.sign_count) + # "software" passkeys (like the ones from macOS) don't update the sign count, + # so we need to touch the record to update the updated_at timestamp + credential.touch unless credential.saved_change_to_sign_count? end session.delete(:webauthn_authentication_challenge) From c28f498047e09377b6465d3ae91680545adfe944 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 27 Nov 2025 11:04:13 +0000 Subject: [PATCH 23/23] make erb-lint happy --- .../_identity_webauthn_credential.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb b/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb index 33c07ec..841024d 100644 --- a/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb +++ b/app/views/identity_webauthn_credentials/_identity_webauthn_credential.html.erb @@ -23,7 +23,7 @@
-
@@ -31,7 +31,7 @@

<%= t("identity_webauthn_credentials.remove_confirmation") %>

- + <%= button_to identity_webauthn_credential_path(identity_webauthn_credential), method: :delete, class: "button danger" do %>