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.
@@ -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(:company_group_id)
68
- required_claims << @config.payload_key(:company_group_domain)
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(:company_slugs) if @config.validate_company_slug?
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
- company_group_id_key = @config.payload_key(:company_group_id).to_s
91
- if payload[company_group_id_key] && !payload[company_group_id_key].is_a?(Numeric) && !payload[company_group_id_key].is_a?(String)
92
- raise AuthenticationError, 'Invalid company_group_id format in JWT payload'
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(:company_group_domain).to_s
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 company_group_domain format in JWT payload'
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.validate_company_slug?
147
+ return unless @config.validate_pathname_slug?
106
148
 
107
- company_slugs_key = @config.payload_key(:company_slugs).to_s
108
- return unless payload[company_slugs_key] && !payload[company_slugs_key].is_a?(Array)
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 company_slugs format in JWT payload - must be array'
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,36 @@
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
+ include DebugLogger
29
+
30
+ # Initialize the middleware
31
+ #
32
+ # @param app [#call] the Rack application
33
+ # @param options [Hash] configuration options (see Configuration#initialize)
5
34
  def initialize(app, options = {})
6
35
  @app = app
7
36
  @config = Configuration.new(options)
@@ -16,6 +45,12 @@ module RackJwtAegis
16
45
  debug_log("Middleware initialized with features: #{enabled_features}")
17
46
  end
18
47
 
48
+ # Process the Rack request
49
+ #
50
+ # @param env [Hash] the Rack environment
51
+ # @return [Array] Rack response array [status, headers, body]
52
+ # @raise [AuthenticationError] if JWT authentication fails
53
+ # @raise [AuthorizationError] if authorization checks fail
19
54
  def call(env)
20
55
  request = Rack::Request.new(env)
21
56
 
@@ -32,7 +67,7 @@ module RackJwtAegis
32
67
  token = extract_jwt_token(request)
33
68
  payload = @jwt_validator.validate(token)
34
69
 
35
- debug_log("JWT validation successful for user: #{payload[@config.payload_key(:user_id)]}")
70
+ debug_log("JWT validation successful for user: #{payload[@config.payload_key(:user_id).to_s]}")
36
71
 
37
72
  # Step 3: Multi-tenant validation (if enabled)
38
73
  if multi_tenant_enabled?
@@ -42,6 +77,10 @@ module RackJwtAegis
42
77
 
43
78
  # Step 4: RBAC permission check (if enabled)
44
79
  if @config.rbac_enabled?
80
+ # Extract and store user roles in request environment for RBAC manager
81
+ user_roles = extract_user_roles(payload)
82
+ request.env['rack_jwt_aegis.user_roles'] = user_roles
83
+
45
84
  @rbac_manager.authorize(request, payload)
46
85
  debug_log('RBAC authorization successful')
47
86
  end
@@ -79,6 +118,11 @@ module RackJwtAegis
79
118
 
80
119
  private
81
120
 
121
+ # Extract JWT token from the Authorization header
122
+ #
123
+ # @param request [Rack::Request] the Rack request object
124
+ # @return [String] the extracted JWT token
125
+ # @raise [AuthenticationError] if authorization header is missing or invalid
82
126
  def extract_jwt_token(request)
83
127
  auth_header = request.get_header('HTTP_AUTHORIZATION')
84
128
 
@@ -94,23 +138,46 @@ module RackJwtAegis
94
138
  token
95
139
  end
96
140
 
141
+ # Check if multi-tenant validation is enabled
142
+ #
143
+ # @return [Boolean] true if subdomain or pathname slug validation is enabled
97
144
  def multi_tenant_enabled?
98
- @config.validate_subdomain? || @config.validate_company_slug?
145
+ @config.validate_subdomain? || @config.validate_pathname_slug?
99
146
  end
100
147
 
148
+ # Generate a string describing enabled features for logging
149
+ #
150
+ # @return [String] comma-separated list of enabled features
101
151
  def enabled_features
102
152
  features = ['JWT']
103
153
  features << 'Subdomain' if @config.validate_subdomain?
104
- features << 'CompanySlug' if @config.validate_company_slug?
154
+ features << 'CompanySlug' if @config.validate_pathname_slug?
105
155
  features << 'RBAC' if @config.rbac_enabled?
106
156
  features.join(', ')
107
157
  end
108
158
 
109
- def debug_log(message)
110
- return unless @config.debug_mode?
111
-
112
- timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
113
- puts "[#{timestamp}] RackJwtAegis: #{message}"
159
+ # Extract user roles from JWT payload for RBAC authorization
160
+ #
161
+ # @param payload [Hash] the JWT payload
162
+ # @return [Array] array of user role IDs
163
+ def extract_user_roles(payload)
164
+ # Use configured payload mapping for role_ids, with fallback to common field names
165
+ role_key = @config.payload_key(:role_ids).to_s
166
+ roles = payload[role_key]
167
+
168
+ # If mapped key doesn't exist, try common fallback field names
169
+ roles = payload['roles'] || payload['role'] || payload['user_roles'] || payload['role_ids'] if roles.nil?
170
+
171
+ case roles
172
+ when Array
173
+ roles.map(&:to_s) # Ensure all roles are strings for consistent lookup
174
+ when String, Integer
175
+ [roles.to_s] # Single role as array
176
+ else
177
+ debug_log("Warning: No valid roles found in JWT payload. Looking for '#{role_key}' field. \
178
+ Available fields: #{payload.keys}".squeeze)
179
+ []
180
+ end
114
181
  end
115
182
  end
116
183
  end
@@ -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
- validate_company_slug(request, payload) if @config.validate_company_slug?
12
- validate_company_header(request, payload) if @config.company_header_name
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
- request_subdomain = extract_subdomain(request_host)
49
+ req_subdomain = extract_subdomain(request_host)
24
50
 
25
51
  # Get JWT domain claim
26
- jwt_domain_key = @config.payload_key(:company_group_domain).to_s
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 company_group_domain for subdomain validation'
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?(request_subdomain, jwt_subdomain)
63
+ return if subdomains_match?(req_subdomain, jwt_subdomain)
38
64
 
39
65
  raise AuthorizationError,
40
- "Subdomain access denied: request subdomain '#{request_subdomain}' does not match JWT subdomain '#{jwt_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 validate_company_slug(request, payload)
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(:company_slugs).to_s
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 company_slugs for company access validation'
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.company_header_name.upcase.tr('-', '_')}"
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(:company_group_id).to_s
74
- jwt_company_group_id = payload[jwt_company_group_key]
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 jwt_company_group_id.nil?
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 = jwt_company_group_id.to_s
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.company_slug_pattern.match(path)
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)