devise-passkeys 0.1.0

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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Passkeys
5
+ module Controllers
6
+ module Concerns
7
+ module PasskeyReauthentication
8
+ extend ActiveSupport::Concern
9
+
10
+ def store_reauthentication_token_in_session
11
+ session[passkey_reauthentication_token_key] = Devise.friendly_token(50)
12
+ end
13
+
14
+ def stored_reauthentication_token
15
+ session[passkey_reauthentication_token_key]
16
+ end
17
+
18
+ def clear_reauthentication_token!
19
+ session.delete(passkey_reauthentication_token_key)
20
+ end
21
+
22
+ def consume_reauthentication_token!
23
+ value = stored_reauthentication_token
24
+ clear_reauthentication_token!
25
+ value
26
+ end
27
+
28
+ def valid_reauthentication_token?(given_reauthentication_token:)
29
+ Devise.secure_compare(consume_reauthentication_token!, given_reauthentication_token)
30
+ end
31
+
32
+ def passkey_reauthentication_token_key
33
+ "#{resource_name}_current_reauthentication_token"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Passkeys
5
+ module Controllers
6
+ module Concerns
7
+ module ReauthenticationChallenge
8
+ extend ActiveSupport::Concern
9
+
10
+ def passkey_reauthentication_challenge_session_key
11
+ "#{resource_name}_current_reauthentication_challenge"
12
+ end
13
+
14
+ def store_reauthentication_challenge_in_session(options_for_authentication:)
15
+ session[passkey_reauthentication_challenge_session_key] = options_for_authentication.challenge
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Passkeys
5
+ module Controllers
6
+ module PasskeysControllerConcern
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ include Devise::Passkeys::Controllers::Concerns::PasskeyReauthentication
11
+ include Devise::Passkeys::Controllers::Concerns::ReauthenticationChallenge
12
+ include Warden::WebAuthn::AuthenticationInitiationHelpers
13
+ include Warden::WebAuthn::RegistrationHelpers
14
+ include Warden::WebAuthn::StrategyHelpers
15
+
16
+ prepend_before_action :authenticate_scope!
17
+ before_action :ensure_at_least_one_passkey, only: %i[new_destroy_challenge destroy]
18
+ before_action :find_passkey, only: %i[new_destroy_challenge destroy]
19
+
20
+ before_action :verify_passkey_challenge, only: [:create]
21
+ before_action :verify_reauthentication_token, only: %i[create destroy]
22
+
23
+ # Authenticates the current scope and gets the current resource from the session.
24
+ def authenticate_scope!
25
+ send(:"authenticate_#{resource_name}!", force: true)
26
+ self.resource = send(:"current_#{resource_name}")
27
+ end
28
+
29
+ def registration_challenge_key
30
+ "#{resource_name}_passkey_creation_challenge"
31
+ end
32
+
33
+ def errors
34
+ warden.errors
35
+ end
36
+
37
+ def raw_credential
38
+ passkey_params[:credential]
39
+ end
40
+ end
41
+
42
+ def new_create_challenge
43
+ options_for_registration = generate_registration_options(
44
+ relying_party: relying_party,
45
+ user_details: user_details_for_registration,
46
+ exclude: exclude_external_ids_for_registration
47
+ )
48
+
49
+ store_challenge_in_session(options_for_registration: options_for_registration)
50
+
51
+ render json: options_for_registration
52
+ end
53
+
54
+ def create
55
+ create_passkey(resource: resource)
56
+ end
57
+
58
+ def new_destroy_challenge
59
+ allowed_passkeys = (resource.passkeys - [@passkey])
60
+
61
+ options_for_authentication = generate_authentication_options(relying_party: relying_party,
62
+ options: { allow: allowed_passkeys.pluck(:external_id) })
63
+
64
+ store_reauthentication_challenge_in_session(options_for_authentication: options_for_authentication)
65
+
66
+ render json: options_for_authentication
67
+ end
68
+
69
+ def destroy
70
+ @passkey.destroy
71
+ redirect_to root_path
72
+ end
73
+
74
+ protected
75
+
76
+ def create_passkey(resource:)
77
+ passkey = resource.passkeys.create!(
78
+ label: passkey_params[:label],
79
+ public_key: @webauthn_credential.public_key,
80
+ external_id: Base64.strict_encode64(@webauthn_credential.raw_id),
81
+ sign_count: @webauthn_credential.sign_count,
82
+ last_used_at: nil
83
+ )
84
+ yield [resource, passkey] if block_given?
85
+ redirect_to root_path
86
+ end
87
+
88
+ def exclude_external_ids_for_registration
89
+ resource.passkeys.pluck(:external_id)
90
+ end
91
+
92
+ def user_details_for_registration
93
+ { id: resource.webauthn_id, name: resource.email }
94
+ end
95
+
96
+ def verify_passkey_challenge
97
+ if parsed_credential.nil?
98
+ render json: { message: find_message(:credential_missing_or_could_not_be_parsed) }, status: :bad_request
99
+ delete_registration_challenge
100
+ return false
101
+ end
102
+ begin
103
+ @webauthn_credential = verify_registration(relying_party: relying_party)
104
+ rescue ::WebAuthn::Error => e
105
+ error_key = Warden::WebAuthn::ErrorKeyFinder.webauthn_error_key(exception: e)
106
+ render json: { message: find_message(error_key) }, status: :bad_request
107
+ end
108
+ end
109
+
110
+ def passkey_params
111
+ params.require(:passkey).permit(:label, :credential)
112
+ end
113
+
114
+ def ensure_at_least_one_passkey
115
+ return unless current_user.passkeys.count <= 1
116
+
117
+ render json: { error: find_message(:must_be_at_least_one_passkey) }, status: :bad_request
118
+ end
119
+
120
+ def find_passkey
121
+ @passkey = resource.passkeys.where(id: params[:id]).first
122
+ return unless @passkey.nil?
123
+
124
+ head :not_found
125
+ nil
126
+ end
127
+
128
+ def verify_reauthentication_token
129
+ return if valid_reauthentication_token?(given_reauthentication_token: reauthentication_params[:reauthentication_token])
130
+
131
+ render json: { error: find_message(:not_reauthenticated) }, status: :bad_request
132
+ end
133
+
134
+ def reauthentication_params
135
+ params.require(:passkey).permit(:reauthentication_token)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Passkeys
5
+ module Controllers
6
+ module ReauthenticationControllerConcern
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ include Devise::Passkeys::Controllers::Concerns::PasskeyReauthentication
11
+ include Devise::Passkeys::Controllers::Concerns::ReauthenticationChallenge
12
+ include Warden::WebAuthn::AuthenticationInitiationHelpers
13
+ include Warden::WebAuthn::StrategyHelpers
14
+
15
+ prepend_before_action :authenticate_scope!
16
+
17
+ before_action :prepare_params, only: [:reauthenticate]
18
+
19
+ # Prepending is crucial to ensure that the relying party is set in the
20
+ # request.env before the strategy is executed
21
+ prepend_before_action :set_relying_party_in_request_env
22
+
23
+ # Authenticates the current scope and gets the current resource from the session.
24
+ def authenticate_scope!
25
+ send(:"authenticate_#{resource_name}!", force: true)
26
+ self.resource = send(:"current_#{resource_name}")
27
+ end
28
+ end
29
+
30
+ def new_challenge
31
+ options_for_authentication = generate_authentication_options(relying_party: relying_party,
32
+ options: { allow: resource.passkeys.pluck(:external_id) })
33
+
34
+ store_reauthentication_challenge_in_session(options_for_authentication: options_for_authentication)
35
+
36
+ render json: options_for_authentication
37
+ end
38
+
39
+ def reauthenticate
40
+ sign_out(resource)
41
+ self.resource = warden.authenticate!(strategy, auth_options)
42
+ sign_in(resource, event: :passkey_reauthentication)
43
+ yield resource if block_given?
44
+
45
+ store_reauthentication_token_in_session
46
+
47
+ render json: { reauthentication_token: stored_reauthentication_token }
48
+ ensure
49
+ delete_reauthentication_challenge
50
+ end
51
+
52
+ protected
53
+
54
+ def prepare_params
55
+ request.params[resource_name] = ActionController::Parameters.new({
56
+ passkey_credential: params[:passkey_credential]
57
+ })
58
+ end
59
+
60
+ def strategy
61
+ :passkey_reauthentication
62
+ end
63
+
64
+ def auth_options
65
+ { scope: resource_name, recall: root_path }
66
+ end
67
+
68
+ def delete_reauthentication_challenge
69
+ session.delete(passkey_reauthentication_challenge_session_key)
70
+ end
71
+
72
+ def set_relying_party_in_request_env
73
+ raise "need to define relying_party for this SessionsController"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Passkeys
5
+ module Controllers
6
+ module RegistrationsControllerConcern
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ include Devise::Passkeys::Controllers::Concerns::PasskeyReauthentication
11
+ include Warden::WebAuthn::RegistrationHelpers
12
+
13
+ before_action :require_no_authentication, only: [:new_challenge]
14
+ before_action :require_email_and_passkey_label, only: %i[new_challenge create]
15
+ before_action :verify_passkey_registration_challenge, only: [:create]
16
+ before_action :configure_sign_up_params, only: [:create]
17
+
18
+ before_action :verify_reauthentication_token, only: %i[update destroy]
19
+
20
+ def registration_user_id_key
21
+ "#{resource_name}_current_webauthn_user_id"
22
+ end
23
+
24
+ def registration_challenge_key
25
+ "#{resource_name}_current_webauthn_registration_challenge"
26
+ end
27
+
28
+ def raw_credential
29
+ passkey_params[:passkey_credential]
30
+ end
31
+ end
32
+
33
+ def new_challenge
34
+ options_for_registration = generate_registration_options(
35
+ relying_party: relying_party,
36
+ user_details: user_details_for_registration,
37
+ exclude: exclude_external_ids_for_registration
38
+ )
39
+
40
+ store_challenge_in_session(options_for_registration: options_for_registration)
41
+
42
+ render json: options_for_registration
43
+ end
44
+
45
+ def create
46
+ super do |resource|
47
+ create_resource_and_passkey(resource: resource)
48
+ end
49
+ end
50
+
51
+ protected
52
+
53
+ def create_resource_and_passkey(resource:)
54
+ return unless resource.persisted?
55
+
56
+ passkey = create_passkey(resource: resource)
57
+
58
+ yield [resource, passkey] if block_given?
59
+ delete_registration_user_id!
60
+ end
61
+
62
+ def create_passkey(resource:)
63
+ resource.passkeys.create!(
64
+ label: passkey_params[:passkey_label],
65
+ public_key: @webauthn_credential.public_key,
66
+ external_id: Base64.strict_encode64(@webauthn_credential.raw_id),
67
+ sign_count: @webauthn_credential.sign_count,
68
+ last_used_at: Time.now.utc
69
+ )
70
+ end
71
+
72
+ def verify_reauthentication_token
73
+ return if valid_reauthentication_token?(given_reauthentication_token: reauthentication_params[:reauthentication_token])
74
+
75
+ render json: { error: find_message(:not_reauthenticated) }, status: :bad_request
76
+ end
77
+
78
+ def reauthentication_params
79
+ params.require(:user).permit(:reauthentication_token)
80
+ end
81
+
82
+ def update_resource(resource, params)
83
+ resource.update(params)
84
+ end
85
+
86
+ # Override if you need to exclude certain external IDs
87
+ def exclude_external_ids_for_registration
88
+ []
89
+ end
90
+
91
+ def passkey_params
92
+ params.require(resource_name).permit(:passkey_label, :passkey_credential)
93
+ end
94
+
95
+ def require_email_and_passkey_label
96
+ if sign_up_params[:email].blank?
97
+ render json: { message: find_message(:email_missing) }, status: :bad_request
98
+ return false
99
+ end
100
+
101
+ if passkey_params[:passkey_label].blank?
102
+ render json: { message: find_message(:passkey_label_missing) }, status: :bad_request
103
+ return false
104
+ end
105
+
106
+ true
107
+ end
108
+
109
+ def verify_passkey_registration_challenge
110
+ @webauthn_credential = verify_registration(relying_party: relying_party)
111
+ rescue ::WebAuthn::Error => e
112
+ error_key = Warden::WebAuthn::ErrorKeyFinder.webauthn_error_key(exception: e)
113
+ render json: { message: find_message(error_key) }, status: :bad_request
114
+ end
115
+
116
+ # If you have extra params to permit, append them to the sanitizer.
117
+ def configure_sign_up_params
118
+ params[:user][:webauthn_id] = registration_user_id
119
+ devise_parameter_sanitizer.permit(:sign_up, keys: [:webauthn_id])
120
+ end
121
+
122
+ def user_details_for_registration
123
+ store_registration_user_id
124
+ { id: registration_user_id, name: sign_up_params[:email] }
125
+ end
126
+
127
+ def registration_user_id
128
+ session[registration_user_id_key]
129
+ end
130
+
131
+ def delete_registration_user_id!
132
+ session.delete(registration_user_id_key)
133
+ end
134
+
135
+ def store_registration_user_id
136
+ session[registration_user_id_key] = WebAuthn.generate_user_id
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Passkeys
5
+ module Controllers
6
+ module SessionsControllerConcern
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ include Warden::WebAuthn::AuthenticationInitiationHelpers
11
+ include Warden::WebAuthn::StrategyHelpers
12
+
13
+ # Prepending is crucial to ensure that the relying party is set in the
14
+ # request.env before the strategy is executed
15
+ prepend_before_action :set_relying_party_in_request_env
16
+
17
+ def authentication_challenge_key
18
+ "#{resource_name}_current_webauthn_authentication_challenge"
19
+ end
20
+ end
21
+
22
+ def new_challenge
23
+ options_for_authentication = generate_authentication_options(relying_party: relying_party)
24
+
25
+ store_challenge_in_session(options_for_authentication: options_for_authentication)
26
+
27
+ render json: options_for_authentication
28
+ end
29
+
30
+ protected
31
+
32
+ def set_relying_party_in_request_env
33
+ raise "need to define relying_party for this SessionsController"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "controllers/concerns/passkey_reauthentication"
4
+ require_relative "controllers/concerns/reauthentication_challenge"
5
+ require_relative "controllers/sessions_controller_concern"
6
+ require_relative "controllers/registrations_controller_concern"
7
+ require_relative "controllers/reauthentication_controller_concern"
8
+ require_relative "controllers/passkeys_controller_concern"
9
+
10
+ module Devise
11
+ module Passkeys
12
+ module Controllers
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Models
5
+ module PasskeyAuthenticatable
6
+ def after_passkey_authentication; end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Passkeys
5
+ class PasskeyIssuer
6
+ def self.build
7
+ new
8
+ end
9
+
10
+ def create_and_return_passkey(resource:, label:, webauthn_credential:, extra_attributes: {})
11
+ # rubocop:disable Lint/UselessAssignment
12
+ passkey_class = passkey_class(resource)
13
+ # rubocop:enable Lint/UselessAssignment
14
+
15
+ resource.passkeys.create!({
16
+ label: label,
17
+ public_key: webauthn_credential.public_key,
18
+ external_id: Base64.strict_encode64(webauthn_credential.raw_id),
19
+ sign_count: webauthn_credential.sign_count,
20
+ last_used_at: nil
21
+ }.merge(extra_attributes))
22
+ end
23
+
24
+ class CredentialFinder
25
+ attr_reader :resource_class
26
+
27
+ def initialize(resource_class:)
28
+ @resource_class = resource_class
29
+ end
30
+
31
+ def find_with_credential_id(encoded_credential_id)
32
+ resource_class.passkeys_class.where(external_id: encoded_credential_id).first
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ attr_accessor :maximum_passkeys_per_user
39
+
40
+ def passkey_class(resource)
41
+ if resource.respond_to?(:association) # ActiveRecord
42
+ resource.association(:passkeys).klass
43
+ elsif resource.respond_to?(:relations) # Mongoid
44
+ resource.relations["passkeys"].klass
45
+ else
46
+ raise "Cannot determine passkey class, unsupported ORM/ODM?"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise::Passkeys
4
+ class Engine < ::Rails::Engine
5
+ end
6
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "devise/strategies/authenticatable"
4
+ require_relative "passkey_issuer"
5
+
6
+ module Devise
7
+ module Strategies
8
+ class PasskeyReauthentication < PasskeyAuthenticatable
9
+ def authentication_challenge_key
10
+ "#{mapping.singular}_current_reauthentication_challenge"
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ Warden::Strategies.add(:passkey_reauthentication, Devise::Strategies::PasskeyReauthentication)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "devise/strategies/authenticatable"
4
+ require_relative "passkey_issuer"
5
+
6
+ module Devise
7
+ module Strategies
8
+ class PasskeyAuthenticatable < Authenticatable
9
+ include Warden::WebAuthn::StrategyHelpers
10
+
11
+ def store?
12
+ super && !mapping.to.skip_session_storage.include?(:passkey_auth)
13
+ end
14
+
15
+ def valid?
16
+ return true unless parsed_credential.nil?
17
+
18
+ # rubocop:disable Lint/UnreachableCode
19
+ fail(:credential_missing_or_could_not_be_parsed)
20
+ false
21
+ # rubocop:enable Lint/UnreachableCode
22
+ end
23
+
24
+ def authenticate!
25
+ passkey = verify_authentication_and_find_stored_credential
26
+
27
+ return if passkey.nil?
28
+
29
+ resource = mapping.to.find_for_passkey(passkey)
30
+
31
+ return fail(:invalid_passkey) unless resource
32
+
33
+ if validate(resource)
34
+ remember_me(resource)
35
+ resource.after_passkey_authentication
36
+ record_passkey_use(passkey: passkey)
37
+ success!(resource)
38
+ return
39
+ end
40
+
41
+ # In paranoid mode, fail with a generic invalid error
42
+ Devise.paranoid ? fail(:invalid_passkey) : fail(:not_found_in_database)
43
+ end
44
+
45
+ def credential_finder
46
+ Devise::Passkeys::PasskeyIssuer::CredentialFinder.new(resource_class: mapping.to)
47
+ end
48
+
49
+ def raw_credential
50
+ params.dig(mapping.singular, :passkey_credential)
51
+ end
52
+
53
+ def authentication_challenge_key
54
+ "#{mapping.singular}_current_webauthn_authentication_challenge"
55
+ end
56
+
57
+ def record_passkey_use(passkey:)
58
+ passkey.update_attribute(:last_used_at, Time.current)
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ Warden::Strategies.add(:passkey_authenticatable, Devise::Strategies::PasskeyAuthenticatable)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Passkeys
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "devise"
4
+ require "warden/webauthn"
5
+ require_relative "passkeys/rails"
6
+ require_relative "passkeys/model"
7
+ require_relative "passkeys/controllers"
8
+ require_relative "passkeys/passkey_issuer"
9
+ require_relative "passkeys/strategy"
10
+ require_relative "passkeys/reauthentication_strategy"
11
+ require_relative "passkeys/version"
12
+
13
+ module Devise
14
+ module Passkeys
15
+ def self.create_and_return_passkey(resource:, label:, webauthn_credential:, extra_attributes: {})
16
+ PasskeyIssuer.build.create_and_return_passkey(
17
+ resource: resource,
18
+ label: label,
19
+ webauthn_credential: webauthn_credential,
20
+ extra_attributes: extra_attributes
21
+ )
22
+ end
23
+ end
24
+ end
25
+
26
+ Devise.add_module :passkey_authenticatable,
27
+ model: "devise/passkeys/model",
28
+ strategy: true,
29
+ no_input: true
@@ -0,0 +1,6 @@
1
+ module Devise
2
+ module Passkeys
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end