Skip to content

Commit 5fd962b

Browse files
committed
generator-for-devise-jwt: Implement the generator for devise-jwt
1 parent c5743cb commit 5fd962b

File tree

4 files changed

+363
-0
lines changed

4 files changed

+363
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Adds Pronto Generator with Gitlab CI ([@coolprobn][])
55
* Adds Rack Mini Profiler generator. ([@mausamp][])
66
* Adds VCR generator. ([@TheZero0-ctrl][])
7+
* Adds Devise JWT generator. ([@TheZero0-ctrl][])
78

89
## 0.13.0 (March 26th, 2024)
910
* Adds Letter Opener generator. ([@coolprobn][])

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ The boring generator introduces following generators:
8787
- Install Pronto with Gitlab CI: `rails generate boring:pronto:gitlab_ci:install`
8888
- Install Rack Mini Profiler: `rails generate boring:rack_mini_profiler:install`
8989
- Install VCR: `rails generate boring:vcr:install --testing_framework=<testing_framework> --stubbing_libraries=<stubbing_libraries>`
90+
- Install Devise JWT: `rails generate boring:devise:jwt:install`
9091

9192
## Screencasts
9293

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# frozen_string_literal: true
2+
3+
module Boring
4+
module Devise
5+
module Jwt
6+
class InstallGenerator < Rails::Generators::Base
7+
desc "Add devise-jwt to the application"
8+
9+
class_option :model_name, type: :string, aliases: "-m",
10+
default: "User",
11+
desc: "Tell us the user model name which will be used for authentication. Defaults to User"
12+
class_option :use_env_variable, type: :boolean, aliases: "-ev",
13+
desc: "Use ENV variable for devise_jwt_secret_key. By default Rails credentials will be used."
14+
class_option :revocation_strategy, type: :string, aliases: "-rs",
15+
enum: %w[JTIMatcher Denylist Allowlist],
16+
default: "Denylist",
17+
desc: "Tell us the revocation strategy to be used. Defaults to Denylist"
18+
class_option :expiration_time_in_days, type: :numeric, aliases: "-et",
19+
default: 15,
20+
desc: "Tell us the expiration time on days for the JWT token. Defaults to 15 days"
21+
22+
def verify_presence_of_devise_gem
23+
gem_file_content_array = File.readlines("Gemfile")
24+
devise_is_installed = gem_file_content_array.any? { |line| line.include?('devise') }
25+
26+
return if devise_is_installed
27+
28+
say "We couldn't find devise gem. Please configure devise gem and run the generator again!", :red
29+
30+
abort
31+
end
32+
33+
def verify_presence_of_devise_initializer
34+
return if File.exist?("config/initializers/devise.rb")
35+
36+
say "We couldn't find devise initializer. Please configure devise gem correctly and run the generator again!", :red
37+
38+
abort
39+
end
40+
41+
def verify_presence_of_devise_model
42+
return if File.exist?("app/models/#{options[:model_name].underscore}.rb")
43+
44+
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
45+
46+
abort
47+
end
48+
49+
def add_devise_jwt_gem
50+
say "Adding devise-jwt gem", :green
51+
gem "devise-jwt"
52+
end
53+
54+
def add_devise_jwt_config_to_devise_initializer
55+
say "Adding devise-jwt configurations to a file `config/initializers/devise.rb`", :green
56+
57+
jwt_config = <<~RUBY
58+
config.jwt do |jwt|
59+
jwt.secret = #{devise_jwt_secret_key}
60+
jwt.dispatch_requests = [
61+
['POST', %r{^/sign_in$}]
62+
]
63+
jwt.revocation_requests = [
64+
['DELETE', %r{^/sign_out$}]
65+
]
66+
jwt.expiration_time = #{options[:expiration_time_in_days]}.day.to_i
67+
end
68+
RUBY
69+
70+
inject_into_file "config/initializers/devise.rb",
71+
optimize_indentation(jwt_config, 2),
72+
before: /^end\s*\Z/m
73+
74+
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",
75+
:yellow
76+
end
77+
78+
def configure_revocation_strategies
79+
say "Configuring #{options[:revocation_strategy]} revocation strategy",
80+
:green
81+
82+
case options[:revocation_strategy]
83+
when "JTIMatcher"
84+
configure_jti_matcher_strategy
85+
when "Denylist"
86+
configure_denylist_strategy
87+
when "Allowlist"
88+
configure_allowlist_strategy
89+
end
90+
end
91+
92+
private
93+
94+
def devise_jwt_secret_key
95+
if options[:use_env_variable]
96+
"ENV['DEVISE_JWT_SECRET_KEY']"
97+
else
98+
"Rails.application.credentials.devise_jwt_secret_key"
99+
end
100+
end
101+
102+
def configure_jti_matcher_strategy
103+
model_name = options[:model_name].underscore
104+
Bundler.with_unbundled_env do
105+
run "bundle exec rails generate migration add_jti_to_#{model_name.pluralize}"
106+
end
107+
migration_content = <<~RUBY
108+
add_column :users, :jti, :string, null: false
109+
add_index :users, :jti, unique: true
110+
RUBY
111+
112+
inject_into_file Dir["db/migrate/*_add_jti_to_#{model_name.pluralize}.rb"][0],
113+
optimize_indentation(migration_content, 4),
114+
after: /def change\n/,
115+
verbose: false
116+
117+
add_devise_jwt_module(
118+
strategy: "self",
119+
include_content: "include Devise::JWT::RevocationStrategies::JTIMatcher"
120+
)
121+
122+
end
123+
124+
def configure_denylist_strategy
125+
Bundler.with_unbundled_env do
126+
run "bundle exec rails generate model jwt_denylist --skip-migration"
127+
run "bundle exec rails generate migration create_jwt_denylist"
128+
end
129+
130+
migration_content = <<~RUBY
131+
t.string :jti, null: false
132+
t.datetime :exp, null: false
133+
RUBY
134+
135+
gsub_file Dir["db/migrate/*_create_jwt_denylist.rb"][0],
136+
/create_table :jwt_denylists do \|t\|/,
137+
"create_table :jwt_denylist do |t|",
138+
verbose: false
139+
140+
inject_into_file Dir["db/migrate/*_create_jwt_denylist.rb"][0],
141+
optimize_indentation(migration_content, 6),
142+
after: /create_table :jwt_denylist do \|t\|\n/,
143+
verbose: false
144+
145+
inject_into_file Dir["db/migrate/*_create_jwt_denylist.rb"][0],
146+
optimize_indentation("add_index :jwt_denylist, :jti", 4),
147+
before: /^ end/,
148+
verbose: false
149+
150+
add_devise_jwt_module(strategy: "JwtDenylist")
151+
152+
jwt_denylist_content = <<~RUBY
153+
include Devise::JWT::RevocationStrategies::Denylist
154+
self.table_name = 'jwt_denylist'
155+
RUBY
156+
157+
inject_into_file "app/models/jwt_denylist.rb",
158+
optimize_indentation(jwt_denylist_content, 2),
159+
after: /ApplicationRecord\n/,
160+
verbose: false
161+
end
162+
163+
def configure_allowlist_strategy
164+
model_name = options[:model_name].underscore
165+
Bundler.with_unbundled_env do
166+
run "bundle exec rails generate model allowlisted_jwt"
167+
end
168+
169+
migration_content = <<~RUBY
170+
t.string :jti, null: false
171+
t.string :aud
172+
# If you want to leverage the `aud` claim, add to it a `NOT NULL` constraint:
173+
# t.string :aud, null: false
174+
t.datetime :exp, null: false
175+
t.references :#{model_name}, foreign_key: { on_delete: :cascade }, null: false
176+
RUBY
177+
178+
inject_into_file Dir["db/migrate/*_create_allowlisted_jwts.rb"][0],
179+
optimize_indentation(migration_content, 6),
180+
after: /create_table :allowlisted_jwts do \|t\|\n/,
181+
verbose: false
182+
183+
inject_into_file Dir["db/migrate/*_create_allowlisted_jwts.rb"][0],
184+
optimize_indentation("add_index :allowlisted_jwts, :jti", 4),
185+
before: /^ end/,
186+
verbose: false
187+
188+
add_devise_jwt_module(
189+
strategy: "self",
190+
include_content: "include Devise::JWT::RevocationStrategies::Allowlist"
191+
)
192+
end
193+
194+
def add_devise_jwt_module(strategy:, include_content: nil)
195+
model_name = options[:model_name].underscore
196+
model_content = File.read("app/models/#{model_name}.rb")
197+
devise_module_pattern = /devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)/
198+
199+
if model_content.match?(devise_module_pattern)
200+
inject_into_file "app/models/#{model_name}.rb",
201+
", :jwt_authenticatable, jwt_revocation_strategy: #{strategy}",
202+
after: devise_module_pattern
203+
else
204+
inject_into_file "app/models/#{model_name}.rb",
205+
optimize_indentation(
206+
"devise :jwt_authenticatable, jwt_revocation_strategy: #{strategy}",
207+
2
208+
),
209+
after: /ApplicationRecord\n/
210+
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.",
211+
:yellow
212+
end
213+
214+
if include_content
215+
inject_into_file "app/models/#{model_name}.rb",
216+
optimize_indentation(include_content, 2),
217+
after: /ApplicationRecord\n/,
218+
verbose: false
219+
end
220+
end
221+
end
222+
end
223+
end
224+
end
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "generators/boring/devise/jwt/install/install_generator"
5+
6+
class DeviseInstallGeneratorTest < Rails::Generators::TestCase
7+
tests Boring::Devise::Jwt::InstallGenerator
8+
setup :build_app
9+
teardown :teardown_app
10+
11+
include GeneratorHelper
12+
include ActiveSupport::Testing::Isolation
13+
14+
def destination_root
15+
app_path
16+
end
17+
18+
def test_should_exit_if_devise_is_not_installed
19+
assert_raises SystemExit do
20+
quietly { generator.verify_presence_of_devise_gem }
21+
end
22+
end
23+
24+
def test_should_exit_if_devise_initializer_is_not_present
25+
assert_raises SystemExit do
26+
quietly { generator.verify_presence_of_devise_initializer }
27+
end
28+
end
29+
30+
def test_should_exit_if_devise_model_is_not_present
31+
assert_raises SystemExit do
32+
quietly { generator.verify_presence_of_devise_model }
33+
end
34+
end
35+
36+
def test_should_configure_devise_jwt
37+
Dir.chdir(app_path) do
38+
setup_devise
39+
quietly { run_generator }
40+
assert_gem "devise-jwt"
41+
assert_file "config/initializers/devise.rb" do |content|
42+
assert_match(/config.jwt do |jwt|/, content)
43+
assert_match(/jwt.secret = Rails.application.credentials.devise_jwt_secret/, content)
44+
assert_match(/jwt\.dispatch_requests\s*=\s*\[\s*/, content)
45+
assert_match(/jwt\.revocation_requests\s*=\s*\[\s*/, content)
46+
assert_match(/jwt\.expiration_time\s*=\s*/, content)
47+
end
48+
assert_migration "db/migrate/create_jwt_denylist.rb"
49+
assert_file "app/models/user.rb" do |content|
50+
51+
assert_match(
52+
/devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)*(?:,\s*:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist)/,
53+
content
54+
)
55+
end
56+
57+
assert_file "app/models/jwt_denylist.rb" do |content|
58+
assert_match(/include Devise::JWT::RevocationStrategies::Denylist/, content)
59+
assert_match(/self\.table_name = 'jwt_denylist'/, content)
60+
end
61+
end
62+
end
63+
64+
def test_should_use_env_variable_for_devise_jwt_secret
65+
Dir.chdir(app_path) do
66+
setup_devise
67+
quietly { run_generator [destination_root, "--use_env_variable"] }
68+
assert_file "config/initializers/devise.rb" do |content|
69+
assert_match(/jwt\.secret\s*=\s*ENV\['DEVISE_JWT_SECRET_KEY'\]/, content)
70+
end
71+
end
72+
end
73+
74+
def test_should_configure_jti_matcher_revocation_strategy
75+
Dir.chdir(app_path) do
76+
setup_devise
77+
quietly { run_generator [destination_root, "--revocation_strategy=JTIMatcher"] }
78+
assert_migration "db/migrate/add_jti_to_users.rb"
79+
assert_file "app/models/user.rb" do |content|
80+
assert_match(/include Devise::JWT::RevocationStrategies::JTIMatcher/, content)
81+
assert_match(
82+
/devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)*(?:,\s*:jwt_authenticatable, jwt_revocation_strategy: self)/,
83+
content
84+
)
85+
end
86+
end
87+
end
88+
89+
def test_should_configure_allowlist_revocation_strategy
90+
Dir.chdir(app_path) do
91+
setup_devise
92+
quietly { run_generator [destination_root, "--revocation_strategy=Allowlist"] }
93+
assert_migration "db/migrate/create_allowlisted_jwts.rb"
94+
assert_file "app/models/user.rb" do |content|
95+
assert_match(/include Devise::JWT::RevocationStrategies::Allowlist/, content)
96+
assert_match(
97+
/devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)*(?:,\s*:jwt_authenticatable, jwt_revocation_strategy: self)/,
98+
content
99+
)
100+
end
101+
assert_file "app/models/allowlisted_jwt.rb"
102+
end
103+
end
104+
105+
private
106+
107+
def setup_devise(model_name: "User")
108+
Bundler.with_unbundled_env do
109+
`bundle add devise`
110+
end
111+
112+
create_devise_initializer
113+
create_devise_model(model_name)
114+
end
115+
116+
def create_devise_initializer
117+
FileUtils.mkdir_p("#{app_path}/config/initializers")
118+
content = <<~RUBY
119+
Devise.setup do |config|
120+
end
121+
RUBY
122+
123+
File.write("#{app_path}/config/initializers/devise.rb", content)
124+
end
125+
126+
def create_devise_model(model_name)
127+
FileUtils.mkdir_p("#{app_path}/app/models")
128+
content = <<~RUBY
129+
class #{model_name} < ApplicationRecord
130+
devise :database_authenticatable, :registerable,
131+
:recoverable, :rememberable, :validatable
132+
end
133+
RUBY
134+
135+
File.write("#{app_path}/app/models/#{model_name.underscore}.rb", content)
136+
end
137+
end

0 commit comments

Comments
 (0)