Skip to content

Commit

Permalink
generator-for-devise-jwt: Implement the generator for devise-jwt
Browse files Browse the repository at this point in the history
  • Loading branch information
TheZero0-ctrl committed Apr 1, 2024
1 parent f4623f5 commit 776e246
Show file tree
Hide file tree
Showing 4 changed files with 364 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## master (unreleased)
* Adds Devise JWT generator. ([@TheZero0-ctrl][])

## 0.13.0 (March 26th, 2024)
* Adds Letter Opener generator. ([@coolprobn][])
Expand Down Expand Up @@ -84,3 +85,4 @@
[@luathn]: https://github.com/luathn
[@coolprobn]: https://github.com/coolprobn
[@aadil]: https://github.com/AdilRT
[@TheZero0-ctrl]: https://github.com/TheZero0-ctrl
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ The boring generator introduces following generators:
- Install Whenever: `rails generate boring:whenever:install`
- Install Rswag: `rails generate boring:rswag:install --rails_port=<rails_app_port> --authentication_type=<api_authentication_type> --skip_api_authentication=<skip_api_authentication> --api_authentication_options=<api_authentication_options> --enable_swagger_ui_authentication=<enable_swagger_ui_authentication>`
- Install Webmock: `rails generate boring:webmock:install --app_test_framework=<test_framework>`
- Install Devise JWT: `rails generate boring:devise:jwt:install`

## Screencasts

Expand Down
224 changes: 224 additions & 0 deletions lib/generators/boring/devise/jwt/install/install_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# frozen_string_literal: true

module Boring
module Devise
module Jwt
class InstallGenerator < Rails::Generators::Base
desc "Add devise-jwt to the application"

class_option :model_name, type: :string, aliases: "-m",
default: "User",
desc: "Tell us the user model name which will be used for authentication. Defaults to User"
class_option :use_env_variable, type: :boolean, aliases: "-ev",
desc: "Use ENV variable for devise_jwt_secret_key. By default Rails credentials will be used."
class_option :revocation_strategy, type: :string, aliases: "-rs",
enum: %w[JTIMatcher Denylist Allowlist],
default: "Denylist",
desc: "Tell us the revocation strategy to be used. Defaults to Denylist"
class_option :expiration_time_in_days, type: :numeric, aliases: "-et",
default: 15,
desc: "Tell us the expiration time on days for the JWT token. Defaults to 15 days"

def verify_presence_of_devise_gem
gem_file_content_array = File.readlines("Gemfile")
devise_is_installed = gem_file_content_array.any? { |line| line.include?('devise') }

return if devise_is_installed

say "We couldn't find devise gem. Please configure devise gem and run the generator again!", :red

abort
end

def verify_presence_of_devise_initializer
return if File.exist?("config/initializers/devise.rb")

say "We couldn't find devise initializer. Please configure devise gem correctly and run the generator again!", :red

abort
end

def verify_presence_of_devise_model
return if File.exist?("app/models/#{options[:model_name].underscore}.rb")

say "We couldn't find the #{options[:model_name]} model. Maybe there is a typo? Please provide the correct model name and run the generator again.", :red

abort
end

def add_devise_jwt_gem
say "Adding devise-jwt gem", :green
gem "devise-jwt"
end

def add_devise_jwt_config_to_devise_initializer
say "Adding devise-jwt configurations to a file `config/initializers/devise.rb`", :green

jwt_config = <<~RUBY
config.jwt do |jwt|
jwt.secret = #{devise_jwt_secret_key}
jwt.dispatch_requests = [
['POST', %r{^/sign_in$}]
]
jwt.revocation_requests = [
['DELETE', %r{^/sign_out$}]
]
jwt.expiration_time = #{options[:expiration_time_in_days]}.day.to_i
end
RUBY

inject_into_file "config/initializers/devise.rb",
optimize_indentation(jwt_config, 2),
before: /^end\s*\Z/m

say "❗️❗️\nValue for jwt.secret will be used from `#{devise_jwt_secret_key}`. You can change this values if they don't match with your app.\n",
:yellow
end

def configure_revocation_strategies
say "Configuring #{options[:revocation_strategy]} revocation strategy",
:green

case options[:revocation_strategy]
when "JTIMatcher"
configure_jti_matcher_strategy
when "Denylist"
configure_denylist_strategy
when "Allowlist"
configure_allowlist_strategy
end
end

private

def devise_jwt_secret_key
if options[:use_env_variable]
"ENV['DEVISE_JWT_SECRET_KEY']"
else
"Rails.application.credentials.devise_jwt_secret_key"
end
end

def configure_jti_matcher_strategy
model_name = options[:model_name].underscore
Bundler.with_unbundled_env do
run "bundle exec rails generate migration add_jti_to_#{model_name.pluralize}"
end
migration_content = <<~RUBY
add_column :users, :jti, :string, null: false
add_index :users, :jti, unique: true
RUBY

inject_into_file Dir["db/migrate/*_add_jti_to_#{model_name.pluralize}.rb"][0],
optimize_indentation(migration_content, 4),
after: /def change\n/,
verbose: false

add_devise_jwt_module(
strategy: "self",
include_content: "include Devise::JWT::RevocationStrategies::JTIMatcher"
)

end

def configure_denylist_strategy
Bundler.with_unbundled_env do
run "bundle exec rails generate model jwt_denylist --skip-migration"
run "bundle exec rails generate migration create_jwt_denylist"
end

migration_content = <<~RUBY
t.string :jti, null: false
t.datetime :exp, null: false
RUBY

gsub_file Dir["db/migrate/*_create_jwt_denylist.rb"][0],
/create_table :jwt_denylists do \|t\|/,
"create_table :jwt_denylist do |t|",
verbose: false

inject_into_file Dir["db/migrate/*_create_jwt_denylist.rb"][0],
optimize_indentation(migration_content, 6),
after: /create_table :jwt_denylist do \|t\|\n/,
verbose: false

inject_into_file Dir["db/migrate/*_create_jwt_denylist.rb"][0],
optimize_indentation("add_index :jwt_denylist, :jti", 4),
before: /^ end/,
verbose: false

add_devise_jwt_module(strategy: "JwtDenylist")

jwt_denylist_content = <<~RUBY
include Devise::JWT::RevocationStrategies::Denylist
self.table_name = 'jwt_denylist'
RUBY

inject_into_file "app/models/jwt_denylist.rb",
optimize_indentation(jwt_denylist_content, 2),
after: /ApplicationRecord\n/,
verbose: false
end

def configure_allowlist_strategy
model_name = options[:model_name].underscore
Bundler.with_unbundled_env do
run "bundle exec rails generate model allowlisted_jwt"
end

migration_content = <<~RUBY
t.string :jti, null: false
t.string :aud
# If you want to leverage the `aud` claim, add to it a `NOT NULL` constraint:
# t.string :aud, null: false
t.datetime :exp, null: false
t.references :#{model_name}, foreign_key: { on_delete: :cascade }, null: false
RUBY

inject_into_file Dir["db/migrate/*_create_allowlisted_jwts.rb"][0],
optimize_indentation(migration_content, 6),
after: /create_table :allowlisted_jwts do \|t\|\n/,
verbose: false

inject_into_file Dir["db/migrate/*_create_allowlisted_jwts.rb"][0],
optimize_indentation("add_index :allowlisted_jwts, :jti", 4),
before: /^ end/,
verbose: false

add_devise_jwt_module(
strategy: "self",
include_content: "include Devise::JWT::RevocationStrategies::Allowlist"
)
end

def add_devise_jwt_module(strategy:, include_content: nil)
model_name = options[:model_name].underscore
model_content = File.read("app/models/#{model_name}.rb")
devise_module_pattern = /devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)/

if model_content.match?(devise_module_pattern)
inject_into_file "app/models/#{model_name}.rb",
", :jwt_authenticatable, jwt_revocation_strategy: #{strategy}",
after: devise_module_pattern
else
inject_into_file "app/models/#{model_name}.rb",
optimize_indentation(
"devise :jwt_authenticatable, jwt_revocation_strategy: #{strategy}",
2
),
after: /ApplicationRecord\n/
say "Successfully added the devise-jwt module to #{model_name} model. However, it looks like the devise module is missing from the #{model_name} model. Please configure the devise module to ensure everything functions correctly.",
:yellow
end

if include_content
inject_into_file "app/models/#{model_name}.rb",
optimize_indentation(include_content, 2),
after: /ApplicationRecord\n/,
verbose: false
end
end
end
end
end
end
137 changes: 137 additions & 0 deletions test/generators/devise/devise_jwt_install_generator_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# frozen_string_literal: true

require "test_helper"
require "generators/boring/devise/jwt/install/install_generator"

class DeviseInstallGeneratorTest < Rails::Generators::TestCase
tests Boring::Devise::Jwt::InstallGenerator
setup :build_app
teardown :teardown_app

include GeneratorHelper
include ActiveSupport::Testing::Isolation

def destination_root
app_path
end

def test_should_exit_if_devise_is_not_installed
assert_raises SystemExit do
quietly { generator.verify_presence_of_devise_gem }
end
end

def test_should_exit_if_devise_initializer_is_not_present
assert_raises SystemExit do
quietly { generator.verify_presence_of_devise_initializer }
end
end

def test_should_exit_if_devise_model_is_not_present
assert_raises SystemExit do
quietly { generator.verify_presence_of_devise_model }
end
end

def test_should_configure_devise_jwt
Dir.chdir(app_path) do
setup_devise
quietly { run_generator }
assert_gem "devise-jwt"
assert_file "config/initializers/devise.rb" do |content|
assert_match(/config.jwt do |jwt|/, content)
assert_match(/jwt.secret = Rails.application.credentials.devise_jwt_secret/, content)
assert_match(/jwt\.dispatch_requests\s*=\s*\[\s*/, content)
assert_match(/jwt\.revocation_requests\s*=\s*\[\s*/, content)
assert_match(/jwt\.expiration_time\s*=\s*/, content)
end
assert_migration "db/migrate/create_jwt_denylist.rb"
assert_file "app/models/user.rb" do |content|

assert_match(
/devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)*(?:,\s*:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist)/,
content
)
end

assert_file "app/models/jwt_denylist.rb" do |content|
assert_match(/include Devise::JWT::RevocationStrategies::Denylist/, content)
assert_match(/self\.table_name = 'jwt_denylist'/, content)
end
end
end

def test_should_use_env_variable_for_devise_jwt_secret
Dir.chdir(app_path) do
setup_devise
quietly { run_generator [destination_root, "--use_env_variable"] }
assert_file "config/initializers/devise.rb" do |content|
assert_match(/jwt\.secret\s*=\s*ENV\['DEVISE_JWT_SECRET_KEY'\]/, content)
end
end
end

def test_should_configure_jti_matcher_revocation_strategy
Dir.chdir(app_path) do
setup_devise
quietly { run_generator [destination_root, "--revocation_strategy=JTIMatcher"] }
assert_migration "db/migrate/add_jti_to_users.rb"
assert_file "app/models/user.rb" do |content|
assert_match(/include Devise::JWT::RevocationStrategies::JTIMatcher/, content)
assert_match(
/devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)*(?:,\s*:jwt_authenticatable, jwt_revocation_strategy: self)/,
content
)
end
end
end

def test_should_configure_allowlist_revocation_strategy
Dir.chdir(app_path) do
setup_devise
quietly { run_generator [destination_root, "--revocation_strategy=Allowlist"] }
assert_migration "db/migrate/create_allowlisted_jwts.rb"
assert_file "app/models/user.rb" do |content|
assert_match(/include Devise::JWT::RevocationStrategies::Allowlist/, content)
assert_match(
/devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)*(?:,\s*:jwt_authenticatable, jwt_revocation_strategy: self)/,
content
)
end
assert_file "app/models/allowlisted_jwt.rb"
end
end

private

def setup_devise(model_name: "User")
Bundler.with_unbundled_env do
`bundle add devise`
end

create_devise_initializer
create_devise_model(model_name)
end

def create_devise_initializer
FileUtils.mkdir_p("#{app_path}/config/initializers")
content = <<~RUBY
Devise.setup do |config|
end
RUBY

File.write("#{app_path}/config/initializers/devise.rb", content)
end

def create_devise_model(model_name)
FileUtils.mkdir_p("#{app_path}/app/models")
content = <<~RUBY
class #{model_name} < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
end
RUBY

File.write("#{app_path}/app/models/#{model_name.underscore}.rb", content)
end
end

0 comments on commit 776e246

Please sign in to comment.