Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

generator-for-devise-jwt: Implement the generator for devise-jwt #5

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* Adds Honeybadger generator. ([@mausamp][])
* Adds Rails ERD generator. ([@mausamp][])
* Adds Annotate Generator. ([@TheZero0-ctrl][])
* Adds Devise JWT generator. ([@TheZero0-ctrl][])

## 0.13.0 (March 26th, 2024)
* Adds Letter Opener generator. ([@coolprobn][])
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ The boring generator introduces following generators:
- Install Honeybadger: `rails generate boring:honeybadger:install`
- Install Rails ERD: `rails generate boring:rails_erd:install`
- Install Annotate: `rails generate boring:annotate:install`
- Install Devise JWT: `rails generate boring:devise:jwt:install`

## Screencasts

Expand Down
189 changes: 189 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,189 @@
# frozen_string_literal: true

require 'boring_generators/generator_helper'

module Boring
module Devise
module Jwt
class InstallGenerator < Rails::Generators::Base
include Rails::Generators::Migration
include BoringGenerators::GeneratorHelper

desc "Add devise-jwt to the application"
source_root File.expand_path('templates', __dir__)

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 self.next_migration_number(dirname)
next_migration_number = current_migration_number(dirname) + 1
ActiveRecord::Migration.next_migration_number(next_migration_number)
end

def verify_presence_of_devise_gem
return if gem_installed?("devise")

say "We couldn't find devise gem. Please configure devise gem and rerun the generator. Consider running `rails generate boring:devise:install` to set up Devise.",
: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 and rerun the generator. Consider running `rails generate boring:devise:install` to set up Devise.",
: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
check_and_install_gem("devise-jwt")
bundle_install
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_db_table = options[:model_name].tableize
@model_class = @model_db_table.camelcase

migration_template "add_jti_to_users.rb", "db/migrate/add_jti_to_#{@model_db_table}.rb"

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"
end

migration_template "create_jwt_denylist.rb", "db/migrate/create_jwt_denylist.rb"

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_underscore = options[:model_name].underscore
Bundler.with_unbundled_env do
run "bundle exec rails generate model allowlisted_jwt --skip-migration"
end

migration_template "create_allowlisted_jwts.rb", "db/migrate/create_allowlisted_jwts.rb"

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddJtiTo<%= @model_class %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
def change
add_column :<%= @model_db_table %>, :jti, :string, null: false
add_index :<%= @model_db_table %>, :jti, unique: true
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class CreateAllowlistedJwts < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
def change
create_table :allowlisted_jwts do |t|
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_underscore %>, foreign_key: { on_delete: :cascade }, null: false

t.timestamps
end
add_index :allowlisted_jwts, :jti
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class CreateJwtDenylist < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
def change
create_table :jwt_denylist do |t|
t.string :jti, null: false
t.datetime :exp, null: false

t.timestamps
end
add_index :jwt_denylist, :jti
end
end
136 changes: 136 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,136 @@
# 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
coolprobn marked this conversation as resolved.
Show resolved Hide resolved

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