omniauth_openid_federation 1.3.0 → 2.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +120 -14
  4. data/app/controllers/omniauth_openid_federation/federation_controller.rb +3 -3
  5. data/config/polyrun_coverage.yml +15 -0
  6. data/config/polyrun_spec_quality.yml +29 -0
  7. data/config/routes.rb +20 -10
  8. data/examples/README_INTEGRATION_TESTING.md +3 -0
  9. data/examples/integration_test_flow.rb +10 -8
  10. data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
  11. data/examples/jobs/federation_files_generation_job.rb.example +3 -2
  12. data/examples/mock_op_server.rb +4 -5
  13. data/examples/mock_rp_server.rb +3 -3
  14. data/examples/standalone_multiple_endpoints_example.rb +494 -0
  15. data/lib/omniauth_openid_federation/access_token.rb +91 -450
  16. data/lib/omniauth_openid_federation/cache.rb +16 -0
  17. data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
  18. data/lib/omniauth_openid_federation/constants.rb +3 -0
  19. data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
  20. data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
  21. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
  22. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
  23. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +8 -91
  24. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
  25. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +54 -21
  26. data/lib/omniauth_openid_federation/federation_endpoint.rb +40 -172
  27. data/lib/omniauth_openid_federation/http_client.rb +145 -32
  28. data/lib/omniauth_openid_federation/http_errors.rb +14 -0
  29. data/lib/omniauth_openid_federation/id_token.rb +19 -0
  30. data/lib/omniauth_openid_federation/jwe.rb +26 -0
  31. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
  32. data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
  33. data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
  34. data/lib/omniauth_openid_federation/jws.rb +3 -11
  35. data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
  36. data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
  37. data/lib/omniauth_openid_federation/rack.rb +2 -0
  38. data/lib/omniauth_openid_federation/rack_endpoint.rb +21 -9
  39. data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
  40. data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
  41. data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
  42. data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
  43. data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
  44. data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
  45. data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
  46. data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
  47. data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
  48. data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
  49. data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
  50. data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
  51. data/lib/omniauth_openid_federation/strategy.rb +27 -1910
  52. data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
  53. data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
  54. data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
  55. data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
  56. data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
  57. data/lib/omniauth_openid_federation/tasks_helper.rb +28 -790
  58. data/lib/omniauth_openid_federation/time_helpers.rb +67 -0
  59. data/lib/omniauth_openid_federation/user_info.rb +15 -0
  60. data/lib/omniauth_openid_federation/utils.rb +14 -9
  61. data/lib/omniauth_openid_federation/validators.rb +55 -44
  62. data/lib/omniauth_openid_federation/version.rb +1 -1
  63. data/lib/omniauth_openid_federation.rb +10 -3
  64. data/lib/tasks/omniauth_openid_federation.rake +5 -5
  65. data/sig/omniauth_openid_federation.rbs +8 -1
  66. data/sig/strategy.rbs +6 -6
  67. metadata +252 -43
@@ -0,0 +1,290 @@
1
+ require "base64"
2
+ require "json"
3
+ require "jwt"
4
+ require "uri"
5
+
6
+ require_relative "string_helpers"
7
+ require_relative "logger"
8
+ require_relative "validators"
9
+ require_relative "utils"
10
+ require_relative "key_extractor"
11
+ require_relative "jwe"
12
+ require_relative "jwks/decode"
13
+ require_relative "federation/entity_statement"
14
+ require_relative "federation/entity_statement_helper"
15
+ require_relative "federation/signed_jwks"
16
+
17
+ module OmniauthOpenidFederation
18
+ # Decrypts and verifies compact JWE/JWS response bodies (nested JWTs).
19
+ class JwtResponseDecoder
20
+ COMPACT_TOKEN_PATTERN = /\A[\w\-.]+\z/
21
+
22
+ def initialize(strategy_options:, client: nil)
23
+ @strategy_options = strategy_options
24
+ @client = client
25
+ end
26
+
27
+ def decode(body)
28
+ body_text = body.to_s.strip
29
+ return {} if OmniauthOpenidFederation::StringHelpers.blank?(body_text)
30
+
31
+ unless compact_token?(body_text)
32
+ return OmniauthOpenidFederation::Utils.to_indifferent_hash(JSON.parse(body_text))
33
+ end
34
+
35
+ signed_jwt = resolve_signed_jwt(body_text)
36
+ return OmniauthOpenidFederation::Utils.to_indifferent_hash(signed_jwt) if signed_jwt.is_a?(Hash)
37
+
38
+ payload = verify_signed_jwt(signed_jwt)
39
+ OmniauthOpenidFederation::Utils.to_indifferent_hash(payload)
40
+ end
41
+
42
+ private
43
+
44
+ def compact_token?(body_text)
45
+ COMPACT_TOKEN_PATTERN.match?(body_text) && body_text.count(".") >= 2
46
+ end
47
+
48
+ def resolve_signed_jwt(body_text)
49
+ if OmniauthOpenidFederation::Jwe.encrypted?(body_text)
50
+ plain_text = OmniauthOpenidFederation::Jwe.decrypt(body_text, encryption_key)
51
+ return plain_text if plain_text.to_s.split(".").length == 3
52
+
53
+ begin
54
+ parsed = JSON.parse(plain_text)
55
+ return parsed if parsed.is_a?(Hash)
56
+ rescue JSON::ParserError
57
+ # fall through to JWT verification path
58
+ end
59
+
60
+ plain_text
61
+ else
62
+ body_text
63
+ end
64
+ end
65
+
66
+ def verify_signed_jwt(signed_jwt)
67
+ parts = signed_jwt.to_s.split(".")
68
+ raise ValidationError, "Signed response is not a valid JWT" if parts.empty?
69
+
70
+ header = JSON.parse(Base64.urlsafe_decode64(parts.first))
71
+ algorithm = header["alg"] || header[:alg]
72
+
73
+ if algorithm.nil? || algorithm.to_s.downcase == "none"
74
+ raise ValidationError, "JWT algorithm '#{algorithm}' is not permitted for signed responses"
75
+ end
76
+
77
+ unless parts.length == 3
78
+ raise ValidationError, "Signed response is not a valid JWT"
79
+ end
80
+
81
+ normalized_strategy_options = OmniauthOpenidFederation::Validators.normalize_hash(@strategy_options)
82
+ signed_jwks = fetch_signed_jwks(normalized_strategy_options)
83
+
84
+ if signed_jwks
85
+ payload, = ::JWT.decode(
86
+ signed_jwt,
87
+ nil,
88
+ true,
89
+ {algorithms: [algorithm], jwks: signed_jwks}
90
+ )
91
+ return payload
92
+ end
93
+
94
+ jwks_uri = resolve_jwks_uri(normalized_strategy_options)
95
+ unless jwks_uri
96
+ raise ConfigurationError,
97
+ "JWKS URI not available. Cannot verify JWT signature. Provide jwks_uri in client_options or entity statement."
98
+ end
99
+
100
+ entity_statement_keys = load_entity_statement_keys_for_jwks_validation(normalized_strategy_options)
101
+ payload, = OmniauthOpenidFederation::Jwks::Decode.jwt(
102
+ signed_jwt,
103
+ jwks_uri.to_s,
104
+ entity_statement_keys: entity_statement_keys
105
+ )
106
+ payload
107
+ rescue ConfigurationError, ValidationError, SignatureError
108
+ raise
109
+ rescue => error
110
+ raise ValidationError, "Failed to verify JWT response: #{error.class} - #{error.message}", error.backtrace
111
+ end
112
+
113
+ def encryption_key
114
+ raw_client_options = @strategy_options[:client_options] || @strategy_options["client_options"] || {}
115
+ client_options = OmniauthOpenidFederation::Validators.normalize_hash(raw_client_options)
116
+
117
+ private_key = client_options[:private_key] ||
118
+ ((@client.respond_to?(:private_key) ? @client.private_key : nil))
119
+ jwks = client_options[:jwks] || client_options["jwks"]
120
+
121
+ metadata = nil
122
+ entity_statement_path = @strategy_options[:entity_statement_path] || @strategy_options["entity_statement_path"]
123
+ if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
124
+ begin
125
+ validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
126
+ entity_statement_path,
127
+ allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
128
+ )
129
+ metadata = JSON.parse(File.read(validated_path)) if File.exist?(validated_path)
130
+ rescue => error
131
+ OmniauthOpenidFederation::Logger.debug("[JwtResponseDecoder] Could not load metadata for key extraction: #{error.message}")
132
+ end
133
+ end
134
+
135
+ key = OmniauthOpenidFederation::KeyExtractor.extract_encryption_key(
136
+ jwks: jwks,
137
+ metadata: metadata,
138
+ private_key: private_key
139
+ ) || private_key
140
+
141
+ OmniauthOpenidFederation::Validators.validate_private_key!(key)
142
+ key
143
+ end
144
+
145
+ def resolve_jwks_uri(strategy_options)
146
+ raw_client_options = strategy_options[:client_options] || strategy_options["client_options"] || {}
147
+ client_options = OmniauthOpenidFederation::Validators.normalize_hash(raw_client_options)
148
+ jwks_uri_value = client_options[:jwks_uri] ||
149
+ ((@client.respond_to?(:jwks_uri) ? @client.jwks_uri : nil))
150
+
151
+ if jwks_uri_value && %r{https?://.+}.match?(jwks_uri_value.to_s)
152
+ return URI.parse(jwks_uri_value.to_s)
153
+ end
154
+
155
+ if jwks_uri_value
156
+ return URI::HTTPS.build(
157
+ host: client_options[:host] || ((@client.respond_to?(:host) ? @client.host : nil)),
158
+ path: jwks_uri_value.to_s
159
+ )
160
+ end
161
+
162
+ remote_uri = resolve_jwks_uri_from_entity_statement(strategy_options)
163
+ remote_uri ? URI.parse(remote_uri.to_s) : nil
164
+ end
165
+
166
+ def fetch_signed_jwks(strategy_options)
167
+ entity_statement_content = load_entity_statement_content(strategy_options)
168
+ return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
169
+
170
+ parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
171
+ entity_statement_content
172
+ )
173
+ return nil if parsed.nil?
174
+
175
+ signed_jwks_uri = parsed[:signed_jwks_uri]
176
+ return nil if OmniauthOpenidFederation::StringHelpers.blank?(signed_jwks_uri)
177
+
178
+ OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(signed_jwks_uri, parsed[:entity_jwks])
179
+ rescue OmniauthOpenidFederation::SecurityError => error
180
+ OmniauthOpenidFederation::Logger.error("[JwtResponseDecoder] Security error: #{error.message}")
181
+ nil
182
+ rescue
183
+ OmniauthOpenidFederation::Logger.warn("[JwtResponseDecoder] Failed to fetch signed JWKS, falling back to standard JWKS")
184
+ nil
185
+ end
186
+
187
+ def load_entity_statement_keys_for_jwks_validation(strategy_options)
188
+ entity_statement_content = load_entity_statement_content(strategy_options)
189
+ return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
190
+
191
+ entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
192
+ parsed = entity_statement.parse
193
+ entity_jwks = parsed[:jwks] if parsed
194
+
195
+ keys = if entity_jwks.is_a?(Hash) && entity_jwks.key?("keys")
196
+ entity_jwks["keys"]
197
+ elsif entity_jwks.is_a?(Hash) && entity_jwks.key?(:keys)
198
+ entity_jwks[:keys]
199
+ elsif entity_jwks.is_a?(Array)
200
+ entity_jwks
201
+ else
202
+ []
203
+ end
204
+ if keys.empty?
205
+ OmniauthOpenidFederation::Logger.warn("[JwtResponseDecoder] No keys found in entity statement")
206
+ return nil
207
+ end
208
+
209
+ OmniauthOpenidFederation::Utils.to_indifferent_hash(
210
+ keys: keys.map { |jwk| jwk.is_a?(Hash) ? jwk : JSON.parse(jwk.to_json) }
211
+ )
212
+ rescue => error
213
+ OmniauthOpenidFederation::Logger.error(
214
+ "[JwtResponseDecoder] Failed to load entity statement keys for JWKS validation: #{error.message}"
215
+ )
216
+ nil
217
+ end
218
+
219
+ def resolve_jwks_uri_from_entity_statement(strategy_options)
220
+ entity_statement_content = load_entity_statement_content(strategy_options, log_fetch_errors: :debug)
221
+ return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
222
+
223
+ entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
224
+ parsed = entity_statement.parse
225
+ return nil unless parsed
226
+
227
+ jwks_uri = parsed.dig(:metadata, :openid_provider, :jwks_uri) ||
228
+ parsed.dig("metadata", "openid_provider", "jwks_uri")
229
+ OmniauthOpenidFederation::StringHelpers.present?(jwks_uri) ? jwks_uri : nil
230
+ rescue => error
231
+ OmniauthOpenidFederation::Logger.debug(
232
+ "[JwtResponseDecoder] Could not extract JWKS URI from entity statement: #{error.message}"
233
+ )
234
+ nil
235
+ end
236
+
237
+ def load_entity_statement_content(strategy_options, log_fetch_errors: :warn)
238
+ normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(strategy_options)
239
+ entity_statement_path = normalized_options[:entity_statement_path]
240
+ entity_statement_url = normalized_options[:entity_statement_url]
241
+ issuer = normalized_options[:issuer]
242
+ entity_statement_fingerprint = normalized_options[:entity_statement_fingerprint]
243
+
244
+ if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
245
+ begin
246
+ validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
247
+ entity_statement_path,
248
+ allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
249
+ )
250
+ return File.read(validated_path) if File.exist?(validated_path)
251
+ rescue OmniauthOpenidFederation::SecurityError => error
252
+ OmniauthOpenidFederation::Logger.error("[JwtResponseDecoder] #{error.message}")
253
+ end
254
+ end
255
+
256
+ if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
257
+ begin
258
+ return OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
259
+ entity_statement_url,
260
+ fingerprint: entity_statement_fingerprint
261
+ ).entity_statement
262
+ rescue => error
263
+ log_entity_statement_fetch_failure(log_fetch_errors, "URL", error)
264
+ end
265
+ end
266
+
267
+ if OmniauthOpenidFederation::StringHelpers.present?(issuer)
268
+ begin
269
+ return OmniauthOpenidFederation::Federation::EntityStatement.fetch_from_issuer!(
270
+ issuer,
271
+ fingerprint: entity_statement_fingerprint
272
+ ).entity_statement
273
+ rescue => error
274
+ log_entity_statement_fetch_failure(log_fetch_errors, "issuer", error)
275
+ end
276
+ end
277
+
278
+ nil
279
+ end
280
+
281
+ def log_entity_statement_fetch_failure(level, source, error)
282
+ message = "[JwtResponseDecoder] Failed to fetch entity statement from #{source}: #{error.message}"
283
+ if level == :debug
284
+ OmniauthOpenidFederation::Logger.debug(message)
285
+ else
286
+ OmniauthOpenidFederation::Logger.warn(message)
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,101 @@
1
+ require "oauth2"
2
+ require "jwt"
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ require_relative "errors"
7
+ require_relative "access_token"
8
+
9
+ module OmniauthOpenidFederation
10
+ class OidcClient < OAuth2::Client
11
+ CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
12
+ CLIENT_ASSERTION_EXPIRATION_SECONDS = 180
13
+
14
+ attr_accessor :private_key, :authorization_code, :jwks_uri
15
+ attr_reader :authorization_endpoint, :token_endpoint, :userinfo_endpoint
16
+
17
+ def initialize(identifier:, redirect_uri:, authorization_endpoint:, token_endpoint:, secret: nil,
18
+ userinfo_endpoint: nil, jwks_uri: nil)
19
+ token_uri = URI.parse(token_endpoint.to_s)
20
+ site = build_site(token_uri)
21
+
22
+ super(
23
+ identifier,
24
+ secret,
25
+ site: site,
26
+ authorize_url: authorization_endpoint,
27
+ token_url: token_endpoint,
28
+ redirect_uri: redirect_uri,
29
+ auth_scheme: :private_key_jwt
30
+ )
31
+
32
+ @authorization_endpoint = authorization_endpoint
33
+ @token_endpoint = token_endpoint
34
+ @userinfo_endpoint = userinfo_endpoint
35
+ @jwks_uri = jwks_uri
36
+ @authorization_code = nil
37
+ end
38
+
39
+ def identifier
40
+ id
41
+ end
42
+
43
+ def identifier=(value)
44
+ @id = value
45
+ end
46
+
47
+ def redirect_uri
48
+ options[:redirect_uri]
49
+ end
50
+
51
+ def redirect_uri=(value)
52
+ options[:redirect_uri] = value
53
+ end
54
+
55
+ def host
56
+ URI.parse(site.to_s).host
57
+ end
58
+
59
+ def access_token!(client_auth_method = :jwt_bearer)
60
+ unless client_auth_method == :jwt_bearer
61
+ raise ConfigurationError, "Unsupported client_auth_method: #{client_auth_method}"
62
+ end
63
+
64
+ OmniauthOpenidFederation::Validators.validate_private_key!(private_key)
65
+
66
+ oauth_token = auth_code.get_token(
67
+ authorization_code,
68
+ redirect_uri: redirect_uri,
69
+ client_assertion_type: CLIENT_ASSERTION_TYPE,
70
+ client_assertion: build_client_assertion
71
+ )
72
+
73
+ OmniauthOpenidFederation::AccessToken.from_oauth2_token(
74
+ oauth_token,
75
+ strategy_options: instance_variable_get(:@strategy_options)
76
+ )
77
+ end
78
+
79
+ private
80
+
81
+ def build_site(token_uri)
82
+ site = "#{token_uri.scheme}://#{token_uri.host}"
83
+ site += ":#{token_uri.port}" if token_uri.port && !token_uri.default_port
84
+ site
85
+ end
86
+
87
+ def build_client_assertion
88
+ now = Time.now.to_i
89
+ payload = {
90
+ iss: identifier,
91
+ sub: identifier,
92
+ aud: token_endpoint.to_s,
93
+ jti: SecureRandom.hex(16),
94
+ iat: now,
95
+ exp: now + CLIENT_ASSERTION_EXPIRATION_SECONDS
96
+ }
97
+
98
+ JWT.encode(payload, private_key, "RS256")
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,2 @@
1
+ require "rack"
2
+ require_relative "rack_endpoint"
@@ -88,7 +88,7 @@ module OmniauthOpenidFederation
88
88
  jwks_to_serve = OmniauthOpenidFederation::FederationEndpoint.current_jwks
89
89
 
90
90
  # Apply server-side caching if available
91
- cache_key = "federation:jwks:#{Digest::SHA256.hexdigest(jwks_to_serve.to_json)}"
91
+ cache_key = OmniauthOpenidFederation::Cache.key_for_served_jwks(config.issuer)
92
92
  cache_ttl = config.jwks_cache_ttl || 3600
93
93
 
94
94
  jwks_json = if CacheAdapter.available?
@@ -116,7 +116,7 @@ module OmniauthOpenidFederation
116
116
  subject_entity_id = request.params["sub"]
117
117
 
118
118
  unless subject_entity_id
119
- return error_response(400, {error: "invalid_request", error_description: "Missing required parameter: sub"}.to_json)
119
+ return error_response(400, {error: "invalid_request", error_description: "Missing required parameter: sub"})
120
120
  end
121
121
 
122
122
  # Security: Validate entity identifier per OpenID Federation 1.0 spec
@@ -125,22 +125,22 @@ module OmniauthOpenidFederation
125
125
  # Validate and get trimmed value
126
126
  subject_entity_id = OmniauthOpenidFederation::Validators.validate_entity_identifier!(subject_entity_id)
127
127
  rescue SecurityError => e
128
- return error_response(400, {error: "invalid_request", error_description: "Invalid subject entity ID: #{e.message}"}.to_json)
128
+ return error_response(400, {error: "invalid_request", error_description: "Invalid subject entity ID: #{e.message}"})
129
129
  rescue => e
130
- return error_response(400, {error: "invalid_request", error_description: "Subject entity ID validation failed: #{e.message}"}.to_json)
130
+ return error_response(400, {error: "invalid_request", error_description: "Subject entity ID validation failed: #{e.message}"})
131
131
  end
132
132
 
133
133
  # Validate that subject is not the issuer (invalid request per spec)
134
134
  config = OmniauthOpenidFederation::FederationEndpoint.configuration
135
135
  if subject_entity_id == config.issuer
136
- return error_response(400, {error: "invalid_request", error_description: "Subject cannot be the issuer"}.to_json)
136
+ return error_response(400, {error: "invalid_request", error_description: "Subject cannot be the issuer"})
137
137
  end
138
138
 
139
139
  # Get Subordinate Statement
140
140
  subordinate_statement = OmniauthOpenidFederation::FederationEndpoint.get_subordinate_statement(subject_entity_id)
141
141
 
142
142
  unless subordinate_statement
143
- return error_response(404, {error: "not_found", error_description: "Subordinate Statement not found for subject: #{subject_entity_id}"}.to_json)
143
+ return error_response(404, {error: "not_found", error_description: "Subordinate Statement not found for subject: #{subject_entity_id}"})
144
144
  end
145
145
 
146
146
  headers = {
@@ -159,7 +159,7 @@ module OmniauthOpenidFederation
159
159
  signed_jwks_jwt = OmniauthOpenidFederation::FederationEndpoint.generate_signed_jwks
160
160
 
161
161
  # Apply server-side caching if available
162
- cache_key = "federation:signed_jwks:#{Digest::SHA256.hexdigest(signed_jwks_jwt)}"
162
+ cache_key = OmniauthOpenidFederation::Cache.key_for_served_signed_jwks(config.issuer)
163
163
  cache_ttl = config.jwks_cache_ttl || 3600
164
164
 
165
165
  cached_jwt = if CacheAdapter.available?
@@ -188,11 +188,23 @@ module OmniauthOpenidFederation
188
188
  # Return error response
189
189
  #
190
190
  # @param status [Integer] HTTP status code
191
- # @param message [String] Error message
191
+ # @param message [String, Hash] Error message (string) or error hash (will be converted to JSON)
192
192
  # @return [Array] Rack response
193
193
  def error_response(status, message)
194
194
  content_type = (status == 503) ? "text/plain" : "application/json"
195
- body = (status == 503) ? message : {error: message}.to_json
195
+ body = if status == 503
196
+ message
197
+ elsif message.is_a?(Hash)
198
+ message.to_json
199
+ else
200
+ # If message is already JSON string, parse and re-encode to ensure proper format
201
+ begin
202
+ parsed = JSON.parse(message)
203
+ parsed.to_json
204
+ rescue JSON::ParserError
205
+ {error: message}.to_json
206
+ end
207
+ end
196
208
 
197
209
  [status, {"Content-Type" => content_type}, [body]]
198
210
  end
@@ -1,4 +1,5 @@
1
1
  require "digest"
2
+ require_relative "cache_adapter"
2
3
  require_relative "utils"
3
4
  require_relative "logger"
4
5
 
@@ -11,34 +12,30 @@ module OmniauthOpenidFederation
11
12
 
12
13
  # Check if request should be throttled
13
14
  #
15
+ # Best-effort only: read-modify-write on the cache adapter is not atomic, so parallel
16
+ # workers can briefly exceed the limit. Treat this as a DoS soft guard, not a hard quota.
17
+ #
14
18
  # @param key [String] Unique identifier for the rate limit (e.g., jwks_uri)
15
19
  # @param max_requests [Integer] Maximum requests allowed in window (default: 10)
16
20
  # @param window [Integer] Time window in seconds (default: 60)
17
21
  # @return [Boolean] true if request should be allowed, false if throttled
18
22
  def self.allow?(key, max_requests: DEFAULT_MAX_REQUESTS, window: DEFAULT_WINDOW_SECONDS)
19
- return true unless defined?(Rails) && Rails.cache
23
+ return true unless CacheAdapter.available?
20
24
 
21
25
  cache_key = "omniauth_openid_federation:rate_limit:#{Digest::SHA256.hexdigest(key)}"
22
26
  current_time = Time.now.to_i
23
27
  window_start = current_time - window
24
28
 
25
- # Get existing request timestamps
26
- timestamps = Rails.cache.read(cache_key) || []
27
-
28
- # Filter out timestamps outside the current window
29
+ timestamps = CacheAdapter.read(cache_key) || []
29
30
  timestamps = timestamps.select { |ts| ts > window_start }
30
31
 
31
- # Check if we've exceeded the limit
32
32
  if timestamps.length >= max_requests
33
33
  OmniauthOpenidFederation::Logger.warn("[RateLimiter] Rate limit exceeded for #{Utils.sanitize_uri(key)}: #{timestamps.length}/#{max_requests} requests in #{window}s")
34
34
  return false
35
35
  end
36
36
 
37
- # Add current request timestamp
38
37
  timestamps << current_time
39
-
40
- # Store updated timestamps (expires after window)
41
- Rails.cache.write(cache_key, timestamps, expires_in: window)
38
+ CacheAdapter.write(cache_key, timestamps, expires_in: window)
42
39
 
43
40
  true
44
41
  end
@@ -47,9 +44,10 @@ module OmniauthOpenidFederation
47
44
  #
48
45
  # @param key [String] Unique identifier for the rate limit
49
46
  def self.reset!(key)
50
- return unless defined?(Rails) && Rails.cache
47
+ return unless CacheAdapter.available?
48
+
51
49
  cache_key = "omniauth_openid_federation:rate_limit:#{Digest::SHA256.hexdigest(key)}"
52
- Rails.cache.delete(cache_key)
50
+ CacheAdapter.delete(cache_key)
53
51
  end
54
52
  end
55
53
  end
@@ -0,0 +1,15 @@
1
+ require "openssl"
2
+
3
+ module OmniauthOpenidFederation
4
+ module SecureCompare
5
+ module_function
6
+
7
+ def secure_compare(left, right)
8
+ left = left.to_s
9
+ right = right.to_s
10
+ return false unless left.bytesize == right.bytesize
11
+
12
+ OpenSSL.fixed_length_secure_compare(left, right)
13
+ end
14
+ end
15
+ end