Skip to content

Passkeys#2623

Draft
monorkin wants to merge 33 commits intomainfrom
passkeys
Draft

Passkeys#2623
monorkin wants to merge 33 commits intomainfrom
passkeys

Conversation

@monorkin
Copy link
Contributor

No description provided.

Copilot AI review requested due to automatic review settings February 27, 2026 10:27
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements passkey authentication (WebAuthn) for the application, allowing users to sign in using biometric authentication, security keys, or platform authenticators instead of traditional passwords or email codes.

Changes:

  • Adds a complete WebAuthn library implementation in lib/action_pack/web_authn/ including CBOR decoding, COSE key handling, and authenticator response validation
  • Implements Identity::Credential model for storing and managing user credentials with passkey registration and authentication methods
  • Adds UI for managing passkeys including registration, editing, and deletion of credentials

Reviewed changes

Copilot reviewed 51 out of 73 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
lib/action_pack/web_authn/*.rb Core WebAuthn library implementing CBOR decoding, COSE key parsing, and authenticator response validation
app/models/identity/credential.rb Model for managing WebAuthn credentials with registration and authentication logic
app/models/identity/credential/authenticator.rb Registry for mapping AAGUIDs to authenticator metadata (icons, names)
app/controllers/users/credentials_controller.rb Controller for credential management (CRUD operations)
app/controllers/sessions/passkeys_controller.rb Controller for passkey-based authentication
app/javascript/controllers/*.js Stimulus controllers for credential registration and conditional mediation
app/views/users/credentials/*.html.erb Views for credential management UI
db/migrate/*.rb Migrations for identity_credentials table
config/passkey_aaguids.yml Configuration mapping AAGUIDs to authenticator details
test/lib/action_pack/web_authn/*.rb Comprehensive test coverage for WebAuthn library
test/controllers/sessions/passkeys_controller_test.rb Integration tests for passkey authentication

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

#
# # Look up the credential by ID
# credential = user.credentials.find_by!(
# credentail_id: params[:id]
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in comment: "credentail_id" should be "credential_id".

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +74
class Identity::Credential < ApplicationRecord
belongs_to :identity

serialize :transports, coder: JSON, type: Array, default: []

class << self
def creation_options(identity:, display_name:)
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: identity.id,
name: identity.email_address,
display_name: display_name,
resident_key: :required,
exclude_credentials: identity.credentials.map(&:to_public_key_credential)
)
end

def request_options(credentials: [])
ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(credentials: credentials.map(&:to_public_key_credential))
end

def register(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin, **attributes)
public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create(
client_data_json: passkey[:client_data_json],
attestation_object: Base64.urlsafe_decode64(passkey[:attestation_object]),
challenge: challenge,
origin: origin,
transports: Array(passkey[:transports])
)

create!(
**attributes,
name: attributes.fetch(:name, Authenticator.find_by_aaguid(public_key_credential.aaguid)&.name),
credential_id: public_key_credential.id,
public_key: public_key_credential.public_key.to_der,
sign_count: public_key_credential.sign_count,
aaguid: public_key_credential.aaguid,
backed_up: public_key_credential.backed_up,
transports: public_key_credential.transports
)
end

def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin)
find_by(credential_id: passkey[:id])&.authenticate(passkey: passkey, challenge: challenge, origin: origin)
end
end

def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin)
pkc = to_public_key_credential
pkc.authenticate(
client_data_json: passkey[:client_data_json],
authenticator_data: Base64.urlsafe_decode64(passkey[:authenticator_data]),
signature: Base64.urlsafe_decode64(passkey[:signature]),
challenge: challenge,
origin: origin
)
update!(sign_count: pkc.sign_count, backed_up: pkc.backed_up)
self
rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError
nil
end

def authenticator
Authenticator.find_by_aaguid(aaguid)
end

def to_public_key_credential
ActionPack::WebAuthn::PublicKeyCredential.new(
id: credential_id,
public_key: OpenSSL::PKey.read(public_key),
sign_count: sign_count,
transports: transports
)
end
end
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing model tests for Identity::Credential. The codebase has comprehensive test coverage for other models (as seen in test/models/), but there are no tests for the newly added Identity::Credential model. Consider adding tests to cover:

  • Credential creation and registration
  • Credential authentication
  • The authenticator lookup functionality
  • Public key credential conversion
  • Edge cases and error handling

Copilot uses AI. Check for mistakes.
Copy link
Member

@dhh dhh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice! Love how it's dependency free. But damn, even in Ruby, the complexity required is pretty intense. All the more benefit for us wrapping this neatly for all Rails users to use, though!

challenge: session.delete(:webauthn_challenge)
)

if credential
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nit, and you could do it either way, but I think these extracted methods are a bit anemic. There's no reuse, and the job of the create action is to do the work and return. So I would inline them.

Current.referrer = request.referrer

ActionPack::WebAuthn::Current.host = request.host
ActionPack::WebAuthn::Current.origin = request.base_url
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems a bit much to have these included everywhere. Maybe do a current_web_authn_request.rb and only include that where they're needed?

)

render json: { location: edit_user_credential_path(Current.user, credential, created: true) }
rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError => error
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This nested class is a bit much. I'd probably just go with something like Action::Pack::WebAuthn::InvalidAuthenticationResponseError or the like.


def update
@credential.update!(credential_params)
redirect_to user_credentials_path(Current.user)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be going under my_credentials_path, as we don't need someone editing another person's credentials.

REGISTRY = Rails.application.config_for(:passkey_aaguids).each_with_object({}) do |(_key, attrs), hash|
authenticator = new(name: attrs[:name], icon: attrs[:icon])
attrs[:aaguids].each { |aaguid| hash[aaguid] = authenticator }
end.freeze
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is getting close to Ruby golf. Would try not to save so aggressively on lines and explain what's going on a little more.

<div class="panel panel--centered flex flex-column gap-half" data-controller="passkey"
data-passkey-public-key-value="<%= @request_options.as_json.to_json %>"
data-passkey-url-value="<%= session_passkey_path %>"
data-passkey-csrf-token-value="<%= form_authenticity_token %>">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double indent attributes of an opening tag.

<section class="panel panel--wide shadow center"
data-controller="credential"
data-credential-public-key-value="<%= @creation_options.as_json.to_json %>"
data-credential-register-url-value="<%= user_credentials_path(Current.user) %>">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double indent, no CR.

@@ -0,0 +1,17 @@
class CreateIdentityCredentials < ActiveRecord::Migration[8.2]
def change
create_table :identity_credentials, id: :uuid do |t|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like this could be generic and then belong to a polymorphic holder stand-in.


unless sign_count_increased?
raise InvalidResponseError, "Sign count did not increase"
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something a bit awkward here. Would extract these into something like validate_client_data_type, validate_signature, validate_sign_count_increase.

false
end

def validate!(challenge:, origin:, user_verification: :preferred)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Rails actually has some generic validator setup that you could consider mixing in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants