devise-passkeys 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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