diff --git a/app/components/auth_welcome.rb b/app/components/auth_welcome.rb index 43ccf85e..f12dd1af 100644 --- a/app/components/auth_welcome.rb +++ b/app/components/auth_welcome.rb @@ -74,6 +74,45 @@ def render_actions plain "→" end end + + render_passkey_login + end + end + + def render_passkey_login + div(x_data: "passkeyLogin") do + div(x_show: "browserSupported", x_cloak: true) do + div(style: "display: flex; align-items: center; gap: 0.75rem; margin: 0 0 0.5rem; color: var(--muted-color);") do + hr(style: "flex: 1; border: none; border-top: 1px solid var(--border-color); margin: 0;") + small(style: "margin: 0;") { t("logins.new.or") } + hr(style: "flex: 1; border: none; border-top: 1px solid var(--border-color); margin: 0;") + end + + div(x_show: "error", x_cloak: true, class: "alert alert-error", role: "alert") do + p(x_text: "error") + end + + form(action: passkey_login_verify_path, method: "post", id: "passkey-login-form") do + input(type: "hidden", name: "authenticity_token", value: form_authenticity_token) + input(type: "hidden", name: "credential_data", id: "passkey-login-credential-data") + input(type: "hidden", name: "return_to", value: @return_to) + end + + button( + type: "button", + class: "webauthn-button secondary", + style: "width: 100%;", + x_on: { click: "login()" }, + x_bind: { disabled: "loading" } + ) do + span(class: "webauthn-icon") { inline_icon("fingerprint", size: 16) } + span(x_show: "!loading") { t("passkey_login.button") } + span(x_show: "loading", x_cloak: true) do + plain t("passkey_login.signing_in") + plain "..." + end + end + end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5b4ca6b5..ac10e0a4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,10 +3,16 @@ class ApplicationController < ActionController::Base include SessionsHelper include StepUpAuthenticatable - helper_method :current_identity, :identity_signed_in?, :current_onboarding_step, :current_user + helper_method :current_identity, :identity_signed_in?, :current_onboarding_step, :current_user, :show_passkey_promotion? def current_user = nil # TODO: this is a temp hack to fix partials until /backend auth is replaced + def show_passkey_promotion? + current_identity.present? && + current_identity.passkey_promotion_allowed? && + !session[:passkey_promotion_dismissed] + end + helper_method :detected_country_alpha2 before_action :invalidate_v1_sessions, :authenticate_identity!, :set_honeybadger_context @@ -111,4 +117,13 @@ def current_onboarding_step private def touch_session_last_seen_at = current_session&.touch_last_seen_at + + # Route eligible identities through the passkey setup step. + def redirect_with_passkey_prompt(destination) + if show_passkey_promotion? + redirect_to(passkey_setup_path(return_to: destination)) + else + redirect_to destination + end + end end diff --git a/app/controllers/concerns/step_up_authenticatable.rb b/app/controllers/concerns/step_up_authenticatable.rb index b8521c30..1ad3f4e7 100644 --- a/app/controllers/concerns/step_up_authenticatable.rb +++ b/app/controllers/concerns/step_up_authenticatable.rb @@ -12,6 +12,8 @@ def require_step_up(action_type, return_to: nil) email_available = !action_type.to_s.in?(StepUpController::ACTIONS_WITHOUT_EMAIL_FALLBACK) return unless has_2fa || email_available return if current_session.recently_stepped_up?(for_action: action_type) + # A recent login counts as re-auth for adding a passkey. + return if action_type.to_s == "add_passkey" && current_session&.recently_authenticated? redirect_to new_step_up_path(action_type: action_type, return_to: return_to || request.fullpath) false diff --git a/app/controllers/concerns/webauthn_authenticatable.rb b/app/controllers/concerns/webauthn_authenticatable.rb index d2d356d5..f597170d 100644 --- a/app/controllers/concerns/webauthn_authenticatable.rb +++ b/app/controllers/concerns/webauthn_authenticatable.rb @@ -16,6 +16,19 @@ def generate_webauthn_authentication_options(identity, session_key:, user_verifi options end + # Verify a discoverable passkey without a prior identity (passkey-first sign in). + def find_and_verify_discoverable_webauthn_credential(credential_data, session_key:) + webauthn_credential = WebAuthn::Credential.from_get(credential_data) + + credential = Identity::WebauthnCredential.active.find_by( + external_id: Base64.urlsafe_encode64(webauthn_credential.id, padding: false) + ) + + return nil unless credential + + verify_webauthn_credential(credential.identity, credential_data:, session_key:) + end + def verify_webauthn_credential(identity, credential_data:, session_key:) # Delete challenge before parsing to prevent reuse on parse failure stored_challenge = session.delete(session_key) diff --git a/app/controllers/identity_webauthn_credentials_controller.rb b/app/controllers/identity_webauthn_credentials_controller.rb index 3fc3a066..6c2dadba 100644 --- a/app/controllers/identity_webauthn_credentials_controller.rb +++ b/app/controllers/identity_webauthn_credentials_controller.rb @@ -1,7 +1,9 @@ class IdentityWebauthnCredentialsController < ApplicationController include WebauthnAuthenticatable + include SafeUrlValidation - before_action -> { require_step_up("add_passkey", return_to: security_path) }, only: [ :new, :options, :create ] + before_action -> { require_step_up("add_passkey", return_to: security_path) }, only: [ :new ] + before_action -> { require_step_up("add_passkey", return_to: passkey_setup_path(return_to: params[:return_to])) }, only: [ :options, :create ] def index @webauthn_credentials = current_identity.webauthn_credentials.order(created_at: :desc) @@ -22,7 +24,7 @@ def options exclude: current_identity.webauthn_credentials.raw_credential_ids, authenticator_selection: { user_verification: "required", - resident_key: "preferred" + resident_key: "required" } ) @@ -57,7 +59,7 @@ def create TwoFactorMailer.authentication_method_enabled(current_identity).deliver_later flash[:success] = t(".successfully_added") - redirect_to security_path + redirect_to url_from(params[:return_to]) || security_path rescue WebAuthn::Error => e Rails.logger.error "WebAuthn registration error: credential creation failed" flash[:error] = "Passkey registration failed. Please try again." diff --git a/app/controllers/logins_controller.rb b/app/controllers/logins_controller.rb index 1b432d93..01733f75 100644 --- a/app/controllers/logins_controller.rb +++ b/app/controllers/logins_controller.rb @@ -340,13 +340,9 @@ def handle_post_verification_redirect redirect_to root_path end else - flash[:success] = "Logged in!" - safe_return_to = @attempt.return_to - begin - redirect_to safe_return_to.presence || root_path - rescue ActionController::Redirecting::UnsafeRedirectError - redirect_to root_path - end + flash[:success] = "Logged in!" + destination = url_from(@attempt.return_to) || root_path + redirect_with_passkey_prompt(destination) end end diff --git a/app/controllers/passkey_logins_controller.rb b/app/controllers/passkey_logins_controller.rb new file mode 100644 index 00000000..8e734b41 --- /dev/null +++ b/app/controllers/passkey_logins_controller.rb @@ -0,0 +1,75 @@ +class PasskeyLoginsController < ApplicationController + include WebauthnAuthenticatable + include SafeUrlValidation + include AhoyAnalytics + + skip_before_action :authenticate_identity! + before_action :ensure_no_user! + + PASSKEY_LOGIN_SESSION_KEY = :passkey_login_challenge + + def options + options = WebAuthn::Credential.options_for_get( + allow: [], + user_verification: "required" + ) + + session[PASSKEY_LOGIN_SESSION_KEY] = options.challenge + render json: options + end + + def verify + credential_data = JSON.parse(params[:credential_data]) + + credential = find_and_verify_discoverable_webauthn_credential( + credential_data, + session_key: PASSKEY_LOGIN_SESSION_KEY + ) + + unless credential + flash[:error] = "Passkey not found. Try using your email instead." + redirect_to login_path(return_to: params[:return_to]) + return + end + + identity = credential.identity + + attempt = LoginAttempt.create!( + identity: identity, + authentication_factors: { webauthn: true }, + provenance: "login", + next_action: "home", + return_to: url_from(params[:return_to]) + ) + + attempt.mark_complete! if attempt.may_mark_complete? + + unless attempt.complete? + flash[:error] = "Unable to complete authentication" + redirect_to login_path(return_to: params[:return_to]) + return + end + + ident_session = sign_in(identity: identity) + attempt.update!(session: ident_session) + + track_event( + "login.completed", + has_mfa: identity.use_two_factor_authentication?, + next_action: attempt.next_action, + scenario: nil + ) + + flash[:success] = "Logged in!" + redirect_to url_from(params[:return_to]) || root_path + rescue WebauthnCredentialCompromisedError + flash[:error] = "Security issue detected with your passkey. It has been disabled for your protection. Please use another login method or register a new passkey." + redirect_to login_path(return_to: params[:return_to]) + rescue WebAuthn::Error + flash[:error] = "Passkey verification failed. Please try again or use your email." + redirect_to login_path(return_to: params[:return_to]) + rescue JSON::ParserError + flash[:error] = "Something went wrong. Please try again." + redirect_to login_path(return_to: params[:return_to]) + end +end diff --git a/app/controllers/passkey_setups_controller.rb b/app/controllers/passkey_setups_controller.rb new file mode 100644 index 00000000..cfc654d4 --- /dev/null +++ b/app/controllers/passkey_setups_controller.rb @@ -0,0 +1,27 @@ +class PasskeySetupsController < ApplicationController + include SafeUrlValidation + + layout "logged_out" + + before_action :redirect_unless_eligible, only: :show + + def show + @return_to = url_from(params[:return_to]) + end + + def skip + session[:passkey_promotion_dismissed] = true + current_identity.dismiss_passkey_promotion! if params[:dont_show_again] == "true" + redirect_to destination + end + + private + + def redirect_unless_eligible + redirect_to destination unless show_passkey_promotion? + end + + def destination + url_from(params[:return_to]) || root_path + end +end diff --git a/app/frontend/js/alpine.js b/app/frontend/js/alpine.js index 70b01271..adc47b7d 100644 --- a/app/frontend/js/alpine.js +++ b/app/frontend/js/alpine.js @@ -2,11 +2,15 @@ import Alpine from 'alpinejs' import { webauthnRegister } from './webauthn-registration.js' import { webauthnAuth } from './webauthn-authentication.js' import { stepUpWebauthn } from './webauthn-step-up.js' +import { passkeyLogin } from './passkey-login.js' +import { passkeySetup } from './passkey-setup.js' import { scopeEditor } from './scope-editor.js' Alpine.data('webauthnRegister', webauthnRegister) Alpine.data('webauthnAuth', webauthnAuth) Alpine.data('stepUpWebauthn', stepUpWebauthn) +Alpine.data('passkeyLogin', passkeyLogin) +Alpine.data('passkeySetup', passkeySetup) Alpine.data('scopeEditor', scopeEditor) window.Alpine = Alpine diff --git a/app/frontend/js/passkey-login.js b/app/frontend/js/passkey-login.js new file mode 100644 index 00000000..cf6d0751 --- /dev/null +++ b/app/frontend/js/passkey-login.js @@ -0,0 +1,89 @@ +export function passkeyLogin() { + return { + loading: false, + error: null, + browserSupported: true, + + init() { + this.browserSupported = !!( + globalThis.PublicKeyCredential?.parseRequestOptionsFromJSON && + navigator.credentials?.get + ); + }, + + async login() { + this.loading = true; + this.error = null; + + try { + const response = await fetch('/passkey/login/options', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content + } + }); + + if (!response.ok) { + throw new Error('Failed to get authentication options from server'); + } + + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + window.location.href = response.url; + return; + } + + const options = await response.json(); + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options); + const credential = await navigator.credentials.get({ publicKey }); + + if (!credential) { + throw new Error('Authentication failed - no credential returned'); + } + + const responseData = credential.response; + const toBase64Url = (buffer) => { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + }; + const credentialJSON = { + id: credential.id, + rawId: toBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: toBase64Url(responseData.clientDataJSON), + authenticatorData: toBase64Url(responseData.authenticatorData), + signature: toBase64Url(responseData.signature), + userHandle: responseData.userHandle ? toBase64Url(responseData.userHandle) : null, + }, + clientExtensionResults: credential.getClientExtensionResults(), + }; + + const credentialDataField = document.getElementById('passkey-login-credential-data'); + const form = document.getElementById('passkey-login-form'); + + if (!credentialDataField || !form) { + throw new Error('Form elements not found'); + } + + credentialDataField.value = JSON.stringify(credentialJSON); + form.submit(); + } catch (error) { + console.error('Passkey login error:', error); + + if (error.name === 'NotAllowedError') { + this.error = 'Sign in 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; + } + } + }; +} \ No newline at end of file diff --git a/app/frontend/js/passkey-setup.js b/app/frontend/js/passkey-setup.js new file mode 100644 index 00000000..0008b07c --- /dev/null +++ b/app/frontend/js/passkey-setup.js @@ -0,0 +1,97 @@ +export function passkeySetup() { + return { + loading: false, + error: null, + dontShowAgain: false, + browserSupported: true, + + init() { + this.browserSupported = !!( + globalThis.PublicKeyCredential?.parseCreationOptionsFromJSON && + navigator.credentials?.create + ); + + if (!this.browserSupported) { + const params = new URLSearchParams(window.location.search); + const returnTo = params.get('return_to'); + window.location.href = returnTo && returnTo.startsWith('/') ? returnTo : '/'; + } + }, + + async setup() { + this.loading = true; + this.error = null; + + try { + const params = new URLSearchParams(window.location.search); + const returnTo = params.get('return_to'); + const url = returnTo ? `/passkeys/options?return_to=${encodeURIComponent(returnTo)}` : '/passkeys/options'; + const response = await fetch(url, { + 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'); + } + + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + window.location.href = response.url; + return; + } + + const options = await response.json(); + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options); + const credential = await navigator.credentials.create({ publicKey }); + + if (!credential) { + throw new Error('Credential creation failed'); + } + + const responseData = credential.response; + const toBase64Url = (buffer) => { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + }; + const credentialJSON = { + id: credential.id, + rawId: toBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: toBase64Url(responseData.clientDataJSON), + attestationObject: toBase64Url(responseData.attestationObject), + }, + clientExtensionResults: credential.getClientExtensionResults(), + }; + + const credentialDataField = document.getElementById('setup-credential-data'); + const form = document.getElementById('setup-registration-form'); + + if (!credentialDataField || !form) { + throw new Error('Form elements not found'); + } + + credentialDataField.value = JSON.stringify(credentialJSON); + form.submit(); + } catch (error) { + console.error('Passkey setup error:', error); + + if (error.name === 'NotAllowedError') { + this.error = 'Setup 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; + } + } + }; +} \ No newline at end of file diff --git a/app/models/identity.rb b/app/models/identity.rb index 4e0300cb..c2ae522b 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -21,6 +21,7 @@ # ysws_eligible :boolean # created_at :datetime not null # updated_at :datetime not null +# passkey_prompt_dismissed_at :datetime # primary_address_id :bigint # slack_id :string # @@ -363,6 +364,14 @@ def backup_codes_enabled? = backup_codes.active.any? def webauthn_enabled? = webauthn_credentials.active.any? + def passkey_promotion_allowed? + !webauthn_enabled? && passkey_prompt_dismissed_at.nil? + end + + def dismiss_passkey_promotion! + update!(passkey_prompt_dismissed_at: Time.current) + end + # Encode identity ID as base64url for WebAuthn user.id # Uses 64-bit unsigned big-endian binary format def webauthn_user_id diff --git a/app/models/identity_session.rb b/app/models/identity_session.rb index 470b41e2..f6bf7ab5 100644 --- a/app/models/identity_session.rb +++ b/app/models/identity_session.rb @@ -46,6 +46,10 @@ def touch_last_seen_at STEP_UP_DURATION = 15.minutes + def recently_authenticated? + created_at > STEP_UP_DURATION.ago + end + def recently_stepped_up?(for_action: nil) return false unless last_step_up_at.present? && last_step_up_at > STEP_UP_DURATION.ago diff --git a/app/views/logins/new.html.erb b/app/views/logins/new.html.erb index 2f5a43f1..5e29cecd 100644 --- a/app/views/logins/new.html.erb +++ b/app/views/logins/new.html.erb @@ -21,6 +21,36 @@ <%= form.submit "#{t('.continue')} →" %> <% end %> +