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.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -0
- data/.yard/yard_gfm_config.rb +21 -0
- data/.yardopts +16 -0
- data/CHANGELOG.md +204 -0
- data/README.md +339 -45
- 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 +205 -44
- data/lib/rack_jwt_aegis/jwt_validator.rb +56 -14
- data/lib/rack_jwt_aegis/middleware.rb +72 -2
- data/lib/rack_jwt_aegis/multi_tenant_validator.rb +43 -18
- data/lib/rack_jwt_aegis/rbac_manager.rb +323 -76
- 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 +36 -1
- metadata +24 -13
- data/examples/basic_usage.rb +0 -85
- /data/sig/{rack_jwt_bastion.rbs → rack_jwt_aegis.rbs} +0 -0
@@ -3,11 +3,41 @@
|
|
3
3
|
require 'jwt'
|
4
4
|
|
5
5
|
module RackJwtAegis
|
6
|
+
# JWT token validation and payload verification
|
7
|
+
#
|
8
|
+
# Handles JWT token decoding, signature verification, and payload validation
|
9
|
+
# including claims verification and type checking based on configuration.
|
10
|
+
#
|
11
|
+
# @author Ken Camajalan Demanawa
|
12
|
+
# @since 0.1.0
|
13
|
+
#
|
14
|
+
# @example Basic usage
|
15
|
+
# config = Configuration.new(jwt_secret: 'your-secret')
|
16
|
+
# validator = JwtValidator.new(config)
|
17
|
+
# payload = validator.validate(jwt_token)
|
18
|
+
#
|
19
|
+
# @example With multi-tenant validation
|
20
|
+
# config = Configuration.new(
|
21
|
+
# jwt_secret: 'your-secret',
|
22
|
+
# validate_subdomain: true,
|
23
|
+
# validate_pathname_slug: true
|
24
|
+
# )
|
25
|
+
# validator = JwtValidator.new(config)
|
26
|
+
# payload = validator.validate(jwt_token) # Will validate tenant claims
|
6
27
|
class JwtValidator
|
28
|
+
# Initialize the JWT validator
|
29
|
+
#
|
30
|
+
# @param config [Configuration] the configuration instance
|
7
31
|
def initialize(config)
|
8
32
|
@config = config
|
9
33
|
end
|
10
34
|
|
35
|
+
# Validate and decode a JWT token
|
36
|
+
#
|
37
|
+
# @param token [String] the JWT token to validate
|
38
|
+
# @return [Hash] the decoded JWT payload
|
39
|
+
# @raise [AuthenticationError] if token is invalid, expired, or malformed
|
40
|
+
# @raise [AuthenticationError] if required claims are missing or invalid
|
11
41
|
def validate(token)
|
12
42
|
# Decode JWT with verification
|
13
43
|
payload, _header = JWT.decode(
|
@@ -35,16 +65,20 @@ module RackJwtAegis
|
|
35
65
|
raise AuthenticationError, 'JWT token not yet valid'
|
36
66
|
rescue JWT::InvalidIatError
|
37
67
|
raise AuthenticationError, 'JWT token issued in the future'
|
38
|
-
rescue JWT::DecodeError => e
|
39
|
-
raise AuthenticationError, "Invalid JWT token: #{e.message}"
|
40
68
|
rescue JWT::VerificationError
|
41
69
|
raise AuthenticationError, 'JWT signature verification failed'
|
70
|
+
rescue JWT::DecodeError => e
|
71
|
+
raise AuthenticationError, "Invalid JWT token: #{e.message}"
|
42
72
|
rescue StandardError => e
|
43
73
|
raise AuthenticationError, "JWT validation error: #{e.message}"
|
44
74
|
end
|
45
75
|
|
46
76
|
private
|
47
77
|
|
78
|
+
# Validate the structure and content of the JWT payload
|
79
|
+
#
|
80
|
+
# @param payload [Object] the decoded payload from JWT
|
81
|
+
# @raise [AuthenticationError] if payload structure is invalid
|
48
82
|
def validate_payload_structure(payload)
|
49
83
|
# Ensure payload is a hash
|
50
84
|
raise AuthenticationError, 'Invalid JWT payload structure' unless payload.is_a?(Hash)
|
@@ -56,6 +90,10 @@ module RackJwtAegis
|
|
56
90
|
validate_claim_types(payload)
|
57
91
|
end
|
58
92
|
|
93
|
+
# Validate that all required claims are present in the payload
|
94
|
+
#
|
95
|
+
# @param payload [Hash] the JWT payload to validate
|
96
|
+
# @raise [AuthenticationError] if required claims are missing
|
59
97
|
def validate_required_claims(payload)
|
60
98
|
required_claims = []
|
61
99
|
|
@@ -64,11 +102,11 @@ module RackJwtAegis
|
|
64
102
|
|
65
103
|
# Multi-tenant validation requirements
|
66
104
|
if @config.validate_subdomain?
|
67
|
-
required_claims << @config.payload_key(:
|
68
|
-
required_claims << @config.payload_key(:
|
105
|
+
required_claims << @config.payload_key(:tenant_id)
|
106
|
+
required_claims << @config.payload_key(:subdomain)
|
69
107
|
end
|
70
108
|
|
71
|
-
required_claims << @config.payload_key(:
|
109
|
+
required_claims << @config.payload_key(:pathname_slugs) if @config.validate_pathname_slug?
|
72
110
|
|
73
111
|
missing_claims = required_claims.select { |claim| payload[claim.to_s].nil? }
|
74
112
|
|
@@ -77,6 +115,10 @@ module RackJwtAegis
|
|
77
115
|
raise AuthenticationError, "JWT payload missing required claims: #{missing_claims.join(', ')}"
|
78
116
|
end
|
79
117
|
|
118
|
+
# Validate the data types of specific claims in the payload
|
119
|
+
#
|
120
|
+
# @param payload [Hash] the JWT payload to validate
|
121
|
+
# @raise [AuthenticationError] if claim types are invalid
|
80
122
|
def validate_claim_types(payload)
|
81
123
|
user_id_key = @config.payload_key(:user_id).to_s
|
82
124
|
|
@@ -87,27 +129,27 @@ module RackJwtAegis
|
|
87
129
|
|
88
130
|
# Company group ID should be numeric or string (if present)
|
89
131
|
if @config.validate_subdomain?
|
90
|
-
|
91
|
-
if payload[
|
92
|
-
raise AuthenticationError, 'Invalid
|
132
|
+
tenant_id_key = @config.payload_key(:tenant_id).to_s
|
133
|
+
if payload[tenant_id_key] && !payload[tenant_id_key].is_a?(Numeric) && !payload[tenant_id_key].is_a?(String)
|
134
|
+
raise AuthenticationError, 'Invalid tenant_id format in JWT payload'
|
93
135
|
end
|
94
136
|
end
|
95
137
|
|
96
138
|
# Company group domain should be string (if present)
|
97
139
|
if @config.validate_subdomain?
|
98
|
-
company_domain_key = @config.payload_key(:
|
140
|
+
company_domain_key = @config.payload_key(:subdomain).to_s
|
99
141
|
if payload[company_domain_key] && !payload[company_domain_key].is_a?(String)
|
100
|
-
raise AuthenticationError, 'Invalid
|
142
|
+
raise AuthenticationError, 'Invalid subdomain format in JWT payload'
|
101
143
|
end
|
102
144
|
end
|
103
145
|
|
104
146
|
# Company slugs should be array (if present)
|
105
|
-
return unless @config.
|
147
|
+
return unless @config.validate_pathname_slug?
|
106
148
|
|
107
|
-
|
108
|
-
return unless payload[
|
149
|
+
pathname_slugs_key = @config.payload_key(:pathname_slugs).to_s
|
150
|
+
return unless payload[pathname_slugs_key] && !payload[pathname_slugs_key].is_a?(Array)
|
109
151
|
|
110
|
-
raise AuthenticationError, 'Invalid
|
152
|
+
raise AuthenticationError, 'Invalid pathname_slugs format in JWT payload - must be array'
|
111
153
|
end
|
112
154
|
end
|
113
155
|
end
|
@@ -1,7 +1,34 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module RackJwtAegis
|
4
|
+
# Main Rack middleware for JWT authentication and authorization
|
5
|
+
#
|
6
|
+
# This middleware handles the complete JWT authentication flow including:
|
7
|
+
# - JWT token extraction and validation
|
8
|
+
# - Multi-tenant validation (subdomain/pathname slug)
|
9
|
+
# - RBAC permission checking
|
10
|
+
# - Custom payload validation
|
11
|
+
# - Request context setting
|
12
|
+
#
|
13
|
+
# @author Ken Camajalan Demanawa
|
14
|
+
# @since 0.1.0
|
15
|
+
#
|
16
|
+
# @example Basic usage
|
17
|
+
# use RackJwtAegis::Middleware, jwt_secret: ENV['JWT_SECRET']
|
18
|
+
#
|
19
|
+
# @example Advanced usage
|
20
|
+
# use RackJwtAegis::Middleware, {
|
21
|
+
# jwt_secret: ENV['JWT_SECRET'],
|
22
|
+
# validate_subdomain: true,
|
23
|
+
# rbac_enabled: true,
|
24
|
+
# cache_store: :redis,
|
25
|
+
# skip_paths: ['/health', '/api/public/*']
|
26
|
+
# }
|
4
27
|
class Middleware
|
28
|
+
# Initialize the middleware
|
29
|
+
#
|
30
|
+
# @param app [#call] the Rack application
|
31
|
+
# @param options [Hash] configuration options (see Configuration#initialize)
|
5
32
|
def initialize(app, options = {})
|
6
33
|
@app = app
|
7
34
|
@config = Configuration.new(options)
|
@@ -16,6 +43,12 @@ module RackJwtAegis
|
|
16
43
|
debug_log("Middleware initialized with features: #{enabled_features}")
|
17
44
|
end
|
18
45
|
|
46
|
+
# Process the Rack request
|
47
|
+
#
|
48
|
+
# @param env [Hash] the Rack environment
|
49
|
+
# @return [Array] Rack response array [status, headers, body]
|
50
|
+
# @raise [AuthenticationError] if JWT authentication fails
|
51
|
+
# @raise [AuthorizationError] if authorization checks fail
|
19
52
|
def call(env)
|
20
53
|
request = Rack::Request.new(env)
|
21
54
|
|
@@ -42,6 +75,10 @@ module RackJwtAegis
|
|
42
75
|
|
43
76
|
# Step 4: RBAC permission check (if enabled)
|
44
77
|
if @config.rbac_enabled?
|
78
|
+
# Extract and store user roles in request environment for RBAC manager
|
79
|
+
user_roles = extract_user_roles(payload)
|
80
|
+
request.env['rack_jwt_aegis.user_roles'] = user_roles
|
81
|
+
|
45
82
|
@rbac_manager.authorize(request, payload)
|
46
83
|
debug_log('RBAC authorization successful')
|
47
84
|
end
|
@@ -79,6 +116,11 @@ module RackJwtAegis
|
|
79
116
|
|
80
117
|
private
|
81
118
|
|
119
|
+
# Extract JWT token from the Authorization header
|
120
|
+
#
|
121
|
+
# @param request [Rack::Request] the Rack request object
|
122
|
+
# @return [String] the extracted JWT token
|
123
|
+
# @raise [AuthenticationError] if authorization header is missing or invalid
|
82
124
|
def extract_jwt_token(request)
|
83
125
|
auth_header = request.get_header('HTTP_AUTHORIZATION')
|
84
126
|
|
@@ -94,18 +136,46 @@ module RackJwtAegis
|
|
94
136
|
token
|
95
137
|
end
|
96
138
|
|
139
|
+
# Check if multi-tenant validation is enabled
|
140
|
+
#
|
141
|
+
# @return [Boolean] true if subdomain or pathname slug validation is enabled
|
97
142
|
def multi_tenant_enabled?
|
98
|
-
@config.validate_subdomain? || @config.
|
143
|
+
@config.validate_subdomain? || @config.validate_pathname_slug?
|
99
144
|
end
|
100
145
|
|
146
|
+
# Generate a string describing enabled features for logging
|
147
|
+
#
|
148
|
+
# @return [String] comma-separated list of enabled features
|
101
149
|
def enabled_features
|
102
150
|
features = ['JWT']
|
103
151
|
features << 'Subdomain' if @config.validate_subdomain?
|
104
|
-
features << 'CompanySlug' if @config.
|
152
|
+
features << 'CompanySlug' if @config.validate_pathname_slug?
|
105
153
|
features << 'RBAC' if @config.rbac_enabled?
|
106
154
|
features.join(', ')
|
107
155
|
end
|
108
156
|
|
157
|
+
# Extract user roles from JWT payload for RBAC authorization
|
158
|
+
#
|
159
|
+
# @param payload [Hash] the JWT payload
|
160
|
+
# @return [Array] array of user role IDs
|
161
|
+
def extract_user_roles(payload)
|
162
|
+
# Try multiple common role field names
|
163
|
+
roles = payload['roles'] || payload['role'] || payload['user_roles'] || payload['role_ids']
|
164
|
+
|
165
|
+
case roles
|
166
|
+
when Array
|
167
|
+
roles.map(&:to_s) # Ensure all roles are strings for consistent lookup
|
168
|
+
when String, Integer
|
169
|
+
[roles.to_s] # Single role as array
|
170
|
+
else
|
171
|
+
debug_log("Warning: No valid roles found in JWT payload. Available fields: #{payload.keys}")
|
172
|
+
[]
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# Log debug message if debug mode is enabled
|
177
|
+
#
|
178
|
+
# @param message [String] the message to log
|
109
179
|
def debug_log(message)
|
110
180
|
return unless @config.debug_mode?
|
111
181
|
|
@@ -1,15 +1,41 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module RackJwtAegis
|
4
|
+
# Multi-tenant validation for subdomain and pathname slug access control
|
5
|
+
#
|
6
|
+
# Validates that users can only access resources within their permitted
|
7
|
+
# tenant boundaries. Supports two levels of tenant validation:
|
8
|
+
# 1. Subdomain-based (Level 1) - Company-Group level isolation
|
9
|
+
# 2. Pathname slug-based (Level 2) - Company level isolation within groups
|
10
|
+
#
|
11
|
+
# @author Ken Camajalan Demanawa
|
12
|
+
# @since 0.1.0
|
13
|
+
#
|
14
|
+
# @example Usage
|
15
|
+
# config = Configuration.new(
|
16
|
+
# jwt_secret: 'secret',
|
17
|
+
# validate_subdomain: true,
|
18
|
+
# validate_pathname_slug: true
|
19
|
+
# )
|
20
|
+
# validator = MultiTenantValidator.new(config)
|
21
|
+
# validator.validate(request, jwt_payload)
|
4
22
|
class MultiTenantValidator
|
23
|
+
# Initialize the multi-tenant validator
|
24
|
+
#
|
25
|
+
# @param config [Configuration] the configuration instance
|
5
26
|
def initialize(config)
|
6
27
|
@config = config
|
7
28
|
end
|
8
29
|
|
30
|
+
# Validate multi-tenant access permissions for the request
|
31
|
+
#
|
32
|
+
# @param request [Rack::Request] the incoming request
|
33
|
+
# @param payload [Hash] the JWT payload containing tenant information
|
34
|
+
# @raise [AuthorizationError] if tenant validation fails
|
9
35
|
def validate(request, payload)
|
10
36
|
validate_subdomain(request, payload) if @config.validate_subdomain?
|
11
|
-
|
12
|
-
validate_company_header(request, payload) if @config.
|
37
|
+
validate_pathname_slug(request, payload) if @config.validate_pathname_slug?
|
38
|
+
validate_company_header(request, payload) if @config.tenant_id_header_name
|
13
39
|
end
|
14
40
|
|
15
41
|
private
|
@@ -20,39 +46,40 @@ module RackJwtAegis
|
|
20
46
|
return if request_host.nil? || request_host.empty?
|
21
47
|
|
22
48
|
# Extract subdomain from request host
|
23
|
-
|
49
|
+
req_subdomain = extract_subdomain(request_host)
|
24
50
|
|
25
51
|
# Get JWT domain claim
|
26
|
-
jwt_domain_key = @config.payload_key(:
|
52
|
+
jwt_domain_key = @config.payload_key(:subdomain).to_s
|
27
53
|
jwt_domain = payload[jwt_domain_key]
|
28
54
|
|
29
55
|
if jwt_domain.nil? || jwt_domain.empty?
|
30
|
-
raise AuthorizationError, 'JWT payload missing
|
56
|
+
raise AuthorizationError, 'JWT payload missing subdomain for subdomain validation'
|
31
57
|
end
|
32
58
|
|
33
59
|
# Extract subdomain from JWT domain
|
34
60
|
jwt_subdomain = extract_subdomain(jwt_domain)
|
35
61
|
|
36
62
|
# Compare subdomains
|
37
|
-
return if subdomains_match?(
|
63
|
+
return if subdomains_match?(req_subdomain, jwt_subdomain)
|
38
64
|
|
39
65
|
raise AuthorizationError,
|
40
|
-
"Subdomain access denied: request subdomain '#{
|
66
|
+
"Subdomain access denied: request subdomain '#{req_subdomain}' " \
|
67
|
+
"does not match JWT subdomain '#{jwt_subdomain}'"
|
41
68
|
end
|
42
69
|
|
43
70
|
# Level 2 Multi-Tenant: Sub-level tenant (Company) validation via URL path
|
44
|
-
def
|
71
|
+
def validate_pathname_slug(request, payload)
|
45
72
|
# Extract company slug from URL path
|
46
73
|
company_slug = extract_company_slug_from_path(request.path)
|
47
74
|
|
48
75
|
return if company_slug.nil? # No company slug in path
|
49
76
|
|
50
77
|
# Get accessible company slugs from JWT
|
51
|
-
jwt_slugs_key = @config.payload_key(:
|
78
|
+
jwt_slugs_key = @config.payload_key(:pathname_slugs).to_s
|
52
79
|
accessible_slugs = payload[jwt_slugs_key]
|
53
80
|
|
54
81
|
if accessible_slugs.nil? || !accessible_slugs.is_a?(Array) || accessible_slugs.empty?
|
55
|
-
raise AuthorizationError, 'JWT payload missing or invalid
|
82
|
+
raise AuthorizationError, 'JWT payload missing or invalid pathname_slugs for company access validation'
|
56
83
|
end
|
57
84
|
|
58
85
|
# Check if requested company slug is in user's accessible list
|
@@ -64,22 +91,20 @@ module RackJwtAegis
|
|
64
91
|
|
65
92
|
# Company Group header validation (additional security layer)
|
66
93
|
def validate_company_header(request, payload)
|
67
|
-
header_name = "HTTP_#{@config.
|
94
|
+
header_name = "HTTP_#{@config.tenant_id_header_name.upcase.tr('-', '_')}"
|
68
95
|
header_value = request.get_header(header_name)
|
69
96
|
|
70
97
|
return if header_value.nil? # Header not present, skip validation
|
71
98
|
|
72
99
|
# Get company group ID from JWT
|
73
|
-
jwt_company_group_key = @config.payload_key(:
|
74
|
-
|
100
|
+
jwt_company_group_key = @config.payload_key(:tenant_id).to_s
|
101
|
+
jwt_tenant_id = payload[jwt_company_group_key]
|
75
102
|
|
76
|
-
if
|
77
|
-
raise AuthorizationError, 'JWT payload missing company_group_id for header validation'
|
78
|
-
end
|
103
|
+
raise AuthorizationError, 'JWT payload missing tenant_id for header validation' if jwt_tenant_id.nil?
|
79
104
|
|
80
105
|
# Normalize values for comparison (both as strings)
|
81
106
|
header_value_str = header_value.to_s
|
82
|
-
jwt_value_str =
|
107
|
+
jwt_value_str = jwt_tenant_id.to_s
|
83
108
|
|
84
109
|
return if header_value_str == jwt_value_str
|
85
110
|
|
@@ -110,7 +135,7 @@ module RackJwtAegis
|
|
110
135
|
return nil if path.nil? || path.empty?
|
111
136
|
|
112
137
|
# Use configured pattern to extract company slug
|
113
|
-
match = @config.
|
138
|
+
match = @config.pathname_slug_pattern.match(path)
|
114
139
|
return nil unless match && match[1]
|
115
140
|
|
116
141
|
# Return captured group (company slug)
|