passkeys-rails 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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +32 -0
  4. data/Rakefile +28 -0
  5. data/app/assets/config/passkeys_rails_manifest.js +1 -0
  6. data/app/assets/stylesheets/passkeys_rails/application.css +15 -0
  7. data/app/controllers/passkeys_rails/application_controller.rb +22 -0
  8. data/app/controllers/passkeys_rails/passkeys_controller.rb +61 -0
  9. data/app/helpers/passkeys_rails/application_helper.rb +21 -0
  10. data/app/helpers/passkeys_rails/passkeys_helper.rb +4 -0
  11. data/app/interactors/passkeys_rails/begin_authentication.rb +9 -0
  12. data/app/interactors/passkeys_rails/begin_challenge.rb +35 -0
  13. data/app/interactors/passkeys_rails/begin_registration.rb +23 -0
  14. data/app/interactors/passkeys_rails/finish_authentication.rb +53 -0
  15. data/app/interactors/passkeys_rails/finish_registration.rb +77 -0
  16. data/app/interactors/passkeys_rails/generate_auth_token.rb +27 -0
  17. data/app/interactors/passkeys_rails/refresh_token.rb +17 -0
  18. data/app/interactors/passkeys_rails/validate_auth_token.rb +33 -0
  19. data/app/models/concerns/passkeys_rails/authenticatable.rb +17 -0
  20. data/app/models/passkeys_rails/agent.rb +15 -0
  21. data/app/models/passkeys_rails/application_record.rb +5 -0
  22. data/app/models/passkeys_rails/error.rb +14 -0
  23. data/app/models/passkeys_rails/passkey.rb +8 -0
  24. data/app/views/layouts/passkeys_rails/application.html.erb +15 -0
  25. data/config/routes.rb +6 -0
  26. data/db/migrate/20230620012530_create_passkeys_rails_agents.rb +18 -0
  27. data/db/migrate/20230620012600_create_passkeys_rails_passkeys.rb +12 -0
  28. data/lib/generators/passkeys_rails/USAGE +10 -0
  29. data/lib/generators/passkeys_rails/install_generator.rb +21 -0
  30. data/lib/generators/passkeys_rails/templates/README +17 -0
  31. data/lib/generators/passkeys_rails/templates/passkeys_rails_config.rb +24 -0
  32. data/lib/passkeys_rails/engine.rb +24 -0
  33. data/lib/passkeys_rails/error_middleware.rb +17 -0
  34. data/lib/passkeys_rails/railtie.rb +17 -0
  35. data/lib/passkeys_rails/version.rb +3 -0
  36. data/lib/passkeys_rails.rb +38 -0
  37. data/lib/tasks/passkeys_rails_tasks.rake +4 -0
  38. metadata +413 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fd944c1cfd019fb6abe5f0ec87383d1b2b60146b237eb78cc3836a2143947f57
4
+ data.tar.gz: 66533dd94392e42e01c1bea10002c3331db983a73d5270f32caa9f13a9f34707
5
+ SHA512:
6
+ metadata.gz: df0a42e46f2dfe8f414d3772215836b6f926a5421c2a7fde9dfbdaae13d4b04effbcc066e3278432b6936770e3822f8a2cf4dcbb44a2d23da48f8e6626b20f8a
7
+ data.tar.gz: 6d9acca7962dede01d787e961e585736ab4ab1c18d685031ba9409344636c96433282b45ef169101a64d6b6835144dcf9cde95dc5e1bc41dfb1ff2390034a1af
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2023 Troy Anderson
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,32 @@
1
+ [![Build Status](https://app.travis-ci.com/alliedcode/passkeys-rails.svg?branch=main)](https://travis-ci.org/alliedcode/passkeys-rails)
2
+ [![codecov](https://codecov.io/gh/alliedcode/passkeys-rails/branch/main/graph/badge.svg?token=UHSNJDUL21)](https://codecov.io/gh/alliedcode/passkeys-rails)
3
+
4
+ # PasskeysRails
5
+ Devise is awesome, but we don't need all that UI/UX for PassKeys. This gem is to make it easy to provide a back end that authenticates a mobile front end with PassKeys.
6
+
7
+ ## Usage
8
+ rails passkeys-rails::install
9
+ PasskeysRails maintains an Agent model and related Passeys. If you have a user model, add `include PasskeysRails::Authenticatable` to your model and include the name of that class (e.g. "User") in the authenticatable_class param when calling the register API.
10
+
11
+ ## Installation
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem "passkeys_rails"
16
+ ```
17
+
18
+ And then execute:
19
+ ```bash
20
+ $ bundle
21
+ ```
22
+
23
+ Or install it yourself as:
24
+ ```bash
25
+ $ gem install passkeys_rails
26
+ ```
27
+
28
+ ## Contributing
29
+ Contribution directions go here.
30
+
31
+ ## License
32
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env rake
2
+
3
+ begin
4
+ require 'bundler/setup'
5
+ rescue LoadError
6
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
7
+ end
8
+
9
+ Bundler::GemHelper.install_tasks
10
+
11
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
12
+ load 'rails/tasks/engine.rake'
13
+ require 'rspec/core/rake_task'
14
+
15
+ RSpec::Core::RakeTask.new(:spec) do |spec|
16
+ spec.pattern = 'spec/**/*_spec.rb'
17
+ end
18
+
19
+ require "rubocop/rake_task"
20
+ RuboCop::RakeTask.new do |task|
21
+ task.requires << 'rubocop-rails'
22
+ task.requires << 'rubocop-performance'
23
+ task.requires << 'rubocop-rspec'
24
+ task.requires << 'rubocop-rake'
25
+ task.requires << 'rubocop-factory_bot'
26
+ end
27
+
28
+ task default: %i[spec rubocop]
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets/passkeys_rails .css
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,22 @@
1
+ module PasskeysRails
2
+ class ApplicationController < ActionController::Base
3
+ rescue_from ::Interactor::Failure, with: :handle_interactor_failure
4
+ rescue_from ActionController::ParameterMissing, with: :handle_missing_parameter
5
+
6
+ protected
7
+
8
+ def handle_missing_parameter(error)
9
+ render_error(:authentication, 'missing_parameter', error.message)
10
+ end
11
+
12
+ def handle_interactor_failure(failure)
13
+ render_error(:authentication, failure.context.code, failure.context.message)
14
+ end
15
+
16
+ private
17
+
18
+ def render_error(context, code, message, status: :unprocessable_entity)
19
+ render json: { error: { context:, code:, message: } }, status:
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,61 @@
1
+ module PasskeysRails
2
+ class PasskeysController < ApplicationController
3
+ def challenge
4
+ result = PasskeysRails::BeginChallenge.call!(username: challenge_params[:username])
5
+
6
+ # Store the challenge so we can verify the future register or authentication request
7
+ session[:passkeys_rails] = result.session_data
8
+
9
+ render json: result.response.as_json
10
+ end
11
+
12
+ def register
13
+ result = PasskeysRails::FinishRegistration.call!(credential: attestation_credential_params.to_h,
14
+ authenticatable_class:,
15
+ username: session.dig(:passkeys_rails, :username),
16
+ challenge: session.dig(:passkeys_rails, :challenge))
17
+
18
+ render json: { username: result.username, auth_token: result.auth_token }
19
+ end
20
+
21
+ def authenticate
22
+ result = PasskeysRails::FinishAuthentication.call!(credential: authentication_params.to_h,
23
+ challenge: session.dig(:passkeys_rails, :challenge))
24
+
25
+ render json: { username: result.username, auth_token: result.auth_token }
26
+ end
27
+
28
+ def refresh
29
+ result = PasskeysRails::RefreshToken.call!(token: refresh_params[:auth_token])
30
+ render json: { username: result.username, auth_token: result.auth_token }
31
+ end
32
+
33
+ protected
34
+
35
+ def challenge_params
36
+ params.permit(:username)
37
+ end
38
+
39
+ def attestation_credential_params
40
+ credential = params.require(:credential)
41
+ credential.require(%i[id rawId type response])
42
+ credential.require(:response).require(%i[attestationObject clientDataJSON])
43
+ credential.permit(:id, :rawId, :type, { response: %i[attestationObject clientDataJSON] })
44
+ end
45
+
46
+ def authenticatable_class
47
+ params[:authenticatable_class]
48
+ end
49
+
50
+ def authentication_params
51
+ params.require(%i[id rawId type response])
52
+ params.require(:response).require(%i[authenticatorData clientDataJSON signature userHandle])
53
+ params.permit(:id, :rawId, :type, { response: %i[authenticatorData clientDataJSON signature userHandle] })
54
+ end
55
+
56
+ def refresh_params
57
+ params.require(:auth_token)
58
+ params.permit(:auth_token)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,21 @@
1
+ module PasskeysRails
2
+ module ApplicationHelper
3
+ def current_agent
4
+ return nil if request.headers['HTTP_X_AUTH'].blank?
5
+
6
+ @current_agent ||= validated_auth_token&.success? && validated_auth_token&.agent
7
+ end
8
+
9
+ def authenticate!
10
+ return if validated_auth_token.success?
11
+
12
+ raise PasskeysRails::Error.new(:authentication,
13
+ code: :unauthorized,
14
+ message: "You are not authorized to access this resource.")
15
+ end
16
+
17
+ def validated_auth_token
18
+ @validated_auth_token ||= ValidateAuthToken.call(auth_token: request.headers['HTTP_X_AUTH'])
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,4 @@
1
+ module PasskeysRails
2
+ module PasskeysHelper
3
+ end
4
+ end
@@ -0,0 +1,9 @@
1
+ module PasskeysRails
2
+ class BeginAuthentication
3
+ include Interactor
4
+
5
+ def call
6
+ context.options = WebAuthn::Credential.options_for_get
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,35 @@
1
+ module PasskeysRails
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 PasskeysRails
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 PasskeysRails
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 PasskeysRails
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 PasskeysRails
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
+ PasskeysRails.auth_token_secret,
16
+ PasskeysRails.auth_token_algorithm)
17
+ end
18
+
19
+ def jwt_payload
20
+ expiration = (Time.current + PasskeysRails.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 PasskeysRails
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 PasskeysRails
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
+ PasskeysRails.auth_token_secret,
25
+ true,
26
+ { required_claims: %w[exp agent_id], algorithm: PasskeysRails.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
@@ -0,0 +1,17 @@
1
+ require 'active_support/concern'
2
+
3
+ module PasskeysRails
4
+ module Authenticatable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ has_one :agent, as: :authenticatable
9
+
10
+ delegate :registered?, to: :agent, allow_nil: true
11
+
12
+ def registering_with(_agent)
13
+ # initialize required attributes
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module PasskeysRails
2
+ class Agent < ApplicationRecord
3
+ belongs_to :authenticatable, polymorphic: true, optional: true
4
+ has_many :passkeys
5
+
6
+ scope :registered, -> { where.not registered_at: nil }
7
+ scope :unregistered, -> { where registered_at: nil }
8
+
9
+ validates :username, presence: true, uniqueness: true
10
+
11
+ def registered?
12
+ registered_at.present?
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ module PasskeysRails
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,14 @@
1
+ module PasskeysRails
2
+ class Error < StandardError
3
+ attr_reader :hash
4
+
5
+ def initialize(message, hash = {})
6
+ @hash = hash
7
+ super(message)
8
+ end
9
+
10
+ def to_h
11
+ { error: hash.merge(context: message) }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,8 @@
1
+ module PasskeysRails
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
7
+ end
8
+ end
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Mobile pass</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "passkeys_rails/application", media: "all" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= yield %>
13
+
14
+ </body>
15
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ PasskeysRails::Engine.routes.draw do
2
+ post 'passkeys/challenge'
3
+ post 'passkeys/register'
4
+ post 'passkeys/authenticate'
5
+ post 'passkeys/refresh'
6
+ end
@@ -0,0 +1,18 @@
1
+ class CreatePasskeysRailsAgents < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :passkeys_rails_agents do |t|
4
+ t.string :username, null: false
5
+ t.references :authenticatable, polymorphic: true
6
+ t.string :webauthn_identifier
7
+ t.datetime :registered_at
8
+ t.datetime :last_authenticated_at
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ # Make the authenticatable index enforce uniqueness
14
+ remove_index :passkeys_rails_agents, %i[authenticatable_type authenticatable_id], name: 'index_passkeys_rails_agents_on_authenticatable'
15
+ add_index :passkeys_rails_agents, %i[authenticatable_type authenticatable_id], unique: true, name: 'index_passkeys_rails_agents_on_authenticatable'
16
+ add_index :passkeys_rails_agents, :username, unique: true
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ class CreatePasskeysRailsPasskeys < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :passkeys_rails_passkeys do |t|
4
+ t.string :identifier
5
+ t.string :public_key
6
+ t.integer :sign_count
7
+ t.references :agent, null: false, foreign_key: { to_table: :passkeys_rails_agents }
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ Description:
2
+ Creates a PasskeysRails config file, updates the routes and adds migrations.
3
+
4
+ Example:
5
+ bin/rails generate passkeys-rails:install
6
+
7
+ This will:
8
+ create config/passkeys_rails.rb
9
+ add database migrations
10
+ update routes to mount the passkeys_rails engine
@@ -0,0 +1,21 @@
1
+ require 'rails/generators'
2
+
3
+ module PasskeysRails
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def copy_config
9
+ template 'passkeys_rails_config.rb', "config/initializers/passkeys_rails.rb"
10
+ end
11
+
12
+ def add_routes
13
+ route 'mount PasskeysRails::Engine => "/passkeys_rails"'
14
+ end
15
+
16
+ def show_readme
17
+ readme "README" if behavior == :invoke
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ ===============================================================================
2
+
3
+ Depending on your application's configuration some manual setup may be required:
4
+
5
+ 1. Add a before_action to all controllers that require authentication to use.
6
+
7
+ For example:
8
+
9
+ before_action :authitencate_passkey!, except: [:index]
10
+
11
+ 2. Optionally include PasskeysRails::Authenticatable to the model(s) you are using as
12
+ your user model(s). For example, the User model.
13
+
14
+ 3. See the reference mobile applications for how to use passkeys-rails for passkey
15
+ authentication.
16
+
17
+ ===============================================================================
@@ -0,0 +1,24 @@
1
+ require 'passkeys_rails'
2
+
3
+ PasskeysRails.config do |c|
4
+ # Secret used to encode the auth token.
5
+ # Changing this value will invalidate all tokens that have been fetched
6
+ # through the API.
7
+ # Default is the application's `secret_key_base`. You can change it below
8
+ # and use your own secret key.
9
+ #
10
+ # c.auth_token_secret = '<%= SecureRandom.hex(64) %>'
11
+
12
+ # Algorithm used to generate the auth token.
13
+ # Changing this value will invalidate all tokens that have been fetched
14
+ # through the API.
15
+ # Default is HS256
16
+ #
17
+ # c.auth_token_algorithm = "HS256"
18
+
19
+ # How long the auth token is valid before requiring a refresh or new login.
20
+ # Set it to 0 for no expiration (not recommended in production).
21
+ # Default is 30 days
22
+ #
23
+ # c.auth_token_expires_in = 30.days
24
+ end
@@ -0,0 +1,24 @@
1
+ require_relative 'error_middleware'
2
+ module PasskeysRails
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace PasskeysRails
5
+
6
+ config.generators do |g|
7
+ g.test_framework :rspec
8
+ g.fixture_replacement :factory_bot
9
+ g.factory_bot dir: 'spec/factories'
10
+ g.assets false
11
+ g.helper false
12
+ end
13
+
14
+ config.to_prepare do
15
+ # include our helper methods in the host application's ApplicationController
16
+ ::ApplicationController.include ApplicationHelper
17
+ end
18
+
19
+ # provide a way to bail out of the render flow if needed
20
+ initializer 'passkeys_rails.configure.middleware' do |app|
21
+ app.middleware.use ErrorMiddleware
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ module PasskeysRails
2
+ class ErrorMiddleware
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def call(env)
8
+ begin
9
+ response = @app.call(env)
10
+ rescue Error => e
11
+ return [401, { 'Content-Type' => 'application/json' }, e.to_h.to_json]
12
+ end
13
+
14
+ response
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ require 'passkeys_rails'
2
+ require 'rails'
3
+
4
+ module PasskeysRails
5
+ class Railtie < Rails::Railtie
6
+ railtie_name :passkeys_rails
7
+
8
+ rake_tasks do
9
+ path = File.expand_path(__dir__)
10
+ Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f }
11
+ end
12
+
13
+ generators do
14
+ require "generators/passkeys_rails/install_generator"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,3 @@
1
+ module PasskeysRails
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,38 @@
1
+ require_relative "passkeys_rails/version"
2
+ require_relative "passkeys_rails/engine"
3
+ require_relative "generators/passkeys_rails/install_generator"
4
+ require "rails"
5
+ require "active_support/core_ext/numeric/time"
6
+ require "active_support/dependencies"
7
+ require "interactor"
8
+ require "jwt"
9
+ require "webauthn"
10
+
11
+ module PasskeysRails
12
+ # Secret used to encode the auth token.
13
+ # Rails.application.secret_key_base is used if none is defined here.
14
+ # Changing this value will invalidate all tokens that have been fetched
15
+ # through the API.
16
+ mattr_accessor(:auth_token_secret)
17
+
18
+ # Algorithm used to generate the auth token.
19
+ # Changing this value will invalidate all tokens that have been fetched
20
+ # through the API.
21
+ mattr_accessor :auth_token_algorithm, default: "HS256"
22
+
23
+ # How long the auth token is valid before requiring a refresh or new login.
24
+ # Set it to 0 for no expiration (not recommended in production).
25
+ mattr_accessor :auth_token_expires_in, default: 30.days
26
+
27
+ class << self
28
+ def config
29
+ yield self
30
+ end
31
+ end
32
+
33
+ require 'passkeys_rails/railtie' if defined?(Rails)
34
+ end
35
+
36
+ ActiveSupport.on_load(:before_initialize) do
37
+ PasskeysRails.auth_token_secret ||= Rails.application.secret_key_base
38
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :passkeys_rails do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,413 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: passkeys-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Troy Anderson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-07-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 7.0.5
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 7.0.5
33
+ - !ruby/object:Gem::Dependency
34
+ name: interactor
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 3.1.2
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 3.1.2
47
+ - !ruby/object:Gem::Dependency
48
+ name: jwt
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 2.7.1
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: 2.7.1
61
+ - !ruby/object:Gem::Dependency
62
+ name: webauthn
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 3.0.0
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 3.0.0
75
+ - !ruby/object:Gem::Dependency
76
+ name: dotenv
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 2.8.1
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 2.8.1
89
+ - !ruby/object:Gem::Dependency
90
+ name: puma
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: 5.6.5
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: 5.6.5
103
+ - !ruby/object:Gem::Dependency
104
+ name: rake
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '13.0'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '13.0'
117
+ - !ruby/object:Gem::Dependency
118
+ name: sprockets-rails
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: 4.2.0
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: 4.2.0
131
+ - !ruby/object:Gem::Dependency
132
+ name: sqlite3
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: 1.6.3
138
+ type: :development
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: 1.6.3
145
+ - !ruby/object:Gem::Dependency
146
+ name: codecov
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: 0.2.12
152
+ type: :development
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: 0.2.12
159
+ - !ruby/object:Gem::Dependency
160
+ name: debug
161
+ requirement: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
164
+ - !ruby/object:Gem::Version
165
+ version: 1.8.0
166
+ type: :development
167
+ prerelease: false
168
+ version_requirements: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: 1.8.0
173
+ - !ruby/object:Gem::Dependency
174
+ name: simplecov
175
+ requirement: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: 0.22.0
180
+ type: :development
181
+ prerelease: false
182
+ version_requirements: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: 0.22.0
187
+ - !ruby/object:Gem::Dependency
188
+ name: reek
189
+ requirement: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: 6.1.4
194
+ type: :development
195
+ prerelease: false
196
+ version_requirements: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - "~>"
199
+ - !ruby/object:Gem::Version
200
+ version: 6.1.4
201
+ - !ruby/object:Gem::Dependency
202
+ name: factory_bot_rails
203
+ requirement: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - "~>"
206
+ - !ruby/object:Gem::Version
207
+ version: 6.2.0
208
+ type: :development
209
+ prerelease: false
210
+ version_requirements: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - "~>"
213
+ - !ruby/object:Gem::Version
214
+ version: 6.2.0
215
+ - !ruby/object:Gem::Dependency
216
+ name: generator_spec
217
+ requirement: !ruby/object:Gem::Requirement
218
+ requirements:
219
+ - - "~>"
220
+ - !ruby/object:Gem::Version
221
+ version: 0.9.4
222
+ type: :development
223
+ prerelease: false
224
+ version_requirements: !ruby/object:Gem::Requirement
225
+ requirements:
226
+ - - "~>"
227
+ - !ruby/object:Gem::Version
228
+ version: 0.9.4
229
+ - !ruby/object:Gem::Dependency
230
+ name: rspec
231
+ requirement: !ruby/object:Gem::Requirement
232
+ requirements:
233
+ - - "~>"
234
+ - !ruby/object:Gem::Version
235
+ version: '3.0'
236
+ type: :development
237
+ prerelease: false
238
+ version_requirements: !ruby/object:Gem::Requirement
239
+ requirements:
240
+ - - "~>"
241
+ - !ruby/object:Gem::Version
242
+ version: '3.0'
243
+ - !ruby/object:Gem::Dependency
244
+ name: rspec-rails
245
+ requirement: !ruby/object:Gem::Requirement
246
+ requirements:
247
+ - - "~>"
248
+ - !ruby/object:Gem::Version
249
+ version: 6.0.3
250
+ type: :development
251
+ prerelease: false
252
+ version_requirements: !ruby/object:Gem::Requirement
253
+ requirements:
254
+ - - "~>"
255
+ - !ruby/object:Gem::Version
256
+ version: 6.0.3
257
+ - !ruby/object:Gem::Dependency
258
+ name: timecop
259
+ requirement: !ruby/object:Gem::Requirement
260
+ requirements:
261
+ - - "~>"
262
+ - !ruby/object:Gem::Version
263
+ version: 0.9.6
264
+ type: :development
265
+ prerelease: false
266
+ version_requirements: !ruby/object:Gem::Requirement
267
+ requirements:
268
+ - - "~>"
269
+ - !ruby/object:Gem::Version
270
+ version: 0.9.6
271
+ - !ruby/object:Gem::Dependency
272
+ name: rubocop
273
+ requirement: !ruby/object:Gem::Requirement
274
+ requirements:
275
+ - - "~>"
276
+ - !ruby/object:Gem::Version
277
+ version: '1.21'
278
+ type: :development
279
+ prerelease: false
280
+ version_requirements: !ruby/object:Gem::Requirement
281
+ requirements:
282
+ - - "~>"
283
+ - !ruby/object:Gem::Version
284
+ version: '1.21'
285
+ - !ruby/object:Gem::Dependency
286
+ name: rubocop-performance
287
+ requirement: !ruby/object:Gem::Requirement
288
+ requirements:
289
+ - - "~>"
290
+ - !ruby/object:Gem::Version
291
+ version: 1.18.0
292
+ type: :development
293
+ prerelease: false
294
+ version_requirements: !ruby/object:Gem::Requirement
295
+ requirements:
296
+ - - "~>"
297
+ - !ruby/object:Gem::Version
298
+ version: 1.18.0
299
+ - !ruby/object:Gem::Dependency
300
+ name: rubocop-rails
301
+ requirement: !ruby/object:Gem::Requirement
302
+ requirements:
303
+ - - "~>"
304
+ - !ruby/object:Gem::Version
305
+ version: 2.20.2
306
+ type: :development
307
+ prerelease: false
308
+ version_requirements: !ruby/object:Gem::Requirement
309
+ requirements:
310
+ - - "~>"
311
+ - !ruby/object:Gem::Version
312
+ version: 2.20.2
313
+ - !ruby/object:Gem::Dependency
314
+ name: rubocop-rake
315
+ requirement: !ruby/object:Gem::Requirement
316
+ requirements:
317
+ - - "~>"
318
+ - !ruby/object:Gem::Version
319
+ version: 0.6.0
320
+ type: :development
321
+ prerelease: false
322
+ version_requirements: !ruby/object:Gem::Requirement
323
+ requirements:
324
+ - - "~>"
325
+ - !ruby/object:Gem::Version
326
+ version: 0.6.0
327
+ - !ruby/object:Gem::Dependency
328
+ name: rubocop-rspec
329
+ requirement: !ruby/object:Gem::Requirement
330
+ requirements:
331
+ - - "~>"
332
+ - !ruby/object:Gem::Version
333
+ version: 2.22.0
334
+ type: :development
335
+ prerelease: false
336
+ version_requirements: !ruby/object:Gem::Requirement
337
+ requirements:
338
+ - - "~>"
339
+ - !ruby/object:Gem::Version
340
+ version: 2.22.0
341
+ description: Devise is awesome, but we don't need all that UI/UX for PassKeys. This
342
+ gem is to make it easy to provide a back end that authenticates a mobile front end
343
+ with PassKeys.
344
+ email:
345
+ - troy@alliedcode.com
346
+ executables: []
347
+ extensions: []
348
+ extra_rdoc_files: []
349
+ files:
350
+ - MIT-LICENSE
351
+ - README.md
352
+ - Rakefile
353
+ - app/assets/config/passkeys_rails_manifest.js
354
+ - app/assets/stylesheets/passkeys_rails/application.css
355
+ - app/controllers/passkeys_rails/application_controller.rb
356
+ - app/controllers/passkeys_rails/passkeys_controller.rb
357
+ - app/helpers/passkeys_rails/application_helper.rb
358
+ - app/helpers/passkeys_rails/passkeys_helper.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
367
+ - app/models/concerns/passkeys_rails/authenticatable.rb
368
+ - app/models/passkeys_rails/agent.rb
369
+ - app/models/passkeys_rails/application_record.rb
370
+ - app/models/passkeys_rails/error.rb
371
+ - app/models/passkeys_rails/passkey.rb
372
+ - app/views/layouts/passkeys_rails/application.html.erb
373
+ - config/routes.rb
374
+ - db/migrate/20230620012530_create_passkeys_rails_agents.rb
375
+ - db/migrate/20230620012600_create_passkeys_rails_passkeys.rb
376
+ - lib/generators/passkeys_rails/USAGE
377
+ - lib/generators/passkeys_rails/install_generator.rb
378
+ - lib/generators/passkeys_rails/templates/README
379
+ - lib/generators/passkeys_rails/templates/passkeys_rails_config.rb
380
+ - lib/passkeys_rails.rb
381
+ - lib/passkeys_rails/engine.rb
382
+ - lib/passkeys_rails/error_middleware.rb
383
+ - lib/passkeys_rails/railtie.rb
384
+ - lib/passkeys_rails/version.rb
385
+ - lib/tasks/passkeys_rails_tasks.rake
386
+ homepage: https://github.com/alliedcode/passkeys-rails
387
+ licenses:
388
+ - MIT
389
+ metadata:
390
+ homepage_uri: https://github.com/alliedcode/passkeys-rails
391
+ source_code_uri: https://github.com/alliedcode/passkeys-rails
392
+ changelog_uri: https://github.com/alliedcode/passkeys-rails/CHANGELOG.md
393
+ rubygems_mfa_required: 'true'
394
+ post_install_message:
395
+ rdoc_options: []
396
+ require_paths:
397
+ - lib
398
+ required_ruby_version: !ruby/object:Gem::Requirement
399
+ requirements:
400
+ - - ">="
401
+ - !ruby/object:Gem::Version
402
+ version: '3.1'
403
+ required_rubygems_version: !ruby/object:Gem::Requirement
404
+ requirements:
405
+ - - ">="
406
+ - !ruby/object:Gem::Version
407
+ version: '0'
408
+ requirements: []
409
+ rubygems_version: 3.4.12
410
+ signing_key:
411
+ specification_version: 4
412
+ summary: PassKey authentication back end with simple API
413
+ test_files: []