jwt_auth_cognito 0.1.1 → 1.0.0.pre.beta.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 +78 -0
- data/BITBUCKET-DEPLOYMENT.md +290 -0
- data/CHANGELOG.md +65 -0
- data/CLAUDE.md +189 -9
- data/Gemfile +5 -5
- data/README.md +147 -1
- data/Rakefile +108 -5
- data/VERSIONING.md +244 -0
- data/bitbucket-pipelines.yml +273 -0
- data/jwt_auth_cognito.gemspec +42 -39
- data/lib/generators/jwt_auth_cognito/install_generator.rb +25 -25
- data/lib/jwt_auth_cognito/api_key_validator.rb +79 -0
- data/lib/jwt_auth_cognito/configuration.rb +38 -21
- data/lib/jwt_auth_cognito/error_utils.rb +110 -0
- data/lib/jwt_auth_cognito/jwks_service.rb +46 -50
- data/lib/jwt_auth_cognito/jwt_validator.rb +319 -92
- data/lib/jwt_auth_cognito/railtie.rb +3 -3
- data/lib/jwt_auth_cognito/redis_service.rb +90 -51
- data/lib/jwt_auth_cognito/ssm_service.rb +109 -0
- data/lib/jwt_auth_cognito/token_blacklist_service.rb +10 -12
- data/lib/jwt_auth_cognito/user_data_service.rb +332 -0
- data/lib/jwt_auth_cognito/version.rb +2 -2
- data/lib/jwt_auth_cognito.rb +42 -10
- data/lib/tasks/jwt_auth_cognito.rake +69 -70
- metadata +63 -26
@@ -1,13 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
3
|
+
require 'redis'
|
4
|
+
require 'digest'
|
5
|
+
require 'openssl'
|
6
6
|
|
7
7
|
module JwtAuthCognito
|
8
8
|
class RedisService
|
9
|
-
BLACKLIST_PREFIX =
|
10
|
-
USER_TOKENS_PREFIX =
|
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,
|
22
|
+
@redis.setex(key, ttl, 'revoked')
|
23
23
|
else
|
24
|
-
@redis.set(key,
|
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
|
37
|
-
rescue Redis::BaseError
|
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
|
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[
|
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
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
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
|
-
|
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
|
-
|
155
|
-
|
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
|
-
#
|
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
|
-
|
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
|
-
#
|
167
|
-
|
168
|
-
|
169
|
-
|
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
|
-
|
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
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'net/http'
|
4
|
+
require 'json'
|
5
|
+
require 'uri'
|
6
|
+
require 'aws-sdk-ssm'
|
7
|
+
|
8
|
+
module JwtAuthCognito
|
9
|
+
class SSMService
|
10
|
+
class << self
|
11
|
+
attr_accessor :client, :certificate_cache
|
12
|
+
end
|
13
|
+
|
14
|
+
@client = nil
|
15
|
+
@certificate_cache = {}
|
16
|
+
|
17
|
+
# Initialize the SSM client
|
18
|
+
def self.get_client
|
19
|
+
@client ||= begin
|
20
|
+
require 'aws-sdk-ssm'
|
21
|
+
region = ENV['AWS_REGION'] || ENV['AWS_DEFAULT_REGION'] || 'us-east-1'
|
22
|
+
Aws::SSM::Client.new(region: region)
|
23
|
+
end
|
24
|
+
rescue LoadError
|
25
|
+
raise ConfigurationError,
|
26
|
+
"aws-sdk-ssm gem is required for SSM functionality. Add 'gem \"aws-sdk-ssm\"' to your Gemfile"
|
27
|
+
end
|
28
|
+
|
29
|
+
# Gets a certificate from AWS Parameter Store (compatible with auth-service)
|
30
|
+
# Uses the same path pattern: /${cert_path}/${cert_name}
|
31
|
+
def self.get_ca_certificate(cert_path, cert_name)
|
32
|
+
full_path = "/#{cert_path}/#{cert_name}"
|
33
|
+
|
34
|
+
# Check cache first
|
35
|
+
if @certificate_cache.key?(full_path)
|
36
|
+
puts '📋 Using cached certificate from SSM'
|
37
|
+
return @certificate_cache[full_path]
|
38
|
+
end
|
39
|
+
|
40
|
+
begin
|
41
|
+
puts "📡 Getting certificate from Parameter Store: #{full_path}"
|
42
|
+
|
43
|
+
client = get_client
|
44
|
+
response = client.get_parameter({
|
45
|
+
name: full_path,
|
46
|
+
with_decryption: true
|
47
|
+
})
|
48
|
+
|
49
|
+
raise ConfigurationError, "Certificate parameter not found or invalid: #{full_path}" unless response.parameter&.value
|
50
|
+
|
51
|
+
# Cache the certificate
|
52
|
+
@certificate_cache[full_path] = response.parameter.value
|
53
|
+
puts '✅ Certificate obtained from SSM and cached'
|
54
|
+
|
55
|
+
response.parameter.value
|
56
|
+
rescue Aws::SSM::Errors::ParameterNotFound
|
57
|
+
raise ConfigurationError, "Certificate parameter not found: #{full_path}"
|
58
|
+
rescue Aws::SSM::Errors::ServiceError => e
|
59
|
+
puts "❌ Error getting certificate from SSM (#{full_path}): #{e.message}"
|
60
|
+
raise ConfigurationError, "Error accessing SSM: #{e.message}"
|
61
|
+
rescue StandardError => e
|
62
|
+
puts "❌ Error getting certificate from SSM (#{full_path}): #{e.message}"
|
63
|
+
raise e
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Gets a parameter from AWS Parameter Store
|
68
|
+
def self.get_parameter(parameter_name, with_decryption = true)
|
69
|
+
# Check cache first
|
70
|
+
return @certificate_cache[parameter_name] if @certificate_cache.key?(parameter_name)
|
71
|
+
|
72
|
+
begin
|
73
|
+
client = get_client
|
74
|
+
response = client.get_parameter({
|
75
|
+
name: parameter_name,
|
76
|
+
with_decryption: with_decryption
|
77
|
+
})
|
78
|
+
|
79
|
+
raise ConfigurationError, "Parameter not found or invalid: #{parameter_name}" unless response.parameter&.value
|
80
|
+
|
81
|
+
# Cache the parameter
|
82
|
+
@certificate_cache[parameter_name] = response.parameter.value
|
83
|
+
|
84
|
+
response.parameter.value
|
85
|
+
rescue Aws::SSM::Errors::ParameterNotFound
|
86
|
+
raise ConfigurationError, "Parameter not found: #{parameter_name}"
|
87
|
+
rescue Aws::SSM::Errors::ServiceError => e
|
88
|
+
puts "❌ Error getting parameter from SSM (#{parameter_name}): #{e.message}"
|
89
|
+
raise ConfigurationError, "Error accessing SSM: #{e.message}"
|
90
|
+
rescue StandardError => e
|
91
|
+
puts "❌ Error getting parameter from SSM (#{parameter_name}): #{e.message}"
|
92
|
+
raise e
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Clears the certificate cache
|
97
|
+
def self.clear_cache
|
98
|
+
@certificate_cache.clear
|
99
|
+
end
|
100
|
+
|
101
|
+
# Gets cache stats
|
102
|
+
def self.cache_stats
|
103
|
+
{
|
104
|
+
size: @certificate_cache.size,
|
105
|
+
keys: @certificate_cache.keys
|
106
|
+
}
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -10,14 +10,12 @@ module JwtAuthCognito
|
|
10
10
|
def add_to_blacklist(token, user_id: nil)
|
11
11
|
token_id = @redis_service.generate_token_id(token)
|
12
12
|
ttl = calculate_ttl(token)
|
13
|
-
|
13
|
+
|
14
14
|
result = @redis_service.save_revoked_token(token_id, ttl)
|
15
|
-
|
15
|
+
|
16
16
|
# Track token for user if provided
|
17
|
-
if user_id
|
18
|
-
|
19
|
-
end
|
20
|
-
|
17
|
+
@redis_service.track_user_token(user_id, token_id, ttl) if user_id
|
18
|
+
|
21
19
|
result
|
22
20
|
end
|
23
21
|
|
@@ -39,18 +37,18 @@ module JwtAuthCognito
|
|
39
37
|
def calculate_ttl(token)
|
40
38
|
begin
|
41
39
|
payload = JWT.decode(token, nil, false).first
|
42
|
-
exp = payload[
|
43
|
-
|
40
|
+
exp = payload['exp']
|
41
|
+
|
44
42
|
if exp
|
45
43
|
ttl = exp - Time.now.to_i
|
46
|
-
return ttl
|
44
|
+
return ttl.positive? ? ttl : 1 # At least 1 second TTL
|
47
45
|
end
|
48
46
|
rescue JWT::DecodeError
|
49
47
|
# If we can't decode the token, use a default TTL
|
50
48
|
end
|
51
|
-
|
49
|
+
|
52
50
|
# Default TTL of 1 day if we can't determine expiration
|
53
|
-
|
51
|
+
86_400
|
54
52
|
end
|
55
53
|
end
|
56
|
-
end
|
54
|
+
end
|