webauthn-rails 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +167 -13
  4. data/Rakefile +12 -4
  5. data/lib/generators/erb/webauthn_authentication/templates/app/views/passkeys/new.html.erb.tt +18 -0
  6. data/lib/generators/erb/webauthn_authentication/templates/app/views/second_factor_authentications/new.html.erb.tt +18 -0
  7. data/lib/generators/erb/webauthn_authentication/templates/app/views/second_factor_webauthn_credentials/new.html.erb.tt +18 -0
  8. data/lib/generators/erb/webauthn_authentication/webauthn_authentication_generator.rb +35 -0
  9. data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/passkeys_controller_test.rb +103 -0
  10. data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/second_factor_authentications_controller_test.rb +131 -0
  11. data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/second_factor_webauthn_credentials_controller_test.rb +103 -0
  12. data/lib/generators/test_unit/webauthn_authentication/templates/test/controllers/webauthn_sessions_controller_test.rb +123 -0
  13. data/lib/generators/test_unit/webauthn_authentication/templates/test/system/manage_webauthn_credentials_test.rb +76 -0
  14. data/lib/generators/test_unit/webauthn_authentication/templates/test/test_helpers/virtual_authenticator_test_helper.rb +9 -0
  15. data/lib/generators/test_unit/webauthn_authentication/webauthn_authentication_generator.rb +25 -0
  16. data/lib/generators/webauthn_authentication/bundle_helper.rb +30 -0
  17. data/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb +61 -0
  18. data/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_authentications_controller.rb +62 -0
  19. data/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb +59 -0
  20. data/lib/generators/webauthn_authentication/templates/app/controllers/webauthn_sessions_controller.rb +50 -0
  21. data/lib/generators/webauthn_authentication/templates/app/javascript/controllers/webauthn_credentials_controller.js +64 -0
  22. data/lib/generators/webauthn_authentication/templates/app/models/webauthn_credential.rb +12 -0
  23. data/lib/generators/webauthn_authentication/templates/config/initializers/webauthn.rb +8 -0
  24. data/lib/generators/webauthn_authentication/webauthn_authentication_generator.rb +184 -0
  25. data/lib/tasks/webauthn/rails_tasks.rake +4 -0
  26. data/lib/webauthn/rails/version.rb +1 -3
  27. data/lib/webauthn/rails.rb +1 -5
  28. metadata +55 -18
  29. data/.rspec +0 -3
  30. data/CHANGELOG.md +0 -5
  31. data/LICENSE.txt +0 -21
  32. data/sig/webauthn/rails.rbs +0 -6
@@ -0,0 +1,103 @@
1
+ require "test_helper"
2
+ require "webauthn/fake_client"
3
+
4
+ class SecondFactorWebauthnCredentialsControllerTest < ActionDispatch::IntegrationTest
5
+ setup do
6
+ @user = users(:one)
7
+ @client = WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)
8
+ end
9
+
10
+ test "create_options" do
11
+ sign_in_as @user
12
+ post create_options_second_factor_webauthn_credentials_url
13
+
14
+ assert_response :success
15
+ body = JSON.parse(response.body)
16
+ assert body["challenge"].present?
17
+ assert body["authenticatorSelection"]["residentKey"] == "discouraged"
18
+ assert body["authenticatorSelection"]["userVerification"] == "discouraged"
19
+
20
+ assert_equal session[:current_registration][:challenge], body["challenge"]
21
+ end
22
+
23
+ test "create_options unauthenticated" do
24
+ post create_options_second_factor_webauthn_credentials_url
25
+
26
+ assert_response :redirect
27
+ assert_redirected_to new_session_url
28
+ end
29
+
30
+ test "create" do
31
+ sign_in_as @user
32
+
33
+ post create_options_second_factor_webauthn_credentials_url
34
+ challenge = session[:current_registration][:challenge]
35
+
36
+ public_key_credential = @client.create(
37
+ challenge: challenge,
38
+ user_verified: false,
39
+ )
40
+
41
+ assert_difference("WebauthnCredential.second_factor.count", 1) do
42
+ post second_factor_webauthn_credentials_url, params: {
43
+ credential: {
44
+ nickname: "My Security Key",
45
+ public_key_credential: public_key_credential.to_json
46
+ }
47
+ }
48
+ end
49
+
50
+ assert_redirected_to root_path
51
+ assert_match (/Security Key registered successfully/), flash[:notice]
52
+ assert_nil session[:current_registration]
53
+ end
54
+
55
+ test "create with WebAuthn error" do
56
+ sign_in_as @user
57
+
58
+ post create_options_second_factor_webauthn_credentials_url
59
+
60
+ public_key_credential = @client.create(
61
+ user_verified: false,
62
+ )
63
+
64
+ assert_no_difference("WebauthnCredential.count") do
65
+ post second_factor_webauthn_credentials_url, params: {
66
+ credential: {
67
+ nickname: "My Security Key",
68
+ public_key_credential: public_key_credential.to_json
69
+ }
70
+ }
71
+ end
72
+
73
+ assert_redirected_to new_second_factor_webauthn_credential_path
74
+ assert_match (/Verification failed/), flash[:alert]
75
+ assert_nil session[:current_registration]
76
+ end
77
+
78
+ test "create unauthenticated" do
79
+ post second_factor_webauthn_credentials_url
80
+
81
+ assert_response :redirect
82
+ assert_redirected_to new_session_url
83
+ end
84
+
85
+ test "destroy" do
86
+ credential = WebauthnCredential.second_factor.create!(
87
+ user: @user,
88
+ nickname: "My Security Key",
89
+ external_id: "external_id",
90
+ public_key: "public_key",
91
+ sign_count: 0
92
+ )
93
+
94
+ sign_in_as @user
95
+
96
+ assert_difference("WebauthnCredential.second_factor.count", -1) do
97
+ delete second_factor_webauthn_credential_url(credential)
98
+ end
99
+
100
+ assert_redirected_to root_path
101
+ assert_match (/Security Key deleted successfully/), flash[:notice]
102
+ end
103
+ end
@@ -0,0 +1,123 @@
1
+ require "test_helper"
2
+ require "webauthn/fake_client"
3
+
4
+ class WebauthnSessionsControllerTest < ActionDispatch::IntegrationTest
5
+ setup do
6
+ @user = users(:one)
7
+ @client = WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)
8
+
9
+ creation_options = WebAuthn::Credential.options_for_create(
10
+ user: { id: @user.webauthn_id, name: @user.email_address }
11
+ )
12
+ create_options = @client.create(challenge: creation_options.challenge)
13
+ credential = WebAuthn::Credential.from_create(create_options)
14
+
15
+ WebauthnCredential.passkey.create!(
16
+ nickname: "My Passkey",
17
+ user: @user,
18
+ external_id: credential.id,
19
+ public_key: credential.public_key,
20
+ sign_count: 0,
21
+ )
22
+ end
23
+
24
+ test "get_options" do
25
+ post get_options_webauthn_session_url
26
+
27
+ assert_response :success
28
+ body = JSON.parse(response.body)
29
+ assert body["challenge"].present?
30
+ assert body["userVerification"] == "required"
31
+
32
+ assert_equal session[:current_authentication][:challenge], body["challenge"]
33
+ end
34
+
35
+ test "create" do
36
+ post get_options_webauthn_session_url
37
+ challenge = session[:current_authentication][:challenge]
38
+
39
+ public_key_credential = @client.get(challenge: challenge, user_verified: true)
40
+
41
+ post webauthn_session_url, params: {
42
+ session: {
43
+ public_key_credential: public_key_credential.to_json
44
+ }
45
+ }
46
+
47
+ assert_redirected_to root_path
48
+ assert_nil session[:current_authentication]
49
+ end
50
+
51
+ test "create with WebAuthn error" do
52
+ post get_options_webauthn_session_url
53
+ challenge = session[:current_authentication][:challenge]
54
+
55
+ public_key_credential = @client.get(challenge: challenge, user_verified: false)
56
+
57
+ post webauthn_session_url, params: {
58
+ session: {
59
+ public_key_credential: public_key_credential.to_json
60
+ }
61
+ }
62
+
63
+ assert_redirected_to new_session_path
64
+ assert_match (/Verification failed/), flash[:alert]
65
+ assert_nil session[:current_authentication]
66
+ end
67
+
68
+ test "create with unrecognized credential" do
69
+ post get_options_webauthn_session_url
70
+ challenge = session[:current_authentication][:challenge]
71
+
72
+ public_key_credential = @client.get(challenge: challenge, user_verified: true)
73
+ public_key_credential["id"] = "invalid-id"
74
+
75
+ post webauthn_session_url, params: {
76
+ session: {
77
+ public_key_credential: public_key_credential.to_json
78
+ }
79
+ }
80
+
81
+ assert_redirected_to new_session_path
82
+ assert_equal "Credential not recognized", flash[:alert]
83
+ assert_nil session[:current_authentication]
84
+ end
85
+
86
+ test "create with a second factor credential" do
87
+ client = WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)
88
+
89
+ creation_options = WebAuthn::Credential.options_for_create(
90
+ user: { id: @user.webauthn_id, name: @user.email_address }
91
+ )
92
+ create_options = client.create(challenge: creation_options.challenge)
93
+ credential = WebAuthn::Credential.from_create(create_options)
94
+
95
+ WebauthnCredential.second_factor.create!(
96
+ nickname: "Second Factor Key",
97
+ user: @user,
98
+ external_id: credential.id,
99
+ public_key: credential.public_key,
100
+ sign_count: 0,
101
+ )
102
+
103
+ post get_options_webauthn_session_url
104
+ challenge = session[:current_authentication][:challenge]
105
+
106
+ public_key_credential = client.get(challenge: challenge, user_verified: true)
107
+
108
+ post webauthn_session_url, params: {
109
+ session: {
110
+ public_key_credential: public_key_credential.to_json
111
+ }
112
+ }
113
+
114
+ assert_redirected_to new_session_path
115
+ assert_equal "Credential not recognized", flash[:alert]
116
+ assert_nil session[:current_authentication]
117
+ end
118
+
119
+ test "destroy" do
120
+ delete webauthn_session_url
121
+ assert_redirected_to new_session_path
122
+ end
123
+ end
@@ -0,0 +1,76 @@
1
+ require "application_system_test_case"
2
+ require_relative "../test_helpers/virtual_authenticator_test_helper"
3
+
4
+ class ManageWebauthnCredentialsTest < ApplicationSystemTestCase
5
+ include VirtualAuthenticatorTestHelper
6
+
7
+ def setup
8
+ user = User.create!(email_address: "alice@example.com", password: "S3cr3tP@ssw0rd!")
9
+ sign_in_as(user)
10
+ @authenticator = add_virtual_authenticator
11
+ end
12
+
13
+ def teardown
14
+ @authenticator.remove!
15
+ end
16
+
17
+ test "add credentials and sign in" do
18
+ visit root_path
19
+
20
+ click_on "Add Passkey"
21
+
22
+ fill_in("Security Key nickname", with: "Touch ID")
23
+ click_on "Add Security Key"
24
+
25
+ assert_current_path "/"
26
+ assert_selector "div", text: "Security Key registered successfully"
27
+ assert_selector "span", text: "Touch ID"
28
+
29
+ click_on "Sign out"
30
+ assert_selector("input[type=submit][value='Sign in']")
31
+
32
+ click_on "Sign In with Passkey"
33
+
34
+ assert_current_path "/"
35
+ assert_selector "h3", text: "Your Passkeys"
36
+ end
37
+
38
+ test "sign in with 2FA WebAuthn credential" do
39
+ visit root_path
40
+
41
+ click_on "Add Second Factor Key"
42
+
43
+ fill_in("Security Key nickname", with: "Touch ID")
44
+ click_on "Add Security Key"
45
+
46
+ assert_current_path "/"
47
+ assert_selector "div", text: "Security Key registered successfully"
48
+ assert_selector "span", text: "Touch ID"
49
+
50
+ click_on "Sign out"
51
+ assert_selector("input[type=submit][value='Sign in']")
52
+
53
+ fill_in "email_address", with: "alice@example.com"
54
+ fill_in "password", with: "S3cr3tP@ssw0rd!"
55
+ click_on "Sign in"
56
+
57
+ assert_selector "h3", text: "Two-factor authentication"
58
+ click_on "Use Security Key"
59
+
60
+ assert_current_path "/"
61
+ assert_selector "h3", text: "Your Passkeys"
62
+ end
63
+
64
+ private
65
+
66
+ def sign_in_as(user)
67
+ visit new_session_path
68
+
69
+ fill_in "email_address", with: user.email_address
70
+ fill_in "password", with: user.password
71
+
72
+ click_on "Sign in"
73
+
74
+ assert_selector "h3", text: "Your Passkeys"
75
+ end
76
+ end
@@ -0,0 +1,9 @@
1
+ module VirtualAuthenticatorTestHelper
2
+ def add_virtual_authenticator
3
+ options = ::Selenium::WebDriver::VirtualAuthenticatorOptions.new
4
+ options.user_verification = true
5
+ options.user_verified = true
6
+ options.resident_key = true
7
+ page.driver.browser.add_virtual_authenticator(options)
8
+ end
9
+ end
@@ -0,0 +1,25 @@
1
+ require "rails/generators/test_unit"
2
+
3
+ module TestUnit
4
+ module Generators
5
+ class WebauthnAuthenticationGenerator < Rails::Generators::Base
6
+ hide!
7
+ source_root File.expand_path("../templates", __FILE__)
8
+
9
+ def create_controller_test_files
10
+ template "test/controllers/passkeys_controller_test.rb"
11
+ template "test/controllers/webauthn_sessions_controller_test.rb"
12
+ template "test/controllers/second_factor_authentications_controller_test.rb"
13
+ template "test/controllers/second_factor_webauthn_credentials_controller_test.rb"
14
+ end
15
+
16
+ def create_system_test_files
17
+ template "test/system/manage_webauthn_credentials_test.rb"
18
+ end
19
+
20
+ def create_test_helper_files
21
+ template "test/test_helpers/virtual_authenticator_test_helper.rb"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # Extracted from: https://github.com/rails/rails/blob/7549ba77254f038b1ca6304a0be6cbda8e2a09c4/railties/lib/rails/generators/bundle_helper.rb
2
+
3
+ module BundleHelper # :nodoc:
4
+ def bundle_command(command, env = {}, params = {})
5
+ say_status :run, "bundle #{command}"
6
+
7
+ # We are going to shell out rather than invoking Bundler::CLI.new(command)
8
+ # because `rails new` loads the Thor gem and on the other hand bundler uses
9
+ # its own vendored Thor, which could be a different version. Running both
10
+ # things in the same process is a recipe for a night with paracetamol.
11
+ #
12
+ # Thanks to James Tucker for the Gem tricks involved in this call.
13
+ _bundle_command = Gem.bin_path("bundler", "bundle")
14
+
15
+ require "bundler"
16
+ Bundler.with_original_env do
17
+ exec_bundle_command(_bundle_command, command, env, params)
18
+ end
19
+ end
20
+
21
+ private
22
+ def exec_bundle_command(bundle_command, command, env, params)
23
+ full_command = %Q("#{Gem.ruby}" "#{bundle_command}" #{command})
24
+ if options[:quiet] || params[:quiet]
25
+ system(env, full_command, out: File::NULL)
26
+ else
27
+ system(env, full_command)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ class PasskeysController < ApplicationController
2
+ def create_options
3
+ create_options = WebAuthn::Credential.options_for_create(
4
+ user: {
5
+ id: Current.user.webauthn_id,
6
+ name: Current.user.email_address
7
+ },
8
+ exclude: Current.user.webauthn_credentials.pluck(:external_id),
9
+ authenticator_selection: {
10
+ resident_key: "required",
11
+ user_verification: "required"
12
+ }
13
+ )
14
+
15
+ session[:current_registration] = { challenge: create_options.challenge }
16
+
17
+ render json: create_options
18
+ end
19
+
20
+ def create
21
+ webauthn_credential = WebAuthn::Credential.from_create(JSON.parse(create_credential_params[:public_key_credential]))
22
+
23
+ begin
24
+ webauthn_credential.verify(
25
+ session[:current_registration][:challenge] || session[:current_registration]["challenge"],
26
+ user_verification: true,
27
+ )
28
+
29
+ credential = Current.user.passkeys.find_or_initialize_by(
30
+ external_id: webauthn_credential.id
31
+ )
32
+
33
+ if credential.update(
34
+ nickname: create_credential_params[:nickname],
35
+ public_key: webauthn_credential.public_key,
36
+ sign_count: webauthn_credential.sign_count
37
+ )
38
+ redirect_to root_path, notice: "Security Key registered successfully"
39
+ else
40
+ flash[:alert] = "Error registering credential"
41
+ render :new
42
+ end
43
+ rescue WebAuthn::Error => e
44
+ redirect_to new_passkey_path, alert: "Verification failed: #{e.message}"
45
+ end
46
+ ensure
47
+ session.delete(:current_registration)
48
+ end
49
+
50
+ def destroy
51
+ Current.user.passkeys.destroy(params[:id])
52
+
53
+ redirect_to root_path, notice: "Security Key deleted successfully"
54
+ end
55
+
56
+ private
57
+
58
+ def create_credential_params
59
+ params.expect(credential: [ :nickname, :public_key_credential ])
60
+ end
61
+ end
@@ -0,0 +1,62 @@
1
+ class SecondFactorAuthenticationsController < ApplicationController
2
+ allow_unauthenticated_access only: %i[new get_options create]
3
+ before_action :require_no_authentication
4
+ before_action :ensure_login_initiated
5
+
6
+ def get_options
7
+ get_options = WebAuthn::Credential.options_for_get(
8
+ allow: user.webauthn_credentials.pluck(:external_id),
9
+ user_verification: "discouraged"
10
+ )
11
+ session[:current_authentication][:challenge] = get_options.challenge
12
+
13
+ render json: get_options
14
+ end
15
+
16
+ def create
17
+ webauthn_credential = WebAuthn::Credential.from_get(JSON.parse(session_params[:public_key_credential]))
18
+
19
+ credential = user.webauthn_credentials.find_by(external_id: webauthn_credential.id)
20
+ unless credential
21
+ redirect_to new_second_factor_authentication_path, alert: "Credential not recognized"
22
+ return
23
+ end
24
+
25
+ begin
26
+ webauthn_credential.verify(
27
+ session[:current_authentication][:challenge] || session[:current_authentication]["challenge"],
28
+ public_key: credential.public_key,
29
+ sign_count: credential.sign_count
30
+ )
31
+
32
+ credential.update!(sign_count: webauthn_credential.sign_count)
33
+ start_new_session_for user
34
+
35
+ redirect_to after_authentication_url
36
+ rescue WebAuthn::Error => e
37
+ redirect_to new_second_factor_authentication_path, alert: "Verification failed: #{e.message}"
38
+ end
39
+ ensure
40
+ session.delete(:current_authentication)
41
+ end
42
+
43
+ private
44
+
45
+ def user
46
+ @user ||= User.find_by(id: current_authentication_user_id)
47
+ end
48
+
49
+ def ensure_login_initiated
50
+ if current_authentication_user_id.blank?
51
+ redirect_to new_session_path
52
+ end
53
+ end
54
+
55
+ def session_params
56
+ params.expect(session: [ :public_key_credential ])
57
+ end
58
+
59
+ def current_authentication_user_id
60
+ session.dig(:current_authentication, :user_id) || session.dig(:current_authentication, "user_id")
61
+ end
62
+ end
@@ -0,0 +1,59 @@
1
+ class SecondFactorWebauthnCredentialsController < ApplicationController
2
+ def create_options
3
+ create_options = WebAuthn::Credential.options_for_create(
4
+ user: {
5
+ id: Current.user.webauthn_id,
6
+ name: Current.user.email_address
7
+ },
8
+ exclude: Current.user.webauthn_credentials.pluck(:external_id),
9
+ authenticator_selection: {
10
+ resident_key: "discouraged",
11
+ user_verification: "discouraged"
12
+ }
13
+ )
14
+
15
+ session[:current_registration] = { challenge: create_options.challenge }
16
+
17
+ render json: create_options
18
+ end
19
+
20
+ def create
21
+ webauthn_credential = WebAuthn::Credential.from_create(JSON.parse(create_credential_params[:public_key_credential]))
22
+
23
+ begin
24
+ webauthn_credential.verify(
25
+ session[:current_registration][:challenge] || session[:current_registration]["challenge"]
26
+ )
27
+
28
+ credential = Current.user.second_factor_webauthn_credentials.find_or_initialize_by(
29
+ external_id: webauthn_credential.id
30
+ )
31
+
32
+ if credential.update(
33
+ nickname: create_credential_params[:nickname],
34
+ public_key: webauthn_credential.public_key,
35
+ sign_count: webauthn_credential.sign_count
36
+ )
37
+ redirect_to root_path, notice: "Security Key registered successfully"
38
+ else
39
+ redirect_to new_second_factor_webauthn_credential_path, alert: "Error registering credential"
40
+ end
41
+ rescue WebAuthn::Error => e
42
+ redirect_to new_second_factor_webauthn_credential_path, alert: "Verification failed: #{e.message}"
43
+ end
44
+ ensure
45
+ session.delete(:current_registration)
46
+ end
47
+
48
+ def destroy
49
+ Current.user.second_factor_webauthn_credentials.destroy(params[:id])
50
+
51
+ redirect_to root_path, notice: "Security Key deleted successfully"
52
+ end
53
+
54
+ private
55
+
56
+ def create_credential_params
57
+ params.expect(credential: [ :nickname, :public_key_credential ])
58
+ end
59
+ end
@@ -0,0 +1,50 @@
1
+ class WebauthnSessionsController < ApplicationController
2
+ allow_unauthenticated_access only: %i[get_options create]
3
+
4
+ def get_options
5
+ get_options = WebAuthn::Credential.options_for_get(user_verification: "required")
6
+ session[:current_authentication] = { challenge: get_options.challenge }
7
+
8
+ render json: get_options
9
+ end
10
+
11
+ def create
12
+ webauthn_credential = WebAuthn::Credential.from_get(JSON.parse(session_params[:public_key_credential]))
13
+
14
+ stored_credential = WebauthnCredential.passkey.find_by(external_id: webauthn_credential.id)
15
+ unless stored_credential
16
+ redirect_to new_session_path, alert: "Credential not recognized"
17
+ return
18
+ end
19
+
20
+ begin
21
+ webauthn_credential.verify(
22
+ session[:current_authentication][:challenge] || session[:current_authentication]["challenge"],
23
+ public_key: stored_credential.public_key,
24
+ sign_count: stored_credential.sign_count,
25
+ user_verification: true,
26
+ )
27
+
28
+ stored_credential.update!(sign_count: webauthn_credential.sign_count)
29
+ start_new_session_for stored_credential.user
30
+
31
+ redirect_to after_authentication_url
32
+ rescue WebAuthn::Error => e
33
+ redirect_to new_session_path, alert: "Verification failed: #{e.message}"
34
+ end
35
+ ensure
36
+ session.delete(:current_authentication)
37
+ end
38
+
39
+ def destroy
40
+ terminate_session
41
+
42
+ redirect_to new_session_path
43
+ end
44
+
45
+ private
46
+
47
+ def session_params
48
+ params.expect(session: [ :public_key_credential ])
49
+ end
50
+ end
@@ -0,0 +1,64 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { create as createWebAuthnJSON, get as getWebAuthnJSON } from "@github/webauthn-json/browser-ponyfill";
3
+
4
+ export default class extends Controller {
5
+ static targets = ["credentialHiddenInput", "submitButton"];
6
+ static values = { optionsUrl: String }
7
+
8
+ connect() {
9
+ this.submitButtonTarget.disabled = false;
10
+ }
11
+
12
+ async create() {
13
+ try {
14
+ const optionsResponse = await fetch(this.optionsUrlValue, {
15
+ method: "POST",
16
+ body: new FormData(this.element),
17
+ headers: {
18
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute("content")
19
+ },
20
+ });
21
+
22
+ const optionsJson = await optionsResponse.json();
23
+ if (optionsResponse.ok) {
24
+ const credentialOptions = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
25
+ const credential = await createWebAuthnJSON({ publicKey: credentialOptions });
26
+
27
+ this.credentialHiddenInputTarget.value = JSON.stringify(credential);
28
+
29
+ this.element.submit();
30
+ } else {
31
+ alert(optionsJson.errors?.[0] || "Unknown error");
32
+ }
33
+ } catch (error) {
34
+ alert(error.message || error);
35
+ }
36
+ }
37
+
38
+ async get() {
39
+ try {
40
+ const optionsResponse = await fetch(this.optionsUrlValue, {
41
+ method: "POST",
42
+ body: new FormData(this.element),
43
+ headers: {
44
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute("content")
45
+ },
46
+ });
47
+
48
+ const optionsJson = await optionsResponse.json();
49
+
50
+ if (optionsResponse.ok) {
51
+ const credentialOptions = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
52
+ const credential = await getWebAuthnJSON({ publicKey: credentialOptions });
53
+
54
+ this.credentialHiddenInputTarget.value = JSON.stringify(credential);
55
+
56
+ this.element.submit();
57
+ } else {
58
+ alert(optionsJson.errors?.[0] || "Unknown error");
59
+ }
60
+ } catch (error) {
61
+ alert(error.message || error);
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,12 @@
1
+ class WebauthnCredential < ApplicationRecord
2
+ belongs_to :user
3
+
4
+ validates :external_id, :public_key, :nickname, :sign_count, presence: true
5
+ validates :external_id, uniqueness: true
6
+ validates :sign_count,
7
+ numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
8
+
9
+ enum :authentication_factor, { first_factor: 0, second_factor: 1 }
10
+
11
+ scope :passkey, -> { first_factor }
12
+ end
@@ -0,0 +1,8 @@
1
+ WebAuthn.configure do |config|
2
+ # This value needs to match `window.location.origin` evaluated by
3
+ # the User Agent during registration and authentication ceremonies.
4
+ # config.allowed_origins = [ "https://auth.example.com" ]
5
+
6
+ # Relying Party name for display purposes
7
+ # config.rp_name = "Example Inc."
8
+ end