omniauth_openid_federation 1.3.2 → 2.1.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/README.md +136 -15
  4. data/SECURITY.md +11 -14
  5. data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
  6. data/config/polyrun_coverage.yml +15 -0
  7. data/config/polyrun_spec_quality.yml +29 -0
  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 +42 -4
  17. data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
  18. data/lib/omniauth_openid_federation/constants.rb +6 -0
  19. data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
  20. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +33 -21
  21. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +4 -2
  22. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +85 -20
  23. data/lib/omniauth_openid_federation/federation/trust_chain_signature.rb +62 -0
  24. data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
  25. data/lib/omniauth_openid_federation/http_client.rb +182 -32
  26. data/lib/omniauth_openid_federation/http_errors.rb +14 -0
  27. data/lib/omniauth_openid_federation/id_token.rb +19 -0
  28. data/lib/omniauth_openid_federation/jwe.rb +26 -0
  29. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
  30. data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
  31. data/lib/omniauth_openid_federation/jws.rb +2 -11
  32. data/lib/omniauth_openid_federation/jwt_response_decoder.rb +288 -0
  33. data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
  34. data/lib/omniauth_openid_federation/rack.rb +2 -0
  35. data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
  36. data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
  37. data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
  38. data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
  39. data/lib/omniauth_openid_federation/strategy/callback_handling.rb +133 -0
  40. data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
  41. data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +216 -0
  42. data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +432 -0
  43. data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
  44. data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
  45. data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +262 -0
  46. data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +135 -0
  47. data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +93 -0
  48. data/lib/omniauth_openid_federation/strategy.rb +30 -1911
  49. data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
  50. data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
  51. data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
  52. data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
  53. data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
  54. data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
  55. data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
  56. data/lib/omniauth_openid_federation/user_info.rb +15 -0
  57. data/lib/omniauth_openid_federation/utils.rb +11 -3
  58. data/lib/omniauth_openid_federation/validators.rb +43 -8
  59. data/lib/omniauth_openid_federation/version.rb +1 -1
  60. data/lib/omniauth_openid_federation.rb +9 -3
  61. data/lib/tasks/omniauth_openid_federation.rake +1 -2
  62. data/sig/omniauth_openid_federation.rbs +2 -1
  63. data/sig/strategy.rbs +6 -6
  64. metadata +188 -63
@@ -0,0 +1,26 @@
1
+ require "jwe"
2
+
3
+ require_relative "errors"
4
+
5
+ module OmniauthOpenidFederation
6
+ # Compact-serialization JWE helpers for nested JWTs (sign then encrypt).
7
+ module Jwe
8
+ PARTS_COUNT = 5
9
+
10
+ def self.encrypted?(token)
11
+ token.to_s.count(".") + 1 == PARTS_COUNT
12
+ end
13
+
14
+ def self.encrypt(plaintext, public_key, alg:, enc:)
15
+ ::JWE.encrypt(plaintext.to_s, public_key, alg: alg.to_s, enc: enc.to_s)
16
+ end
17
+
18
+ def self.decrypt(ciphertext, private_key)
19
+ ::JWE.decrypt(ciphertext.to_s, private_key)
20
+ rescue ::JWE::DecodeError, ::JWE::InvalidData, ::JWE::BadCEK, ::JWE::NotImplementedError, ArgumentError => error
21
+ raise OmniauthOpenidFederation::DecryptionError,
22
+ "Failed to decrypt JWE: #{error.message}",
23
+ error.backtrace
24
+ end
25
+ end
26
+ end
@@ -113,19 +113,6 @@ module OmniauthOpenidFederation
113
113
  else
114
114
  raise e
115
115
  end
116
- rescue => e
117
- # Other errors might be due to key rotation
118
- if retried
119
- # If already re-tried to fetch, raise error
120
- error_msg = "JWT decode failed after cache refresh: #{e.class} - #{e.message}"
121
- OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
122
- raise ValidationError, error_msg, e.backtrace
123
- else
124
- OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] JWT decode error (clearing cache and retrying - possible key rotation): #{e.class} - #{e.message}")
125
- # Reset cache to force re-fetching of keys
126
- OmniauthOpenidFederation::Cache.delete_jwks(jwks_uri)
127
- run(encoded_jwt, jwks_uri, retried: true, entity_statement_keys: entity_statement_keys, &block)
128
- end
129
116
  end
130
117
  end
131
118
 
@@ -1,4 +1,3 @@
1
- require "http"
2
1
  require "jwt"
3
2
  require "openssl"
4
3
  require_relative "../logger"
@@ -44,52 +43,30 @@ module OmniauthOpenidFederation
44
43
  cache_ttl ||= config.cache_ttl
45
44
  rotate_on_errors = config.rotate_on_errors
46
45
 
47
- # Use cache adapter if available, otherwise fetch directly
48
46
  if CacheAdapter.available?
49
- if force_refresh
50
- # Force refresh: clear cache and fetch fresh
51
- CacheAdapter.delete(cache_key)
52
- end
47
+ CacheAdapter.delete(cache_key) if force_refresh
53
48
 
54
- if cache_ttl.nil?
55
- # Manual rotation: cache forever, only rotate on errors if rotate_on_errors is enabled
56
- begin
57
- CacheAdapter.fetch(cache_key, expires_in: nil) do
58
- fetch_jwks(jwks_uri, entity_statement_keys)
59
- end
60
- rescue KeyRelatedError => e
61
- # Rotate on key-related errors if configured
62
- if rotate_on_errors
63
- OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
64
- CacheAdapter.delete(cache_key)
65
- fetch_jwks(jwks_uri, entity_statement_keys)
66
- else
67
- raise
68
- end
69
- end
70
- else
71
- # TTL-based cache: expires after cache_ttl seconds
72
- # Rotate on errors if configured
73
- begin
74
- CacheAdapter.fetch(cache_key, expires_in: cache_ttl) do
75
- fetch_jwks(jwks_uri, entity_statement_keys)
76
- end
77
- rescue KeyRelatedError => e
78
- # Rotate on key-related errors if configured
79
- if rotate_on_errors
80
- OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
81
- CacheAdapter.delete(cache_key)
82
- fetch_jwks(jwks_uri, entity_statement_keys)
83
- else
84
- raise
85
- end
86
- end
49
+ fetch_from_cache(cache_key, cache_ttl: cache_ttl, rotate_on_errors: rotate_on_errors) do
50
+ fetch_jwks(jwks_uri, entity_statement_keys)
87
51
  end
88
52
  else
89
53
  fetch_jwks(jwks_uri, entity_statement_keys)
90
54
  end
91
55
  end
92
56
 
57
+ def self.fetch_from_cache(cache_key, cache_ttl:, rotate_on_errors:)
58
+ CacheAdapter.fetch(cache_key, expires_in: cache_ttl) { yield }
59
+ rescue KeyRelatedError => error
60
+ if rotate_on_errors
61
+ OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{error.message}")
62
+ CacheAdapter.delete(cache_key)
63
+ yield
64
+ else
65
+ raise
66
+ end
67
+ end
68
+ private_class_method :fetch_from_cache
69
+
93
70
  def self.fetch_jwks(jwks_uri, entity_statement_keys)
94
71
  # Rate limiting to prevent DoS
95
72
  unless RateLimiter.allow?(jwks_uri)
@@ -1,5 +1,4 @@
1
1
  require "jwt"
2
- require "jwe"
3
2
  require "securerandom"
4
3
  require "base64"
5
4
  require_relative "string_helpers"
@@ -56,10 +55,6 @@ module OmniauthOpenidFederation
56
55
  # )
57
56
  # signed_jwt = jws.sign
58
57
  class Jws
59
- # Request object expiration constants
60
- REQUEST_OBJECT_EXPIRATION_SECONDS = 600 # 10 minutes in seconds
61
- REQUEST_OBJECT_EXPIRATION_MINUTES = 10
62
-
63
58
  # State generation constants
64
59
  STATE_BYTES = 16 # Number of hex bytes for state parameter
65
60
 
@@ -223,7 +218,7 @@ module OmniauthOpenidFederation
223
218
  response_type: @response_type,
224
219
  scope: @scope,
225
220
  state: state,
226
- exp: (TimeHelpers.now + (defined?(ActiveSupport) ? REQUEST_OBJECT_EXPIRATION_MINUTES.minutes : REQUEST_OBJECT_EXPIRATION_SECONDS)).to_i,
221
+ exp: (TimeHelpers.now + TimeHelpers.request_object_expiration_offset).to_i,
227
222
  jti: SecureRandom.uuid # JWT ID to prevent replay
228
223
  }
229
224
 
@@ -393,11 +388,7 @@ module OmniauthOpenidFederation
393
388
  # Convert JWK to OpenSSL public key
394
389
  public_key = KeyExtractor.jwk_to_openssl_key(encryption_key_data)
395
390
 
396
- # Encrypt the signed JWT using JWE gem
397
- # For JWE, we encrypt the signed JWT string as plaintext
398
- # The pattern is: sign first, then encrypt (nested JWT)
399
- # JWE.encrypt(plaintext, key, alg: "RSA-OAEP", enc: "A128CBC-HS256")
400
- JWE.encrypt(
391
+ OmniauthOpenidFederation::Jwe.encrypt(
401
392
  signed_jwt,
402
393
  public_key,
403
394
  alg: encryption_alg,
@@ -0,0 +1,288 @@
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, OmniauthOpenidFederation::ValidationError,
180
+ OmniauthOpenidFederation::FetchError => error
181
+ OmniauthOpenidFederation::Logger.error("[JwtResponseDecoder] Signed JWKS failure (fail closed): #{error.message}")
182
+ raise
183
+ end
184
+
185
+ def load_entity_statement_keys_for_jwks_validation(strategy_options)
186
+ entity_statement_content = load_entity_statement_content(strategy_options)
187
+ return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
188
+
189
+ entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
190
+ parsed = entity_statement.parse
191
+ entity_jwks = parsed[:jwks] if parsed
192
+
193
+ keys = if entity_jwks.is_a?(Hash) && entity_jwks.key?("keys")
194
+ entity_jwks["keys"]
195
+ elsif entity_jwks.is_a?(Hash) && entity_jwks.key?(:keys)
196
+ entity_jwks[:keys]
197
+ elsif entity_jwks.is_a?(Array)
198
+ entity_jwks
199
+ else
200
+ []
201
+ end
202
+ if keys.empty?
203
+ OmniauthOpenidFederation::Logger.warn("[JwtResponseDecoder] No keys found in entity statement")
204
+ return nil
205
+ end
206
+
207
+ OmniauthOpenidFederation::Utils.to_indifferent_hash(
208
+ keys: keys.map { |jwk| jwk.is_a?(Hash) ? jwk : JSON.parse(jwk.to_json) }
209
+ )
210
+ rescue => error
211
+ OmniauthOpenidFederation::Logger.error(
212
+ "[JwtResponseDecoder] Failed to load entity statement keys for JWKS validation: #{error.message}"
213
+ )
214
+ nil
215
+ end
216
+
217
+ def resolve_jwks_uri_from_entity_statement(strategy_options)
218
+ entity_statement_content = load_entity_statement_content(strategy_options, log_fetch_errors: :debug)
219
+ return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
220
+
221
+ entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
222
+ parsed = entity_statement.parse
223
+ return nil unless parsed
224
+
225
+ jwks_uri = parsed.dig(:metadata, :openid_provider, :jwks_uri) ||
226
+ parsed.dig("metadata", "openid_provider", "jwks_uri")
227
+ OmniauthOpenidFederation::StringHelpers.present?(jwks_uri) ? jwks_uri : nil
228
+ rescue => error
229
+ OmniauthOpenidFederation::Logger.debug(
230
+ "[JwtResponseDecoder] Could not extract JWKS URI from entity statement: #{error.message}"
231
+ )
232
+ nil
233
+ end
234
+
235
+ def load_entity_statement_content(strategy_options, log_fetch_errors: :warn)
236
+ normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(strategy_options)
237
+ entity_statement_path = normalized_options[:entity_statement_path]
238
+ entity_statement_url = normalized_options[:entity_statement_url]
239
+ issuer = normalized_options[:issuer]
240
+ entity_statement_fingerprint = normalized_options[:entity_statement_fingerprint]
241
+
242
+ if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
243
+ begin
244
+ validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
245
+ entity_statement_path,
246
+ allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
247
+ )
248
+ return File.read(validated_path) if File.exist?(validated_path)
249
+ rescue OmniauthOpenidFederation::SecurityError => error
250
+ OmniauthOpenidFederation::Logger.error("[JwtResponseDecoder] #{error.message}")
251
+ end
252
+ end
253
+
254
+ if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
255
+ begin
256
+ return OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
257
+ entity_statement_url,
258
+ fingerprint: entity_statement_fingerprint
259
+ ).entity_statement
260
+ rescue => error
261
+ log_entity_statement_fetch_failure(log_fetch_errors, "URL", error)
262
+ end
263
+ end
264
+
265
+ if OmniauthOpenidFederation::StringHelpers.present?(issuer)
266
+ begin
267
+ return OmniauthOpenidFederation::Federation::EntityStatement.fetch_from_issuer!(
268
+ issuer,
269
+ fingerprint: entity_statement_fingerprint
270
+ ).entity_statement
271
+ rescue => error
272
+ log_entity_statement_fetch_failure(log_fetch_errors, "issuer", error)
273
+ end
274
+ end
275
+
276
+ nil
277
+ end
278
+
279
+ def log_entity_statement_fetch_failure(level, source, error)
280
+ message = "[JwtResponseDecoder] Failed to fetch entity statement from #{source}: #{error.message}"
281
+ if level == :debug
282
+ OmniauthOpenidFederation::Logger.debug(message)
283
+ else
284
+ OmniauthOpenidFederation::Logger.warn(message)
285
+ end
286
+ end
287
+ end
288
+ 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?
@@ -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?
@@ -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