jwt_auth_cognito 0.1.0 → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "jwt"
3
+ require 'jwt'
4
4
 
5
5
  module JwtAuthCognito
6
6
  class JwtValidator
@@ -8,44 +8,108 @@ module JwtAuthCognito
8
8
  @config = config
9
9
  @jwks_service = JwksService.new(config)
10
10
  @blacklist_service = TokenBlacklistService.new(config)
11
+ @api_key_validator = config.enable_api_key_validation ? ApiKeyValidator.new(config) : nil
12
+ @user_data_service = config.enable_user_data_retrieval ? UserDataService.new(nil, config.user_data_config) : nil
13
+ @initialized = false
14
+ end
15
+
16
+ def initialize!
17
+ return if @initialized
18
+
19
+ begin
20
+ @jwks_service.initialize!
21
+ @blacklist_service.initialize! if @blacklist_service.respond_to?(:initialize!)
22
+ @user_data_service&.initialize!
23
+ @initialized = true
24
+ rescue StandardError => e
25
+ ErrorUtils.log_error(e, 'JWT Validator initialization failed')
26
+ raise JwtAuthCognito::ConfigurationError, ErrorUtils::JWT_ERROR_MESSAGES['INITIALIZATION_FAILED']
27
+ end
11
28
  end
12
29
 
13
30
  def validate_token(token, options = {})
31
+ validate_token_with_api_key(token, nil, options)
32
+ end
33
+
34
+ def validate_token_with_api_key(token, api_key = nil, options = {})
14
35
  @config.validate!
15
-
16
- # Check blacklist first
17
- if @blacklist_service.is_blacklisted?(token)
18
- return { valid: false, error: "Token has been revoked" }
36
+
37
+ # Validate API key if provided and enabled
38
+ api_key_data = nil
39
+ if api_key && @config.enable_api_key_validation && @api_key_validator
40
+ api_key_result = @api_key_validator.validate_api_key(api_key)
41
+ return { valid: false, error: api_key_result[:error] || 'API key validation failed' } unless api_key_result[:valid]
42
+
43
+ api_key_data = api_key_result[:key_data]
19
44
  end
20
45
 
46
+ # Check blacklist first
47
+ return { valid: false, error: 'Token has been revoked' } if @blacklist_service.is_blacklisted?(token)
48
+
21
49
  # Choose validation method based on configuration
22
- case @config.validation_mode
23
- when :secure
24
- validate_token_secure(token, options)
25
- when :basic
26
- validate_token_basic(token, options)
27
- else
28
- raise ConfigurationError, "Invalid validation_mode: #{@config.validation_mode}"
50
+ result = case @config.validation_mode
51
+ when :secure
52
+ validate_token_secure(token, options)
53
+ when :basic
54
+ validate_token_basic(token, options)
55
+ else
56
+ raise ConfigurationError, "Invalid validation_mode: #{@config.validation_mode}"
57
+ end
58
+
59
+ # Add API key data to result if validation succeeded
60
+ result[:api_key] = api_key_data if result[:valid] && api_key_data
61
+
62
+ result
63
+ end
64
+
65
+ def validate_token_enriched(token, api_key = nil, options = {})
66
+ # First, perform standard token validation
67
+ basic_result = validate_token_with_api_key(token, api_key, options)
68
+
69
+ # If basic validation fails, return early
70
+ return basic_result unless basic_result[:valid] && basic_result[:payload]
71
+
72
+ # If user data retrieval is not enabled, return basic result
73
+ return basic_result unless @config.enable_user_data_retrieval && @user_data_service
74
+
75
+ # Extract user ID from the token
76
+ user_id = basic_result[:payload]['sub']
77
+ unless user_id
78
+ puts 'Token does not contain sub claim, cannot retrieve user data'
79
+ return basic_result
80
+ end
81
+
82
+ begin
83
+ # Get comprehensive user data from Redis
84
+ user_data = @user_data_service.get_comprehensive_user_data(user_id)
85
+
86
+ # Add user data to the result
87
+ enriched_result = basic_result.dup
88
+ enriched_result[:user_permissions] = user_data['permissions']
89
+ enriched_result[:user_organizations] = user_data['organizations']
90
+ enriched_result[:applications] = user_data['applications']
91
+
92
+ enriched_result
93
+ rescue StandardError => e
94
+ ErrorUtils.log_error(e, 'User data retrieval failed')
95
+ # Return basic result even if user data retrieval fails
96
+ basic_result
29
97
  end
30
98
  end
31
99
 
32
100
  def validate_access_token(token)
33
101
  result = validate_token(token)
34
-
35
- if result[:valid] && result[:payload]["token_use"] != "access"
36
- return { valid: false, error: "Token is not an access token" }
37
- end
38
-
102
+
103
+ return { valid: false, error: 'Token is not an access token' } if result[:valid] && result[:payload]['token_use'] != 'access'
104
+
39
105
  result
40
106
  end
41
107
 
42
108
  def validate_id_token(token)
43
109
  result = validate_token(token)
44
-
45
- if result[:valid] && result[:payload]["token_use"] != "id"
46
- return { valid: false, error: "Token is not an ID token" }
47
- end
48
-
110
+
111
+ return { valid: false, error: 'Token is not an ID token' } if result[:valid] && result[:payload]['token_use'] != 'id'
112
+
49
113
  result
50
114
  end
51
115
 
@@ -64,11 +128,34 @@ module JwtAuthCognito
64
128
  # Utility methods inspired by Node.js package
65
129
  def extract_token_from_header(authorization_header)
66
130
  return nil unless authorization_header
67
-
131
+
68
132
  match = authorization_header.match(/\ABearer (.+)\z/)
69
133
  match ? match[1] : nil
70
134
  end
71
135
 
136
+ def extract_api_key_from_header(api_key_header)
137
+ # Support common API key header formats
138
+ return nil unless api_key_header
139
+
140
+ api_key_header.strip
141
+ end
142
+
143
+ def extract_api_key_from_headers(headers)
144
+ # Check various common API key header names (case insensitive)
145
+ api_key_headers = %w[x-api-key X-API-Key X-API-KEY X-Api-Key]
146
+
147
+ api_key_headers.each do |header_name|
148
+ # Convert headers to a case-insensitive hash for lookup
149
+ header_key = headers.keys.find { |key| key.downcase == header_name.downcase }
150
+ next unless header_key
151
+
152
+ value = headers[header_key]
153
+ return extract_api_key_from_header(value) if value
154
+ end
155
+
156
+ nil
157
+ end
158
+
72
159
  def decode_token(token)
73
160
  JWT.decode(token, nil, false).first
74
161
  rescue JWT::DecodeError => e
@@ -80,15 +167,15 @@ module JwtAuthCognito
80
167
  return payload if payload.is_a?(Hash) && payload[:error]
81
168
 
82
169
  {
83
- sub: payload["sub"],
84
- username: payload["cognito:username"] || payload["username"],
85
- email: payload["email"],
86
- token_use: payload["token_use"],
87
- client_id: payload["aud"],
88
- issued_at: payload["iat"] ? Time.at(payload["iat"]) : nil,
89
- expires_at: payload["exp"] ? Time.at(payload["exp"]) : nil,
90
- not_before: payload["nbf"] ? Time.at(payload["nbf"]) : nil,
91
- jti: payload["jti"],
170
+ sub: payload['sub'],
171
+ username: payload['cognito:username'] || payload['username'],
172
+ email: payload['email'],
173
+ token_use: payload['token_use'],
174
+ client_id: payload['aud'],
175
+ issued_at: payload['iat'] ? Time.at(payload['iat']) : nil,
176
+ expires_at: payload['exp'] ? Time.at(payload['exp']) : nil,
177
+ not_before: payload['nbf'] ? Time.at(payload['nbf']) : nil,
178
+ jti: payload['jti'],
92
179
  has_client_secret: @config.has_client_secret?
93
180
  }
94
181
  end
@@ -107,7 +194,7 @@ module JwtAuthCognito
107
194
  payload = decode_token(token)
108
195
  return true if payload.is_a?(Hash) && payload[:error]
109
196
 
110
- exp = payload["exp"]
197
+ exp = payload['exp']
111
198
  return false unless exp
112
199
 
113
200
  Time.now.to_i >= exp
@@ -117,18 +204,18 @@ module JwtAuthCognito
117
204
  payload = decode_token(token)
118
205
  return nil if payload.is_a?(Hash) && payload[:error]
119
206
 
120
- exp = payload["exp"]
207
+ exp = payload['exp']
121
208
  return nil unless exp
122
209
 
123
210
  seconds = exp - Time.now.to_i
124
- seconds > 0 ? seconds : 0
211
+ seconds.positive? ? seconds : 0
125
212
  end
126
213
 
127
214
  # Create a convenience factory method
128
215
  def self.create_cognito_validator(config = nil)
129
216
  if config
130
217
  old_config = JwtAuthCognito.configuration
131
- JwtAuthCognito.configure { |c| c = config }
218
+ JwtAuthCognito.configure { |_c| config }
132
219
  validator = new
133
220
  JwtAuthCognito.instance_variable_set(:@configuration, old_config)
134
221
  validator
@@ -142,84 +229,75 @@ module JwtAuthCognito
142
229
  def validate_token_secure(token, options = {})
143
230
  # Use JWKS validation for production
144
231
  result = @jwks_service.validate_token_with_jwks(token)
145
-
232
+
146
233
  if result[:valid]
147
234
  # Additional custom validations
148
235
  validate_custom_claims(result[:payload], options)
149
236
  end
150
-
237
+
151
238
  result
152
239
  end
153
240
 
154
241
  def validate_token_basic(token, options = {})
155
242
  # Basic validation without signature verification (development only)
156
- begin
157
- payload, header = JWT.decode(token, nil, false)
158
-
159
- # Basic claim validation
160
- validate_basic_claims(payload)
161
- validate_custom_claims(payload, options)
162
-
163
- {
164
- valid: true,
165
- payload: payload,
166
- sub: payload["sub"],
167
- username: payload["cognito:username"] || payload["username"],
168
- token_use: payload["token_use"]
169
- }
170
- rescue JWT::DecodeError => e
171
- { valid: false, error: "JWT decode error: #{e.message}" }
172
- rescue ValidationError => e
173
- { valid: false, error: e.message }
174
- rescue StandardError => e
175
- { valid: false, error: "Validation error: #{e.message}" }
176
- end
243
+
244
+ payload, = JWT.decode(token, nil, false)
245
+
246
+ # Basic claim validation
247
+ validate_basic_claims(payload)
248
+ validate_custom_claims(payload, options)
249
+
250
+ {
251
+ valid: true,
252
+ payload: payload,
253
+ sub: payload['sub'],
254
+ username: payload['cognito:username'] || payload['username'],
255
+ token_use: payload['token_use']
256
+ }
257
+ rescue JWT::DecodeError => e
258
+ { valid: false, error: "JWT decode error: #{e.message}" }
259
+ rescue ValidationError => e
260
+ { valid: false, error: e.message }
261
+ rescue StandardError => e
262
+ { valid: false, error: "Validation error: #{e.message}" }
177
263
  end
178
264
 
179
265
  def validate_basic_claims(payload)
180
266
  now = Time.now.to_i
181
-
267
+
182
268
  # Check expiration
183
- raise ValidationError, "Token has expired" if payload["exp"] && payload["exp"] < now
184
-
269
+ raise ValidationError, 'Token has expired' if payload['exp'] && payload['exp'] < now
270
+
185
271
  # Check issuer
186
272
  expected_issuer = @config.cognito_issuer
187
- if payload["iss"] != expected_issuer
188
- raise ValidationError, "Invalid issuer. Expected: #{expected_issuer}, got: #{payload["iss"]}"
189
- end
190
-
273
+ raise ValidationError, "Invalid issuer. Expected: #{expected_issuer}, got: #{payload['iss']}" if payload['iss'] != expected_issuer
274
+
191
275
  # Check token use
192
- unless %w[access id].include?(payload["token_use"])
193
- raise ValidationError, "Invalid token_use claim"
194
- end
276
+ return if %w[access id].include?(payload['token_use'])
277
+
278
+ raise ValidationError, 'Invalid token_use claim'
195
279
  end
196
280
 
197
281
  def validate_custom_claims(payload, options)
198
282
  # Validate specific user ID if provided
199
- if options[:user_id] && payload["sub"] != options[:user_id]
200
- raise ValidationError, "Token subject does not match expected user ID"
201
- end
202
-
283
+ raise ValidationError, 'Token subject does not match expected user ID' if options[:user_id] && payload['sub'] != options[:user_id]
284
+
203
285
  # Validate specific client ID if provided
204
- if options[:client_id] && payload["aud"] != options[:client_id]
205
- raise ValidationError, "Token audience does not match expected client ID"
206
- end
207
-
286
+ raise ValidationError, 'Token audience does not match expected client ID' if options[:client_id] && payload['aud'] != options[:client_id]
287
+
208
288
  # Validate token type if specified
209
- if options[:token_use] && payload["token_use"] != options[:token_use]
210
- raise ValidationError, "Token use does not match expected type"
211
- end
212
-
289
+ raise ValidationError, 'Token use does not match expected type' if options[:token_use] && payload['token_use'] != options[:token_use]
290
+
213
291
  # Custom scope validation
214
- if options[:required_scopes]
215
- token_scopes = payload["scope"]&.split(" ") || []
216
- required_scopes = Array(options[:required_scopes])
217
-
218
- missing_scopes = required_scopes - token_scopes
219
- if missing_scopes.any?
220
- raise ValidationError, "Token missing required scopes: #{missing_scopes.join(", ")}"
221
- end
222
- end
292
+ return unless options[:required_scopes]
293
+
294
+ token_scopes = payload['scope']&.split || []
295
+ required_scopes = Array(options[:required_scopes])
296
+
297
+ missing_scopes = required_scopes - token_scopes
298
+ return unless missing_scopes.any?
299
+
300
+ raise ValidationError, "Token missing required scopes: #{missing_scopes.join(', ')}"
223
301
  end
224
302
  end
225
- end
303
+ end
@@ -3,11 +3,11 @@
3
3
  module JwtAuthCognito
4
4
  class Railtie < Rails::Railtie
5
5
  rake_tasks do
6
- load "tasks/jwt_auth_cognito.rake"
6
+ load 'tasks/jwt_auth_cognito.rake'
7
7
  end
8
8
 
9
9
  generators do
10
- require "generators/jwt_auth_cognito/install_generator"
10
+ require 'generators/jwt_auth_cognito/install_generator'
11
11
  end
12
12
  end
13
- end
13
+ end
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "redis"
4
- require "digest"
5
- require "openssl"
3
+ require 'redis'
4
+ require 'digest'
5
+ require 'openssl'
6
6
 
7
7
  module JwtAuthCognito
8
8
  class RedisService
9
- BLACKLIST_PREFIX = "jwt_blacklist:"
10
- USER_TOKENS_PREFIX = "user_tokens:"
9
+ BLACKLIST_PREFIX = 'jwt_blacklist:'
10
+ USER_TOKENS_PREFIX = 'user_tokens:'
11
11
 
12
12
  def initialize(config = JwtAuthCognito.configuration)
13
13
  @config = config
@@ -17,13 +17,13 @@ module JwtAuthCognito
17
17
  def save_revoked_token(token_id, ttl = nil)
18
18
  connect_redis
19
19
  key = "#{BLACKLIST_PREFIX}#{token_id}"
20
-
20
+
21
21
  if ttl
22
- @redis.setex(key, ttl, "revoked")
22
+ @redis.setex(key, ttl, 'revoked')
23
23
  else
24
- @redis.set(key, "revoked")
24
+ @redis.set(key, 'revoked')
25
25
  end
26
-
26
+
27
27
  true
28
28
  rescue Redis::BaseError => e
29
29
  raise BlacklistError, "Failed to save revoked token: #{e.message}"
@@ -33,8 +33,8 @@ module JwtAuthCognito
33
33
  connect_redis
34
34
  key = "#{BLACKLIST_PREFIX}#{token_id}"
35
35
  result = @redis.exists?(key)
36
- result.is_a?(Integer) ? result > 0 : result
37
- rescue Redis::BaseError => e
36
+ result.is_a?(Integer) ? result.positive? : result
37
+ rescue Redis::BaseError
38
38
  # Graceful degradation - if Redis is down, don't block validation
39
39
  false
40
40
  end
@@ -50,19 +50,19 @@ module JwtAuthCognito
50
50
 
51
51
  def invalidate_user_tokens(user_id)
52
52
  connect_redis
53
-
53
+
54
54
  # Get all tokens for the user
55
55
  user_key = "#{USER_TOKENS_PREFIX}#{user_id}"
56
56
  token_ids = @redis.smembers(user_key)
57
-
57
+
58
58
  # Add all tokens to blacklist
59
59
  token_ids.each do |token_id|
60
60
  save_revoked_token(token_id)
61
61
  end
62
-
62
+
63
63
  # Clear the user's token set
64
64
  @redis.del(user_key)
65
-
65
+
66
66
  token_ids.length
67
67
  rescue Redis::BaseError => e
68
68
  raise BlacklistError, "Failed to invalidate user tokens: #{e.message}"
@@ -70,28 +70,28 @@ module JwtAuthCognito
70
70
 
71
71
  def track_user_token(user_id, token_id, ttl = nil)
72
72
  connect_redis
73
-
73
+
74
74
  user_key = "#{USER_TOKENS_PREFIX}#{user_id}"
75
75
  @redis.sadd(user_key, token_id)
76
-
76
+
77
77
  # Set expiration on the user's token set
78
78
  @redis.expire(user_key, ttl) if ttl
79
-
79
+
80
80
  true
81
- rescue Redis::BaseError => e
81
+ rescue Redis::BaseError
82
82
  # Non-critical operation, log but don't fail
83
83
  false
84
84
  end
85
85
 
86
86
  def generate_token_id(token)
87
87
  # Try to extract jti from token first
88
- begin
88
+ begin
89
89
  payload = JWT.decode(token, nil, false).first
90
- return payload["jti"] if payload["jti"]
90
+ return payload['jti'] if payload['jti']
91
91
  rescue JWT::DecodeError
92
92
  # Fall back to hash if token can't be decoded
93
93
  end
94
-
94
+
95
95
  # Generate hash-based ID
96
96
  Digest::SHA256.hexdigest(token)[0, 16]
97
97
  end
@@ -102,23 +102,21 @@ module JwtAuthCognito
102
102
  return @redis if @redis
103
103
 
104
104
  redis_options = build_redis_options
105
-
105
+
106
106
  # Retry logic with exponential backoff (similar to Node.js implementation)
107
107
  max_retries = 3
108
108
  retry_count = 0
109
-
109
+
110
110
  begin
111
111
  @redis = Redis.new(redis_options)
112
112
  @redis.ping # Test connection
113
113
  @redis
114
114
  rescue Redis::BaseError => e
115
115
  retry_count += 1
116
- if retry_count <= max_retries
117
- sleep(0.1 * (2 ** retry_count)) # Exponential backoff
118
- retry
119
- else
120
- raise BlacklistError, "Failed to connect to Redis after #{max_retries} retries: #{e.message}"
121
- end
116
+ raise BlacklistError, "Failed to connect to Redis after #{max_retries} retries: #{e.message}" unless retry_count <= max_retries
117
+
118
+ sleep(0.1 * (2**retry_count)) # Exponential backoff
119
+ retry
122
120
  end
123
121
  end
124
122
 
@@ -145,35 +143,76 @@ module JwtAuthCognito
145
143
 
146
144
  def build_ssl_params
147
145
  ssl_params = {}
148
-
146
+
149
147
  # Set TLS version constraints
150
- if @config.redis_tls_min_version
151
- ssl_params[:min_version] = parse_tls_version(@config.redis_tls_min_version)
148
+ ssl_params[:min_version] = parse_tls_version(@config.redis_tls_min_version) if @config.redis_tls_min_version
149
+
150
+ ssl_params[:max_version] = parse_tls_version(@config.redis_tls_max_version) if @config.redis_tls_max_version
151
+
152
+ # CA certificate configuration with multiple sources
153
+ ca_cert_data = load_ca_certificate
154
+ if ca_cert_data
155
+ # Create a temporary file for the CA certificate
156
+ require 'tempfile'
157
+ temp_file = Tempfile.new('redis_ca_cert')
158
+ temp_file.write(ca_cert_data)
159
+ temp_file.close
160
+ ssl_params[:ca_file] = temp_file.path
161
+
162
+ # Store reference to prevent garbage collection
163
+ @temp_ca_file = temp_file
152
164
  end
153
-
154
- if @config.redis_tls_max_version
155
- ssl_params[:max_version] = parse_tls_version(@config.redis_tls_max_version)
165
+
166
+ # Verification mode
167
+ ssl_params[:verify_mode] = case @config.redis_verify_mode
168
+ when 'none'
169
+ OpenSSL::SSL::VERIFY_NONE
170
+ when 'peer'
171
+ OpenSSL::SSL::VERIFY_PEER
172
+ else
173
+ OpenSSL::SSL::VERIFY_PEER
174
+ end
175
+
176
+ ssl_params
177
+ end
178
+
179
+ def load_ca_certificate
180
+ # Priority order for certificate loading (matching Node.js implementation):
181
+ # 1. SSM Parameter Store (for auth-service compatibility)
182
+ # 2. Local file system
183
+ # 3. Environment variable
184
+
185
+ # 1. Try SSM Parameter Store first (for auth-service compatibility)
186
+ if @config.redis_ca_cert_ssm_path && @config.redis_ca_cert_ssm_name
187
+ begin
188
+ puts '🔍 Loading CA certificate from SSM...'
189
+ return JwtAuthCognito::SSMService.get_ca_certificate(
190
+ @config.redis_ca_cert_ssm_path,
191
+ @config.redis_ca_cert_ssm_name
192
+ )
193
+ rescue StandardError => e
194
+ puts "⚠️ Failed to load certificate from SSM: #{e.message}"
195
+ puts '⚠️ Falling back to file system...'
196
+ end
156
197
  end
157
-
158
- # CA certificate configuration
198
+
199
+ # 2. Try local file system
159
200
  if @config.redis_ca_cert_path && @config.redis_ca_cert_name
160
201
  ca_cert_file = File.join(@config.redis_ca_cert_path, @config.redis_ca_cert_name)
161
202
  if File.exist?(ca_cert_file)
162
- ssl_params[:ca_file] = ca_cert_file
203
+ puts "📁 Loading CA certificate from file system: #{ca_cert_file}"
204
+ return File.read(ca_cert_file)
163
205
  end
164
206
  end
165
-
166
- # Verification mode
167
- case @config.redis_verify_mode
168
- when 'none'
169
- ssl_params[:verify_mode] = OpenSSL::SSL::VERIFY_NONE
170
- when 'peer'
171
- ssl_params[:verify_mode] = OpenSSL::SSL::VERIFY_PEER
172
- else
173
- ssl_params[:verify_mode] = OpenSSL::SSL::VERIFY_PEER
207
+
208
+ # 3. Try environment variable
209
+ if ENV['REDIS_CA_CERT']
210
+ puts '🌍 Loading CA certificate from environment variable'
211
+ return ENV['REDIS_CA_CERT']
174
212
  end
175
-
176
- ssl_params
213
+
214
+ puts '⚠️ No CA certificate found, proceeding without certificate validation'
215
+ nil
177
216
  end
178
217
 
179
218
  def parse_tls_version(version_string)
@@ -191,4 +230,4 @@ module JwtAuthCognito
191
230
  end
192
231
  end
193
232
  end
194
- end
233
+ end