passkeys-rails 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 666859b1227428b580202f3c2f9822cf937db0ecaf26381dddefd45b6d8e9b3e
4
- data.tar.gz: 7cbce30a6f2efb7eb15aa4a747156da1e355c795ca346fdc8af2ecad6af837d6
3
+ metadata.gz: afbb635c3ddfd36d60bccbf936e2a67e091a6bb15822683c9c1db050cdd49909
4
+ data.tar.gz: c9ef1cf6d9e9f5a760037efb831b856dbf706c0860fbf945eadd616c37cb297d
5
5
  SHA512:
6
- metadata.gz: d0bff5cc099209252fa1853c9e75e8f23edce8de4ac910f2e958e9279ec81a4452074fa6611d42424e4809fc010a129072e7d719e9de8b4e0a3e40e7619ad7f9
7
- data.tar.gz: 98e62cd971c37781aecadc0dbe09b1c32d6aa2e0837f24c816971c660d8d00c19c738a339a43f021a4a27e843b79b04b3128f65f7ff21b971289fa232dd0df75
6
+ metadata.gz: 92e7f2a72715c7c4b313d57f7dc1c928cf334375fcdd28fc104f86721562b45484220234e8e5924106fce344e94d95f9cb7e29d0d6879e4ac73451e7a1a17915
7
+ data.tar.gz: 686d6a95cec8274eaadd3fa1555a89f0e1554bc248c1f30eca1ac253933a158e8d12e08bcd74e53b42cbe33a9e0a1630ed14d1ce3a7205a2fe289366d3cbe2c3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ### 0.3.0
2
+
3
+ * Added debug_register endpoint.
4
+ * Fixed authenticatable_params for register enpoint.
5
+ * Added notifications to certain controller actions.
6
+ * Improved spec error helper.
7
+
8
+ ### 0.2.1
9
+
10
+ Added ability to pass either the auth token string or a request with one in the header to authenticate methods.
11
+
1
12
  ### 0.2.0
2
13
 
3
14
  * Added passkeys/debug_login functionality.
data/README.md CHANGED
@@ -56,7 +56,7 @@ Finally, execute:
56
56
  $ rails generate passkeys_rails:install
57
57
  ```
58
58
 
59
- This will add the `passkeys_rails.rb` configuration file, passkeys routes, and a couple of database migrations to your project.
59
+ This will add the `config/initializers/passkeys_rails.rb` configuration file, passkeys routes, and a couple of database migrations to your project.
60
60
 
61
61
  ### Adding to an standard rails project
62
62
 
@@ -113,6 +113,41 @@ This will add the `passkeys_rails.rb` configuration file, passkeys routes, and a
113
113
 
114
114
  To access the currently authenticated entity, use `current_agent`. If you associated the registration of the agent with one of your own models, use `current_agent.authenticatable`. For example, if you associated the `User` class with the registration, `current_agent.authenticatable` will be a User object.
115
115
 
116
+ ### Notifications
117
+
118
+ Certain actions trigger notifications that can be subscribed. See `subscribe` in `passkeys_rails.rb`.
119
+
120
+ #### Events
121
+
122
+ - `:did_register ` - a new agent has registered
123
+
124
+ - `:did_authenticate` - an agent has been authenticated
125
+
126
+ - `:did_refresh` - an agent's auth token has been refreshed
127
+
128
+ A convenient place to set these up in is in `passkeys_rails.rb`
129
+
130
+ ```ruby
131
+ PasskeysRails.config do |c|
132
+ c.subscribe(:did_register) do |event, agent, request|
133
+ # do something with the agent and/or request
134
+ end
135
+
136
+ c.subscribe(:did_authenticate) do |event, agent, request|
137
+ # do something with the agent and/or request
138
+ end
139
+ end
140
+ ```
141
+
142
+ Subscriptions can also be done elsewhere as subscribe is a PasskeysRails class method.
143
+
144
+ ```ruby
145
+ PasskeysRails.subscribe(:did_register) do |event, agent, request|
146
+ # do something with the agent and/or request
147
+ end
148
+ ```
149
+
150
+
116
151
  ### Authentication Failure
117
152
 
118
153
  1. In the event of authentication failure, PasskeysRails returns an error code and message.
@@ -1,10 +1,15 @@
1
1
  module PasskeysRails
2
2
  class ApplicationController < ActionController::Base
3
+ rescue_from StandardError, with: :handle_standard_error
3
4
  rescue_from ::Interactor::Failure, with: :handle_interactor_failure
4
5
  rescue_from ActionController::ParameterMissing, with: :handle_missing_parameter
5
6
 
6
7
  protected
7
8
 
9
+ def handle_standard_error(error)
10
+ render_error(:authentication, 'error', error.message.truncate(512), status: 500)
11
+ end
12
+
8
13
  def handle_missing_parameter(error)
9
14
  render_error(:authentication, 'missing_parameter', error.message)
10
15
  end
@@ -1,5 +1,8 @@
1
1
  module PasskeysRails
2
2
  class PasskeysController < ApplicationController
3
+ skip_before_action :verify_authenticity_token
4
+ wrap_parameters false
5
+
3
6
  def challenge
4
7
  result = PasskeysRails::BeginChallenge.call!(username: challenge_params[:username])
5
8
 
@@ -11,23 +14,30 @@ module PasskeysRails
11
14
 
12
15
  def register
13
16
  result = PasskeysRails::FinishRegistration.call!(credential: attestation_credential_params.to_h,
14
- authenticatable_info: authenticatable_info&.to_h,
17
+ authenticatable_info: authenticatable_params&.to_h,
15
18
  username: session.dig(:passkeys_rails, :username),
16
19
  challenge: session.dig(:passkeys_rails, :challenge))
17
20
 
18
- render json: { username: result.username, auth_token: result.auth_token }
21
+ broadcast(:did_register, agent: result.agent)
22
+
23
+ render json: auth_response(result)
19
24
  end
20
25
 
21
26
  def authenticate
22
27
  result = PasskeysRails::FinishAuthentication.call!(credential: authentication_params.to_h,
23
28
  challenge: session.dig(:passkeys_rails, :challenge))
24
29
 
25
- render json: { username: result.username, auth_token: result.auth_token }
30
+ broadcast(:did_authenticate, agent: result.agent)
31
+
32
+ render json: auth_response(result)
26
33
  end
27
34
 
28
35
  def refresh
29
36
  result = PasskeysRails::RefreshToken.call!(token: refresh_params[:auth_token])
30
- render json: { username: result.username, auth_token: result.auth_token }
37
+
38
+ broadcast(:did_refresh, agent: result.agent)
39
+
40
+ render json: auth_response(result)
31
41
  end
32
42
 
33
43
  # This action exists to allow easier mobile app debugging as it may not
@@ -36,11 +46,31 @@ module PasskeysRails
36
46
  # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment.
37
47
  def debug_login
38
48
  result = PasskeysRails::DebugLogin.call!(username: debug_login_params[:username])
39
- render json: { username: result.username, auth_token: result.auth_token }
49
+
50
+ broadcast(:did_authenticate, agent: result.agent)
51
+
52
+ render json: auth_response(result)
53
+ end
54
+
55
+ # This action exists to allow easier mobile app debugging as it may not
56
+ # be possible to acess Passkey functionality in mobile simulators.
57
+ # It is only routable if DEBUG_LOGIN_REGEX is set in the server environment.
58
+ # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment.
59
+ def debug_register
60
+ result = PasskeysRails::DebugRegister.call!(username: debug_login_params[:username],
61
+ authenticatable_info: authenticatable_params&.to_h)
62
+
63
+ broadcast(:did_register, agent: result.agent)
64
+
65
+ render json: auth_response(result)
40
66
  end
41
67
 
42
68
  protected
43
69
 
70
+ def auth_response(result)
71
+ { username: result.username, auth_token: result.auth_token }
72
+ end
73
+
44
74
  def challenge_params
45
75
  params.permit(:username)
46
76
  end
@@ -52,8 +82,8 @@ module PasskeysRails
52
82
  credential.permit(:id, :rawId, :type, { response: %i[attestationObject clientDataJSON] })
53
83
  end
54
84
 
55
- def authenticatable_info
56
- params.require[:authenticatable].permit(:class, :params) if params[:authenticatable].present?
85
+ def authenticatable_params
86
+ params.require(:authenticatable).permit(:class, params: {}) if params[:authenticatable].present?
57
87
  end
58
88
 
59
89
  def authentication_params
@@ -71,5 +101,9 @@ module PasskeysRails
71
101
  params.require(:username)
72
102
  params.permit(:username)
73
103
  end
104
+
105
+ def broadcast(event_name, agent:)
106
+ ActiveSupport::Notifications.instrument("passkeys_rails.#{event_name}", { agent:, request: })
107
+ end
74
108
  end
75
109
  end
@@ -5,6 +5,7 @@
5
5
  module PasskeysRails
6
6
  class DebugLogin
7
7
  include Interactor
8
+ include Debuggable
8
9
 
9
10
  delegate :username, to: :context
10
11
 
@@ -12,6 +13,7 @@ module PasskeysRails
12
13
  ensure_debug_mode
13
14
  ensure_regex_match
14
15
 
16
+ context.agent = agent
15
17
  context.username = agent.username
16
18
  context.auth_token = GenerateAuthToken.call!(agent:).auth_token
17
19
  rescue Interactor::Failure => e
@@ -20,18 +22,6 @@ module PasskeysRails
20
22
 
21
23
  private
22
24
 
23
- def ensure_debug_mode
24
- context.fail!(code: :not_allowed, message: 'Action not allowed') if username_regex.blank?
25
- end
26
-
27
- def ensure_regex_match
28
- context.fail!(code: :not_allowed, message: 'Invalid username') unless username&.match?(username_regex)
29
- end
30
-
31
- def username_regex
32
- PasskeysRails.debug_login_regex
33
- end
34
-
35
25
  def agent
36
26
  @agent ||= begin
37
27
  agent = Agent.find_by(username:)
@@ -0,0 +1,44 @@
1
+ # This functionality exists to allow easier mobile app debugging as it may not
2
+ # be possible to acess Passkey functionality in mobile simulators.
3
+ # It is only operational if DEBUG_LOGIN_REGEX is set in the server environment.
4
+ # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment.
5
+ module PasskeysRails
6
+ class DebugRegister
7
+ include Interactor
8
+ include Debuggable
9
+ include AuthenticatableCreator
10
+
11
+ delegate :username, :authenticatable_info, to: :context
12
+
13
+ def call
14
+ ensure_debug_mode
15
+ ensure_regex_match
16
+
17
+ ActiveRecord::Base.transaction do
18
+ create_authenticatable! if aux_class_name.present?
19
+ end
20
+
21
+ context.agent = agent
22
+ context.username = agent.username
23
+ context.auth_token = GenerateAuthToken.call!(agent:).auth_token
24
+ rescue Interactor::Failure => e
25
+ context.fail! code: e.context.code, message: e.context.message
26
+ end
27
+
28
+ private
29
+
30
+ def agent
31
+ @agent ||= begin
32
+ result = BeginRegistration.call(username:)
33
+ context.fail!(code: result.code, message: result.message) if result.failure?
34
+
35
+ agent = Agent.find_by(username:)
36
+ context.fail!(code: :agent_not_found, message: "No agent found with that username") if agent.blank?
37
+
38
+ agent.update! registered_at: Time.current
39
+
40
+ agent
41
+ end
42
+ end
43
+ end
44
+ end
@@ -8,6 +8,7 @@ module PasskeysRails
8
8
  def call
9
9
  verify_credential!
10
10
 
11
+ context.agent = agent
11
12
  context.username = agent.username
12
13
  context.auth_token = GenerateAuthToken.call!(agent:).auth_token
13
14
  rescue Interactor::Failure => e
@@ -2,13 +2,19 @@
2
2
  module PasskeysRails
3
3
  class FinishRegistration
4
4
  include Interactor
5
+ include AuthenticatableCreator
5
6
 
6
7
  delegate :credential, :username, :challenge, :authenticatable_info, to: :context
7
8
 
8
9
  def call
9
10
  verify_credential!
10
- store_passkey_and_register_agent!
11
11
 
12
+ agent.transaction do
13
+ store_passkey_and_register_agent!
14
+ create_authenticatable! if aux_class_name.present?
15
+ end
16
+
17
+ context.agent = agent
12
18
  context.username = agent.username
13
19
  context.auth_token = GenerateAuthToken.call!(agent:).auth_token
14
20
  rescue Interactor::Failure => e
@@ -26,66 +32,16 @@ module PasskeysRails
26
32
  end
27
33
 
28
34
  def store_passkey_and_register_agent!
29
- agent.transaction do
30
- begin
31
- # Store Credential ID, Credential Public Key and Sign Count for future authentications
32
- agent.passkeys.create!(
33
- identifier: webauthn_credential.id,
34
- public_key: webauthn_credential.public_key,
35
- sign_count: webauthn_credential.sign_count
36
- )
37
-
38
- agent.update! registered_at: Time.current
39
- rescue StandardError => e
40
- context.fail! code: :passkey_error, message: e.message
41
- end
42
-
43
- create_authenticatable! if aux_class_name.present?
44
- end
45
- end
46
-
47
- def authenticatable_class
48
- authenticatable_info && authenticatable_info[:class]
49
- end
50
-
51
- def authenticatable_params
52
- authenticatable_info && authenticatable_info[:params]
53
- end
54
-
55
- def aux_class_name
56
- @aux_class_name ||= authenticatable_class || PasskeysRails.default_class
57
- end
58
-
59
- def aux_class
60
- whitelist = PasskeysRails.class_whitelist
61
-
62
- @aux_class ||= begin
63
- if whitelist.is_a?(Array)
64
- unless whitelist.include?(aux_class_name)
65
- context.fail!(code: :invalid_authenticatable_class, message: "authenticatable_class (#{aux_class_name}) is not in the whitelist")
66
- end
67
- elsif whitelist.present?
68
- context.fail!(code: :invalid_class_whitelist,
69
- message: "class_whitelist is invalid. It should be nil or an array of zero or more class names.")
70
- end
71
-
72
- begin
73
- aux_class_name.constantize
74
- rescue StandardError
75
- context.fail!(code: :invalid_authenticatable_class, message: "authenticatable_class (#{aux_class_name}) is not defined")
76
- end
77
- end
78
- end
79
-
80
- def create_authenticatable!
81
- authenticatable = aux_class.create! do |obj|
82
- obj.agent = agent if obj.respond_to?(:agent=)
83
- obj.registering_with(authenticatable_params) if obj.respond_to?(:registering_with)
84
- end
85
-
86
- agent.update!(authenticatable:)
87
- rescue ActiveRecord::RecordInvalid => e
88
- context.fail!(code: :record_invalid, message: e.message)
35
+ # Store Credential ID, Credential Public Key and Sign Count for future authentications
36
+ agent.passkeys.create!(
37
+ identifier: webauthn_credential.id,
38
+ public_key: webauthn_credential.public_key,
39
+ sign_count: webauthn_credential.sign_count
40
+ )
41
+
42
+ agent.update! registered_at: Time.current
43
+ rescue StandardError => e
44
+ context.fail! code: :passkey_error, message: e.message
89
45
  end
90
46
 
91
47
  def webauthn_credential
@@ -8,6 +8,7 @@ module PasskeysRails
8
8
  def call
9
9
  agent = ValidateAuthToken.call!(auth_token: token).agent
10
10
 
11
+ context.agent = agent
11
12
  context.username = agent.username
12
13
  context.auth_token = GenerateAuthToken.call!(agent:).auth_token
13
14
  rescue Interactor::Failure => e
@@ -0,0 +1,53 @@
1
+ module PasskeysRails
2
+ module AuthenticatableCreator
3
+ extend ActiveSupport::Concern
4
+
5
+ protected
6
+
7
+ def create_authenticatable!
8
+ authenticatable = aux_class.new
9
+ authenticatable.agent = agent if authenticatable.respond_to?(:agent=)
10
+ authenticatable.registering_with(authenticatable_params) if authenticatable.respond_to?(:registering_with)
11
+ authenticatable.save!
12
+
13
+ agent.update!(authenticatable:)
14
+ rescue ActiveRecord::RecordInvalid => e
15
+ context.fail!(code: :record_invalid, message: e.message)
16
+ end
17
+
18
+ def aux_class_name
19
+ @aux_class_name ||= authenticatable_class || PasskeysRails.default_class
20
+ end
21
+
22
+ private
23
+
24
+ def authenticatable_class
25
+ authenticatable_info && authenticatable_info[:class]
26
+ end
27
+
28
+ def authenticatable_params
29
+ authenticatable_info && authenticatable_info[:params]
30
+ end
31
+
32
+ def aux_class
33
+ whitelist = PasskeysRails.class_whitelist
34
+
35
+ @aux_class ||= begin
36
+ if whitelist.is_a?(Array)
37
+ unless whitelist.include?(aux_class_name)
38
+ context.fail!(code: :invalid_authenticatable_class, message: "authenticatable_class (#{aux_class_name}) is not in the whitelist")
39
+ end
40
+ elsif whitelist.present?
41
+ context.fail!(code: :invalid_class_whitelist,
42
+ message: "class_whitelist is invalid. It should be nil or an array of zero or more class names.")
43
+ end
44
+
45
+ begin
46
+ aux_class_name.constantize
47
+ rescue StandardError
48
+ context.fail!(code: :invalid_authenticatable_class, message: "authenticatable_class (#{aux_class_name}) is not defined")
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,19 @@
1
+ module PasskeysRails
2
+ module Debuggable
3
+ extend ActiveSupport::Concern
4
+
5
+ protected
6
+
7
+ def ensure_debug_mode
8
+ context.fail!(code: :not_allowed, message: 'Action not allowed') if username_regex.blank?
9
+ end
10
+
11
+ def ensure_regex_match
12
+ context.fail!(code: :not_allowed, message: 'Invalid username') unless username&.match?(username_regex)
13
+ end
14
+
15
+ def username_regex
16
+ PasskeysRails.debug_login_regex
17
+ end
18
+ end
19
+ end
data/config/routes.rb CHANGED
@@ -1,13 +1,14 @@
1
1
  PasskeysRails::Engine.routes.draw do
2
- post 'passkeys/challenge'
3
- post 'passkeys/register'
4
- post 'passkeys/authenticate'
5
- post 'passkeys/refresh'
2
+ post 'challenge', to: 'passkeys#challenge'
3
+ post 'register', to: 'passkeys#register'
4
+ post 'authenticate', to: 'passkeys#authenticate'
5
+ post 'refresh', to: 'passkeys#refresh'
6
6
 
7
- # This route exists to allow easier mobile app debugging as it may not
7
+ # These routes exist to allow easier mobile app debugging as it may not
8
8
  # be possible to acess Passkey functionality in mobile simulators.
9
9
  # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment.
10
10
  constraints(->(_request) { PasskeysRails.debug_login_regex.present? }) do
11
- post 'passkeys/debug_login'
11
+ post 'debug_login', to: 'passkeys#debug_login'
12
+ post 'debug_register', to: 'passkeys#debug_register'
12
13
  end
13
14
  end
@@ -45,4 +45,19 @@ PasskeysRails.config do |c|
45
45
  # for example: %w[User AdminUser]
46
46
  #
47
47
  # c.class_whitelist = nil
48
+
49
+ # To subscribe to various events in PasskeysRails, use the subscribe method.
50
+ # It can be called multiple times to subscribe to more than one event.
51
+ #
52
+ # Valid events:
53
+ # :did_register
54
+ # :did_authenticate
55
+ # :did_refresh
56
+ #
57
+ # Each event will include the event name, current agent and http request.
58
+ #
59
+ # For example:
60
+ # c.subscribe(:did_register) do |event, agent, request|
61
+ # puts("#{event} | #{agent.id} | #{request.headers}")
62
+ # end
48
63
  end
@@ -45,6 +45,26 @@ module PasskeysRails
45
45
  # for example: %w[User AdminUser]
46
46
  mattr_accessor :class_whitelist, default: nil
47
47
 
48
+ # Convenience method to subscribe to various events in PasskeysRails.
49
+ #
50
+ # Valid events:
51
+ # :did_register
52
+ # :did_authenticate
53
+ # :did_refresh
54
+ #
55
+ # Each event will include the event name, current agent and http request.
56
+ # For example:
57
+ #
58
+ # subscribe(:did_register) do |event, agent, request|
59
+ # # do something with the agent and/or request
60
+ # end
61
+ #
62
+ def self.subscribe(event_name)
63
+ ActiveSupport::Notifications.subscribe("passkeys_rails.#{event_name}") do |name, _start, _finish, _id, payload|
64
+ yield(name.gsub(/^passkeys_rails\./, ''), payload[:agent], payload[:request]) if block_given?
65
+ end
66
+ end
67
+
48
68
  # This is only used by the debug_login endpoint.
49
69
  # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment.
50
70
  def self.debug_login_regex
@@ -1,3 +1,3 @@
1
1
  module PasskeysRails
2
- VERSION = "0.2.1".freeze
2
+ VERSION = "0.3.0".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: passkeys-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Troy Anderson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-07-29 00:00:00.000000000 Z
11
+ date: 2023-08-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -358,12 +358,15 @@ files:
358
358
  - app/interactors/passkeys_rails/begin_challenge.rb
359
359
  - app/interactors/passkeys_rails/begin_registration.rb
360
360
  - app/interactors/passkeys_rails/debug_login.rb
361
+ - app/interactors/passkeys_rails/debug_register.rb
361
362
  - app/interactors/passkeys_rails/finish_authentication.rb
362
363
  - app/interactors/passkeys_rails/finish_registration.rb
363
364
  - app/interactors/passkeys_rails/generate_auth_token.rb
364
365
  - app/interactors/passkeys_rails/refresh_token.rb
365
366
  - app/interactors/passkeys_rails/validate_auth_token.rb
366
367
  - app/models/concerns/passkeys_rails/authenticatable.rb
368
+ - app/models/concerns/passkeys_rails/authenticatable_creator.rb
369
+ - app/models/concerns/passkeys_rails/debuggable.rb
367
370
  - app/models/passkeys_rails/agent.rb
368
371
  - app/models/passkeys_rails/application_record.rb
369
372
  - app/models/passkeys_rails/error.rb