Skip to content

Commit 13cf467

Browse files
feat: follow REST standard for getting passkey options
1 parent 5e231a1 commit 13cf467

11 files changed

Lines changed: 159 additions & 106 deletions
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

app/controllers/devise/passkeys_controller.rb

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

33
module Devise
44
class PasskeysController < DeviseController
5-
before_action :authenticate_scope!, only: %i[new create destroy options_for_create]
5+
before_action :authenticate_scope!
66

77
def new; end
88

@@ -32,38 +32,6 @@ def destroy
3232
redirect_to after_update_path
3333
end
3434

35-
def options_for_get
36-
passkey_authentication_options =
37-
WebAuthn::Credential.options_for_get(
38-
user_verification: "required"
39-
)
40-
41-
# Store challenge in session for later verification
42-
session[:authentication_challenge] = passkey_authentication_options.challenge
43-
44-
render json: passkey_authentication_options
45-
end
46-
47-
def options_for_create
48-
create_passkey_options =
49-
WebAuthn::Credential.options_for_create(
50-
user: {
51-
id: resource.webauthn_id,
52-
name: resource_human_palatable_identifier
53-
},
54-
exclude: resource.passkeys.pluck(:external_id),
55-
authenticator_selection: {
56-
resident_key: "required",
57-
user_verification: "required"
58-
}
59-
)
60-
61-
# Store challenge in session for later verification
62-
session[:webauthn_challenge] = create_passkey_options.challenge
63-
64-
render json: create_passkey_options
65-
end
66-
6735
private
6836

6937
def authenticate_scope!
@@ -90,12 +58,5 @@ def verify_and_save_passkey(passkey_from_params)
9058
def after_update_path
9159
new_passkey_path(resource_name)
9260
end
93-
94-
def resource_human_palatable_identifier
95-
authentication_keys = resource.class.authentication_keys
96-
authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
97-
98-
authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
99-
end
10061
end
10162
end

lib/devise/webauthn/helpers/credentials_helper.rb

Lines changed: 2 additions & 2 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_url: options_for_create_passkeys_path(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_url: options_for_get_passkeys_path(resource) }) 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)

lib/devise/webauthn/routes.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ class Mapper
66
protected
77

88
def devise_passkey_authentication(_mapping, controllers)
9-
resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys] do
10-
get :options_for_get, on: :collection
11-
get :options_for_create, on: :collection
12-
end
9+
resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys]
10+
resources :passkey_authentication_options, only: %i[index],
11+
controller: controllers[:passkey_authentication_options]
12+
resources :passkey_registration_options, only: %i[index],
13+
controller: controllers[:passkey_registration_options]
1314
end
1415

1516
def devise_two_factor_authentication(_mapping, controllers)

lib/devise/webauthn/url_helpers.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ module Webauthn
2222
#
2323
module UrlHelpers
2424
{
25-
passkeys: [nil, :options_for_get, :options_for_create],
25+
passkeys: [nil],
2626
passkey: [nil, :new],
27+
passkeys_authentication_options: [nil],
28+
passkeys_registration_options: [nil],
2729
two_factor_authentication: [nil, :new],
2830
second_factor_webauthn_credentials: [nil, :options_for_get, :options_for_create],
2931
second_factor_webauthn_credential: [nil, :new]
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 %>PasskeysAuthenticationOptionsController < Devise::PasskeysController
4+
# GET /resource/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 %>PasskeysRegistrationOptionsController < Devise::PasskeysController
4+
# GET /resource/passkeys
5+
# def index
6+
# super
7+
# end
8+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Devise::PasskeyAuthenticationOptionsController, type: :request do
6+
describe "GET #index" do
7+
it "stores the challenge in session and returns it as json" do
8+
get account_passkeys_authentication_options_path
9+
10+
expect(response).to have_http_status(:ok)
11+
12+
json = response.parsed_body
13+
expect(json["challenge"]).to be_present
14+
expect(session[:authentication_challenge]).to eq(json["challenge"])
15+
end
16+
17+
it "generates a new challenge on each request" do
18+
get account_passkeys_authentication_options_path
19+
first_challenge = session[:authentication_challenge]
20+
21+
get account_passkeys_authentication_options_path
22+
second_challenge = session[:authentication_challenge]
23+
24+
expect(first_challenge).to be_present
25+
expect(second_challenge).not_to eq(first_challenge)
26+
end
27+
end
28+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Devise::PasskeyRegistrationOptionsController, type: :request do
6+
let(:user) { Account.create!(email: "test@example.com", password: "password123") }
7+
8+
describe "GET #index" do
9+
context "when user is not authenticated" do
10+
it "redirects to the sign-in page" do
11+
get account_passkeys_registration_options_path
12+
expect(response).to redirect_to(new_account_session_path)
13+
end
14+
end
15+
16+
context "when user is authenticated" do
17+
before do
18+
sign_in user, scope: :account
19+
end
20+
21+
it "returns webauthn create options as json and stores the challenge in session" do
22+
get account_passkeys_registration_options_path
23+
24+
expect(response).to have_http_status(:ok)
25+
26+
json = response.parsed_body
27+
expect(json["challenge"]).to be_present
28+
29+
expect(session[:webauthn_challenge]).to eq(json["challenge"])
30+
end
31+
32+
it "generates a new challenge on each request" do
33+
get account_passkeys_registration_options_path
34+
first_challenge = session[:webauthn_challenge]
35+
36+
get account_passkeys_registration_options_path
37+
second_challenge = session[:webauthn_challenge]
38+
39+
expect(first_challenge).to be_present
40+
expect(second_challenge).to be_present
41+
expect(second_challenge).not_to eq(first_challenge)
42+
end
43+
end
44+
end
45+
end

0 commit comments

Comments
 (0)