Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,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"
Expand Down
26 changes: 25 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,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)
Expand Down Expand Up @@ -133,17 +134,19 @@ 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)
argon2-kdf (>= 0.2)
block_cipher_kit (0.0.4)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.1.0)
brakeman (7.1.1)
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)
Expand All @@ -159,6 +162,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)
Expand Down Expand Up @@ -360,6 +366,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)
Expand Down Expand Up @@ -515,6 +524,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)
Expand Down Expand Up @@ -549,6 +560,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)
Expand All @@ -574,6 +589,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)
Expand Down Expand Up @@ -660,6 +683,7 @@ DEPENDENCIES
valid_email2!
vite_rails
web-console
webauthn (~> 3.1)
wicked (~> 2.0)

BUNDLED WITH
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ so is the onboarding controller, she should really be ripped out and replaced.

- make sure you have working installations of ruby ≥ 3.4.4 & nodejs
- clone repo
- create .env.development, populate `DATABASE_URL` w/ a local postgres instance
- create .env.development, populate `DATABASE_URL` w/ a local postgres instance and `LOCKBOX_MASTER_KEY` with the value of `openssl rand -hex 32`
- if you want to use docker, you can run `docker compose -f docker-compose-dbonly.yml up` to spin up a database and plug `postgresql://postgres@localhost:5432/identity_vault_development` in as your `DATABASE_URL`
- if you don't have docker and are on macOS, [orbstack](https://orbstack.dev) may be helpful
- run `bundle install`
- run `bin/rails db:prepare`
- console in (`bin/rails console`)
Expand Down
69 changes: 69 additions & 0 deletions app/controllers/identity_webauthn_credentials_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
class IdentityWebauthnCredentialsController < ApplicationController
def index
@webauthn_credentials = current_identity.webauthn_credentials.order(created_at: :desc)
render layout: request.headers["HX-Request"] ? "htmx" : false
end

def new
render layout: request.headers["HX-Request"] ? "htmx" : false
end

def options
challenge = WebAuthn::Credential.options_for_create(
user: {
id: current_identity.webauthn_user_id,
name: current_identity.primary_email,
display_name: "#{current_identity.first_name} #{current_identity.last_name}"
},
exclude: current_identity.webauthn_credentials.raw_credential_ids,
authenticator_selection: {
user_verification: "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
credential_data = JSON.parse(params[:credential_data])
nickname = params[: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)

flash[:success] = t(".successfully_added")
redirect_to security_path
rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn registration error: #{e.message}"
flash[:error] = "Passkey registration failed. Please try again."
render :new, status: :unprocessable_entity
rescue => e
Rails.logger.error "Unexpected WebAuthn registration error: #{e.message}"
flash[:error] = "An unexpected error occurred. Please try again."
render :new, status: :unprocessable_entity
end
end

def destroy
credential = current_identity.webauthn_credentials.find(params[:id])
credential.destroy

flash[:success] = t(".successfully_removed")
redirect_to security_path
end
end
81 changes: 79 additions & 2 deletions app/controllers/logins_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -157,6 +161,77 @@ def verify_backup_code
handle_post_verification_redirect
end

def webauthn
render status: :unprocessable_entity
end

def skip_webauthn
# the user wants to skip using a passkey, use email code instead
send_v2_login_code(@identity, @attempt)
redirect_to login_attempt_path(id: @attempt.to_param), status: :see_other
end

def webauthn_options
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
credential_data = JSON.parse(params[:credential_data])

webauthn_credential = WebAuthn::Credential.from_get(credential_data)

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

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)
# "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)
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
Expand Down Expand Up @@ -315,6 +390,8 @@ def redirect_to_next_factor

if available.include?(:totp)
redirect_to totp_login_attempt_path(id: @attempt.to_param), status: :see_other
elsif available.include?(:webauthn)
redirect_to webauthn_login_attempt_path(id: @attempt.to_param), status: :see_other
elsif available.include?(:backup_code)
redirect_to backup_code_login_attempt_path(id: @attempt.to_param), status: :see_other
else
Expand Down
6 changes: 6 additions & 0 deletions app/frontend/js/alpine.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import Alpine from 'alpinejs'
import { webauthnRegister } from './webauthn-registration.js'
import { webauthnAuth } from './webauthn-authentication.js'

Alpine.data('webauthnRegister', webauthnRegister)
Alpine.data('webauthnAuth', webauthnAuth)

window.Alpine = Alpine
Alpine.start()
85 changes: 85 additions & 0 deletions app/frontend/js/webauthn-authentication.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
export function webauthnAuth() {
return {
loading: false,
error: null,
browserSupported: true,

init() {
this.browserSupported = !!(
globalThis.PublicKeyCredential?.parseRequestOptionsFromJSON &&
navigator.credentials?.get
);

if (this.browserSupported) {
this.authenticate();
}
},

getLoginAttemptId() {
const pathParts = window.location.pathname.split('/');
const loginIndex = pathParts.indexOf('login');
if (loginIndex >= 0 && pathParts.length > loginIndex + 1) {
return pathParts[loginIndex + 1];
}
throw new Error('Could not determine login attempt ID');
},

async getAuthenticationOptions() {
const loginAttemptId = this.getLoginAttemptId();
const response = await fetch(`/login/${loginAttemptId}/webauthn/options`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
}
});

if (!response.ok) {
throw new Error('Failed to get authentication options from server');
}

return await response.json();
},

async authenticate() {
this.loading = true;
this.error = null;

try {
const options = await this.getAuthenticationOptions();
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options);
const credential = await navigator.credentials.get({ publicKey });

if (!credential) {
throw new Error('Authentication failed - no credential returned');
}

const credentialJSON = credential.toJSON();

const credentialDataField = document.getElementById('credential-data');
const form = document.getElementById('webauthn-form');

if (!credentialDataField || !form) {
throw new Error('Form elements not found');
}

credentialDataField.value = JSON.stringify(credentialJSON);
form.submit();
} catch (error) {
console.error('Passkey authentication error:', error);

if (error.name === 'NotAllowedError') {
this.error = 'Authentication was cancelled or not allowed';
} else if (error.name === 'InvalidStateError') {
this.error = 'No passkey found for this account';
} else if (error.name === 'NotSupportedError') {
this.error = 'Passkeys are not supported on this device';
} else {
this.error = error.message || 'An unexpected error occurred';
}

this.loading = false;
}
}
};
}
Loading
Loading