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.
@@ -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,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.validate_company_slug?
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.validate_company_slug?
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
- 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)