Skip to content

Commit cc107b4

Browse files
Form helpers should not be the ones storing the challenge in the session (#73)
* feat: migrate to use options_for_get * feat: migrate to use options_for_create * fix: rubocop offenses * test: call new endpoints to set the challenge in the session * test: options_for_get and options_for_create endpoints * test: set authentication factors * chore: fetch options endpoints from javascript * fix: ensure an authenticated user when upgrading security key * style(rubocop): disable `Metrics/ModuleLength` cop * test: assert current paths * feat: follow REST standard for getting passkey options * feat: follow REST standard for getting security key options * chore: scope options controllers under passkey and security_key * test: set the options in the session * chore: allow generating new controllers * chore: revert scoping controllers * docs: update CHANGELOG --------- Co-authored-by: Joaquin Tomas <joaquintomas2003@gmail.com>
1 parent e05e015 commit cc107b4

25 files changed

Lines changed: 378 additions & 11 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Changed
6+
7+
- 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]
8+
59
## [v0.3.0](https://github.com/cedarcode/devise-webauthn/compare/v0.2.2...v0.3.0/) - 2026-01-16
610

711
### Added

app/assets/javascript/devise/webauthn.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ export class WebauthnCreateElement extends HTMLElement {
2020
event.preventDefault();
2121

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

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

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

108108
this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyAuthenticationCredentialWithGracefullyHandlingAuthenticatorIssues(credential);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module Devise
4+
class PasskeyAuthenticationOptionsController < DeviseController
5+
def index
6+
passkey_options =
7+
WebAuthn::Credential.options_for_get(
8+
user_verification: "required"
9+
)
10+
11+
# Store challenge in session for later verification
12+
session[:authentication_challenge] = passkey_options.challenge
13+
14+
render json: passkey_options
15+
end
16+
end
17+
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
module Devise
4+
class PasskeyRegistrationOptionsController < DeviseController
5+
before_action :authenticate_scope!
6+
7+
def index
8+
passkey_options =
9+
WebAuthn::Credential.options_for_create(
10+
user: {
11+
id: resource.webauthn_id,
12+
name: resource_human_palatable_identifier
13+
},
14+
exclude: resource.passkeys.pluck(:external_id),
15+
authenticator_selection: {
16+
resident_key: "required",
17+
user_verification: "required"
18+
}
19+
)
20+
21+
# Store challenge in session for later verification
22+
session[:webauthn_challenge] = passkey_options.challenge
23+
24+
render json: passkey_options
25+
end
26+
27+
private
28+
29+
def authenticate_scope!
30+
send(:"authenticate_#{resource_name}!", force: true)
31+
self.resource = send(:"current_#{resource_name}")
32+
end
33+
34+
def resource_human_palatable_identifier
35+
authentication_keys = resource.class.authentication_keys
36+
authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
37+
38+
authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
39+
end
40+
end
41+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module Devise
4+
class SecurityKeyAuthenticationOptionsController < DeviseController
5+
before_action :set_resource
6+
7+
def index
8+
security_key_authentication_options =
9+
WebAuthn::Credential.options_for_get(
10+
allow: @resource.webauthn_credentials.pluck(:external_id),
11+
user_verification: "discouraged"
12+
)
13+
14+
# Store challenge in session for later verification
15+
session[:two_factor_authentication_challenge] = security_key_authentication_options.challenge
16+
17+
render json: security_key_authentication_options
18+
end
19+
20+
private
21+
22+
def set_resource
23+
@resource = resource_class.find(session[:current_authentication_resource_id])
24+
end
25+
end
26+
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
module Devise
4+
class SecurityKeyRegistrationOptionsController < DeviseController
5+
before_action :authenticate_scope!
6+
7+
def index
8+
create_security_key_options =
9+
WebAuthn::Credential.options_for_create(
10+
user: {
11+
id: resource.webauthn_id,
12+
name: resource_human_palatable_identifier
13+
},
14+
exclude: resource.webauthn_credentials.pluck(:external_id),
15+
authenticator_selection: {
16+
resident_key: "discouraged",
17+
user_verification: "discouraged"
18+
}
19+
)
20+
21+
# Store challenge in session for later verification
22+
session[:webauthn_challenge] = create_security_key_options.challenge
23+
24+
render json: create_security_key_options
25+
end
26+
27+
private
28+
29+
def authenticate_scope!
30+
send(:"authenticate_#{resource_name}!", force: true)
31+
self.resource = send(:"current_#{resource_name}")
32+
end
33+
34+
def resource_human_palatable_identifier
35+
authentication_keys = resource.class.authentication_keys
36+
authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
37+
38+
authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
39+
end
40+
end
41+
end

lib/devise/webauthn/helpers/credentials_helper.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def passkey_creation_form_for(resource, form_classes: nil, &block)
1010
method: :post,
1111
class: form_classes
1212
) do |f|
13-
tag.webauthn_create(data: { options_json: create_passkey_options(resource) }) do
13+
tag.webauthn_create(data: { options_url: passkey_registration_options_path(resource) }) do
1414
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
1515
concat capture(f, &block)
1616
end
@@ -23,7 +23,7 @@ def login_with_passkey_button(text = nil, session_path:, button_classes: nil, fo
2323
method: :post,
2424
class: form_classes
2525
) do |f|
26-
tag.webauthn_get(data: { options_json: passkey_authentication_options }) do
26+
tag.webauthn_get(data: { options_url: passkey_authentication_options_path(resource) }) do
2727
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
2828

2929
concat f.button(text, type: "submit", class: button_classes, &block)
@@ -37,7 +37,9 @@ def security_key_creation_form_for(resource, form_classes: nil, &block)
3737
method: :post,
3838
class: form_classes
3939
) do |f|
40-
tag.webauthn_create(data: { options_json: create_security_key_options(resource) }) do
40+
tag.webauthn_create(
41+
data: { options_url: security_key_registration_options_path(resource) }
42+
) do
4143
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
4244
concat capture(f, &block)
4345
end
@@ -50,7 +52,7 @@ def login_with_security_key_button(text = nil, resource:, button_classes: nil, f
5052
method: :post,
5153
class: form_classes
5254
) do |f|
53-
tag.webauthn_get(data: { options_json: security_key_authentication_options(resource) }) do
55+
tag.webauthn_get(data: { options_url: security_key_authentication_options_path(resource) }) do
5456
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
5557
concat f.button(text, type: "submit", class: button_classes, &block)
5658
end

lib/devise/webauthn/routes.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ class Mapper
77

88
def devise_passkey_authentication(_mapping, controllers)
99
resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys]
10+
11+
resources :passkey_authentication_options, only: :index,
12+
controller: controllers[:passkey_authentication_options]
13+
resources :passkey_registration_options, only: :index, controller: controllers[:passkey_registration_options]
1014
end
1115

1216
def devise_two_factor_authentication(_mapping, controllers)
@@ -17,6 +21,11 @@ def devise_two_factor_authentication(_mapping, controllers)
1721
resources :second_factor_webauthn_credentials,
1822
only: %i[new create update destroy],
1923
controller: controllers[:second_factor_webauthn_credentials]
24+
25+
resources :security_key_authentication_options, only: %i[index],
26+
controller: controllers[:security_key_authentication_options]
27+
resources :security_key_registration_options, only: %i[index],
28+
controller: controllers[:security_key_registration_options]
2029
end
2130
end
2231
end

lib/devise/webauthn/url_helpers.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@ module UrlHelpers
2424
{
2525
passkeys: [nil],
2626
passkey: [nil, :new],
27+
passkey_authentication_options: [nil],
28+
passkey_registration_options: [nil],
2729
two_factor_authentication: [nil, :new],
2830
second_factor_webauthn_credentials: [nil],
29-
second_factor_webauthn_credential: [nil, :new]
31+
second_factor_webauthn_credential: [nil, :new],
32+
security_key_authentication_options: [nil],
33+
security_key_registration_options: [nil]
3034
}.each do |route, actions|
3135
%i[path url].each do |path_or_url|
3236
actions.each do |action|

lib/generators/devise/webauthn/controllers_generator.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ class ControllersGenerator < Rails::Generators::Base
99
passkeys
1010
second_factor_webauthn_credentials
1111
two_factor_authentications
12+
passkey_authentication_options
13+
passkey_registration_options
14+
security_key_authentication_options
15+
security_key_registration_options
1216
].freeze
1317

1418
desc "Create inherited Devise::Webauthn controllers in your app/controllers folder."

0 commit comments

Comments
 (0)