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.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module JwtAuthCognito
6
+ class ApiKeyValidator
7
+ def initialize(config)
8
+ @config = config
9
+ @redis_service = RedisService.new(config)
10
+ end
11
+
12
+ def validate_api_key(api_key)
13
+ # Validate basic format (64 hex characters)
14
+ return { valid: false, error: 'Invalid API key format' } unless api_key&.match?(/\A[a-fA-F0-9]{64}\z/)
15
+
16
+ begin
17
+ key_data = @redis_service.get("api-keys:#{api_key}")
18
+ return { valid: false, error: 'API key not found' } unless key_data
19
+
20
+ parsed = JSON.parse(key_data)
21
+
22
+ # Verify it's active
23
+ return { valid: false, error: 'API key is inactive' } unless parsed['isActive']
24
+
25
+ # Update last used (fire and forget for performance)
26
+ update_last_used(api_key, parsed)
27
+
28
+ {
29
+ valid: true,
30
+ key_data: {
31
+ name: parsed['name'],
32
+ permissions: parsed['permissions'],
33
+ app_id: parsed['appId'],
34
+ scope: parsed['scope'],
35
+ created_at: parsed['createdAt'],
36
+ last_used: parsed['lastUsed'],
37
+ is_active: parsed['isActive'],
38
+ metadata: parsed['metadata'] || {}
39
+ }
40
+ }
41
+ rescue StandardError => e
42
+ puts "Error validating API key: #{e.message}"
43
+ { valid: false, error: 'API key validation failed' }
44
+ end
45
+ end
46
+
47
+ def has_permission?(key_data, permission)
48
+ key_data[:permissions]&.include?(permission) || false
49
+ end
50
+
51
+ def system_api_key?(key_data)
52
+ key_data[:scope] == 'system'
53
+ end
54
+
55
+ def client_api_key?(key_data)
56
+ key_data[:scope] == 'client'
57
+ end
58
+
59
+ def can_access_app?(key_data, app_id)
60
+ # System API keys can access any app
61
+ return true if key_data[:scope] == 'system'
62
+
63
+ # App API keys can only access their specific app
64
+ context_app_id = key_data[:app_id] || key_data[:metadata]&.dig('appId')
65
+ context_app_id == app_id
66
+ end
67
+
68
+ private
69
+
70
+ def update_last_used(api_key, key_data)
71
+ Thread.new do
72
+ key_data['lastUsed'] = (Time.now.to_f * 1000).to_i
73
+ @redis_service.set("api-keys:#{api_key}", key_data.to_json)
74
+ rescue StandardError => e
75
+ puts "Error updating last used timestamp: #{e.message}"
76
+ end
77
+ end
78
+ end
79
+ end
@@ -7,34 +7,40 @@ module JwtAuthCognito
7
7
  :redis_ssl, :redis_timeout, :redis_connect_timeout, :redis_read_timeout,
8
8
  :redis_ca_cert_path, :redis_ca_cert_name, :redis_verify_mode,
9
9
  :redis_tls_min_version, :redis_tls_max_version,
10
- :jwks_cache_ttl, :validation_mode, :environment
10
+ :redis_ca_cert_ssm_path, :redis_ca_cert_ssm_name,
11
+ :jwks_cache_ttl, :validation_mode, :environment,
12
+ :enable_api_key_validation, :enable_user_data_retrieval
11
13
 
12
14
  def initialize
13
- @cognito_region = ENV['COGNITO_REGION'] || ENV['AWS_REGION'] || "us-east-1"
14
- @cognito_user_pool_id = ENV['COGNITO_USER_POOL_ID']
15
- @cognito_client_id = ENV['COGNITO_CLIENT_ID']
16
- @cognito_client_secret = ENV['COGNITO_CLIENT_SECRET']
17
-
15
+ @cognito_region = ENV['COGNITO_REGION'] || ENV['AWS_REGION'] || 'us-east-1'
16
+ @cognito_user_pool_id = ENV.fetch('COGNITO_USER_POOL_ID', nil)
17
+ @cognito_client_id = ENV.fetch('COGNITO_CLIENT_ID', nil)
18
+ @cognito_client_secret = ENV.fetch('COGNITO_CLIENT_SECRET', nil)
19
+
18
20
  # Redis configuration with environment variables
19
- @redis_host = ENV['REDIS_HOST'] || "localhost"
21
+ @redis_host = ENV['REDIS_HOST'] || 'localhost'
20
22
  @redis_port = (ENV['REDIS_PORT'] || 6379).to_i
21
- @redis_password = ENV['REDIS_PASSWORD']
23
+ @redis_password = ENV.fetch('REDIS_PASSWORD', nil)
22
24
  @redis_db = (ENV['REDIS_DB'] || 0).to_i
23
25
  @redis_ssl = ENV['REDIS_TLS'] == 'true' || ENV['REDIS_SSL'] == 'true'
24
26
  @redis_timeout = (ENV['REDIS_TIMEOUT'] || 5).to_i
25
27
  @redis_connect_timeout = (ENV['REDIS_CONNECT_TIMEOUT'] || 10).to_i
26
28
  @redis_read_timeout = (ENV['REDIS_READ_TIMEOUT'] || 10).to_i
27
-
29
+
28
30
  # TLS specific configuration
29
- @redis_ca_cert_path = ENV['REDIS_CA_CERT_PATH']
30
- @redis_ca_cert_name = ENV['REDIS_CA_CERT_NAME']
31
+ @redis_ca_cert_path = ENV.fetch('REDIS_CA_CERT_PATH', nil)
32
+ @redis_ca_cert_name = ENV.fetch('REDIS_CA_CERT_NAME', nil)
33
+ @redis_ca_cert_ssm_path = ENV.fetch('REDIS_CA_CERT_SSM_PATH', nil)
34
+ @redis_ca_cert_ssm_name = ENV.fetch('REDIS_CA_CERT_SSM_NAME', nil)
31
35
  @redis_verify_mode = ENV['REDIS_VERIFY_MODE'] || 'peer'
32
36
  @redis_tls_min_version = ENV['REDIS_TLS_MIN_VERSION'] || 'TLSv1.2'
33
37
  @redis_tls_max_version = ENV['REDIS_TLS_MAX_VERSION'] || 'TLSv1.3'
34
-
38
+
35
39
  @jwks_cache_ttl = (ENV['JWKS_CACHE_TTL'] || 3600).to_i # 1 hour
36
40
  @environment = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || ENV['NODE_ENV'] || 'development'
37
41
  @validation_mode = production? ? :secure : :basic
42
+ @enable_api_key_validation = ENV['ENABLE_API_KEY_VALIDATION'] == 'true'
43
+ @enable_user_data_retrieval = ENV['ENABLE_USER_DATA_RETRIEVAL'] == 'true'
38
44
  end
39
45
 
40
46
  def production?
@@ -54,9 +60,9 @@ module JwtAuthCognito
54
60
  end
55
61
 
56
62
  def validate!
57
- raise ConfigurationError, "cognito_user_pool_id is required" unless cognito_user_pool_id
58
- raise ConfigurationError, "cognito_region is required" unless cognito_region
59
- raise ConfigurationError, "redis_host is required" unless redis_host
63
+ raise ConfigurationError, 'cognito_user_pool_id is required' unless cognito_user_pool_id
64
+ raise ConfigurationError, 'cognito_region is required' unless cognito_region
65
+ raise ConfigurationError, 'redis_host is required' unless redis_host
60
66
  end
61
67
 
62
68
  def has_client_secret?
@@ -64,20 +70,31 @@ module JwtAuthCognito
64
70
  end
65
71
 
66
72
  def calculate_secret_hash(identifier)
67
- return "" unless has_client_secret?
68
- return "" unless cognito_client_id
73
+ return '' unless has_client_secret?
74
+ return '' unless cognito_client_id
69
75
 
70
76
  message = identifier + cognito_client_id
71
-
77
+
72
78
  require 'openssl'
73
79
  require 'base64'
74
-
80
+
75
81
  begin
76
82
  hmac = OpenSSL::HMAC.digest('SHA256', cognito_client_secret, message)
77
83
  Base64.encode64(hmac).strip
78
- rescue => e
84
+ rescue StandardError => e
79
85
  raise ConfigurationError, "Error calculating secret hash: #{e.message}"
80
86
  end
81
87
  end
88
+
89
+ def user_data_config
90
+ {
91
+ enable_user_data_retrieval: enable_user_data_retrieval,
92
+ include_applications: ENV['INCLUDE_APPLICATIONS'] != 'false',
93
+ include_organizations: ENV['INCLUDE_ORGANIZATIONS'] != 'false',
94
+ include_roles: ENV['INCLUDE_ROLES'] != 'false',
95
+ include_effective_permissions: ENV['INCLUDE_EFFECTIVE_PERMISSIONS'] == 'true',
96
+ cache_timeout: (ENV['USER_DATA_CACHE_TIMEOUT'] || 300).to_i
97
+ }
98
+ end
82
99
  end
83
- end
100
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JwtAuthCognito
4
+ module ErrorUtils
5
+ JWT_ERROR_MESSAGES = {
6
+ 'TOKEN_EXPIRED' => 'Token has expired',
7
+ 'INVALID_TOKEN' => 'Invalid token format',
8
+ 'TOKEN_NOT_ACTIVE' => 'Token not active yet',
9
+ 'INVALID_SIGNATURE' => 'Invalid token signature',
10
+ 'SIGNATURE_VERIFICATION_FAILED' => 'Token signature verification failed',
11
+ 'INVALID_AUDIENCE' => 'Invalid token audience',
12
+ 'INVALID_ISSUER' => 'Invalid token issuer',
13
+ 'VALIDATION_TIMEOUT' => 'Token validation timeout',
14
+ 'TOKEN_REVOKED' => 'Token has been revoked',
15
+ 'API_KEY_INVALID' => 'Invalid API key',
16
+ 'API_KEY_EXPIRED' => 'API key has expired',
17
+ 'INITIALIZATION_FAILED' => 'Service initialization failed',
18
+ 'REDIS_CONNECTION_FAILED' => 'Redis connection failed',
19
+ 'USER_DATA_RETRIEVAL_FAILED' => 'User data retrieval failed'
20
+ }.freeze
21
+
22
+ def self.extract_error_details(error, context = nil)
23
+ message = 'Unknown error occurred'
24
+ code = nil
25
+
26
+ case error
27
+ when JwtAuthCognito::TokenExpiredError
28
+ message = 'Token has expired'
29
+ code = 'TOKEN_EXPIRED'
30
+ when JwtAuthCognito::TokenNotActiveError
31
+ message = 'Token not active yet'
32
+ code = 'TOKEN_NOT_ACTIVE'
33
+ when JwtAuthCognito::TokenFormatError
34
+ message = 'Invalid token format'
35
+ code = 'INVALID_TOKEN'
36
+ when JwtAuthCognito::TokenRevokedError
37
+ message = 'Token has been revoked'
38
+ code = 'TOKEN_REVOKED'
39
+ when JwtAuthCognito::JWKSError
40
+ message = 'Invalid token signature'
41
+ code = 'INVALID_SIGNATURE'
42
+ when JwtAuthCognito::RedisConnectionError
43
+ message = 'Redis connection failed'
44
+ code = 'REDIS_CONNECTION_FAILED'
45
+ when StandardError
46
+ message = error.message
47
+
48
+ # Check message content for specific error patterns
49
+ case message
50
+ when /expired/i
51
+ message = 'Token has expired'
52
+ code = 'TOKEN_EXPIRED'
53
+ when /invalid.*signature/i, /signature.*verification.*failed/i
54
+ message = 'Invalid token signature'
55
+ code = 'INVALID_SIGNATURE'
56
+ when /invalid.*audience/i, /aud/
57
+ message = 'Invalid token audience'
58
+ code = 'INVALID_AUDIENCE'
59
+ when /invalid.*issuer/i, /iss/
60
+ message = 'Invalid token issuer'
61
+ code = 'INVALID_ISSUER'
62
+ when /not.*active/i, /nbf/
63
+ message = 'Token not active yet'
64
+ code = 'TOKEN_NOT_ACTIVE'
65
+ when /invalid.*format/i, /malformed/i
66
+ message = 'Invalid token format'
67
+ code = 'INVALID_TOKEN'
68
+ end
69
+ when String
70
+ message = error
71
+ else
72
+ message = error.to_s
73
+ end
74
+
75
+ {
76
+ message: message,
77
+ code: code,
78
+ context: context
79
+ }.compact
80
+ end
81
+
82
+ def self.log_error(error, context = nil)
83
+ details = extract_error_details(error, context)
84
+ log_message = if details[:context]
85
+ "#{details[:context]}: #{details[:message]}"
86
+ else
87
+ details[:message]
88
+ end
89
+
90
+ log_message += " (#{details[:code]})" if details[:code]
91
+
92
+ puts "ERROR: #{log_message}"
93
+ end
94
+
95
+ def self.get_user_friendly_error_message(error)
96
+ details = extract_error_details(error)
97
+ details[:message]
98
+ end
99
+
100
+ def self.format_validation_error(error, context = nil)
101
+ details = extract_error_details(error, context)
102
+
103
+ {
104
+ valid: false,
105
+ error: details[:message],
106
+ error_code: details[:code]
107
+ }.compact
108
+ end
109
+ end
110
+ end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "json"
5
- require "jwt"
6
- require "openssl"
7
- require "base64"
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'jwt'
6
+ require 'openssl'
7
+ require 'base64'
8
8
 
9
9
  module JwtAuthCognito
10
10
  class JwksService
@@ -16,35 +16,35 @@ module JwtAuthCognito
16
16
 
17
17
  def validate_token_with_jwks(token)
18
18
  @config.validate!
19
-
19
+
20
20
  header = JWT.decode(token, nil, false).last
21
- kid = header["kid"]
22
-
23
- raise ValidationError, "Token missing key ID (kid)" unless kid
24
-
21
+ kid = header['kid']
22
+
23
+ raise ValidationError, 'Token missing key ID (kid)' unless kid
24
+
25
25
  public_key = get_public_key(kid)
26
26
  decoded_token = JWT.decode(
27
27
  token,
28
28
  public_key,
29
29
  true,
30
30
  {
31
- algorithm: "RS256",
31
+ algorithm: 'RS256',
32
32
  iss: @config.cognito_issuer,
33
33
  verify_iss: true,
34
34
  aud: @config.cognito_client_id,
35
35
  verify_aud: @config.cognito_client_id ? true : false
36
36
  }
37
37
  )
38
-
38
+
39
39
  payload = decoded_token.first
40
40
  validate_token_claims(payload)
41
-
41
+
42
42
  {
43
43
  valid: true,
44
44
  payload: payload,
45
- sub: payload["sub"],
46
- username: payload["cognito:username"] || payload["username"],
47
- token_use: payload["token_use"]
45
+ sub: payload['sub'],
46
+ username: payload['cognito:username'] || payload['username'],
47
+ token_use: payload['token_use']
48
48
  }
49
49
  rescue JWT::DecodeError => e
50
50
  { valid: false, error: "JWT decode error: #{e.message}" }
@@ -58,35 +58,33 @@ module JwtAuthCognito
58
58
 
59
59
  def get_public_key(kid)
60
60
  # Check cache first
61
- if @cache[kid] && cache_valid?(kid)
62
- return @cache[kid]
63
- end
61
+ return @cache[kid] if @cache[kid] && cache_valid?(kid)
64
62
 
65
63
  # Fetch JWKS
66
64
  jwks = fetch_jwks
67
- key_data = jwks["keys"].find { |key| key["kid"] == kid }
68
-
69
- raise ValidationError, "Key ID not found in JWKS" unless key_data
70
-
65
+ key_data = jwks['keys'].find { |key| key['kid'] == kid }
66
+
67
+ raise ValidationError, 'Key ID not found in JWKS' unless key_data
68
+
71
69
  # Convert JWK to PEM
72
70
  public_key = jwk_to_pem(key_data)
73
-
71
+
74
72
  # Cache the key
75
73
  @cache[kid] = public_key
76
74
  @cache_timestamps[kid] = Time.now
77
-
75
+
78
76
  public_key
79
77
  end
80
78
 
81
79
  def fetch_jwks
82
80
  uri = URI(@config.jwks_url)
83
-
84
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 10) do |http|
81
+
82
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 10, read_timeout: 10) do |http|
85
83
  request = Net::HTTP::Get.new(uri)
86
84
  response = http.request(request)
87
-
88
- raise ValidationError, "Failed to fetch JWKS: #{response.code}" unless response.code == "200"
89
-
85
+
86
+ raise ValidationError, "Failed to fetch JWKS: #{response.code}" unless response.code == '200'
87
+
90
88
  JSON.parse(response.body)
91
89
  end
92
90
  rescue JSON::ParserError => e
@@ -97,45 +95,43 @@ module JwtAuthCognito
97
95
 
98
96
  def jwk_to_pem(key_data)
99
97
  # Convert JWK RSA key to PEM format
100
- n = base64url_decode(key_data["n"])
101
- e = base64url_decode(key_data["e"])
102
-
98
+ n = base64url_decode(key_data['n'])
99
+ e = base64url_decode(key_data['e'])
100
+
103
101
  key = OpenSSL::PKey::RSA.new
104
102
  key.n = OpenSSL::BN.new(n, 2)
105
103
  key.e = OpenSSL::BN.new(e, 2)
106
-
104
+
107
105
  key
108
106
  end
109
107
 
110
108
  def base64url_decode(str)
111
- str += "=" * (4 - str.length.modulo(4))
112
- Base64.decode64(str.tr("-_", "+/"))
109
+ str += '=' * (4 - str.length.modulo(4))
110
+ Base64.decode64(str.tr('-_', '+/'))
113
111
  end
114
112
 
115
113
  def cache_valid?(kid)
116
114
  return false unless @cache_timestamps[kid]
117
-
115
+
118
116
  Time.now - @cache_timestamps[kid] < @config.jwks_cache_ttl
119
117
  end
120
118
 
121
119
  def validate_token_claims(payload)
122
120
  now = Time.now.to_i
123
-
121
+
124
122
  # Check expiration
125
- raise ValidationError, "Token has expired" if payload["exp"] && payload["exp"] < now
126
-
123
+ raise ValidationError, 'Token has expired' if payload['exp'] && payload['exp'] < now
124
+
127
125
  # Check not before
128
- raise ValidationError, "Token not yet valid" if payload["nbf"] && payload["nbf"] > now
129
-
126
+ raise ValidationError, 'Token not yet valid' if payload['nbf'] && payload['nbf'] > now
127
+
130
128
  # Check issued at (allow some clock skew)
131
- if payload["iat"] && payload["iat"] > now + 300
132
- raise ValidationError, "Token issued in the future"
133
- end
134
-
129
+ raise ValidationError, 'Token issued in the future' if payload['iat'] && payload['iat'] > now + 300
130
+
135
131
  # Check token use
136
- unless %w[access id].include?(payload["token_use"])
137
- raise ValidationError, "Invalid token_use claim"
138
- end
132
+ return if %w[access id].include?(payload['token_use'])
133
+
134
+ raise ValidationError, 'Invalid token_use claim'
139
135
  end
140
136
  end
141
- end
137
+ end