Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a522711
feat: migrate to use options_for_get
nicolastemciuc Dec 10, 2025
673ba40
feat: migrate to use options_for_create
nicolastemciuc Dec 10, 2025
6167f3b
fix: rubocop offenses
nicolastemciuc Dec 10, 2025
7c1b63c
test: call new endpoints to set the challenge in the session
nicolastemciuc Dec 10, 2025
c886e27
test: options_for_get and options_for_create endpoints
nicolastemciuc Dec 10, 2025
6a5d083
test: set authentication factors
nicolastemciuc Dec 11, 2025
d5dd6a6
chore: fetch options endpoints from javascript
nicolastemciuc Jan 15, 2026
ebc2e8f
fix: ensure an authenticated user when upgrading security key
nicolastemciuc Jan 15, 2026
5eb888d
Merge branch 'master' into temciuc--not-store-the-challenge-in-helper
nicolastemciuc Jan 16, 2026
75857db
Merge remote-tracking branch 'origin/master' into temciuc--not-store-…
nicolastemciuc Jan 16, 2026
983d639
style(rubocop): disable `Metrics/ModuleLength` cop
nicolastemciuc Jan 16, 2026
5e231a1
test: assert current paths
joaquintomas2003 Jan 16, 2026
d94c938
feat: follow REST standard for getting passkey options
nicolastemciuc Jan 16, 2026
5523100
feat: follow REST standard for getting security key options
nicolastemciuc Jan 19, 2026
40a09c9
Merge branch 'master' into temciuc--not-store-the-challenge-in-helper
nicolastemciuc Jan 19, 2026
7da06bb
Merge remote-tracking branch 'origin/master' into temciuc--not-store-…
nicolastemciuc Jan 20, 2026
ceccb3e
chore: scope options controllers under passkey and security_key
nicolastemciuc Jan 20, 2026
5322c74
Merge remote-tracking branch 'origin/master' into temciuc--not-store-…
nicolastemciuc Jan 20, 2026
be02455
test: set the options in the session
nicolastemciuc Jan 20, 2026
8c5ede6
chore: allow generating new controllers
nicolastemciuc Jan 20, 2026
81d4261
chore: revert scoping controllers
nicolastemciuc Jan 22, 2026
d0ee47d
docs: update CHANGELOG
nicolastemciuc Jan 22, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Changed

- Options for getting or creating passkeys and security keys are now served by dedicated Rails controllers and retrieved via JavaScript fetch requests. [#73](https://github.com/cedarcode/devise-webauthn/pull/73) [@nicolastemciuc]

## [v0.3.0](https://github.com/cedarcode/devise-webauthn/compare/v0.2.2...v0.3.0/) - 2026-01-16

### Added
Expand Down
8 changes: 4 additions & 4 deletions app/assets/javascript/devise/webauthn.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export class WebauthnCreateElement extends HTMLElement {
event.preventDefault();

try {
const options = JSON.parse(this.getAttribute('data-options-json'));
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options);
const response = await fetch(this.getAttribute('data-options-url'));
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(await response.json());
const credential = await navigator.credentials.create({ publicKey });

this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyRegistrationCredentialWithGracefullyHandlingAuthenticatorIssues(credential);
Expand Down Expand Up @@ -101,8 +101,8 @@ export class WebauthnGetElement extends HTMLElement {
event.preventDefault();

try {
const options = JSON.parse(this.getAttribute('data-options-json'));
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options);
const response = await fetch(this.getAttribute('data-options-url'));
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(await response.json());
const credential = await navigator.credentials.get({ publicKey });

this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyAuthenticationCredentialWithGracefullyHandlingAuthenticatorIssues(credential);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module Devise
class PasskeyAuthenticationOptionsController < DeviseController
def index
passkey_options =
WebAuthn::Credential.options_for_get(
user_verification: "required"
)

# Store challenge in session for later verification
session[:authentication_challenge] = passkey_options.challenge

render json: passkey_options
end
end
end
41 changes: 41 additions & 0 deletions app/controllers/devise/passkey_registration_options_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module Devise
class PasskeyRegistrationOptionsController < DeviseController
before_action :authenticate_scope!

def index
passkey_options =
WebAuthn::Credential.options_for_create(
user: {
id: resource.webauthn_id,
name: resource_human_palatable_identifier
},
exclude: resource.passkeys.pluck(:external_id),
authenticator_selection: {
resident_key: "required",
user_verification: "required"
}
)

# Store challenge in session for later verification
session[:webauthn_challenge] = passkey_options.challenge

render json: passkey_options
end

private

def authenticate_scope!
send(:"authenticate_#{resource_name}!", force: true)
self.resource = send(:"current_#{resource_name}")
end

def resource_human_palatable_identifier
authentication_keys = resource.class.authentication_keys
authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)

authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module Devise
class SecurityKeyAuthenticationOptionsController < DeviseController
before_action :set_resource

def index
security_key_authentication_options =
WebAuthn::Credential.options_for_get(
allow: @resource.webauthn_credentials.pluck(:external_id),
user_verification: "discouraged"
)

# Store challenge in session for later verification
session[:two_factor_authentication_challenge] = security_key_authentication_options.challenge

render json: security_key_authentication_options
end

private

def set_resource
@resource = resource_class.find(session[:current_authentication_resource_id])
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module Devise
class SecurityKeyRegistrationOptionsController < DeviseController
before_action :authenticate_scope!

def index
create_security_key_options =
WebAuthn::Credential.options_for_create(
user: {
id: resource.webauthn_id,
name: resource_human_palatable_identifier
},
exclude: resource.webauthn_credentials.pluck(:external_id),
authenticator_selection: {
resident_key: "discouraged",
user_verification: "discouraged"
}
)

# Store challenge in session for later verification
session[:webauthn_challenge] = create_security_key_options.challenge

render json: create_security_key_options
end

private

def authenticate_scope!
send(:"authenticate_#{resource_name}!", force: true)
self.resource = send(:"current_#{resource_name}")
end

def resource_human_palatable_identifier
authentication_keys = resource.class.authentication_keys
authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)

authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
end
end
end
10 changes: 6 additions & 4 deletions lib/devise/webauthn/helpers/credentials_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def passkey_creation_form_for(resource, form_classes: nil, &block)
method: :post,
class: form_classes
) do |f|
tag.webauthn_create(data: { options_json: create_passkey_options(resource) }) do
tag.webauthn_create(data: { options_url: passkey_registration_options_path(resource) }) do
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
concat capture(f, &block)
end
Expand All @@ -23,7 +23,7 @@ def login_with_passkey_button(text = nil, session_path:, button_classes: nil, fo
method: :post,
class: form_classes
) do |f|
tag.webauthn_get(data: { options_json: passkey_authentication_options }) do
tag.webauthn_get(data: { options_url: passkey_authentication_options_path(resource) }) do
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })

concat f.button(text, type: "submit", class: button_classes, &block)
Expand All @@ -37,7 +37,9 @@ def security_key_creation_form_for(resource, form_classes: nil, &block)
method: :post,
class: form_classes
) do |f|
tag.webauthn_create(data: { options_json: create_security_key_options(resource) }) do
tag.webauthn_create(
data: { options_url: security_key_registration_options_path(resource) }
) do
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
concat capture(f, &block)
end
Expand All @@ -50,7 +52,7 @@ def login_with_security_key_button(text = nil, resource:, button_classes: nil, f
method: :post,
class: form_classes
) do |f|
tag.webauthn_get(data: { options_json: security_key_authentication_options(resource) }) do
tag.webauthn_get(data: { options_url: security_key_authentication_options_path(resource) }) do
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
concat f.button(text, type: "submit", class: button_classes, &block)
end
Expand Down
9 changes: 9 additions & 0 deletions lib/devise/webauthn/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ class Mapper

def devise_passkey_authentication(_mapping, controllers)
resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys]

resources :passkey_authentication_options, only: :index,
controller: controllers[:passkey_authentication_options]
resources :passkey_registration_options, only: :index, controller: controllers[:passkey_registration_options]
end

def devise_two_factor_authentication(_mapping, controllers)
Expand All @@ -17,6 +21,11 @@ def devise_two_factor_authentication(_mapping, controllers)
resources :second_factor_webauthn_credentials,
only: %i[new create update destroy],
controller: controllers[:second_factor_webauthn_credentials]

resources :security_key_authentication_options, only: %i[index],
controller: controllers[:security_key_authentication_options]
resources :security_key_registration_options, only: %i[index],
controller: controllers[:security_key_registration_options]
end
end
end
Expand Down
6 changes: 5 additions & 1 deletion lib/devise/webauthn/url_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ module UrlHelpers
{
passkeys: [nil],
passkey: [nil, :new],
passkey_authentication_options: [nil],
passkey_registration_options: [nil],
two_factor_authentication: [nil, :new],
second_factor_webauthn_credentials: [nil],
second_factor_webauthn_credential: [nil, :new]
second_factor_webauthn_credential: [nil, :new],
security_key_authentication_options: [nil],
security_key_registration_options: [nil]
}.each do |route, actions|
%i[path url].each do |path_or_url|
actions.each do |action|
Expand Down
4 changes: 4 additions & 0 deletions lib/generators/devise/webauthn/controllers_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ class ControllersGenerator < Rails::Generators::Base
passkeys
second_factor_webauthn_credentials
two_factor_authentications
passkey_authentication_options
passkey_registration_options
security_key_authentication_options
security_key_registration_options
].freeze

desc "Create inherited Devise::Webauthn controllers in your app/controllers folder."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

class <%= @scope_prefix %>PasskeyAuthenticationOptionsController < Devise::PasskeyAuthenticationOptionsController
# GET /resource/passkey_authentication_options
# def index
# super
# end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

class <%= @scope_prefix %>PasskeyRegistrationOptionsController < Devise::PasskeyRegistrationOptionsController
# GET /resource/passkey_registration_options
# def index
# super
# end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

class <%= @scope_prefix %>SecurityKeyAuthenticationOptionsController < Devise::SecurityKeyAuthenticationOptionsController
# GET /resource/security_key_authentication_options
# def index
# super
# end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

class <%= @scope_prefix %>SecurityKeyRegistrationOptionsController < Devise::SecurityKeyRegistrationOptionsController
# GET /resource/securiy_key_registration_options
# def index
# super
# end
end
8 changes: 8 additions & 0 deletions spec/generators/devise/webauthn/controllers_generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
assert_no_file "app/controllers/passkeys_controller.rb"
assert_no_file "app/controllers/second_factor_webauthn_credentials_controller.rb"
assert_no_file "app/controllers/two_factor_authentications_controller.rb"
assert_no_file "app/controllers/passkey_authentication_options_controller.rb"
assert_no_file "app/controllers/passkey_registration_options_controller.rb"
assert_no_file "app/controllers/security_key_authentication_options_controller.rb"
assert_no_file "app/controllers/security_key_registration_options_controller.rb"
end
end

Expand All @@ -32,6 +36,10 @@
assert_file "app/controllers/users/passkeys_controller.rb", /Users::PasskeysController/
assert_file "app/controllers/users/second_factor_webauthn_credentials_controller.rb", /Users::SecondFactorWebauthnCredentialsController/
assert_file "app/controllers/users/two_factor_authentications_controller.rb", /Users::TwoFactorAuthenticationsController/
assert_file "app/controllers/users/passkey_authentication_options_controller.rb", /Users::PasskeyAuthenticationOptionsController/
assert_file "app/controllers/users/passkey_registration_options_controller.rb", /Users::PasskeyRegistrationOptionsController/
assert_file "app/controllers/users/security_key_authentication_options_controller.rb", /Users::SecurityKeyAuthenticationOptionsController/
assert_file "app/controllers/users/security_key_registration_options_controller.rb", /Users::SecurityKeyRegistrationOptionsController/
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Devise::PasskeyAuthenticationOptionsController, type: :request do
describe "GET #index" do
it "stores the challenge in session and returns it as json" do
get account_passkey_authentication_options_path

expect(response).to have_http_status(:ok)

json = response.parsed_body
expect(json["challenge"]).to be_present
expect(session[:authentication_challenge]).to eq(json["challenge"])
end

it "generates a new challenge on each request" do
get account_passkey_authentication_options_path
first_challenge = session[:authentication_challenge]

get account_passkey_authentication_options_path
second_challenge = session[:authentication_challenge]

expect(first_challenge).to be_present
expect(second_challenge).not_to eq(first_challenge)
end
end
end
4 changes: 4 additions & 0 deletions spec/requests/devise/passkey_authentication_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def generate_assertion(fake_client, challenge:, credential:)
describe "sign-in with passkeys" do
let!(:passkey) { create_passkey_for(user, client) }

before do
get account_passkey_authentication_options_path # To set the challenge in session
end

it "completes authentication with valid credential" do
get new_account_session_path

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Devise::PasskeyRegistrationOptionsController, type: :request do
let(:user) { Account.create!(email: "test@example.com", password: "password123") }

describe "GET #index" do
context "when user is not authenticated" do
it "redirects to the sign-in page" do
get account_passkey_registration_options_path
expect(response).to redirect_to(new_account_session_path)
end
end

context "when user is authenticated" do
before do
sign_in user, scope: :account
end

it "returns webauthn create options as json and stores the challenge in session" do
get account_passkey_registration_options_path

expect(response).to have_http_status(:ok)

json = response.parsed_body
expect(json["challenge"]).to be_present

expect(session[:webauthn_challenge]).to eq(json["challenge"])
end

it "generates a new challenge on each request" do
get account_passkey_registration_options_path
first_challenge = session[:webauthn_challenge]

get account_passkey_registration_options_path
second_challenge = session[:webauthn_challenge]

expect(first_challenge).to be_present
expect(second_challenge).to be_present
expect(second_challenge).not_to eq(first_challenge)
end
end
end
end
Loading