rack_jwt_aegis 0.0.0 → 1.0.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.
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+ require 'securerandom'
6
+ require 'base64'
7
+
8
+ # CLI for rack_jwt_aegis gem
9
+ class RackJwtAegisCLI
10
+ def initialize
11
+ @options = {
12
+ length: 64,
13
+ format: :hex,
14
+ count: 1,
15
+ }
16
+ end
17
+
18
+ def run(args = ARGV)
19
+ parse_options(args)
20
+
21
+ case @command
22
+ when :generate_secret
23
+ generate_secrets
24
+ when :version
25
+ show_version
26
+ else
27
+ show_help
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def parse_options(args)
34
+ @command = :generate_secret # default command
35
+
36
+ OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
37
+ opts.banner = 'Usage: rack-jwt-aegis [command] [options]'
38
+ opts.separator ''
39
+ opts.separator 'Commands:'
40
+ opts.separator ' secret Generate JWT secret(s) (default)'
41
+ opts.separator ' version Show version'
42
+ opts.separator ' help Show this help'
43
+ opts.separator ''
44
+ opts.separator 'Secret generation options:'
45
+
46
+ opts.on('-l', '--length LENGTH', Integer, 'Secret length in bytes (default: 64)') do |length|
47
+ @options[:length] = length
48
+ end
49
+
50
+ opts.on('-f', '--format FORMAT', [:hex, :base64, :raw], # rubocop:disable Naming/VariableNumber
51
+ 'Output format: hex, base64, raw (default: hex)') do |format|
52
+ @options[:format] = format
53
+ end
54
+
55
+ opts.on('-c', '--count COUNT', Integer, 'Number of secrets to generate (default: 1)') do |count|
56
+ @options[:count] = count
57
+ end
58
+
59
+ opts.on('-e', '--env', 'Output in environment variable format') do
60
+ @options[:env_format] = true
61
+ end
62
+
63
+ opts.on('-q', '--quiet', 'Quiet mode - only output the secret(s)') do
64
+ @options[:quiet] = true
65
+ end
66
+
67
+ opts.on('-h', '--help', 'Show this help') do
68
+ @command = :help
69
+ end
70
+
71
+ opts.on('-v', '--version', 'Show version') do
72
+ @command = :version
73
+ end
74
+
75
+ opts.separator ''
76
+ opts.separator 'Examples:'
77
+ opts.separator ' rack-jwt-aegis secret # Generate hex secret'
78
+ opts.separator ' rack-jwt-aegis secret -f base64 # Generate base64 secret'
79
+ opts.separator ' rack-jwt-aegis secret -l 32 -c 3 # Generate 3 secrets, 32 bytes each'
80
+ opts.separator ' rack-jwt-aegis secret -e # Output as JWT_SECRET=...'
81
+ opts.separator ' rack-jwt-aegis secret -q # Quiet mode'
82
+ opts.separator ''
83
+ end.parse!(args)
84
+
85
+ # Handle command from remaining args
86
+ return unless args.length.positive?
87
+
88
+ case args[0].downcase
89
+ when 'secret', 'generate'
90
+ @command = :generate_secret
91
+ when 'version'
92
+ @command = :version
93
+ when 'help'
94
+ @command = :help
95
+ else
96
+ # Invalid command - show help
97
+ @command = :help
98
+ @invalid_command = args[0]
99
+ end
100
+ end
101
+
102
+ def generate_secrets
103
+ unless @options[:quiet]
104
+ puts '🛡️ Rack JWT Aegis - Secret Generator'
105
+ puts '=' * 50
106
+ puts
107
+ end
108
+
109
+ @options[:count].times do |i|
110
+ secret = SecureRandom.random_bytes(@options[:length])
111
+ formatted_secret = format_secret(secret)
112
+
113
+ if @options[:env_format]
114
+ puts "JWT_SECRET=#{formatted_secret}"
115
+ elsif @options[:quiet]
116
+ puts formatted_secret
117
+ else
118
+ puts "Secret #{i + 1}:" if @options[:count] > 1
119
+ puts formatted_secret
120
+ unless @options[:quiet]
121
+ puts "Length: #{@options[:length]} bytes (#{formatted_secret.length} characters)"
122
+ puts "Format: #{@options[:format]}"
123
+ puts "Entropy: ~#{(@options[:length] * 8).to_f} bits"
124
+ puts
125
+ end
126
+ end
127
+ end
128
+
129
+ return if @options[:quiet]
130
+
131
+ puts '💡 Usage in your application:'
132
+ puts " export JWT_SECRET=\"#{format_secret(SecureRandom.random_bytes(@options[:length]))}\""
133
+ puts ' # or add to your .env file'
134
+ puts
135
+ puts '⚠️ Security reminders:'
136
+ puts ' - Store secrets securely (environment variables, not in code)'
137
+ puts ' - Use different secrets for different environments'
138
+ puts ' - Rotate secrets periodically'
139
+ puts ' - Never commit secrets to version control'
140
+ end
141
+
142
+ def format_secret(secret)
143
+ case @options[:format]
144
+ when :hex
145
+ secret.unpack1('H*')
146
+ when :base64 # rubocop:disable Naming/VariableNumber
147
+ Base64.strict_encode64(secret)
148
+ when :raw
149
+ secret
150
+ end
151
+ end
152
+
153
+ def show_version
154
+ # Try to load the version from the gem
155
+
156
+ require_relative '../lib/rack_jwt_aegis/version'
157
+ puts "rack_jwt_aegis #{RackJwtAegis::VERSION}"
158
+ rescue LoadError
159
+ puts 'rack_jwt_aegis (version unknown)'
160
+ end
161
+
162
+ def show_help
163
+ if @invalid_command
164
+ puts "❌ Unknown command: #{@invalid_command}"
165
+ puts
166
+ end
167
+
168
+ puts <<~HELP
169
+ 🛡️ Rack JWT Aegis CLI
170
+
171
+ A command-line tool for generating secure JWT secrets and managing
172
+ rack_jwt_aegis configurations.
173
+
174
+ USAGE:
175
+ rack-jwt-aegis [command] [options]
176
+
177
+ COMMANDS:
178
+ secret Generate secure JWT secret(s)
179
+ version Show gem version
180
+ help Show this help message
181
+
182
+ SECRET GENERATION:
183
+ The secret command generates cryptographically secure random
184
+ secrets suitable for JWT signing.
185
+
186
+ EXAMPLES:
187
+ # Generate a 64-byte hex secret (default)
188
+ rack-jwt-aegis secret
189
+
190
+ # Generate a base64-encoded secret
191
+ rack-jwt-aegis secret --format base64
192
+
193
+ # Generate 3 secrets at once
194
+ rack-jwt-aegis secret --count 3
195
+
196
+ # Generate secret for environment variable
197
+ rack-jwt-aegis secret --env
198
+
199
+ # Quiet mode (only output secret)
200
+ rack-jwt-aegis secret --quiet
201
+
202
+ # Custom length (32 bytes)
203
+ rack-jwt-aegis secret --length 32
204
+
205
+ SECURITY NOTES:
206
+ - Generated secrets use SecureRandom for cryptographic security
207
+ - Default 64-byte secrets provide ~512 bits of entropy
208
+ - Always store secrets securely (environment variables, secret managers)
209
+ - Use different secrets for different environments
210
+ - Rotate secrets periodically
211
+
212
+ For more information, visit:
213
+ https://github.com/kanutocd/rack_jwt_aegis
214
+ HELP
215
+ end
216
+ end
217
+
218
+ # Run CLI if this file is executed directly
219
+ if $PROGRAM_NAME == __FILE__
220
+ exit_status = 1
221
+ cli = RackJwtAegisCLI.new
222
+ begin
223
+ cli.run
224
+ exit_status = 0
225
+ rescue Interrupt
226
+ puts "\n\n⚠️ Operation cancelled."
227
+ rescue OptionParser::InvalidArgument, OptionParser::InvalidOption => e
228
+ warn "❌ Error: #{e.message}"
229
+ puts
230
+ puts "Use 'rack-jwt-aegis --help' for usage information."
231
+ rescue StandardError => e
232
+ warn "❌ Error: #{e.message}"
233
+ end
234
+ exit exit_status
235
+ end
@@ -1,32 +1,176 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RackJwtAegis
4
+ # Configuration class for RackJwtAegis middleware
5
+ #
6
+ # Manages all configuration options for JWT authentication, multi-tenant validation,
7
+ # RBAC authorization, and caching behavior.
8
+ #
9
+ # @author Ken Camajalan Demanawa
10
+ # @since 0.1.0
11
+ #
12
+ # @example Basic configuration
13
+ # config = Configuration.new(jwt_secret: 'your-secret')
14
+ #
15
+ # @example Full configuration
16
+ # config = Configuration.new(
17
+ # jwt_secret: ENV['JWT_SECRET'],
18
+ # jwt_algorithm: 'HS256',
19
+ # validate_subdomain: true,
20
+ # validate_pathname_slug: true,
21
+ # rbac_enabled: true,
22
+ # cache_store: :redis,
23
+ # cache_write_enabled: true,
24
+ # skip_paths: ['/health', '/api/public/*'],
25
+ # debug_mode: Rails.env.development?
26
+ # )
4
27
  class Configuration
5
- # Core JWT settings
6
- attr_accessor :jwt_secret, :jwt_algorithm
28
+ # @!group Core JWT Settings
7
29
 
8
- # Feature toggles
9
- attr_accessor :validate_subdomain, :validate_company_slug, :rbac_enabled
30
+ # The secret key used for JWT signature verification
31
+ # @return [String] the JWT secret key
32
+ # @note This is required and must not be empty
33
+ attr_accessor :jwt_secret
10
34
 
11
- # Multi-tenant settings
12
- attr_accessor :company_header_name, :company_slug_pattern, :payload_mapping
35
+ # The JWT algorithm to use for token verification
36
+ # @return [String] the JWT algorithm (default: 'HS256')
37
+ # @note Supported algorithms: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512
38
+ attr_accessor :jwt_algorithm
13
39
 
14
- # Path management
40
+ # @!endgroup
41
+
42
+ # @!group Feature Toggles
43
+
44
+ # Whether to validate subdomain-based multi-tenancy
45
+ # @return [Boolean] true if subdomain validation is enabled
46
+ attr_accessor :validate_subdomain
47
+
48
+ # Whether to validate pathname slug-based multi-tenancy
49
+ # @return [Boolean] true if pathname slug validation is enabled
50
+ attr_accessor :validate_pathname_slug
51
+
52
+ # Whether RBAC (Role-Based Access Control) is enabled
53
+ # @return [Boolean] true if RBAC is enabled
54
+ attr_accessor :rbac_enabled
55
+
56
+ # @!endgroup
57
+
58
+ # @!group Multi-tenant Settings
59
+
60
+ # The HTTP header name containing the tenant ID
61
+ # @return [String] the tenant ID header name (default: 'X-Tenant-Id')
62
+ attr_accessor :tenant_id_header_name
63
+
64
+ # The regular expression pattern to extract pathname slugs
65
+ # @return [Regexp] the pathname slug pattern (default: /^\/api\/v1\/([^\/]+)\//)
66
+ attr_accessor :pathname_slug_pattern
67
+
68
+ # Mapping of standard payload keys to custom JWT claim names
69
+ # @return [Hash] the payload mapping configuration
70
+ # @example
71
+ # { user_id: :sub, tenant_id: :company_id, subdomain: :domain }
72
+ attr_accessor :payload_mapping
73
+
74
+ # @!endgroup
75
+
76
+ # @!group Path Management
77
+
78
+ # Array of paths that should skip JWT authentication
79
+ # @return [Array<String, Regexp>] paths to skip authentication for
80
+ # @example
81
+ # ['/health', '/api/public', /^\/assets/]
15
82
  attr_accessor :skip_paths
16
83
 
17
- # Cache configuration
18
- attr_accessor :cache_store, :cache_options, :cache_write_enabled
19
- attr_accessor :rbac_cache_store, :rbac_cache_options, :permission_cache_store, :permission_cache_options
84
+ # @!endgroup
85
+
86
+ # @!group Cache Configuration
87
+
88
+ # The primary cache store adapter type
89
+ # @return [Symbol] the cache store type (:memory, :redis, :memcached, :solid_cache)
90
+ attr_accessor :cache_store
91
+
92
+ # Options passed to the cache store adapter
93
+ # @return [Hash] cache store configuration options
94
+ attr_accessor :cache_options
95
+
96
+ # Whether the middleware can write to cache stores
97
+ # @return [Boolean] true if cache writing is enabled
98
+ attr_accessor :cache_write_enabled
20
99
 
21
- # Custom validators
100
+ # The RBAC cache store adapter type (separate from main cache)
101
+ # @return [Symbol] the RBAC cache store type
102
+ attr_accessor :rbac_cache_store
103
+
104
+ # Options for the RBAC cache store
105
+ # @return [Hash] RBAC cache configuration options
106
+ attr_accessor :rbac_cache_options
107
+
108
+ # The permission cache store adapter type
109
+ # @return [Symbol] the permission cache store type
110
+ attr_accessor :permission_cache_store
111
+
112
+ # Options for the permission cache store
113
+ # @return [Hash] permission cache configuration options
114
+ attr_accessor :permission_cache_options
115
+
116
+ # Time-to-live for user permissions cache in seconds
117
+ # @return [Integer] TTL in seconds (default: 1800 - 30 minutes)
118
+ attr_accessor :user_permissions_ttl
119
+
120
+ # @!endgroup
121
+
122
+ # @!group Custom Validators
123
+
124
+ # Custom payload validation proc
125
+ # @return [Proc] a callable that receives (payload, request) and returns boolean
126
+ # @example
127
+ # ->(payload, request) { payload['role'] == 'admin' }
22
128
  attr_accessor :custom_payload_validator
23
129
 
24
- # Response customization
25
- attr_accessor :unauthorized_response, :forbidden_response
130
+ # @!endgroup
131
+
132
+ # @!group Response Customization
26
133
 
27
- # Development settings
134
+ # Custom response for unauthorized requests (401)
135
+ # @return [Hash] the unauthorized response body
136
+ # @example
137
+ # { error: 'Authentication required', code: 'AUTH_001' }
138
+ attr_accessor :unauthorized_response
139
+
140
+ # Custom response for forbidden requests (403)
141
+ # @return [Hash] the forbidden response body
142
+ # @example
143
+ # { error: 'Access denied', code: 'AUTH_002' }
144
+ attr_accessor :forbidden_response
145
+
146
+ # @!endgroup
147
+
148
+ # @!group Development Settings
149
+
150
+ # Whether debug mode is enabled for additional logging
151
+ # @return [Boolean] true if debug mode is enabled
28
152
  attr_accessor :debug_mode
29
153
 
154
+ # @!endgroup
155
+
156
+ # Initialize a new Configuration instance
157
+ #
158
+ # @param options [Hash] configuration options
159
+ # @option options [String] :jwt_secret (required) JWT secret key for signature verification
160
+ # @option options [String] :jwt_algorithm ('HS256') JWT algorithm to use
161
+ # @option options [Boolean] :validate_subdomain (false) enable subdomain validation
162
+ # @option options [Boolean] :validate_pathname_slug (false) enable pathname slug validation
163
+ # @option options [Boolean] :rbac_enabled (false) enable RBAC authorization
164
+ # @option options [String] :tenant_id_header_name ('X-Tenant-Id') tenant ID header name
165
+ # @option options [Regexp] :pathname_slug_pattern default pattern for pathname slugs
166
+ # @option options [Hash] :payload_mapping mapping of JWT claim names
167
+ # @option options [Array<String, Regexp>] :skip_paths ([]) paths to skip authentication
168
+ # @option options [Symbol] :cache_store cache adapter type
169
+ # @option options [Hash] :cache_options cache configuration options
170
+ # @option options [Boolean] :cache_write_enabled (false) enable cache writing
171
+ # @option options [Integer] :user_permissions_ttl (1800) user permissions cache TTL in seconds
172
+ # @option options [Boolean] :debug_mode (false) enable debug logging
173
+ # @raise [ConfigurationError] if jwt_secret is missing or configuration is invalid
30
174
  def initialize(options = {})
31
175
  # Set defaults
32
176
  set_defaults
@@ -42,27 +186,39 @@ module RackJwtAegis
42
186
  validate!
43
187
  end
44
188
 
189
+ # Check if RBAC is enabled
190
+ # @return [Boolean] true if RBAC is enabled
45
191
  def rbac_enabled?
46
- config_boolean(rbac_enabled)
192
+ config_boolean?(rbac_enabled)
47
193
  end
48
194
 
195
+ # Check if subdomain validation is enabled
196
+ # @return [Boolean] true if subdomain validation is enabled
49
197
  def validate_subdomain?
50
- config_boolean(validate_subdomain)
198
+ config_boolean?(validate_subdomain)
51
199
  end
52
200
 
53
- def validate_company_slug?
54
- config_boolean(validate_company_slug)
201
+ # Check if pathname slug validation is enabled
202
+ # @return [Boolean] true if pathname slug validation is enabled
203
+ def validate_pathname_slug?
204
+ config_boolean?(validate_pathname_slug)
55
205
  end
56
206
 
207
+ # Check if debug mode is enabled
208
+ # @return [Boolean] true if debug mode is enabled
57
209
  def debug_mode?
58
- config_boolean(debug_mode)
210
+ config_boolean?(debug_mode)
59
211
  end
60
212
 
213
+ # Check if cache write access is enabled
214
+ # @return [Boolean] true if cache writing is enabled
61
215
  def cache_write_enabled?
62
- config_boolean(cache_write_enabled)
216
+ config_boolean?(cache_write_enabled)
63
217
  end
64
218
 
65
- # Check if path should be skipped
219
+ # Check if the given path should skip JWT authentication
220
+ # @param path [String] the request path to check
221
+ # @return [Boolean] true if the path should be skipped
66
222
  def skip_path?(path)
67
223
  return false if skip_paths.nil? || skip_paths.empty?
68
224
 
@@ -78,7 +234,12 @@ module RackJwtAegis
78
234
  end
79
235
  end
80
236
 
81
- # Get mapped payload key
237
+ # Get the mapped payload key for a standard key
238
+ # @param standard_key [Symbol] the standard key to map
239
+ # @return [Symbol] the mapped key from payload_mapping, or the original key if no mapping exists
240
+ # @example
241
+ # config.payload_key(:user_id) #=> :sub (if mapped)
242
+ # config.payload_key(:user_id) #=> :user_id (if not mapped)
82
243
  def payload_key(standard_key)
83
244
  payload_mapping&.fetch(standard_key, standard_key) || standard_key
84
245
  end
@@ -86,13 +247,12 @@ module RackJwtAegis
86
247
  private
87
248
 
88
249
  # Convert various falsy/truthy values to proper boolean for configuration
89
- def config_boolean(value)
90
- return false if value.nil?
91
- return false if value == false
92
- return false if value == 0
93
- return false if value == ''
94
- return false if value.is_a?(String) && value.downcase.strip == 'false'
95
-
250
+ def config_boolean?(value)
251
+ if (value.is_a?(Numeric) && value.zero?) ||
252
+ (value.is_a?(String) && ['false', '0', '', 'no'].include?(value.downcase.strip))
253
+ return false
254
+ end
255
+
96
256
  # Everything else is truthy
97
257
  !!value
98
258
  end
@@ -100,18 +260,19 @@ module RackJwtAegis
100
260
  def set_defaults
101
261
  @jwt_algorithm = 'HS256'
102
262
  @validate_subdomain = false
103
- @validate_company_slug = false
263
+ @validate_pathname_slug = false
104
264
  @rbac_enabled = false
105
- @company_header_name = 'X-Company-Group-Id'
106
- @company_slug_pattern = %r{^/api/v1/([^/]+)/}
265
+ @tenant_id_header_name = 'X-Tenant-Id'
266
+ @pathname_slug_pattern = %r{^/api/v1/([^/]+)/}
107
267
  @skip_paths = []
108
268
  @cache_write_enabled = false
269
+ @user_permissions_ttl = 1800 # 30 minutes default
109
270
  @debug_mode = false
110
271
  @payload_mapping = {
111
272
  user_id: :user_id,
112
- company_group_id: :company_group_id,
113
- company_group_domain: :company_group_domain,
114
- company_slugs: :company_slugs,
273
+ tenant_id: :tenant_id,
274
+ subdomain: :subdomain,
275
+ pathname_slugs: :pathname_slugs,
115
276
  }
116
277
  @unauthorized_response = { error: 'Authentication required' }
117
278
  @forbidden_response = { error: 'Access denied' }
@@ -151,23 +312,23 @@ module RackJwtAegis
151
312
  end
152
313
 
153
314
  # Set default fallback for permission_cache_store when rbac_cache_store is provided
154
- if !rbac_cache_store.nil? && permission_cache_store.nil?
155
- @permission_cache_store = :memory # Default fallback
156
- end
315
+ return unless !rbac_cache_store.nil? && permission_cache_store.nil?
316
+
317
+ @permission_cache_store = :memory # Default fallback
157
318
  end
158
319
 
159
320
  def validate_multi_tenant_settings!
160
- if validate_company_slug? && company_slug_pattern.nil?
161
- raise ConfigurationError, 'company_slug_pattern is required when validate_company_slug is true'
321
+ if validate_pathname_slug? && pathname_slug_pattern.nil?
322
+ raise ConfigurationError, 'pathname_slug_pattern is required when validate_pathname_slug is true'
162
323
  end
163
324
 
164
- if validate_subdomain? && !payload_mapping.key?(:company_group_domain)
165
- raise ConfigurationError, 'payload_mapping must include :company_group_domain when validate_subdomain is true'
325
+ if validate_subdomain? && !payload_mapping.key?(:subdomain)
326
+ raise ConfigurationError, 'payload_mapping must include :subdomain when validate_subdomain is true'
166
327
  end
167
328
 
168
- return unless validate_company_slug? && !payload_mapping.key?(:company_slugs)
329
+ return unless validate_pathname_slug? && !payload_mapping.key?(:pathname_slugs)
169
330
 
170
- raise ConfigurationError, 'payload_mapping must include :company_slugs when validate_company_slug is true'
331
+ raise ConfigurationError, 'payload_mapping must include :pathname_slugs when validate_pathname_slug is true'
171
332
  end
172
333
  end
173
334
  end