Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/components/auth_welcome.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 16 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions app/controllers/concerns/step_up_authenticatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions app/controllers/concerns/webauthn_authenticatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions app/controllers/identity_webauthn_credentials_controller.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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"
}
)

Expand Down Expand Up @@ -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."
Expand Down
10 changes: 3 additions & 7 deletions app/controllers/logins_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
75 changes: 75 additions & 0 deletions app/controllers/passkey_logins_controller.rb
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions app/controllers/passkey_setups_controller.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions app/frontend/js/alpine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions app/frontend/js/passkey-login.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
};
}
Loading