Skip to content

Commit ec11bd4

Browse files
feat: follow REST standard for getting security key options
1 parent d94c938 commit ec11bd4

11 files changed

Lines changed: 163 additions & 140 deletions

app/controllers/devise/second_factor_webauthn_credentials_controller.rb

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
module Devise
44
class SecondFactorWebauthnCredentialsController < DeviseController
5-
before_action :authenticate_scope!, only: %i[new create update destroy options_for_create]
6-
before_action :set_resource, only: :options_for_get
5+
before_action :authenticate_scope!
76

87
def new; end
98

@@ -43,39 +42,6 @@ def destroy
4342
redirect_to after_destroy_path
4443
end
4544

46-
def options_for_get
47-
security_key_authentication_options =
48-
WebAuthn::Credential.options_for_get(
49-
allow: @resource.webauthn_credentials.pluck(:external_id),
50-
user_verification: "discouraged"
51-
)
52-
53-
# Store challenge in session for later verification
54-
session[:two_factor_authentication_challenge] = security_key_authentication_options.challenge
55-
56-
render json: security_key_authentication_options
57-
end
58-
59-
def options_for_create
60-
create_security_key_options =
61-
WebAuthn::Credential.options_for_create(
62-
user: {
63-
id: resource.webauthn_id,
64-
name: resource_human_palatable_identifier
65-
},
66-
exclude: resource.webauthn_credentials.pluck(:external_id),
67-
authenticator_selection: {
68-
resident_key: "discouraged",
69-
user_verification: "discouraged"
70-
}
71-
)
72-
73-
# Store challenge in session for later verification
74-
session[:webauthn_challenge] = create_security_key_options.challenge
75-
76-
render json: create_security_key_options
77-
end
78-
7945
private
8046

8147
def authenticate_scope!
@@ -113,16 +79,5 @@ def after_update_path
11379
def after_destroy_path
11480
new_second_factor_webauthn_credential_path(resource_name)
11581
end
116-
117-
def resource_human_palatable_identifier
118-
authentication_keys = resource.class.authentication_keys
119-
authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
120-
121-
authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
122-
end
123-
124-
def set_resource
125-
@resource = resource_class.find(session[:current_authentication_resource_id])
126-
end
12782
end
12883
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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def security_key_creation_form_for(resource, form_classes: nil, &block)
3838
class: form_classes
3939
) do |f|
4040
tag.webauthn_create(
41-
data: { options_url: options_for_create_second_factor_webauthn_credentials_path(resource) }
41+
data: { options_url: security_key_registration_options_path(resource) }
4242
) do
4343
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
4444
concat capture(f, &block)
@@ -52,7 +52,7 @@ def login_with_security_key_button(text = nil, resource:, button_classes: nil, f
5252
method: :post,
5353
class: form_classes
5454
) do |f|
55-
tag.webauthn_get(data: { options_url: options_for_get_second_factor_webauthn_credentials_path(resource) }) do
55+
tag.webauthn_get(data: { options_url: security_key_authentication_options_path(resource) }) do
5656
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
5757
concat f.button(text, type: "submit", class: button_classes, &block)
5858
end

lib/devise/webauthn/routes.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ def devise_two_factor_authentication(_mapping, controllers)
2020

2121
resources :second_factor_webauthn_credentials,
2222
only: %i[new create update destroy],
23-
controller: controllers[:second_factor_webauthn_credentials] do
24-
get :options_for_get, on: :collection
25-
get :options_for_create, on: :collection
26-
end
23+
controller: controllers[:second_factor_webauthn_credentials]
24+
resources :security_key_authentication_options, only: %i[index],
25+
controller: controllers[:security_key_authentication_options]
26+
resources :security_key_registration_options, only: %i[index],
27+
controller: controllers[:security_key_registration_options]
2728
end
2829
end
2930
end

lib/devise/webauthn/url_helpers.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ module UrlHelpers
2727
passkey_authentication_options: [nil],
2828
passkey_registration_options: [nil],
2929
two_factor_authentication: [nil, :new],
30-
second_factor_webauthn_credentials: [nil, :options_for_get, :options_for_create],
31-
second_factor_webauthn_credential: [nil, :new]
30+
second_factor_webauthn_credentials: [nil],
31+
second_factor_webauthn_credential: [nil, :new],
32+
security_key_authentication_options: [nil],
33+
security_key_registration_options: [nil]
3234
}.each do |route, actions|
3335
%i[path url].each do |path_or_url|
3436
actions.each do |action|
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
class <%= @scope_prefix %>SecurityKeyAuthenticationOptionsController < Devise::SecurityKeyAuthenticationOptionsController
4+
# GET /resource/security_key_authentication_options
5+
# def index
6+
# super
7+
# end
8+
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
class <%= @scope_prefix %>SecurityKeyRegistrationOptionsController < Devise::SecurityKeyRegistrationOptionsController
4+
# GET /resource/securiy_key_registration_options
5+
# def index
6+
# super
7+
# end
8+
end

spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb

Lines changed: 1 addition & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
context "when user is authenticated" do
4242
before do
4343
sign_in user
44-
get options_for_create_account_second_factor_webauthn_credentials_path # To set the challenge in session
44+
get account_security_key_registration_options_path # To set the challenge in session
4545
end
4646

4747
context "with valid parameters" do
@@ -157,89 +157,4 @@
157157
end
158158
end
159159
end
160-
161-
# rubocop:disable RSpec/MultipleExpectations
162-
describe "GET #options_for_get" do
163-
before do
164-
user.webauthn_credentials.create!(
165-
external_id: "second-factor-id",
166-
name: "Second Factor Key",
167-
public_key: "pk",
168-
sign_count: 0,
169-
authentication_factor: :second_factor
170-
)
171-
172-
# rubocop:disable RSpec/AnyInstance
173-
allow_any_instance_of(described_class)
174-
.to receive(:set_resource) do |instance|
175-
instance.instance_variable_set(:@resource, user)
176-
end
177-
# rubocop:enable RSpec/AnyInstance
178-
end
179-
180-
it "returns authentication options and stores the challenge in the session" do
181-
get options_for_get_account_second_factor_webauthn_credentials_path
182-
183-
expect(response).to have_http_status(:ok)
184-
expect(response.media_type).to eq("application/json")
185-
186-
body = response.parsed_body
187-
expect(body).to include("challenge")
188-
expect(body["userVerification"]).to eq("discouraged")
189-
190-
expect(body["allowCredentials"].size).to eq(1)
191-
192-
expect(session[:two_factor_authentication_challenge]).to be_present
193-
end
194-
end
195-
196-
describe "GET #options_for_create" do
197-
context "when user is authenticated" do
198-
before do
199-
sign_in user, scope: :account
200-
end
201-
202-
it "returns security key creation options and stores the challenge in the session" do
203-
get options_for_create_account_second_factor_webauthn_credentials_path
204-
205-
expect(response).to have_http_status(:ok)
206-
expect(response.media_type).to eq("application/json")
207-
208-
body = response.parsed_body
209-
expect(body).to include("challenge")
210-
expect(body.dig("user", "name")).to eq(user.email)
211-
212-
expect(body.dig("authenticatorSelection", "residentKey")).to eq("discouraged")
213-
expect(body.dig("authenticatorSelection", "userVerification")).to eq("discouraged")
214-
215-
expect(session[:webauthn_challenge]).to be_present
216-
end
217-
218-
it "includes existing first-factor credentials in the excludeCredentials list" do
219-
user.webauthn_credentials.create!(
220-
external_id: "existing-id",
221-
name: "Existing Key",
222-
public_key: "pk",
223-
sign_count: 0,
224-
authentication_factor: :first_factor
225-
)
226-
227-
get options_for_create_account_second_factor_webauthn_credentials_path
228-
229-
body = response.parsed_body
230-
exclude_credentials = body["excludeCredentials"] || []
231-
232-
expect(exclude_credentials.size).to eq(user.webauthn_credentials.count)
233-
end
234-
end
235-
236-
context "when user is not authenticated" do
237-
it "redirects to the sign-in page" do
238-
get options_for_create_account_second_factor_webauthn_credentials_path
239-
240-
expect(response).to redirect_to(new_account_session_path)
241-
end
242-
end
243-
end
244-
# rubocop:enable RSpec/MultipleExpectations
245160
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Devise::SecurityKeyAuthenticationOptionsController, type: :request do
6+
let(:user) { Account.create!(email: "test@example.com", password: "password123") }
7+
8+
describe "GET #index" do
9+
before do
10+
sign_in user, scope: :account
11+
end
12+
13+
it "returns authentication options and stores the challenge in the session" do
14+
get account_security_key_authentication_options_path
15+
16+
expect(response).to have_http_status(:ok)
17+
18+
body = response.parsed_body
19+
expect(body).to include("challenge")
20+
21+
expect(session[:two_factor_authentication_challenge]).to eq(body["challenge"])
22+
end
23+
end
24+
end

0 commit comments

Comments
 (0)