neeto-jwt-engine 1.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 (112) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +89 -0
  3. data/Rakefile +20 -0
  4. data/app/controllers/concerns/neeto_jwt_engine/api_exceptions.rb +77 -0
  5. data/app/controllers/concerns/neeto_jwt_engine/authenticatable.rb +20 -0
  6. data/app/controllers/neeto_jwt_engine/application_controller.rb +7 -0
  7. data/app/controllers/neeto_jwt_engine/configurations_controller.rb +54 -0
  8. data/app/models/concerns/neeto_jwt_engine/incinerable_concern.rb +26 -0
  9. data/app/models/neeto_jwt_engine/application_record.rb +7 -0
  10. data/app/models/neeto_jwt_engine/configuration.rb +24 -0
  11. data/app/models/neeto_jwt_engine/onetime_link.rb +19 -0
  12. data/app/services/neeto_jwt_engine/elliptic_key_generator_service.rb +44 -0
  13. data/app/views/layouts/neeto_jwt_engine/application.html.erb +15 -0
  14. data/config/brakeman.ignore +5 -0
  15. data/config/locales/en.yml +1 -0
  16. data/config/routes.rb +7 -0
  17. data/db/migrate/20250329064017_create_neeto_jwt_engine_configurations.rb +14 -0
  18. data/db/migrate/20250716075611_create_neeto_jwt_engine_onetime_links.rb +13 -0
  19. data/lib/neeto-jwt-engine.rb +5 -0
  20. data/lib/neeto_jwt_engine/engine.rb +7 -0
  21. data/lib/neeto_jwt_engine/exceptions.rb +5 -0
  22. data/lib/neeto_jwt_engine/version.rb +5 -0
  23. data/lib/omniauth/strategies/jwt.rb +93 -0
  24. data/test/controllers/neeto_jwt_engine/configurations_controller_test.rb +58 -0
  25. data/test/dummy/Rakefile +8 -0
  26. data/test/dummy/app/assets/config/manifest.js +3 -0
  27. data/test/dummy/app/assets/stylesheets/application.css +15 -0
  28. data/test/dummy/app/channels/application_cable/channel.rb +6 -0
  29. data/test/dummy/app/channels/application_cable/connection.rb +6 -0
  30. data/test/dummy/app/controllers/application_controller.rb +5 -0
  31. data/test/dummy/app/controllers/concerns/sso_helpers.rb +20 -0
  32. data/test/dummy/app/helpers/application_helper.rb +4 -0
  33. data/test/dummy/app/javascript/packs/application.js +19 -0
  34. data/test/dummy/app/jobs/application_job.rb +9 -0
  35. data/test/dummy/app/mailers/application_mailer.rb +6 -0
  36. data/test/dummy/app/models/application_record.rb +5 -0
  37. data/test/dummy/app/models/organization.rb +10 -0
  38. data/test/dummy/app/models/user.rb +11 -0
  39. data/test/dummy/app/services/sample_data/common/admin_service.rb +26 -0
  40. data/test/dummy/app/services/sample_data/common/base.rb +43 -0
  41. data/test/dummy/app/services/sample_data/common/database_cleanup_service.rb +11 -0
  42. data/test/dummy/app/services/sample_data/common/loader_service.rb +34 -0
  43. data/test/dummy/app/services/sample_data/common/organization_service.rb +27 -0
  44. data/test/dummy/app/services/sample_data/loaders_list.rb +14 -0
  45. data/test/dummy/app/services/sample_data/user_service.rb +26 -0
  46. data/test/dummy/app/views/home/index.html.erb +1 -0
  47. data/test/dummy/app/views/layouts/application.html.erb +15 -0
  48. data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
  49. data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
  50. data/test/dummy/app/views/users/index.json.jbuilder +19 -0
  51. data/test/dummy/bin/rails +6 -0
  52. data/test/dummy/bin/rake +6 -0
  53. data/test/dummy/bin/setup +37 -0
  54. data/test/dummy/bin/webpacker +16 -0
  55. data/test/dummy/bin/webpacker-dev-server +19 -0
  56. data/test/dummy/config/application.rb +29 -0
  57. data/test/dummy/config/boot.rb +7 -0
  58. data/test/dummy/config/cable.yml +10 -0
  59. data/test/dummy/config/database.yml +17 -0
  60. data/test/dummy/config/database.yml.ci +17 -0
  61. data/test/dummy/config/database.yml.postgresql +17 -0
  62. data/test/dummy/config/environment.rb +7 -0
  63. data/test/dummy/config/environments/development.rb +70 -0
  64. data/test/dummy/config/environments/production.rb +116 -0
  65. data/test/dummy/config/environments/test.rb +63 -0
  66. data/test/dummy/config/initializers/application_controller_renderer.rb +9 -0
  67. data/test/dummy/config/initializers/backtrace_silencers.rb +10 -0
  68. data/test/dummy/config/initializers/content_security_policy.rb +29 -0
  69. data/test/dummy/config/initializers/cookies_serializer.rb +7 -0
  70. data/test/dummy/config/initializers/inflections.rb +17 -0
  71. data/test/dummy/config/initializers/mime_types.rb +5 -0
  72. data/test/dummy/config/initializers/permissions_policy.rb +12 -0
  73. data/test/dummy/config/initializers/strong_migrations.rb +4 -0
  74. data/test/dummy/config/initializers/wrap_parameters.rb +16 -0
  75. data/test/dummy/config/locales/en.yml +33 -0
  76. data/test/dummy/config/puma.rb +45 -0
  77. data/test/dummy/config/routes.rb +13 -0
  78. data/test/dummy/config/storage.yml +34 -0
  79. data/test/dummy/config/webpack/development.js +5 -0
  80. data/test/dummy/config/webpack/environment.js +13 -0
  81. data/test/dummy/config/webpack/production.js +5 -0
  82. data/test/dummy/config/webpack/resolve.js +11 -0
  83. data/test/dummy/config/webpack/test.js +5 -0
  84. data/test/dummy/config/webpack/webpack.config.js +20 -0
  85. data/test/dummy/config/webpacker.yml +73 -0
  86. data/test/dummy/config.ru +8 -0
  87. data/test/dummy/db/migrate/20220419104218_create_organizations.rb +15 -0
  88. data/test/dummy/db/migrate/20220419114209_create_users.rb +20 -0
  89. data/test/dummy/db/migrate/20240607032904_add_deactivated_at_to_organizations.rb +7 -0
  90. data/test/dummy/db/schema.rb +68 -0
  91. data/test/dummy/lib/tasks/setup.rake +48 -0
  92. data/test/dummy/log/development.log +71 -0
  93. data/test/dummy/log/test.log +5189 -0
  94. data/test/dummy/public/404.html +67 -0
  95. data/test/dummy/public/422.html +67 -0
  96. data/test/dummy/public/500.html +66 -0
  97. data/test/dummy/public/apple-touch-icon-precomposed.png +0 -0
  98. data/test/dummy/public/apple-touch-icon.png +0 -0
  99. data/test/dummy/public/favicon.ico +0 -0
  100. data/test/dummy/tmp/local_secret.txt +1 -0
  101. data/test/dummy/tmp/restart.txt +0 -0
  102. data/test/factories/neeto_jwt_engine/configuration.rb +7 -0
  103. data/test/factories/neeto_jwt_engine/onetime_link.rb +7 -0
  104. data/test/factories/organization.rb +8 -0
  105. data/test/factories/user.rb +13 -0
  106. data/test/models/neeto_jwt_engine/configuration_test.rb +23 -0
  107. data/test/models/neeto_jwt_engine/onetime_link_test.rb +27 -0
  108. data/test/neeto_jwt_engine_test.rb +12 -0
  109. data/test/services/neeto_jwt_engine/elliptic_key_generator_service_test.rb +30 -0
  110. data/test/support/assertion_support.rb +9 -0
  111. data/test/test_helper.rb +30 -0
  112. metadata +168 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 67424c25f36ea508e8f821f48d9446716df49eb7c7843b472efad9b657dfd3c5
4
+ data.tar.gz: 5f57e3eddc4fc2ae8fc61684ca8f9b19b2a247dbb7fac36d52f33f3eea74d206
5
+ SHA512:
6
+ metadata.gz: e48aff6c53b0f16f8368a6d5a237334d40ab8a36de3a495f271167b904aa83a06afed4e75458ec32ae552a599a69721657f6460c4f726f4fd43c2b0227789451
7
+ data.tar.gz: a3cd885167bdd1c2138fe222c8f48ed9a33ed3cdef38f6b6a6ea53f3a7784ef6680e040e6d06912835b3f6657dc60a1ffdd499f93a66a2adfeefab7f9daf1088
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # Neeto JWT Engine
2
+
3
+ Neeto JWT Engine is a Rails engine that provides JWT-based authentication for Neeto products. It is designed to be a flexible and extensible authentication solution that can be easily integrated into any Rails application.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'neeto-jwt-engine'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ $ bundle install
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ $ gem install neeto-jwt-engine
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### 1. Mount the engine
28
+
29
+ Add the following line to your application's `config/routes.rb` file:
30
+
31
+ ```ruby
32
+ mount NeetoJwtEngine::Engine => "/neeto_jwt"
33
+ ```
34
+
35
+ ### 2. Run the migrations
36
+
37
+ Run the following command to create the necessary tables in your database:
38
+
39
+ ```bash
40
+ $ rails neeto_jwt_engine:install:migrations
41
+ $ rails db:migrate
42
+ ```
43
+
44
+ ### How to generate public-private key pairs
45
+
46
+ Neeto JWT Engine provides a way to generate public-private key pairs for secure authentication.
47
+
48
+ #### Create a public-private key pair for an organization
49
+
50
+ To generate a new key pair, you can make a `POST` request to the `/neeto_jwt_engine/configurations` endpoint. This will create a new configuration with a public-private key pair and return a one-time link to download the private key.
51
+
52
+ You must include a `NEETO-JWT-X-TOKEN` header in the request, and its value should match the `NEETO_JWT_X_TOKEN` environment variable set in your application. Search for "NeetoJWT X-Token" in 1Password to obtain this token.
53
+
54
+ **Request:**
55
+
56
+ ```bash
57
+ curl \
58
+ --request POST \
59
+ --header 'Content-Type: application/json' \
60
+ --header 'NEETO-JWT-X-TOKEN: <your-token>' \
61
+ https://<workspace>.neetoauth.com/neeto_jwt/configurations
62
+ ```
63
+
64
+ **Response:**
65
+
66
+ ```json
67
+ {
68
+ "message": "This is a one-time link. DO NOT click on the link yourself. In case the link is expired, whether by accident or by mis-use, create a fresh public-private key pair using the /neeto-jwt/configurations/create route.",
69
+ "onetime_link": "http://example.com/neeto_jwt_engine/configurations/your-onetime-token"
70
+ }
71
+ ```
72
+
73
+ #### Download private key using a one-time link
74
+
75
+ To download the private key, you can make a `GET` request to the one-time link provided in the `create` API response. This will download the private key as a file and expire the link.
76
+
77
+ **Request:**
78
+
79
+ ```bash
80
+ GET https://<workspace>.neetoauth.com/neeto_jwt/configurations/your-onetime-token
81
+ ```
82
+
83
+ **Response:**
84
+
85
+ The private key will be downloaded as a file named `neeto-jwt-<subdomain>-private.key`.
86
+
87
+ ## Development
88
+
89
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
6
+ load "rails/tasks/engine.rake"
7
+
8
+ load "rails/tasks/statistics.rake"
9
+
10
+ require "bundler/gem_tasks"
11
+
12
+ require "rake/testtask"
13
+
14
+ Rake::TestTask.new(:test) do |t|
15
+ t.libs << "test"
16
+ t.pattern = "test/**/*_test.rb"
17
+ t.verbose = false
18
+ end
19
+
20
+ task default: :test
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngine
4
+ module ApiExceptions
5
+ extend ActiveSupport::Concern
6
+
7
+ class ::NotAuthorizedError < StandardError
8
+ end
9
+
10
+ included do
11
+ protect_from_forgery
12
+
13
+ rescue_from StandardError, with: :handle_api_exception
14
+
15
+ def handle_api_exception(exception)
16
+ case exception
17
+ when -> (e) { e.message.include?("PG::") || e.message.include?("SQLite3::") }
18
+ handle_database_level_exception(exception)
19
+
20
+ when ActionController::ParameterMissing
21
+ log_exception_to_honeybadger(exception)
22
+ render_error(exception, :internal_server_error)
23
+
24
+ when ActiveRecord::RecordNotFound
25
+ render_error("#{exception.model} is not found", :not_found)
26
+
27
+ when ActionDispatch::Http::Parameters::ParseError
28
+ render_error("Http::Parameters parse error", :unprocessable_entity)
29
+
30
+ when ActiveRecord::RecordNotUnique
31
+ render_error(exception)
32
+
33
+ when ActiveModel::ValidationError, ActiveRecord::RecordInvalid, ArgumentError
34
+ error_message = exception.message.gsub("Validation failed: ", "")
35
+ render_error(error_message, :unprocessable_entity)
36
+
37
+ else
38
+ handle_generic_exception(exception)
39
+ end
40
+ end
41
+
42
+ def handle_database_level_exception(exception)
43
+ handle_generic_exception(exception, :internal_server_error)
44
+ end
45
+
46
+ def handle_generic_exception(exception, status = :internal_server_error)
47
+ log_exception_to_honeybadger(exception)
48
+ log_exception(exception)
49
+ render_error(exception, status)
50
+ end
51
+
52
+ def log_exception(exception)
53
+ [exception.class.to_s, exception.to_s, exception.backtrace.join("\n")].each do |str|
54
+ Rails.env.test? ? puts(str) : Rails.logger.info(str)
55
+ end
56
+ end
57
+
58
+ def log_exception_to_honeybadger(exception)
59
+ return if Rails.env.development? || Rails.env.test?
60
+
61
+ Honeybadger.notify(exception)
62
+ end
63
+
64
+ def render_error(error, status = :unprocessable_entity, context = {})
65
+ error_message = error
66
+ is_exception = error.kind_of?(StandardError)
67
+ if is_exception
68
+ is_having_record = error.methods.include? "record"
69
+ error_message = is_having_record ? error.record.errors_to_sentence : error.message
70
+ end
71
+ error_message = error_message.first if error_message.is_a?(Array) && error_message.length == 1
72
+ key = error_message.is_a?(Array) ? "errors" : "error"
73
+ render json: { key => error_message }.merge!(context), status:
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngine
4
+ module Authenticatable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ skip_before_action :verify_authenticity_token
9
+ before_action :authenticate_using_x_token, only: :create
10
+ end
11
+
12
+ private
13
+
14
+ def authenticate_using_x_token
15
+ if request.headers["NEETO-JWT-X-TOKEN"] != ENV["NEETO_JWT_X_TOKEN"]
16
+ render status: :unauthorized, json: { error: "Invalid NEETO_JWT_X_TOKEN" }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngine
4
+ class ApplicationController < ActionController::Base
5
+ include NeetoJwtEngine::ApiExceptions
6
+ end
7
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngine
4
+ class ConfigurationsController < ApplicationController
5
+ MESSAGE = <<~EOF
6
+ This is a one-time link. DO NOT click on the link yourself. In case the link
7
+ is expired, whether by accident or by mis-use, create a fresh public-private
8
+ key pair using the /neeto-jwt/configurations/create route.
9
+ EOF
10
+
11
+ include NeetoJwtEngine::Authenticatable
12
+
13
+ before_action :load_organization!
14
+ before_action :load_configuration!, only: :show
15
+
16
+ def show
17
+ filename = "neeto-jwt-#{@organization.subdomain}-private.key"
18
+ Tempfile.open(filename) do |file|
19
+ file.write @configuration.private_key
20
+ file.rewind
21
+
22
+ send_data file.read, filename:
23
+ end
24
+ end
25
+
26
+ def create
27
+ @organization.jwt_configuration&.onetime_link&.update!(expired: true)
28
+ configuration = NeetoJwtEngine::Configuration.create!(organization: @organization)
29
+ onetime_link = configuration.create_onetime_link!
30
+ onetime_link = "#{request.base_url}/neeto_jwt/configurations/#{onetime_link.link}"
31
+ render status: :ok, json: { message: MESSAGE, onetime_link: }
32
+ end
33
+
34
+ private
35
+
36
+ def load_configuration!
37
+ onetime_link = NeetoJwtEngine::OnetimeLink.find_by(link: params[:id])
38
+
39
+ unless onetime_link.present?
40
+ render status: :not_found, json: { error: "The onetime link has either expired or is not found." }
41
+ return
42
+ end
43
+
44
+ @configuration = onetime_link.configuration
45
+ onetime_link.update!(expired: true)
46
+ end
47
+
48
+ def load_organization!
49
+ @organization = Rails.env.heroku? ?
50
+ Organization.active.find_by(subdomain: Rails.application.secrets.app_subdomain)
51
+ : Organization.active.where.not(subdomain: "app").find_by(subdomain: request.subdomain)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngine
4
+ module IncinerableConcern
5
+ extend ActiveSupport::Concern
6
+
7
+ MODELS_REQUIRE_DESTROY = []
8
+ CYCLIC_DEPENDENCIES = {}
9
+
10
+ def self.associated_models(org_id)
11
+ {
12
+ "NeetoJwtEngine::Configuration": {
13
+ joins: {},
14
+ where: ["organization_id = ?", org_id]
15
+ }
16
+ }
17
+ end
18
+
19
+ def self.get_uninstalled_skipped_models
20
+ models = []
21
+ models
22
+ end
23
+
24
+ SKIPPED_MODELS = [] + get_uninstalled_skipped_models
25
+ end
26
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngine
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngine
4
+ class Configuration < ApplicationRecord
5
+ belongs_to :organization
6
+ has_one :onetime_link, class_name: "NeetoJwtEngine::OnetimeLink"
7
+
8
+ default_scope -> { where(enabled: true) }
9
+
10
+ validates :public_key, presence: true
11
+ validates :private_key, presence: true
12
+
13
+ before_validation :generate_public_and_private_keys!
14
+
15
+ private
16
+
17
+ def generate_public_and_private_keys!
18
+ generator = NeetoJwtEngine::EllipticKeyGeneratorService.new("ES256")
19
+
20
+ self.private_key = generator.private_key
21
+ self.public_key = generator.public_key
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngine
4
+ class OnetimeLink < ApplicationRecord
5
+ belongs_to :configuration, class_name: "NeetoJwtEngine::Configuration"
6
+
7
+ default_scope -> { where(expired: false) }
8
+
9
+ validates :link, presence: true, uniqueness: true
10
+
11
+ before_validation :generate_link, only: :create
12
+
13
+ private
14
+
15
+ def generate_link
16
+ self.link = SecureRandom.hex(8)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This service is initially written only to support the `ES256 encryption`.
4
+ # This can be easily extended to support other Elliptic Curve Digital Signature
5
+ # Algorithm (ECDSA) algorithms.
6
+ module NeetoJwtEngine
7
+ class EllipticKeyGeneratorService
8
+ CURVES = {
9
+ ES256: "prime256v1"
10
+ }.freeze
11
+
12
+ attr_reader :curve, :elliptic_curve_key, :private_key, :public_key
13
+
14
+ def initialize(algorithm = "ES256")
15
+ @curve = CURVES[algorithm.to_sym]
16
+ @elliptic_curve_key = OpenSSL::PKey::EC.generate(curve)
17
+ end
18
+
19
+ def private_key
20
+ elliptic_curve_key.to_pem
21
+ end
22
+
23
+ def public_key
24
+ public_key_point = elliptic_curve_key.public_key
25
+ return nil unless public_key_point
26
+
27
+ asn1 = OpenSSL::ASN1::Sequence(
28
+ [
29
+ OpenSSL::ASN1::Sequence(
30
+ [
31
+ OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
32
+ OpenSSL::ASN1::ObjectId(curve)
33
+ ]
34
+ ),
35
+ OpenSSL::ASN1::BitString(public_key_point.to_octet_string(elliptic_curve_key.group.point_conversion_form))
36
+ ]
37
+ )
38
+
39
+ public_elliptic_curve_key = OpenSSL::PKey::EC.new(asn1.to_der)
40
+
41
+ public_elliptic_curve_key.to_pem
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Neeto JWT</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "neeto_jwt_engine/application", media: "all" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= yield %>
13
+
14
+ </body>
15
+ </html>
@@ -0,0 +1,5 @@
1
+ {
2
+ "ignored_warnings": [],
3
+ "updated": "2025-03-29 16:05:18 +0530",
4
+ "brakeman_version": "5.4.1"
5
+ }
@@ -0,0 +1 @@
1
+ en:
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ NeetoJwtEngine::Engine.routes.draw do
4
+ defaults format: :json do
5
+ resources :configurations, only: %i(show create)
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateNeetoJwtEngineConfigurations < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :neeto_jwt_engine_configurations, id: :uuid do |t|
6
+ t.text :public_key, null: false
7
+ t.text :private_key, null: false
8
+ t.boolean :enabled, null: false, default: true
9
+ t.references :organization, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
10
+
11
+ t.timestamps
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateNeetoJwtEngineOnetimeLinks < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :neeto_jwt_engine_onetime_links, id: :uuid do |t|
6
+ t.string :link, null: false, index: { unique: true }
7
+ t.boolean :expired, null: false, default: false
8
+ t.references :configuration, null: false, type: :uuid, foreign_key: { to_table: :neeto_jwt_engine_configurations }
9
+
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "neeto_jwt_engine/version"
4
+ require "neeto_jwt_engine/engine"
5
+ require "omniauth/strategies/jwt"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngine
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace NeetoJwtEngine
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngineclass
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeetoJwtEngine
4
+ VERSION = "1.1.0"
5
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omniauth"
4
+ require "jwt"
5
+ require "openssl"
6
+
7
+ module OmniAuth
8
+ module Strategies
9
+ class Jwt
10
+ include ::OmniAuth::Strategy
11
+
12
+ ALGORITHM = "ES256"
13
+ CLAIMS = %w(email workspace iat exp)
14
+ TWO_MINUTES_IN_SECONDS = 120
15
+
16
+ class InvalidClaimError < StandardError; end
17
+ class TokenReplayError < StandardError; end
18
+
19
+ def request_phase
20
+ session[:redirect_uri] = request.params["redirect_uri"]
21
+ session[:login_organization_id] = configuration.organization.id
22
+ session[:client_app_name] = request.params["client_app_name"]
23
+
24
+ begin
25
+ verify_claims!
26
+ verify_token_replay!
27
+ redirect callback_url
28
+ rescue InvalidClaimError => e
29
+ fail!(:claim_invalid, e)
30
+ rescue => e
31
+ fail!(:jwt_invalid, e)
32
+ end
33
+ end
34
+
35
+ def callback_phase
36
+ super
37
+ end
38
+
39
+ info do
40
+ jwt_payload.slice("email")
41
+ end
42
+
43
+ private
44
+
45
+ def jwt_payload
46
+ @_jwt_payload ||= JWT.decode(request.params["jwt"], public_key, false, { algorithm: ALGORITHM })[0]
47
+ end
48
+
49
+ def public_key
50
+ OpenSSL::PKey::EC.new(configuration.public_key)
51
+ end
52
+
53
+ def configuration
54
+ return @_configuration if defined?(@_configuration)
55
+
56
+ begin
57
+ subdomain = request.host.split(".").first
58
+ @_configuration = NeetoJwtEngine::Configuration
59
+ .joins(:organization)
60
+ .find_by!({ organization: { subdomain: } })
61
+ rescue ActiveRecord::RecordNotFound
62
+ raise InvalidClaimError, "'#{subdomain}' workspace is not registered for JWT authentication."
63
+ end
64
+ end
65
+
66
+ def verify_claims!
67
+ CLAIMS.each do |field|
68
+ raise InvalidClaimError.new("Missing field '#{field}' is required.") if !jwt_payload.key?(field.to_s)
69
+ end
70
+
71
+ organization = configuration.organization
72
+
73
+ if (Time.zone.now.to_i - jwt_payload["iat"].to_i).abs > TWO_MINUTES_IN_SECONDS
74
+ raise InvalidClaimError, "TokenExpired: 'iat' is outside the allowed time window."
75
+ elsif (jwt_payload["exp"].to_i - Time.zone.now.to_i).abs > TWO_MINUTES_IN_SECONDS
76
+ raise InvalidClaimError, "TokenExpired: 'exp' is outside the allowed time window."
77
+ elsif organization.users.exists?(email: jwt_payload["email"]) == false
78
+ raise InvalidClaimError, "User '#{jwt_payload["email"]}' is not part of the '#{jwt_payload["workspace"]}' workspace."
79
+ end
80
+ end
81
+
82
+ def verify_token_replay!
83
+ jwt = request.params["jwt"]
84
+
85
+ if Rails.cache.read("jwt_token:#{jwt}")
86
+ raise TokenReplayError, "The token has already been used."
87
+ else
88
+ Rails.cache.write("jwt_token:#{jwt}", true, expires_in: 5.minutes)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module NeetoJwtEngine
6
+ class ConfigurationsControllerTest < ActionDispatch::IntegrationTest
7
+ include Engine.routes.url_helpers
8
+
9
+ def setup
10
+ @organization = create(:organization)
11
+ host! test_domain(@organization.subdomain)
12
+ end
13
+
14
+ def test_should_authenticate_creation_of_key_pairs
15
+ post configurations_path
16
+ assert_response :unauthorized
17
+ end
18
+
19
+ def test_should_create_onetime_link
20
+ post configurations_path, headers: headers
21
+
22
+ assert_response :ok
23
+ assert response_json["onetime_link"]
24
+ end
25
+
26
+ def test_creating_a_new_onetime_link_expires_previous_link
27
+ configuration = create(:configuration, organization: @organization)
28
+ onetime_link = create(:onetime_link, configuration:)
29
+
30
+ post configurations_path, headers: headers
31
+
32
+ assert_response :ok
33
+ assert onetime_link.reload.expired
34
+ end
35
+
36
+ def test_download_private_key_using_onetime_link
37
+ onetime_link = create(:onetime_link)
38
+
39
+ get configuration_path(onetime_link.link)
40
+
41
+ assert_response :ok
42
+ assert_includes response.body, "-----BEGIN EC PRIVATE KEY-----"
43
+ end
44
+
45
+ def test_cannot_download_private_key_using_expired_onetime_link
46
+ onetime_link = create(:onetime_link, expired: true)
47
+
48
+ get configuration_path(onetime_link.link)
49
+ assert_response :not_found
50
+ end
51
+
52
+ private
53
+
54
+ def headers
55
+ @_heaaders ||= { "NEETO-JWT-X-TOKEN" => ENV["NEETO_JWT_X_TOKEN"] }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add your own tasks in files placed in lib/tasks ending in .rake,
4
+ # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
5
+
6
+ require_relative "config/application"
7
+
8
+ Rails.application.load_tasks
@@ -0,0 +1,3 @@
1
+ //= link_tree ../images
2
+ //= link_directory ../stylesheets .css
3
+ //= link neeto_jwt_manifest.js