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.
- checksums.yaml +7 -0
- data/README.md +89 -0
- data/Rakefile +20 -0
- data/app/controllers/concerns/neeto_jwt_engine/api_exceptions.rb +77 -0
- data/app/controllers/concerns/neeto_jwt_engine/authenticatable.rb +20 -0
- data/app/controllers/neeto_jwt_engine/application_controller.rb +7 -0
- data/app/controllers/neeto_jwt_engine/configurations_controller.rb +54 -0
- data/app/models/concerns/neeto_jwt_engine/incinerable_concern.rb +26 -0
- data/app/models/neeto_jwt_engine/application_record.rb +7 -0
- data/app/models/neeto_jwt_engine/configuration.rb +24 -0
- data/app/models/neeto_jwt_engine/onetime_link.rb +19 -0
- data/app/services/neeto_jwt_engine/elliptic_key_generator_service.rb +44 -0
- data/app/views/layouts/neeto_jwt_engine/application.html.erb +15 -0
- data/config/brakeman.ignore +5 -0
- data/config/locales/en.yml +1 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20250329064017_create_neeto_jwt_engine_configurations.rb +14 -0
- data/db/migrate/20250716075611_create_neeto_jwt_engine_onetime_links.rb +13 -0
- data/lib/neeto-jwt-engine.rb +5 -0
- data/lib/neeto_jwt_engine/engine.rb +7 -0
- data/lib/neeto_jwt_engine/exceptions.rb +5 -0
- data/lib/neeto_jwt_engine/version.rb +5 -0
- data/lib/omniauth/strategies/jwt.rb +93 -0
- data/test/controllers/neeto_jwt_engine/configurations_controller_test.rb +58 -0
- data/test/dummy/Rakefile +8 -0
- data/test/dummy/app/assets/config/manifest.js +3 -0
- data/test/dummy/app/assets/stylesheets/application.css +15 -0
- data/test/dummy/app/channels/application_cable/channel.rb +6 -0
- data/test/dummy/app/channels/application_cable/connection.rb +6 -0
- data/test/dummy/app/controllers/application_controller.rb +5 -0
- data/test/dummy/app/controllers/concerns/sso_helpers.rb +20 -0
- data/test/dummy/app/helpers/application_helper.rb +4 -0
- data/test/dummy/app/javascript/packs/application.js +19 -0
- data/test/dummy/app/jobs/application_job.rb +9 -0
- data/test/dummy/app/mailers/application_mailer.rb +6 -0
- data/test/dummy/app/models/application_record.rb +5 -0
- data/test/dummy/app/models/organization.rb +10 -0
- data/test/dummy/app/models/user.rb +11 -0
- data/test/dummy/app/services/sample_data/common/admin_service.rb +26 -0
- data/test/dummy/app/services/sample_data/common/base.rb +43 -0
- data/test/dummy/app/services/sample_data/common/database_cleanup_service.rb +11 -0
- data/test/dummy/app/services/sample_data/common/loader_service.rb +34 -0
- data/test/dummy/app/services/sample_data/common/organization_service.rb +27 -0
- data/test/dummy/app/services/sample_data/loaders_list.rb +14 -0
- data/test/dummy/app/services/sample_data/user_service.rb +26 -0
- data/test/dummy/app/views/home/index.html.erb +1 -0
- data/test/dummy/app/views/layouts/application.html.erb +15 -0
- data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
- data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
- data/test/dummy/app/views/users/index.json.jbuilder +19 -0
- data/test/dummy/bin/rails +6 -0
- data/test/dummy/bin/rake +6 -0
- data/test/dummy/bin/setup +37 -0
- data/test/dummy/bin/webpacker +16 -0
- data/test/dummy/bin/webpacker-dev-server +19 -0
- data/test/dummy/config/application.rb +29 -0
- data/test/dummy/config/boot.rb +7 -0
- data/test/dummy/config/cable.yml +10 -0
- data/test/dummy/config/database.yml +17 -0
- data/test/dummy/config/database.yml.ci +17 -0
- data/test/dummy/config/database.yml.postgresql +17 -0
- data/test/dummy/config/environment.rb +7 -0
- data/test/dummy/config/environments/development.rb +70 -0
- data/test/dummy/config/environments/production.rb +116 -0
- data/test/dummy/config/environments/test.rb +63 -0
- data/test/dummy/config/initializers/application_controller_renderer.rb +9 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +10 -0
- data/test/dummy/config/initializers/content_security_policy.rb +29 -0
- data/test/dummy/config/initializers/cookies_serializer.rb +7 -0
- data/test/dummy/config/initializers/inflections.rb +17 -0
- data/test/dummy/config/initializers/mime_types.rb +5 -0
- data/test/dummy/config/initializers/permissions_policy.rb +12 -0
- data/test/dummy/config/initializers/strong_migrations.rb +4 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +16 -0
- data/test/dummy/config/locales/en.yml +33 -0
- data/test/dummy/config/puma.rb +45 -0
- data/test/dummy/config/routes.rb +13 -0
- data/test/dummy/config/storage.yml +34 -0
- data/test/dummy/config/webpack/development.js +5 -0
- data/test/dummy/config/webpack/environment.js +13 -0
- data/test/dummy/config/webpack/production.js +5 -0
- data/test/dummy/config/webpack/resolve.js +11 -0
- data/test/dummy/config/webpack/test.js +5 -0
- data/test/dummy/config/webpack/webpack.config.js +20 -0
- data/test/dummy/config/webpacker.yml +73 -0
- data/test/dummy/config.ru +8 -0
- data/test/dummy/db/migrate/20220419104218_create_organizations.rb +15 -0
- data/test/dummy/db/migrate/20220419114209_create_users.rb +20 -0
- data/test/dummy/db/migrate/20240607032904_add_deactivated_at_to_organizations.rb +7 -0
- data/test/dummy/db/schema.rb +68 -0
- data/test/dummy/lib/tasks/setup.rake +48 -0
- data/test/dummy/log/development.log +71 -0
- data/test/dummy/log/test.log +5189 -0
- data/test/dummy/public/404.html +67 -0
- data/test/dummy/public/422.html +67 -0
- data/test/dummy/public/500.html +66 -0
- data/test/dummy/public/apple-touch-icon-precomposed.png +0 -0
- data/test/dummy/public/apple-touch-icon.png +0 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/dummy/tmp/local_secret.txt +1 -0
- data/test/dummy/tmp/restart.txt +0 -0
- data/test/factories/neeto_jwt_engine/configuration.rb +7 -0
- data/test/factories/neeto_jwt_engine/onetime_link.rb +7 -0
- data/test/factories/organization.rb +8 -0
- data/test/factories/user.rb +13 -0
- data/test/models/neeto_jwt_engine/configuration_test.rb +23 -0
- data/test/models/neeto_jwt_engine/onetime_link_test.rb +27 -0
- data/test/neeto_jwt_engine_test.rb +12 -0
- data/test/services/neeto_jwt_engine/elliptic_key_generator_service_test.rb +30 -0
- data/test/support/assertion_support.rb +9 -0
- data/test/test_helper.rb +30 -0
- 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,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,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 @@
|
|
1
|
+
en:
|
data/config/routes.rb
ADDED
@@ -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,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
|
data/test/dummy/Rakefile
ADDED
@@ -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
|