rack_jwt_aegis 0.0.0 → 1.0.1
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 +4 -4
- data/.rubocop.yml +9 -0
- data/.yard/yard_gfm_config.rb +21 -0
- data/.yardopts +16 -0
- data/CHANGELOG.md +243 -0
- data/README.md +408 -53
- data/Rakefile +52 -0
- data/bin/console +11 -0
- data/bin/docs +20 -0
- data/bin/setup +8 -0
- data/exe/rack_jwt_aegis +235 -0
- data/lib/rack_jwt_aegis/configuration.rb +225 -44
- data/lib/rack_jwt_aegis/debug_logger.rb +51 -0
- data/lib/rack_jwt_aegis/jwt_validator.rb +56 -14
- data/lib/rack_jwt_aegis/middleware.rb +75 -8
- data/lib/rack_jwt_aegis/multi_tenant_validator.rb +43 -18
- data/lib/rack_jwt_aegis/rbac_manager.rb +320 -80
- data/lib/rack_jwt_aegis/request_context.rb +64 -23
- data/lib/rack_jwt_aegis/version.rb +1 -1
- data/lib/rack_jwt_aegis.rb +37 -1
- metadata +25 -13
- data/examples/basic_usage.rb +0 -85
- /data/sig/{rack_jwt_bastion.rbs → rack_jwt_aegis.rbs} +0 -0
data/exe/rack_jwt_aegis
ADDED
@@ -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
|
6
|
-
attr_accessor :jwt_secret, :jwt_algorithm
|
28
|
+
# @!group Core JWT Settings
|
7
29
|
|
8
|
-
#
|
9
|
-
|
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
|
-
#
|
12
|
-
|
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
|
-
#
|
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
|
-
#
|
18
|
-
|
19
|
-
|
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
|
99
|
+
|
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
|
20
111
|
|
21
|
-
#
|
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
|
-
#
|
25
|
-
|
130
|
+
# @!endgroup
|
131
|
+
|
132
|
+
# @!group Response Customization
|
26
133
|
|
27
|
-
#
|
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
|
-
|
54
|
-
|
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
|
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
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
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,20 @@ module RackJwtAegis
|
|
100
260
|
def set_defaults
|
101
261
|
@jwt_algorithm = 'HS256'
|
102
262
|
@validate_subdomain = false
|
103
|
-
@
|
263
|
+
@validate_pathname_slug = false
|
104
264
|
@rbac_enabled = false
|
105
|
-
@
|
106
|
-
@
|
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
|
-
|
113
|
-
|
114
|
-
|
273
|
+
tenant_id: :tenant_id,
|
274
|
+
subdomain: :subdomain,
|
275
|
+
pathname_slugs: :pathname_slugs,
|
276
|
+
role_ids: :role_ids,
|
115
277
|
}
|
116
278
|
@unauthorized_response = { error: 'Authentication required' }
|
117
279
|
@forbidden_response = { error: 'Access denied' }
|
@@ -119,6 +281,7 @@ module RackJwtAegis
|
|
119
281
|
|
120
282
|
def validate!
|
121
283
|
validate_jwt_settings!
|
284
|
+
validate_payload_mapping!
|
122
285
|
validate_cache_settings!
|
123
286
|
validate_multi_tenant_settings!
|
124
287
|
end
|
@@ -132,6 +295,24 @@ module RackJwtAegis
|
|
132
295
|
raise ConfigurationError, "Unsupported JWT algorithm: #{jwt_algorithm}"
|
133
296
|
end
|
134
297
|
|
298
|
+
def validate_payload_mapping!
|
299
|
+
# Allow nil payload_mapping (will use defaults)
|
300
|
+
return if payload_mapping.nil?
|
301
|
+
|
302
|
+
raise ConfigurationError, 'payload_mapping must be a Hash' unless payload_mapping.is_a?(Hash)
|
303
|
+
|
304
|
+
# Validate all values are symbols
|
305
|
+
invalid_values = payload_mapping.reject { |_key, value| value.is_a?(Symbol) }
|
306
|
+
return if invalid_values.empty?
|
307
|
+
|
308
|
+
raise ConfigurationError, "payload_mapping values must be symbols, invalid: #{invalid_values.inspect}"
|
309
|
+
|
310
|
+
# NOTE: We don't validate required keys because users may provide
|
311
|
+
# partial mappings that are intended to override defaults. The payload_key method
|
312
|
+
# handles missing keys by returning the standard key as fallback.
|
313
|
+
# This includes RBAC keys - if :role_ids is not mapped, it falls back to 'role_ids'.
|
314
|
+
end
|
315
|
+
|
135
316
|
def validate_cache_settings!
|
136
317
|
return unless rbac_enabled?
|
137
318
|
|
@@ -151,23 +332,23 @@ module RackJwtAegis
|
|
151
332
|
end
|
152
333
|
|
153
334
|
# Set default fallback for permission_cache_store when rbac_cache_store is provided
|
154
|
-
|
155
|
-
|
156
|
-
|
335
|
+
return unless !rbac_cache_store.nil? && permission_cache_store.nil?
|
336
|
+
|
337
|
+
@permission_cache_store = :memory # Default fallback
|
157
338
|
end
|
158
339
|
|
159
340
|
def validate_multi_tenant_settings!
|
160
|
-
if
|
161
|
-
raise ConfigurationError, '
|
341
|
+
if validate_pathname_slug? && pathname_slug_pattern.nil?
|
342
|
+
raise ConfigurationError, 'pathname_slug_pattern is required when validate_pathname_slug is true'
|
162
343
|
end
|
163
344
|
|
164
|
-
if validate_subdomain? && !payload_mapping.key?(:
|
165
|
-
raise ConfigurationError, 'payload_mapping must include :
|
345
|
+
if validate_subdomain? && !payload_mapping.key?(:subdomain)
|
346
|
+
raise ConfigurationError, 'payload_mapping must include :subdomain when validate_subdomain is true'
|
166
347
|
end
|
167
348
|
|
168
|
-
return unless
|
349
|
+
return unless validate_pathname_slug? && !payload_mapping.key?(:pathname_slugs)
|
169
350
|
|
170
|
-
raise ConfigurationError, 'payload_mapping must include :
|
351
|
+
raise ConfigurationError, 'payload_mapping must include :pathname_slugs when validate_pathname_slug is true'
|
171
352
|
end
|
172
353
|
end
|
173
354
|
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RackJwtAegis
|
4
|
+
# Shared debug logging functionality
|
5
|
+
#
|
6
|
+
# Provides consistent debug logging across all RackJwtAegis components
|
7
|
+
# with configurable log levels and automatic timestamp formatting.
|
8
|
+
#
|
9
|
+
# @author Ken Camajalan Demanawa
|
10
|
+
# @since 1.0.0
|
11
|
+
module DebugLogger
|
12
|
+
# Log debug message if debug mode is enabled
|
13
|
+
#
|
14
|
+
# @param message [String] the message to log
|
15
|
+
# @param level [Symbol] the log level (:info, :warn, :error) (default: :info)
|
16
|
+
# @param component [String] the component name for log prefixing (optional)
|
17
|
+
def debug_log(message, level = :info, component = nil)
|
18
|
+
return unless @config.debug_mode?
|
19
|
+
|
20
|
+
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
|
21
|
+
|
22
|
+
# Determine component name for log prefix
|
23
|
+
component_name = component || infer_component_name
|
24
|
+
|
25
|
+
formatted_message = "[#{timestamp}] #{component_name}: #{message}"
|
26
|
+
|
27
|
+
case level
|
28
|
+
when :error, :warn
|
29
|
+
warn formatted_message
|
30
|
+
else
|
31
|
+
puts formatted_message
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
# Infer component name from class name
|
38
|
+
#
|
39
|
+
# @return [String] the inferred component name
|
40
|
+
def infer_component_name
|
41
|
+
case self.class.name
|
42
|
+
when /Middleware/
|
43
|
+
'RackJwtAegis'
|
44
|
+
when /RbacManager/
|
45
|
+
'RbacManager'
|
46
|
+
else
|
47
|
+
self.class.name.split('::').last || 'RackJwtAegis'
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|