omniauth_openid_federation 1.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 +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.md +22 -0
  4. data/README.md +822 -0
  5. data/SECURITY.md +129 -0
  6. data/examples/README_INTEGRATION_TESTING.md +399 -0
  7. data/examples/README_MOCK_OP.md +243 -0
  8. data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
  9. data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
  10. data/examples/app/models/user.rb.example +39 -0
  11. data/examples/config/initializers/devise.rb.example +97 -0
  12. data/examples/config/initializers/federation_endpoint.rb.example +206 -0
  13. data/examples/config/mock_op.yml.example +83 -0
  14. data/examples/config/open_id_connect_config.rb.example +210 -0
  15. data/examples/config/routes.rb.example +12 -0
  16. data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
  17. data/examples/integration_test_flow.rb +1334 -0
  18. data/examples/jobs/README.md +194 -0
  19. data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
  20. data/examples/jobs/federation_files_generation_job.rb.example +87 -0
  21. data/examples/mock_op_server.rb +775 -0
  22. data/examples/mock_rp_server.rb +435 -0
  23. data/lib/omniauth_openid_federation/access_token.rb +504 -0
  24. data/lib/omniauth_openid_federation/cache.rb +39 -0
  25. data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
  26. data/lib/omniauth_openid_federation/configuration.rb +135 -0
  27. data/lib/omniauth_openid_federation/constants.rb +13 -0
  28. data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
  29. data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
  30. data/lib/omniauth_openid_federation/errors.rb +52 -0
  31. data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
  32. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
  33. data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
  34. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
  35. data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
  36. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
  37. data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
  38. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
  39. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
  40. data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
  41. data/lib/omniauth_openid_federation/http_client.rb +70 -0
  42. data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
  43. data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
  44. data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
  45. data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
  46. data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
  47. data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
  48. data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
  49. data/lib/omniauth_openid_federation/jws.rb +416 -0
  50. data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
  51. data/lib/omniauth_openid_federation/logger.rb +99 -0
  52. data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
  53. data/lib/omniauth_openid_federation/railtie.rb +29 -0
  54. data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
  55. data/lib/omniauth_openid_federation/strategy.rb +2029 -0
  56. data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
  57. data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
  58. data/lib/omniauth_openid_federation/utils.rb +166 -0
  59. data/lib/omniauth_openid_federation/validators.rb +126 -0
  60. data/lib/omniauth_openid_federation/version.rb +3 -0
  61. data/lib/omniauth_openid_federation.rb +98 -0
  62. data/lib/tasks/omniauth_openid_federation.rake +376 -0
  63. data/sig/federation.rbs +218 -0
  64. data/sig/jwks.rbs +63 -0
  65. data/sig/omniauth_openid_federation.rbs +254 -0
  66. data/sig/strategy.rbs +60 -0
  67. metadata +352 -0
@@ -0,0 +1,331 @@
1
+ require "net/http"
2
+ require "digest"
3
+ require "jwt"
4
+ require "base64"
5
+ require "openssl"
6
+ require "timeout"
7
+ require_relative "../key_extractor"
8
+ require_relative "../logger"
9
+ require_relative "../errors"
10
+ require_relative "../configuration"
11
+ require_relative "../http_client"
12
+ require_relative "../utils"
13
+ require_relative "entity_statement_parser"
14
+
15
+ # Entity Statement implementation for OpenID Federation 1.0
16
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
17
+ # @see https://openid.github.io/federation/main.html OpenID Federation Documentation
18
+ #
19
+ # Entity Statements are self-signed JWTs that contain provider metadata and JWKS.
20
+ # This implementation supports:
21
+ # - Fetching entity statements from /.well-known/openid-federation endpoint (Section 9)
22
+ # - Fingerprint validation (SHA-256 hash) for integrity verification
23
+ # - Previous statement validation for update verification
24
+ # - Metadata extraction for OpenID Provider configuration
25
+ #
26
+ # Note: This implementation handles self-signed entity statements directly.
27
+ # Full trust chain resolution (Section 10) is not implemented as it's not required
28
+ # for direct entity statement validation use cases.
29
+ module OmniauthOpenidFederation
30
+ module Federation
31
+ # Entity Statement implementation for OpenID Federation 1.0
32
+ #
33
+ # @example Fetch and validate an entity statement from full URL
34
+ # statement = EntityStatement.fetch!(
35
+ # "https://provider.example.com/.well-known/openid-federation",
36
+ # fingerprint: "expected-fingerprint-hash"
37
+ # )
38
+ # metadata = statement.parse
39
+ #
40
+ # @example Fetch and validate an entity statement from issuer and endpoint
41
+ # statement = EntityStatement.fetch_from_issuer!(
42
+ # "https://provider.example.com",
43
+ # entity_statement_endpoint: "/.well-known/openid-federation",
44
+ # fingerprint: "expected-fingerprint-hash"
45
+ # )
46
+ # metadata = statement.parse
47
+ class EntityStatement
48
+ # Compatibility aliases for backward compatibility
49
+ FetchError = OmniauthOpenidFederation::FetchError
50
+ ValidationError = OmniauthOpenidFederation::ValidationError
51
+ attr_reader :entity_statement, :fingerprint, :metadata
52
+
53
+ # Fetch entity statement from URL
54
+ #
55
+ # @param url [String] The URL to fetch the entity statement from
56
+ # @param fingerprint [String, nil] Expected SHA-256 fingerprint for validation
57
+ # @param previous_statement [String, EntityStatement, Hash, nil] Previous statement for validation
58
+ # @param timeout [Integer] HTTP request timeout in seconds (default: 10)
59
+ # @return [EntityStatement] The fetched and validated entity statement
60
+ # @raise [FetchError] If fetching fails
61
+ # @raise [ValidationError] If validation fails
62
+
63
+ def initialize(entity_statement_content, fingerprint: nil)
64
+ @entity_statement = entity_statement_content
65
+ @fingerprint = fingerprint || calculate_fingerprint
66
+ @metadata = nil
67
+ end
68
+
69
+ # Fetch entity statement from issuer and endpoint path
70
+ #
71
+ # @param issuer_uri [String, URI] Issuer URI (e.g., "https://provider.example.com")
72
+ # @param entity_statement_endpoint [String, nil] Entity statement endpoint path (defaults to "/.well-known/openid-federation")
73
+ # @param fingerprint [String, nil] Expected SHA-256 fingerprint for validation
74
+ # @param previous_statement [String, EntityStatement, Hash, nil] Previous statement for validation
75
+ # @param timeout [Integer] HTTP request timeout in seconds (default: 10)
76
+ # @return [EntityStatement] The fetched and validated entity statement
77
+ # @raise [FetchError] If fetching fails
78
+ # @raise [ValidationError] If validation fails
79
+ def self.fetch_from_issuer!(issuer_uri, entity_statement_endpoint: nil, fingerprint: nil, previous_statement: nil, timeout: 10)
80
+ url = OmniauthOpenidFederation::Utils.build_entity_statement_url(
81
+ issuer_uri,
82
+ entity_statement_endpoint: entity_statement_endpoint
83
+ )
84
+ fetch!(url, fingerprint: fingerprint, previous_statement: previous_statement, timeout: timeout)
85
+ end
86
+
87
+ # Fetch entity statement from URL
88
+ #
89
+ # @param url [String] The URL to fetch the entity statement from
90
+ # @param fingerprint [String, nil] Expected SHA-256 fingerprint for validation
91
+ # @param previous_statement [String, EntityStatement, Hash, nil] Previous statement for validation
92
+ # @param timeout [Integer] HTTP request timeout in seconds (default: 10)
93
+ # @return [EntityStatement] The fetched and validated entity statement
94
+ # @raise [FetchError] If fetching fails
95
+ # @raise [ValidationError] If validation fails
96
+ def self.fetch!(url, fingerprint: nil, previous_statement: nil, timeout: 10)
97
+ # Use HttpClient for retry logic and configurable SSL verification
98
+ # Note: HttpClient uses HTTP gem, but entity statements might need Net::HTTP
99
+ # For now, we'll use a simple HTTP.get approach with HttpClient's retry logic
100
+ begin
101
+ # Convert URL to URI for HttpClient
102
+ response = HttpClient.get(url, timeout: timeout)
103
+ rescue OmniauthOpenidFederation::NetworkError => e
104
+ OmniauthOpenidFederation::Logger.error("[EntityStatement] Failed to fetch entity statement: #{e.message}")
105
+ raise FetchError, "Failed to fetch entity statement from #{url}: #{e.message}", e.backtrace
106
+ end
107
+
108
+ unless response.status.success?
109
+ error_msg = "Failed to fetch entity statement from #{url}: HTTP #{response.status}"
110
+ OmniauthOpenidFederation::Logger.error("[EntityStatement] #{error_msg}")
111
+ raise FetchError, error_msg
112
+ end
113
+
114
+ # HTTP gem returns body as StringIO or similar, convert to string
115
+ entity_statement = response.body.to_s
116
+
117
+ instance = new(entity_statement, fingerprint: nil) # Don't set fingerprint in constructor
118
+
119
+ # Validate using full OpenID Federation validation (includes signature validation)
120
+ # This is required for OpenID Federation compliance
121
+ begin
122
+ EntityStatementParser.parse(entity_statement, validate_signature: true, validate_full: true)
123
+ OmniauthOpenidFederation::Logger.debug("[EntityStatement] Full validation successful")
124
+ rescue SignatureError, ValidationError => e
125
+ error_msg = "Entity statement validation failed: #{e.message}"
126
+ OmniauthOpenidFederation::Logger.error("[EntityStatement] #{error_msg}")
127
+ # Instrument entity statement validation failure
128
+ OmniauthOpenidFederation::Instrumentation.notify_entity_statement_validation_failed(
129
+ entity_statement_url: url,
130
+ validation_step: "full_validation",
131
+ error_message: e.message,
132
+ error_class: e.class.name
133
+ )
134
+ raise ValidationError, error_msg, e.backtrace
135
+ end
136
+
137
+ # Validate if fingerprint provided
138
+ if fingerprint
139
+ calculated_fingerprint = instance.calculate_fingerprint
140
+ unless instance.validate_fingerprint(fingerprint)
141
+ error_msg = "Entity statement fingerprint mismatch. Expected: #{fingerprint}, Got: #{calculated_fingerprint}"
142
+ OmniauthOpenidFederation::Logger.error("[EntityStatement] #{error_msg}")
143
+ # Instrument fingerprint mismatch
144
+ OmniauthOpenidFederation::Instrumentation.notify_fingerprint_mismatch(
145
+ entity_statement_url: url,
146
+ expected_fingerprint: fingerprint,
147
+ calculated_fingerprint: calculated_fingerprint
148
+ )
149
+ raise ValidationError, error_msg
150
+ end
151
+ OmniauthOpenidFederation::Logger.debug("[EntityStatement] Fingerprint validation successful: #{fingerprint}")
152
+ end
153
+
154
+ # Validate against previous statement if provided
155
+ if previous_statement
156
+ unless instance.validate_against_previous(previous_statement)
157
+ error_msg = "Entity statement validation against previous statement failed"
158
+ OmniauthOpenidFederation::Logger.error("[EntityStatement] #{error_msg}")
159
+ raise ValidationError, error_msg
160
+ end
161
+ OmniauthOpenidFederation::Logger.debug("[EntityStatement] Previous statement validation successful")
162
+ end
163
+
164
+ instance
165
+ end
166
+
167
+ # Calculate SHA-256 fingerprint of the entity statement
168
+ #
169
+ # @return [String] The lowercase hexadecimal fingerprint
170
+ def calculate_fingerprint
171
+ Digest::SHA256.hexdigest(entity_statement).downcase
172
+ end
173
+
174
+ # Validate fingerprint against expected value
175
+ #
176
+ # @param expected_fingerprint [String] The expected fingerprint
177
+ # @return [Boolean] true if fingerprints match
178
+ def validate_fingerprint(expected_fingerprint)
179
+ calculated = fingerprint.downcase
180
+ expected = expected_fingerprint.to_s.downcase
181
+ calculated == expected
182
+ end
183
+
184
+ # Validate against a previous entity statement
185
+ #
186
+ # @param previous_statement [String, EntityStatement, Hash] The previous statement to validate against
187
+ # @return [Boolean] true if validation passes
188
+ def validate_against_previous(previous_statement)
189
+ # Decode current statement
190
+ current_payload = decode_payload
191
+
192
+ # Handle different input types
193
+ previous_payload = if previous_statement.is_a?(String)
194
+ decode_jwt_payload(previous_statement)
195
+ elsif previous_statement.instance_of?(::OmniauthOpenidFederation::Federation::EntityStatement)
196
+ # If it's an EntityStatement instance, decode its payload
197
+ previous_statement.decode_payload
198
+ else
199
+ previous_statement
200
+ end
201
+
202
+ # Check if issuer matches
203
+ return false unless current_payload["iss"] == previous_payload["iss"]
204
+
205
+ # Check if this is a valid update (e.g., exp time is later)
206
+ current_exp = current_payload["exp"]
207
+ previous_exp = previous_payload["exp"]
208
+
209
+ return false if current_exp && previous_exp && current_exp < previous_exp
210
+
211
+ # Additional validation can be added here (e.g., check authority_hints)
212
+ true
213
+ end
214
+
215
+ # Parse entity statement and extract metadata
216
+ #
217
+ # @return [Hash] Hash containing issuer, subject, expiration, JWKS, and provider metadata
218
+ def parse
219
+ return @metadata if @metadata
220
+
221
+ payload = decode_payload
222
+
223
+ # Extract provider metadata
224
+ metadata_section = payload.fetch("metadata", {})
225
+ metadata_section.fetch("openid_provider", {})
226
+
227
+ # Extract entity JWKS - ensure it's a hash with keys array
228
+ entity_jwks = payload.fetch("jwks", {})
229
+ # Normalize to ensure it has :keys or "keys" key
230
+ if entity_jwks.nil? || !entity_jwks.is_a?(Hash)
231
+ entity_jwks = {keys: []}
232
+ elsif !entity_jwks.key?(:keys) && !entity_jwks.key?("keys")
233
+ entity_jwks = {keys: []}
234
+ end
235
+
236
+ # Extract all entity types from metadata
237
+ metadata_section = payload.fetch("metadata", {})
238
+ provider_metadata = metadata_section.fetch("openid_provider", {})
239
+ rp_metadata = metadata_section.fetch("openid_relying_party", {})
240
+
241
+ @metadata = {
242
+ issuer: payload["iss"],
243
+ sub: payload["sub"],
244
+ exp: payload["exp"],
245
+ iat: payload["iat"],
246
+ jwks: entity_jwks,
247
+ metadata: {},
248
+ # Advanced claims (Entity Configuration specific)
249
+ authority_hints: payload["authority_hints"] || payload[:authority_hints],
250
+ trust_marks: payload["trust_marks"] || payload[:trust_marks],
251
+ trust_mark_issuers: payload["trust_mark_issuers"] || payload[:trust_mark_issuers],
252
+ trust_mark_owners: payload["trust_mark_owners"] || payload[:trust_mark_owners],
253
+ # Advanced claims (Subordinate Statement specific)
254
+ metadata_policy: payload["metadata_policy"] || payload[:metadata_policy],
255
+ metadata_policy_crit: payload["metadata_policy_crit"] || payload[:metadata_policy_crit],
256
+ constraints: payload["constraints"] || payload[:constraints],
257
+ source_endpoint: payload["source_endpoint"] || payload[:source_endpoint],
258
+ # Other claims
259
+ crit: payload["crit"] || payload[:crit],
260
+ # Determine statement type
261
+ is_entity_configuration: (payload["iss"] == payload["sub"]),
262
+ is_subordinate_statement: (payload["iss"] != payload["sub"])
263
+ }
264
+
265
+ # Extract OpenID Provider metadata if present
266
+ if provider_metadata.any?
267
+ @metadata[:metadata][:openid_provider] = {
268
+ issuer: provider_metadata["issuer"],
269
+ authorization_endpoint: provider_metadata["authorization_endpoint"],
270
+ token_endpoint: provider_metadata["token_endpoint"],
271
+ userinfo_endpoint: provider_metadata["userinfo_endpoint"],
272
+ jwks_uri: provider_metadata["jwks_uri"],
273
+ signed_jwks_uri: provider_metadata["signed_jwks_uri"],
274
+ end_session_endpoint: provider_metadata["end_session_endpoint"],
275
+ client_registration_types_supported: provider_metadata["client_registration_types_supported"],
276
+ federation_registration_endpoint: provider_metadata["federation_registration_endpoint"]
277
+ }
278
+ end
279
+
280
+ # Extract OpenID Relying Party metadata if present
281
+ if rp_metadata.any?
282
+ @metadata[:metadata][:openid_relying_party] = {
283
+ application_type: rp_metadata["application_type"],
284
+ redirect_uris: rp_metadata["redirect_uris"],
285
+ client_registration_types: rp_metadata["client_registration_types"],
286
+ signed_jwks_uri: rp_metadata["signed_jwks_uri"],
287
+ jwks_uri: rp_metadata["jwks_uri"],
288
+ organization_name: rp_metadata["organization_name"],
289
+ logo_uri: rp_metadata["logo_uri"],
290
+ grant_types: rp_metadata["grant_types"],
291
+ response_types: rp_metadata["response_types"],
292
+ scope: rp_metadata["scope"]
293
+ }
294
+ end
295
+
296
+ @metadata
297
+ end
298
+
299
+ # Save entity statement to file
300
+ #
301
+ # @param file_path [String] Path to save the entity statement
302
+ def save_to_file(file_path)
303
+ File.write(file_path, entity_statement)
304
+ end
305
+
306
+ # Decode and return the JWT payload
307
+ #
308
+ # @return [Hash] The decoded JWT payload
309
+ def decode_payload
310
+ decode_jwt_payload(entity_statement)
311
+ end
312
+
313
+ private
314
+
315
+ # Standard JWT has 3 parts: header.payload.signature
316
+ JWT_PARTS_COUNT = 3
317
+
318
+ def decode_jwt_payload(jwt_string)
319
+ jwt_parts = jwt_string.split(".")
320
+ raise ValidationError, "Invalid JWT format" if jwt_parts.length != JWT_PARTS_COUNT
321
+
322
+ # Decode payload (second part)
323
+ JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
324
+ rescue JSON::ParserError => e
325
+ raise ValidationError, "Failed to parse entity statement payload: #{e.message}"
326
+ rescue ArgumentError => e
327
+ raise ValidationError, "Failed to decode entity statement: #{e.message}"
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,188 @@
1
+ require "jwt"
2
+ require "base64"
3
+ require "openssl"
4
+ require "time"
5
+ require_relative "../logger"
6
+ require_relative "../errors"
7
+
8
+ # Entity Statement Builder for OpenID Federation 1.0
9
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
10
+ #
11
+ # Builds self-signed entity statement JWTs that contain provider metadata and JWKS.
12
+ # Entity statements are used to publish provider configuration and enable signed JWKS support.
13
+ #
14
+ # @example Generate an entity statement
15
+ # builder = EntityStatementBuilder.new(
16
+ # issuer: "https://provider.example.com",
17
+ # subject: "https://provider.example.com",
18
+ # private_key: private_key,
19
+ # jwks: jwks_hash,
20
+ # metadata: {
21
+ # openid_provider: {
22
+ # issuer: "https://provider.example.com",
23
+ # authorization_endpoint: "https://provider.example.com/oauth2/authorize",
24
+ # token_endpoint: "https://provider.example.com/oauth2/token",
25
+ # userinfo_endpoint: "https://provider.example.com/oauth2/userinfo",
26
+ # jwks_uri: "https://provider.example.com/.well-known/jwks.json",
27
+ # signed_jwks_uri: "https://provider.example.com/.well-known/signed-jwks.json"
28
+ # }
29
+ # }
30
+ # )
31
+ # entity_statement_jwt = builder.build
32
+ module OmniauthOpenidFederation
33
+ module Federation
34
+ # Entity Statement Builder for OpenID Federation 1.0
35
+ #
36
+ # Builds self-signed entity statement JWTs for publishing provider configuration.
37
+ class EntityStatementBuilder
38
+ # @param issuer [String] Entity issuer (typically the provider URL)
39
+ # @param subject [String] Entity subject (typically same as issuer for self-issued statements)
40
+ # @param private_key [OpenSSL::PKey::RSA] Private key for signing the entity statement
41
+ # @param jwks [Hash] JWKS hash with "keys" array containing public keys
42
+ # @param metadata [Hash] Provider metadata hash with openid_provider section
43
+ # @param expiration_seconds [Integer] Expiration time in seconds from now (default: 86400 = 24 hours)
44
+ # @param kid [String, nil] Key ID to use for signing (defaults to first key's kid in JWKS)
45
+ # @param authority_hints [Array<String>, nil] Optional: Array of Entity Identifiers for Immediate Superiors (Entity Configuration only)
46
+ # @param trust_marks [Array<Hash>, nil] Optional: Array of Trust Mark objects (Entity Configuration only)
47
+ # @param trust_mark_issuers [Hash, nil] Optional: Trust Mark issuers configuration (Trust Anchor only)
48
+ # @param trust_mark_owners [Hash, nil] Optional: Trust Mark owners configuration (Trust Anchor only)
49
+ # @param metadata_policy [Hash, nil] Optional: Metadata policy (Subordinate Statement only)
50
+ # @param metadata_policy_crit [Array<String>, nil] Optional: Critical metadata policy operators (Subordinate Statement only)
51
+ # @param constraints [Hash, nil] Optional: Trust Chain constraints (Subordinate Statement only)
52
+ # @param source_endpoint [String, nil] Optional: Fetch endpoint URL (Subordinate Statement only)
53
+ # @param crit [Array<String>, nil] Optional: Critical claims that must be understood
54
+ def initialize(issuer:, subject:, private_key:, jwks:, metadata:, expiration_seconds: 86400, kid: nil,
55
+ authority_hints: nil, trust_marks: nil, trust_mark_issuers: nil, trust_mark_owners: nil,
56
+ metadata_policy: nil, metadata_policy_crit: nil, constraints: nil, source_endpoint: nil, crit: nil)
57
+ @issuer = issuer
58
+ @subject = subject
59
+ @private_key = private_key
60
+ @jwks = normalize_jwks(jwks)
61
+ @metadata = metadata
62
+ @expiration_seconds = expiration_seconds
63
+ @kid = kid || extract_kid_from_jwks(@jwks)
64
+ @authority_hints = authority_hints
65
+ @trust_marks = trust_marks
66
+ @trust_mark_issuers = trust_mark_issuers
67
+ @trust_mark_owners = trust_mark_owners
68
+ @metadata_policy = metadata_policy
69
+ @metadata_policy_crit = metadata_policy_crit
70
+ @constraints = constraints
71
+ @source_endpoint = source_endpoint
72
+ @crit = crit
73
+ end
74
+
75
+ # Build and sign the entity statement JWT
76
+ #
77
+ # @return [String] The signed entity statement JWT string
78
+ # @raise [ConfigurationError] If required parameters are missing
79
+ # @raise [SignatureError] If signing fails
80
+ def build
81
+ validate_parameters
82
+
83
+ payload = build_payload
84
+
85
+ # Build JWT header
86
+ # Per OpenID Federation 1.0 Section 3.1: typ MUST be "entity-statement+jwt"
87
+ header = {
88
+ alg: "RS256",
89
+ typ: "entity-statement+jwt",
90
+ kid: @kid
91
+ }
92
+
93
+ begin
94
+ JWT.encode(payload, @private_key, "RS256", header)
95
+ rescue => e
96
+ error_msg = "Failed to sign entity statement: #{e.class} - #{e.message}"
97
+ OmniauthOpenidFederation::Logger.error("[EntityStatementBuilder] #{error_msg}")
98
+ raise SignatureError, error_msg, e.backtrace
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def validate_parameters
105
+ raise ConfigurationError, "Issuer is required" if @issuer.nil? || @issuer.empty?
106
+ raise ConfigurationError, "Subject is required" if @subject.nil? || @subject.empty?
107
+ raise ConfigurationError, "Private key is required" if @private_key.nil?
108
+ raise ConfigurationError, "JWKS is required" if @jwks.nil? || @jwks.empty?
109
+ raise ConfigurationError, "Metadata is required" if @metadata.nil? || @metadata.empty?
110
+ raise ConfigurationError, "JWKS must contain at least one key" if @jwks["keys"].nil? || @jwks["keys"].empty?
111
+ raise ConfigurationError, "Key ID (kid) is required" if @kid.nil? || @kid.empty?
112
+ end
113
+
114
+ def build_payload
115
+ now = Time.now.to_i
116
+ is_entity_configuration = (@issuer == @subject)
117
+ is_subordinate_statement = !is_entity_configuration
118
+
119
+ payload = {
120
+ iss: @issuer,
121
+ sub: @subject,
122
+ iat: now,
123
+ exp: now + @expiration_seconds,
124
+ jwks: @jwks,
125
+ metadata: @metadata
126
+ }
127
+
128
+ # Entity Configuration specific claims
129
+ if is_entity_configuration
130
+ payload[:authority_hints] = @authority_hints if @authority_hints
131
+ payload[:trust_marks] = @trust_marks if @trust_marks
132
+ payload[:trust_mark_issuers] = @trust_mark_issuers if @trust_mark_issuers
133
+ payload[:trust_mark_owners] = @trust_mark_owners if @trust_mark_owners
134
+ end
135
+
136
+ # Subordinate Statement specific claims
137
+ if is_subordinate_statement
138
+ payload[:metadata_policy] = @metadata_policy if @metadata_policy
139
+ payload[:metadata_policy_crit] = @metadata_policy_crit if @metadata_policy_crit
140
+ payload[:constraints] = @constraints if @constraints
141
+ payload[:source_endpoint] = @source_endpoint if @source_endpoint
142
+ end
143
+
144
+ # Common optional claims
145
+ payload[:crit] = @crit if @crit
146
+
147
+ payload
148
+ end
149
+
150
+ def normalize_jwks(jwks)
151
+ # Ensure JWKS is a hash with "keys" array
152
+ if jwks.is_a?(Hash)
153
+ # If it has :keys or "keys", use as-is
154
+ if jwks.key?(:keys) || jwks.key?("keys")
155
+ keys = jwks[:keys] || jwks["keys"]
156
+ {"keys" => normalize_keys(keys)}
157
+ else
158
+ # If it's just a hash, wrap it
159
+ {"keys" => [jwks]}
160
+ end
161
+ elsif jwks.is_a?(Array)
162
+ {"keys" => normalize_keys(jwks)}
163
+ else
164
+ raise ConfigurationError, "JWKS must be a Hash or Array"
165
+ end
166
+ end
167
+
168
+ def normalize_keys(keys)
169
+ keys.map do |key|
170
+ if key.is_a?(Hash)
171
+ # Convert symbol keys to string keys
172
+ key.transform_keys(&:to_s)
173
+ else
174
+ key
175
+ end
176
+ end
177
+ end
178
+
179
+ def extract_kid_from_jwks(jwks)
180
+ keys = jwks["keys"] || jwks[:keys] || []
181
+ return nil if keys.empty?
182
+
183
+ first_key = keys.first
184
+ first_key["kid"] || first_key[:kid]
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,142 @@
1
+ require_relative "entity_statement"
2
+ require_relative "../logger"
3
+ require_relative "../errors"
4
+ require_relative "../http_client"
5
+ require_relative "../utils"
6
+
7
+ # Entity Statement Fetcher abstraction for OpenID Federation
8
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
9
+ #
10
+ # Provides a pluggable interface for fetching entity statements from various sources.
11
+ # This abstraction improves testability and allows different fetching strategies.
12
+ module OmniauthOpenidFederation
13
+ module Federation
14
+ module EntityStatementFetcher
15
+ # Base class for entity statement fetchers
16
+ # Subclasses must implement #fetch_entity_statement
17
+ class Base
18
+ # Get the entity statement (cached)
19
+ #
20
+ # @return [EntityStatement] The entity statement instance
21
+ def entity_statement
22
+ @entity_statement ||= begin
23
+ content = fetch_entity_statement
24
+ EntityStatement.new(content)
25
+ end
26
+ end
27
+
28
+ # Reload the entity statement (clear cache)
29
+ #
30
+ # @return [void]
31
+ def reload!
32
+ @entity_statement = nil
33
+ end
34
+
35
+ private
36
+
37
+ # Fetch the entity statement content
38
+ # Must be implemented by subclasses
39
+ #
40
+ # @return [String] The entity statement JWT string
41
+ # @raise [NotImplementedError] If not implemented by subclass
42
+ def fetch_entity_statement
43
+ raise NotImplementedError, "#{self.class} must implement #fetch_entity_statement"
44
+ end
45
+ end
46
+
47
+ # Fetches entity statement from a federation URL
48
+ class UrlFetcher < Base
49
+ attr_reader :entity_statement_url, :fingerprint, :timeout
50
+
51
+ # Initialize URL fetcher
52
+ #
53
+ # @param entity_statement_url [String] The URL to fetch from
54
+ # @param fingerprint [String, nil] Expected SHA-256 fingerprint for verification
55
+ # @param timeout [Integer] HTTP timeout in seconds (default: 10)
56
+ def initialize(entity_statement_url, fingerprint: nil, timeout: 10)
57
+ @entity_statement_url = entity_statement_url
58
+ @fingerprint = fingerprint
59
+ @timeout = timeout
60
+ end
61
+
62
+ private
63
+
64
+ def fetch_entity_statement
65
+ OmniauthOpenidFederation::Logger.debug("[EntityStatementFetcher::UrlFetcher] Fetching entity statement from #{Utils.sanitize_uri(@entity_statement_url)}")
66
+
67
+ begin
68
+ response = HttpClient.get(@entity_statement_url, timeout: @timeout)
69
+ rescue OmniauthOpenidFederation::NetworkError => e
70
+ sanitized_uri = Utils.sanitize_uri(@entity_statement_url)
71
+ OmniauthOpenidFederation::Logger.error("[EntityStatementFetcher::UrlFetcher] Failed to fetch entity statement from #{sanitized_uri}: #{e.message}")
72
+ raise FetchError, "Failed to fetch entity statement from #{sanitized_uri}: #{e.message}", e.backtrace
73
+ end
74
+
75
+ unless response.status.success?
76
+ sanitized_uri = Utils.sanitize_uri(@entity_statement_url)
77
+ error_msg = "Failed to fetch entity statement from #{sanitized_uri}: HTTP #{response.status}"
78
+ OmniauthOpenidFederation::Logger.error("[EntityStatementFetcher::UrlFetcher] #{error_msg}")
79
+ raise FetchError, error_msg
80
+ end
81
+
82
+ entity_statement_content = response.body.to_s
83
+
84
+ # Validate fingerprint if provided
85
+ if @fingerprint
86
+ temp_statement = EntityStatement.new(entity_statement_content)
87
+ calculated_fingerprint = temp_statement.calculate_fingerprint
88
+ unless calculated_fingerprint == @fingerprint
89
+ error_msg = "Entity statement fingerprint mismatch. Expected: #{@fingerprint}, Got: #{calculated_fingerprint}"
90
+ OmniauthOpenidFederation::Logger.error("[EntityStatementFetcher::UrlFetcher] #{error_msg}")
91
+ # Instrument fingerprint mismatch
92
+ OmniauthOpenidFederation::Instrumentation.notify_fingerprint_mismatch(
93
+ entity_statement_url: @entity_statement_url,
94
+ expected_fingerprint: @fingerprint,
95
+ calculated_fingerprint: calculated_fingerprint
96
+ )
97
+ raise ValidationError, error_msg
98
+ end
99
+ end
100
+
101
+ entity_statement_content
102
+ end
103
+ end
104
+
105
+ # Fetches entity statement from a local file
106
+ class FileFetcher < Base
107
+ attr_reader :file_path
108
+
109
+ # Initialize file fetcher
110
+ #
111
+ # @param file_path [String] Path to the entity statement file
112
+ # @param allowed_dirs [Array<String>, nil] Allowed directories for path validation (default: Rails.root/config if Rails available)
113
+ def initialize(file_path, allowed_dirs: nil)
114
+ @file_path = file_path
115
+ @allowed_dirs = allowed_dirs || ((defined?(Rails) && Rails.root) ? [Rails.root.join("config").to_s] : nil)
116
+ end
117
+
118
+ private
119
+
120
+ def fetch_entity_statement
121
+ # Validate file path to prevent path traversal
122
+ validated_path = Utils.validate_file_path!(
123
+ @file_path,
124
+ allowed_dirs: @allowed_dirs
125
+ )
126
+
127
+ unless File.exist?(validated_path)
128
+ sanitized_path = Utils.sanitize_path(validated_path)
129
+ OmniauthOpenidFederation::Logger.warn("[EntityStatementFetcher::FileFetcher] Entity statement file not found: #{sanitized_path}")
130
+ raise FetchError, "Entity statement file not found: #{sanitized_path}"
131
+ end
132
+
133
+ OmniauthOpenidFederation::Logger.debug("[EntityStatementFetcher::FileFetcher] Loading entity statement from file: #{Utils.sanitize_path(validated_path)}")
134
+ File.read(validated_path).strip
135
+ rescue SecurityError => e
136
+ OmniauthOpenidFederation::Logger.error("[EntityStatementFetcher::FileFetcher] #{e.message}")
137
+ raise FetchError, e.message, e.backtrace
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end