passkeys-rails 0.1.2 → 0.1.3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +1 -1
- data/app/controllers/concerns/passkeys/rails/authentication.rb +29 -0
- data/app/interactors/passkeys/rails/begin_authentication.rb +9 -0
- data/app/interactors/passkeys/rails/begin_challenge.rb +35 -0
- data/app/interactors/passkeys/rails/begin_registration.rb +23 -0
- data/app/interactors/passkeys/rails/finish_authentication.rb +53 -0
- data/app/interactors/passkeys/rails/finish_registration.rb +77 -0
- data/app/interactors/passkeys/rails/generate_auth_token.rb +27 -0
- data/app/interactors/passkeys/rails/refresh_token.rb +17 -0
- data/app/interactors/passkeys/rails/validate_auth_token.rb +33 -0
- data/app/models/concerns/passkeys/rails/authenticatable.rb +8 -10
- data/app/models/passkeys/rails/agent.rb +9 -11
- data/app/models/passkeys/rails/application_record.rb +3 -5
- data/app/models/passkeys/rails/error.rb +9 -11
- data/app/models/passkeys/rails/passkey.rb +6 -8
- data/config/initializers/application_controller.rb +12 -0
- data/lib/passkeys/rails/engine.rb +9 -32
- data/lib/passkeys/rails/version.rb +1 -1
- metadata +12 -11
- data/lib/passkeys/rails/controllers/helpers.rb +0 -33
- data/lib/passkeys/rails/interactors/begin_authentication.rb +0 -11
- data/lib/passkeys/rails/interactors/begin_challenge.rb +0 -37
- data/lib/passkeys/rails/interactors/begin_registration.rb +0 -25
- data/lib/passkeys/rails/interactors/finish_authentication.rb +0 -55
- data/lib/passkeys/rails/interactors/finish_registration.rb +0 -79
- data/lib/passkeys/rails/interactors/generate_auth_token.rb +0 -29
- data/lib/passkeys/rails/interactors/refresh_token.rb +0 -19
- data/lib/passkeys/rails/interactors/validate_auth_token.rb +0 -35
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 84469b812d394ac75533be6f935292932f43fceab669d0842be1daa077abe7ee
|
4
|
+
data.tar.gz: 8430b3679760bc10af93da66835b549646e50bca94bb25fc008ec067a37e32b0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 654a20573b11dae74154bc3b8576f7204d2694218732ec88fdb80004286d20dc8ef0426fbf2f186efc33645ff9ce8604e32cdf59cae9b77712af271255807103
|
7
|
+
data.tar.gz: cb5380598b67e0969ec619570729cea1e534a8e100942adbb9d4389ce9b1eba0937e35d359dd3d8eb2646965e5343519c5404eccefbe606fed1f5a25d9ca6b08
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
[](https://badge.fury.io/rb/passkeys-rails)
|
1
|
+
[](https://badge.fury.io/rb/passkeys-rails)
|
2
2
|
[](https://travis-ci.org/alliedcode/passkeys-rails)
|
3
3
|
[](https://codecov.io/gh/alliedcode/passkeys-rails)
|
4
4
|
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Passkeys::Rails
|
2
|
+
module Authentication
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
rescue_from Passkeys::Rails::Error do |e|
|
7
|
+
render json: e.to_h, status: :unauthorized
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def current_agent
|
12
|
+
return nil if request.headers['HTTP_X_AUTH'].blank?
|
13
|
+
|
14
|
+
@current_agent ||= validated_auth_token&.success? && validated_auth_token&.agent
|
15
|
+
end
|
16
|
+
|
17
|
+
def authenticate_passkey!
|
18
|
+
return if validated_auth_token.success?
|
19
|
+
|
20
|
+
raise Passkeys::Rails::Error.new(:authentication,
|
21
|
+
code: :unauthorized,
|
22
|
+
message: "You are not authorized to access this resource.")
|
23
|
+
end
|
24
|
+
|
25
|
+
def validated_auth_token
|
26
|
+
@validated_auth_token ||= Passkeys::Rails::ValidateAuthToken.call(auth_token: request.headers['HTTP_X_AUTH'])
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Passkeys::Rails
|
2
|
+
class BeginChallenge
|
3
|
+
include Interactor
|
4
|
+
|
5
|
+
delegate :username, to: :context
|
6
|
+
|
7
|
+
def call
|
8
|
+
result = generate_challenge!
|
9
|
+
|
10
|
+
options = result.options
|
11
|
+
|
12
|
+
context.response = options
|
13
|
+
context.session_data = session_data(options)
|
14
|
+
rescue Interactor::Failure => e
|
15
|
+
context.fail! code: e.context.code, message: e.context.message
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def generate_challenge!
|
21
|
+
if username.present?
|
22
|
+
BeginRegistration.call!(username:)
|
23
|
+
else
|
24
|
+
BeginAuthentication.call!
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def session_data(options)
|
29
|
+
{
|
30
|
+
username:,
|
31
|
+
challenge: WebAuthn.standard_encoder.encode(options.challenge)
|
32
|
+
}
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Passkeys::Rails
|
2
|
+
class BeginRegistration
|
3
|
+
include Interactor
|
4
|
+
|
5
|
+
delegate :username, to: :context
|
6
|
+
|
7
|
+
def call
|
8
|
+
agent = create_unregistered_agent
|
9
|
+
|
10
|
+
context.options = WebAuthn::Credential.options_for_create(user: { id: agent.webauthn_identifier, name: agent.username })
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def create_unregistered_agent
|
16
|
+
agent = Agent.create(username:, webauthn_identifier: WebAuthn.generate_user_id)
|
17
|
+
|
18
|
+
context.fail!(code: :validation_errors, message: agent.errors.full_messages.to_sentence) unless agent.valid?
|
19
|
+
|
20
|
+
agent
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# Finish authentication ceremony
|
2
|
+
module Passkeys::Rails
|
3
|
+
class FinishAuthentication
|
4
|
+
include Interactor
|
5
|
+
|
6
|
+
delegate :credential, :challenge, to: :context
|
7
|
+
|
8
|
+
def call
|
9
|
+
verify_credential!
|
10
|
+
|
11
|
+
context.username = agent.username
|
12
|
+
context.auth_token = GenerateAuthToken.call!(agent:).auth_token
|
13
|
+
rescue Interactor::Failure => e
|
14
|
+
context.fail! code: e.context.code, message: e.context.message
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def verify_credential!
|
20
|
+
webauthn_credential.verify(
|
21
|
+
challenge,
|
22
|
+
public_key: passkey.public_key,
|
23
|
+
sign_count: passkey.sign_count
|
24
|
+
)
|
25
|
+
|
26
|
+
passkey.update!(sign_count: webauthn_credential.sign_count)
|
27
|
+
agent.update!(last_authenticated_at: Time.current)
|
28
|
+
rescue WebAuthn::SignCountVerificationError
|
29
|
+
# Cryptographic verification of the authenticator data succeeded, but the signature counter was less than or equal
|
30
|
+
# to the stored value. This can have several reasons and depending on your risk tolerance you can choose to fail or
|
31
|
+
# pass authentication. For more information see https://www.w3.org/TR/webauthn/#sign-counter
|
32
|
+
rescue WebAuthn::Error => e
|
33
|
+
context.fail!(code: :webauthn_error, message: e.message)
|
34
|
+
end
|
35
|
+
|
36
|
+
def webauthn_credential
|
37
|
+
@webauthn_credential ||= WebAuthn::Credential.from_get(credential)
|
38
|
+
end
|
39
|
+
|
40
|
+
def passkey
|
41
|
+
@passkey ||= begin
|
42
|
+
passkey = Passkey.find_by(identifier: webauthn_credential.id)
|
43
|
+
context.fail!(code: :passkey_not_found, message: "Unable to find the specified passkey") if passkey.blank?
|
44
|
+
|
45
|
+
passkey
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def agent
|
50
|
+
passkey.agent
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# Finish registration ceremony
|
2
|
+
module Passkeys::Rails
|
3
|
+
class FinishRegistration
|
4
|
+
include Interactor
|
5
|
+
|
6
|
+
delegate :credential, :username, :challenge, :authenticatable_class, to: :context
|
7
|
+
|
8
|
+
def call
|
9
|
+
verify_credential!
|
10
|
+
store_passkey_and_register_agent!
|
11
|
+
|
12
|
+
context.username = agent.username
|
13
|
+
context.auth_token = GenerateAuthToken.call!(agent:).auth_token
|
14
|
+
rescue Interactor::Failure => e
|
15
|
+
context.fail! code: e.context.code, message: e.context.message
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def verify_credential!
|
21
|
+
webauthn_credential.verify(challenge)
|
22
|
+
rescue WebAuthn::Error => e
|
23
|
+
context.fail!(code: :webauthn_error, message: e.message)
|
24
|
+
rescue StandardError => e
|
25
|
+
context.fail!(code: :error, message: e.message)
|
26
|
+
end
|
27
|
+
|
28
|
+
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 authenticatable_class.present?
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def create_authenticatable!
|
48
|
+
klass = begin
|
49
|
+
authenticatable_class.constantize
|
50
|
+
rescue StandardError
|
51
|
+
context.fail!(code: :invalid_authenticatable_class, message: "authenticatable_class (#{authenticatable_class}) is not defined")
|
52
|
+
end
|
53
|
+
|
54
|
+
begin
|
55
|
+
authenticatable = klass.create! do |obj|
|
56
|
+
obj.registering_with(agent) if obj.respond_to?(:registering_with)
|
57
|
+
end
|
58
|
+
agent.update!(authenticatable:)
|
59
|
+
rescue ActiveRecord::RecordInvalid => e
|
60
|
+
context.fail!(code: :record_invalid, message: e.message)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def webauthn_credential
|
65
|
+
@webauthn_credential ||= WebAuthn::Credential.from_create(credential)
|
66
|
+
end
|
67
|
+
|
68
|
+
def agent
|
69
|
+
@agent ||= begin
|
70
|
+
agent = Agent.find_by(username:)
|
71
|
+
context.fail!(code: :agent_not_found, message: "Agent not found for session value: \"#{username}\"") if agent.blank?
|
72
|
+
|
73
|
+
agent
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Passkeys::Rails
|
2
|
+
class GenerateAuthToken
|
3
|
+
include Interactor
|
4
|
+
|
5
|
+
delegate :agent, to: :context
|
6
|
+
|
7
|
+
def call
|
8
|
+
context.auth_token = generate_auth_token
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def generate_auth_token
|
14
|
+
JWT.encode(jwt_payload,
|
15
|
+
Passkeys::Rails.auth_token_secret,
|
16
|
+
Passkeys::Rails.auth_token_algorithm)
|
17
|
+
end
|
18
|
+
|
19
|
+
def jwt_payload
|
20
|
+
expiration = (Time.current + Passkeys::Rails.auth_token_expires_in).to_i
|
21
|
+
|
22
|
+
payload = { agent_id: agent.id }
|
23
|
+
payload[:exp] = expiration unless expiration.zero?
|
24
|
+
payload
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# Finish authentication ceremony
|
2
|
+
module Passkeys::Rails
|
3
|
+
class RefreshToken
|
4
|
+
include Interactor
|
5
|
+
|
6
|
+
delegate :token, to: :context
|
7
|
+
|
8
|
+
def call
|
9
|
+
agent = ValidateAuthToken.call!(auth_token: token).agent
|
10
|
+
|
11
|
+
context.username = agent.username
|
12
|
+
context.auth_token = GenerateAuthToken.call!(agent:).auth_token
|
13
|
+
rescue Interactor::Failure => e
|
14
|
+
context.fail! code: e.context.code, message: e.context.message
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Passkeys::Rails
|
2
|
+
class ValidateAuthToken
|
3
|
+
include Interactor
|
4
|
+
|
5
|
+
delegate :auth_token, to: :context
|
6
|
+
|
7
|
+
def call
|
8
|
+
context.fail!(code: :missing_token, message: "X-Auth header is required") if auth_token.blank?
|
9
|
+
|
10
|
+
context.agent = fetch_agent
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def fetch_agent
|
16
|
+
agent = Agent.find_by(id: payload['agent_id'])
|
17
|
+
context.fail!(code: :invalid_token, message: "Invalid token - no agent exists with agent_id") if agent.blank?
|
18
|
+
|
19
|
+
agent
|
20
|
+
end
|
21
|
+
|
22
|
+
def payload
|
23
|
+
JWT.decode(auth_token,
|
24
|
+
Passkeys::Rails.auth_token_secret,
|
25
|
+
true,
|
26
|
+
{ required_claims: %w[exp agent_id], algorithm: Passkeys::Rails.auth_token_algorithm }).first
|
27
|
+
rescue JWT::ExpiredSignature
|
28
|
+
context.fail!(code: :expired_token, message: "The token has expired")
|
29
|
+
rescue StandardError => e
|
30
|
+
context.fail!(code: :token_error, message: e.message)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -1,18 +1,16 @@
|
|
1
1
|
require 'active_support/concern'
|
2
2
|
|
3
|
-
module Passkeys
|
4
|
-
module
|
5
|
-
|
6
|
-
extend ActiveSupport::Concern
|
3
|
+
module Passkeys::Rails
|
4
|
+
module Authenticatable
|
5
|
+
extend ActiveSupport::Concern
|
7
6
|
|
8
|
-
|
9
|
-
|
7
|
+
included do
|
8
|
+
has_one :agent, as: :authenticatable
|
10
9
|
|
11
|
-
|
10
|
+
delegate :registered?, to: :agent, allow_nil: true
|
12
11
|
|
13
|
-
|
14
|
-
|
15
|
-
end
|
12
|
+
def registering_with(_agent)
|
13
|
+
# initialize required attributes
|
16
14
|
end
|
17
15
|
end
|
18
16
|
end
|
@@ -1,17 +1,15 @@
|
|
1
|
-
module Passkeys
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
has_many :passkeys
|
1
|
+
module Passkeys::Rails
|
2
|
+
class Agent < ApplicationRecord
|
3
|
+
belongs_to :authenticatable, polymorphic: true, optional: true
|
4
|
+
has_many :passkeys
|
6
5
|
|
7
|
-
|
8
|
-
|
6
|
+
scope :registered, -> { where.not registered_at: nil }
|
7
|
+
scope :unregistered, -> { where registered_at: nil }
|
9
8
|
|
10
|
-
|
9
|
+
validates :username, presence: true, uniqueness: true
|
11
10
|
|
12
|
-
|
13
|
-
|
14
|
-
end
|
11
|
+
def registered?
|
12
|
+
registered_at.present?
|
15
13
|
end
|
16
14
|
end
|
17
15
|
end
|
@@ -1,16 +1,14 @@
|
|
1
|
-
module Passkeys
|
2
|
-
|
3
|
-
|
4
|
-
attr_reader :hash
|
1
|
+
module Passkeys::Rails
|
2
|
+
class Error < StandardError
|
3
|
+
attr_reader :hash
|
5
4
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
5
|
+
def initialize(message, hash = {})
|
6
|
+
@hash = hash
|
7
|
+
super(message)
|
8
|
+
end
|
10
9
|
|
11
|
-
|
12
|
-
|
13
|
-
end
|
10
|
+
def to_h
|
11
|
+
{ error: hash.merge(context: message) }
|
14
12
|
end
|
15
13
|
end
|
16
14
|
end
|
@@ -1,10 +1,8 @@
|
|
1
|
-
module Passkeys
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
validates :sign_count, presence: true
|
8
|
-
end
|
1
|
+
module Passkeys::Rails
|
2
|
+
class Passkey < ApplicationRecord
|
3
|
+
belongs_to :agent
|
4
|
+
validates :identifier, presence: true, uniqueness: true
|
5
|
+
validates :public_key, presence: true
|
6
|
+
validates :sign_count, presence: true
|
9
7
|
end
|
10
8
|
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# These should be autoloaded, but if these aren't required here, apps using this
|
2
|
+
# gem will throw an exception that Passkeys::Rails::Authentication can't be found
|
3
|
+
require_relative '../../app/controllers/concerns/passkeys/rails/authentication'
|
4
|
+
require_relative '../../app/models/passkeys/rails/error'
|
5
|
+
|
6
|
+
class ActionController::Base
|
7
|
+
include Passkeys::Rails::Authentication
|
8
|
+
end
|
9
|
+
|
10
|
+
class ActionController::API
|
11
|
+
include Passkeys::Rails::Authentication
|
12
|
+
end
|
@@ -6,39 +6,16 @@ require "interactor"
|
|
6
6
|
require "jwt"
|
7
7
|
require "webauthn"
|
8
8
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
require_relative "interactors/begin_registration"
|
13
|
-
require_relative "interactors/finish_authentication"
|
14
|
-
require_relative "interactors/finish_registration"
|
15
|
-
require_relative "interactors/generate_auth_token"
|
16
|
-
require_relative "interactors/refresh_token"
|
17
|
-
require_relative "interactors/validate_auth_token"
|
9
|
+
module Passkeys::Rails
|
10
|
+
class Engine < ::Rails::Engine
|
11
|
+
isolate_namespace Passkeys::Rails
|
18
12
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
g.test_framework :rspec
|
26
|
-
g.fixture_replacement :factory_bot
|
27
|
-
g.factory_bot dir: 'spec/factories'
|
28
|
-
g.assets false
|
29
|
-
g.helper false
|
30
|
-
end
|
31
|
-
|
32
|
-
# Include helpers
|
33
|
-
initializer "passkeys-rails.helpers" do
|
34
|
-
ActiveSupport.on_load(:action_controller_base) do
|
35
|
-
include Passkeys::Rails::Controllers::Helpers
|
36
|
-
end
|
37
|
-
|
38
|
-
ActiveSupport.on_load(:action_controller_api) do
|
39
|
-
include Passkeys::Rails::Controllers::Helpers
|
40
|
-
end
|
41
|
-
end
|
13
|
+
config.generators do |g|
|
14
|
+
g.test_framework :rspec
|
15
|
+
g.fixture_replacement :factory_bot
|
16
|
+
g.factory_bot dir: 'spec/factories'
|
17
|
+
g.assets false
|
18
|
+
g.helper false
|
42
19
|
end
|
43
20
|
end
|
44
21
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: passkeys-rails
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Troy Anderson
|
@@ -353,14 +353,24 @@ files:
|
|
353
353
|
- Rakefile
|
354
354
|
- app/assets/config/passkeys_rails_manifest.js
|
355
355
|
- app/assets/stylesheets/passkeys_rails/application.css
|
356
|
+
- app/controllers/concerns/passkeys/rails/authentication.rb
|
356
357
|
- app/controllers/passkeys/rails/application_controller.rb
|
357
358
|
- app/controllers/passkeys/rails/passkeys_controller.rb
|
359
|
+
- app/interactors/passkeys/rails/begin_authentication.rb
|
360
|
+
- app/interactors/passkeys/rails/begin_challenge.rb
|
361
|
+
- app/interactors/passkeys/rails/begin_registration.rb
|
362
|
+
- app/interactors/passkeys/rails/finish_authentication.rb
|
363
|
+
- app/interactors/passkeys/rails/finish_registration.rb
|
364
|
+
- app/interactors/passkeys/rails/generate_auth_token.rb
|
365
|
+
- app/interactors/passkeys/rails/refresh_token.rb
|
366
|
+
- app/interactors/passkeys/rails/validate_auth_token.rb
|
358
367
|
- app/models/concerns/passkeys/rails/authenticatable.rb
|
359
368
|
- app/models/passkeys/rails/agent.rb
|
360
369
|
- app/models/passkeys/rails/application_record.rb
|
361
370
|
- app/models/passkeys/rails/error.rb
|
362
371
|
- app/models/passkeys/rails/passkey.rb
|
363
372
|
- app/views/layouts/passkeys/rails/application.html.erb
|
373
|
+
- config/initializers/application_controller.rb
|
364
374
|
- config/routes.rb
|
365
375
|
- db/migrate/20230620012530_create_passkeys_rails_agents.rb
|
366
376
|
- db/migrate/20230620012600_create_passkeys_rails_passkeys.rb
|
@@ -369,16 +379,7 @@ files:
|
|
369
379
|
- lib/generators/passkeys_rails/templates/README
|
370
380
|
- lib/generators/passkeys_rails/templates/passkeys_rails_config.rb
|
371
381
|
- lib/passkeys-rails.rb
|
372
|
-
- lib/passkeys/rails/controllers/helpers.rb
|
373
382
|
- lib/passkeys/rails/engine.rb
|
374
|
-
- lib/passkeys/rails/interactors/begin_authentication.rb
|
375
|
-
- lib/passkeys/rails/interactors/begin_challenge.rb
|
376
|
-
- lib/passkeys/rails/interactors/begin_registration.rb
|
377
|
-
- lib/passkeys/rails/interactors/finish_authentication.rb
|
378
|
-
- lib/passkeys/rails/interactors/finish_registration.rb
|
379
|
-
- lib/passkeys/rails/interactors/generate_auth_token.rb
|
380
|
-
- lib/passkeys/rails/interactors/refresh_token.rb
|
381
|
-
- lib/passkeys/rails/interactors/validate_auth_token.rb
|
382
383
|
- lib/passkeys/rails/railtie.rb
|
383
384
|
- lib/passkeys/rails/version.rb
|
384
385
|
- lib/tasks/passkeys_rails_tasks.rake
|
@@ -405,7 +406,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
405
406
|
- !ruby/object:Gem::Version
|
406
407
|
version: '0'
|
407
408
|
requirements: []
|
408
|
-
rubygems_version: 3.4.
|
409
|
+
rubygems_version: 3.4.17
|
409
410
|
signing_key:
|
410
411
|
specification_version: 4
|
411
412
|
summary: PassKey authentication back end with simple API
|
@@ -1,33 +0,0 @@
|
|
1
|
-
module Passkeys
|
2
|
-
module Rails
|
3
|
-
module Controllers
|
4
|
-
module Helpers
|
5
|
-
extend ActiveSupport::Concern
|
6
|
-
|
7
|
-
included do
|
8
|
-
rescue_from Passkeys::Rails::Error do |e|
|
9
|
-
render json: e.to_h, status: :unauthorized
|
10
|
-
end
|
11
|
-
end
|
12
|
-
|
13
|
-
def current_agent
|
14
|
-
return nil if request.headers['HTTP_X_AUTH'].blank?
|
15
|
-
|
16
|
-
@current_agent ||= validated_auth_token&.success? && validated_auth_token&.agent
|
17
|
-
end
|
18
|
-
|
19
|
-
def authenticate_passkey!
|
20
|
-
return if validated_auth_token.success?
|
21
|
-
|
22
|
-
raise Passkeys::Rails::Error.new(:authentication,
|
23
|
-
code: :unauthorized,
|
24
|
-
message: "You are not authorized to access this resource.")
|
25
|
-
end
|
26
|
-
|
27
|
-
def validated_auth_token
|
28
|
-
@validated_auth_token ||= Passkeys::Rails::ValidateAuthToken.call(auth_token: request.headers['HTTP_X_AUTH'])
|
29
|
-
end
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|
33
|
-
end
|
@@ -1,37 +0,0 @@
|
|
1
|
-
module Passkeys
|
2
|
-
module Rails
|
3
|
-
class BeginChallenge
|
4
|
-
include Interactor
|
5
|
-
|
6
|
-
delegate :username, to: :context
|
7
|
-
|
8
|
-
def call
|
9
|
-
result = generate_challenge!
|
10
|
-
|
11
|
-
options = result.options
|
12
|
-
|
13
|
-
context.response = options
|
14
|
-
context.session_data = session_data(options)
|
15
|
-
rescue Interactor::Failure => e
|
16
|
-
context.fail! code: e.context.code, message: e.context.message
|
17
|
-
end
|
18
|
-
|
19
|
-
private
|
20
|
-
|
21
|
-
def generate_challenge!
|
22
|
-
if username.present?
|
23
|
-
BeginRegistration.call!(username:)
|
24
|
-
else
|
25
|
-
BeginAuthentication.call!
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
def session_data(options)
|
30
|
-
{
|
31
|
-
username:,
|
32
|
-
challenge: WebAuthn.standard_encoder.encode(options.challenge)
|
33
|
-
}
|
34
|
-
end
|
35
|
-
end
|
36
|
-
end
|
37
|
-
end
|
@@ -1,25 +0,0 @@
|
|
1
|
-
module Passkeys
|
2
|
-
module Rails
|
3
|
-
class BeginRegistration
|
4
|
-
include Interactor
|
5
|
-
|
6
|
-
delegate :username, to: :context
|
7
|
-
|
8
|
-
def call
|
9
|
-
agent = create_unregistered_agent
|
10
|
-
|
11
|
-
context.options = WebAuthn::Credential.options_for_create(user: { id: agent.webauthn_identifier, name: agent.username })
|
12
|
-
end
|
13
|
-
|
14
|
-
private
|
15
|
-
|
16
|
-
def create_unregistered_agent
|
17
|
-
agent = Agent.create(username:, webauthn_identifier: WebAuthn.generate_user_id)
|
18
|
-
|
19
|
-
context.fail!(code: :validation_errors, message: agent.errors.full_messages.to_sentence) unless agent.valid?
|
20
|
-
|
21
|
-
agent
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end
|
25
|
-
end
|
@@ -1,55 +0,0 @@
|
|
1
|
-
# Finish authentication ceremony
|
2
|
-
module Passkeys
|
3
|
-
module Rails
|
4
|
-
class FinishAuthentication
|
5
|
-
include Interactor
|
6
|
-
|
7
|
-
delegate :credential, :challenge, to: :context
|
8
|
-
|
9
|
-
def call
|
10
|
-
verify_credential!
|
11
|
-
|
12
|
-
context.username = agent.username
|
13
|
-
context.auth_token = GenerateAuthToken.call!(agent:).auth_token
|
14
|
-
rescue Interactor::Failure => e
|
15
|
-
context.fail! code: e.context.code, message: e.context.message
|
16
|
-
end
|
17
|
-
|
18
|
-
private
|
19
|
-
|
20
|
-
def verify_credential!
|
21
|
-
webauthn_credential.verify(
|
22
|
-
challenge,
|
23
|
-
public_key: passkey.public_key,
|
24
|
-
sign_count: passkey.sign_count
|
25
|
-
)
|
26
|
-
|
27
|
-
passkey.update!(sign_count: webauthn_credential.sign_count)
|
28
|
-
agent.update!(last_authenticated_at: Time.current)
|
29
|
-
rescue WebAuthn::SignCountVerificationError
|
30
|
-
# Cryptographic verification of the authenticator data succeeded, but the signature counter was less than or equal
|
31
|
-
# to the stored value. This can have several reasons and depending on your risk tolerance you can choose to fail or
|
32
|
-
# pass authentication. For more information see https://www.w3.org/TR/webauthn/#sign-counter
|
33
|
-
rescue WebAuthn::Error => e
|
34
|
-
context.fail!(code: :webauthn_error, message: e.message)
|
35
|
-
end
|
36
|
-
|
37
|
-
def webauthn_credential
|
38
|
-
@webauthn_credential ||= WebAuthn::Credential.from_get(credential)
|
39
|
-
end
|
40
|
-
|
41
|
-
def passkey
|
42
|
-
@passkey ||= begin
|
43
|
-
passkey = Passkey.find_by(identifier: webauthn_credential.id)
|
44
|
-
context.fail!(code: :passkey_not_found, message: "Unable to find the specified passkey") if passkey.blank?
|
45
|
-
|
46
|
-
passkey
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
def agent
|
51
|
-
passkey.agent
|
52
|
-
end
|
53
|
-
end
|
54
|
-
end
|
55
|
-
end
|
@@ -1,79 +0,0 @@
|
|
1
|
-
# Finish registration ceremony
|
2
|
-
module Passkeys
|
3
|
-
module Rails
|
4
|
-
class FinishRegistration
|
5
|
-
include Interactor
|
6
|
-
|
7
|
-
delegate :credential, :username, :challenge, :authenticatable_class, to: :context
|
8
|
-
|
9
|
-
def call
|
10
|
-
verify_credential!
|
11
|
-
store_passkey_and_register_agent!
|
12
|
-
|
13
|
-
context.username = agent.username
|
14
|
-
context.auth_token = GenerateAuthToken.call!(agent:).auth_token
|
15
|
-
rescue Interactor::Failure => e
|
16
|
-
context.fail! code: e.context.code, message: e.context.message
|
17
|
-
end
|
18
|
-
|
19
|
-
private
|
20
|
-
|
21
|
-
def verify_credential!
|
22
|
-
webauthn_credential.verify(challenge)
|
23
|
-
rescue WebAuthn::Error => e
|
24
|
-
context.fail!(code: :webauthn_error, message: e.message)
|
25
|
-
rescue StandardError => e
|
26
|
-
context.fail!(code: :error, message: e.message)
|
27
|
-
end
|
28
|
-
|
29
|
-
def store_passkey_and_register_agent!
|
30
|
-
agent.transaction do
|
31
|
-
begin
|
32
|
-
# Store Credential ID, Credential Public Key and Sign Count for future authentications
|
33
|
-
agent.passkeys.create!(
|
34
|
-
identifier: webauthn_credential.id,
|
35
|
-
public_key: webauthn_credential.public_key,
|
36
|
-
sign_count: webauthn_credential.sign_count
|
37
|
-
)
|
38
|
-
|
39
|
-
agent.update! registered_at: Time.current
|
40
|
-
rescue StandardError => e
|
41
|
-
context.fail! code: :passkey_error, message: e.message
|
42
|
-
end
|
43
|
-
|
44
|
-
create_authenticatable! if authenticatable_class.present?
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
def create_authenticatable!
|
49
|
-
klass = begin
|
50
|
-
authenticatable_class.constantize
|
51
|
-
rescue StandardError
|
52
|
-
context.fail!(code: :invalid_authenticatable_class, message: "authenticatable_class (#{authenticatable_class}) is not defined")
|
53
|
-
end
|
54
|
-
|
55
|
-
begin
|
56
|
-
authenticatable = klass.create! do |obj|
|
57
|
-
obj.registering_with(agent) if obj.respond_to?(:registering_with)
|
58
|
-
end
|
59
|
-
agent.update!(authenticatable:)
|
60
|
-
rescue ActiveRecord::RecordInvalid => e
|
61
|
-
context.fail!(code: :record_invalid, message: e.message)
|
62
|
-
end
|
63
|
-
end
|
64
|
-
|
65
|
-
def webauthn_credential
|
66
|
-
@webauthn_credential ||= WebAuthn::Credential.from_create(credential)
|
67
|
-
end
|
68
|
-
|
69
|
-
def agent
|
70
|
-
@agent ||= begin
|
71
|
-
agent = Agent.find_by(username:)
|
72
|
-
context.fail!(code: :agent_not_found, message: "Agent not found for session value: \"#{username}\"") if agent.blank?
|
73
|
-
|
74
|
-
agent
|
75
|
-
end
|
76
|
-
end
|
77
|
-
end
|
78
|
-
end
|
79
|
-
end
|
@@ -1,29 +0,0 @@
|
|
1
|
-
module Passkeys
|
2
|
-
module Rails
|
3
|
-
class GenerateAuthToken
|
4
|
-
include Interactor
|
5
|
-
|
6
|
-
delegate :agent, to: :context
|
7
|
-
|
8
|
-
def call
|
9
|
-
context.auth_token = generate_auth_token
|
10
|
-
end
|
11
|
-
|
12
|
-
private
|
13
|
-
|
14
|
-
def generate_auth_token
|
15
|
-
JWT.encode(jwt_payload,
|
16
|
-
Passkeys::Rails.auth_token_secret,
|
17
|
-
Passkeys::Rails.auth_token_algorithm)
|
18
|
-
end
|
19
|
-
|
20
|
-
def jwt_payload
|
21
|
-
expiration = (Time.current + Passkeys::Rails.auth_token_expires_in).to_i
|
22
|
-
|
23
|
-
payload = { agent_id: agent.id }
|
24
|
-
payload[:exp] = expiration unless expiration.zero?
|
25
|
-
payload
|
26
|
-
end
|
27
|
-
end
|
28
|
-
end
|
29
|
-
end
|
@@ -1,19 +0,0 @@
|
|
1
|
-
# Finish authentication ceremony
|
2
|
-
module Passkeys
|
3
|
-
module Rails
|
4
|
-
class RefreshToken
|
5
|
-
include Interactor
|
6
|
-
|
7
|
-
delegate :token, to: :context
|
8
|
-
|
9
|
-
def call
|
10
|
-
agent = ValidateAuthToken.call!(auth_token: token).agent
|
11
|
-
|
12
|
-
context.username = agent.username
|
13
|
-
context.auth_token = GenerateAuthToken.call!(agent:).auth_token
|
14
|
-
rescue Interactor::Failure => e
|
15
|
-
context.fail! code: e.context.code, message: e.context.message
|
16
|
-
end
|
17
|
-
end
|
18
|
-
end
|
19
|
-
end
|
@@ -1,35 +0,0 @@
|
|
1
|
-
module Passkeys
|
2
|
-
module Rails
|
3
|
-
class ValidateAuthToken
|
4
|
-
include Interactor
|
5
|
-
|
6
|
-
delegate :auth_token, to: :context
|
7
|
-
|
8
|
-
def call
|
9
|
-
context.fail!(code: :missing_token, message: "X-Auth header is required") if auth_token.blank?
|
10
|
-
|
11
|
-
context.agent = fetch_agent
|
12
|
-
end
|
13
|
-
|
14
|
-
private
|
15
|
-
|
16
|
-
def fetch_agent
|
17
|
-
agent = Agent.find_by(id: payload['agent_id'])
|
18
|
-
context.fail!(code: :invalid_token, message: "Invalid token - no agent exists with agent_id") if agent.blank?
|
19
|
-
|
20
|
-
agent
|
21
|
-
end
|
22
|
-
|
23
|
-
def payload
|
24
|
-
JWT.decode(auth_token,
|
25
|
-
Passkeys::Rails.auth_token_secret,
|
26
|
-
true,
|
27
|
-
{ required_claims: %w[exp agent_id], algorithm: Passkeys::Rails.auth_token_algorithm }).first
|
28
|
-
rescue JWT::ExpiredSignature
|
29
|
-
context.fail!(code: :expired_token, message: "The token has expired")
|
30
|
-
rescue StandardError => e
|
31
|
-
context.fail!(code: :token_error, message: e.message)
|
32
|
-
end
|
33
|
-
end
|
34
|
-
end
|
35
|
-
end
|