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,225 @@
1
+ require_relative "entity_statement"
2
+ require_relative "entity_statement_validator"
3
+ require_relative "../http_client"
4
+ require_relative "../logger"
5
+ require_relative "../errors"
6
+ require_relative "../utils"
7
+ require "set"
8
+ require "cgi"
9
+
10
+ # Trust Chain Resolver for OpenID Federation 1.0
11
+ # @see https://openid.net/specs/openid-federation-1_0.html#section-10 Section 10: Trust Chain Resolution
12
+ #
13
+ # Resolves trust chains from a Leaf Entity up to a Trust Anchor by:
14
+ # 1. Fetching the Leaf Entity's Entity Configuration
15
+ # 2. Following authority_hints to fetch Subordinate Statements
16
+ # 3. Validating each statement in the chain
17
+ # 4. Continuing until a Trust Anchor is reached
18
+ #
19
+ # @example Resolve a trust chain
20
+ # resolver = TrustChainResolver.new(
21
+ # leaf_entity_id: "https://rp.example.com",
22
+ # trust_anchors: [
23
+ # {
24
+ # entity_id: "https://ta.example.com",
25
+ # jwks: trust_anchor_jwks
26
+ # }
27
+ # ]
28
+ # )
29
+ # trust_chain = resolver.resolve!
30
+ module OmniauthOpenidFederation
31
+ module Federation
32
+ # Trust Chain Resolver for OpenID Federation 1.0
33
+ #
34
+ # Resolves and validates trust chains from a Leaf Entity to a Trust Anchor.
35
+ class TrustChainResolver
36
+ # Initialize resolver
37
+ #
38
+ # @param leaf_entity_id [String] Entity Identifier of the Leaf Entity
39
+ # @param trust_anchors [Array<Hash>] Array of Trust Anchor configurations
40
+ # Each hash must have:
41
+ # - :entity_id or "entity_id" - Trust Anchor Entity Identifier
42
+ # - :jwks or "jwks" - Trust Anchor JWKS for validation
43
+ # @param max_chain_length [Integer] Maximum chain length to prevent infinite loops (default: 10)
44
+ # @param timeout [Integer] HTTP request timeout in seconds (default: 10)
45
+ def initialize(leaf_entity_id:, trust_anchors:, max_chain_length: 10, timeout: 10)
46
+ @leaf_entity_id = leaf_entity_id
47
+ @trust_anchors = normalize_trust_anchors(trust_anchors)
48
+ @max_chain_length = max_chain_length
49
+ @timeout = timeout
50
+ @resolved_statements = []
51
+ @visited_entities = Set.new
52
+ end
53
+
54
+ # Resolve the trust chain
55
+ #
56
+ # @return [Array<Hash>] Array of validated entity statements in order (Leaf to Trust Anchor)
57
+ # @raise [ValidationError] If trust chain resolution fails
58
+ # @raise [FetchError] If fetching entity statements fails
59
+ def resolve!
60
+ OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Starting trust chain resolution for: #{@leaf_entity_id}")
61
+
62
+ # Step 1: Fetch Leaf Entity's Entity Configuration
63
+ leaf_config = fetch_entity_configuration(@leaf_entity_id)
64
+ validate_entity_statement(leaf_config, nil) # No issuer for Entity Configuration
65
+ @resolved_statements << leaf_config
66
+ @visited_entities.add(@leaf_entity_id)
67
+
68
+ # Step 2: Follow authority_hints to build the chain
69
+ current_entity_id = @leaf_entity_id
70
+ current_config = leaf_config
71
+
72
+ while current_config && !is_trust_anchor?(current_config)
73
+ authority_hints = extract_authority_hints(current_config)
74
+
75
+ if authority_hints.nil? || authority_hints.empty?
76
+ raise ValidationError, "Entity #{current_entity_id} has no authority_hints and is not a Trust Anchor"
77
+ end
78
+
79
+ # Try each authority hint until we find a valid chain
80
+ found_next = false
81
+ authority_hints.each do |authority_id|
82
+ next if @visited_entities.include?(authority_id)
83
+
84
+ if @resolved_statements.length >= @max_chain_length
85
+ raise ValidationError, "Trust chain length exceeds maximum (#{@max_chain_length})"
86
+ end
87
+
88
+ begin
89
+ # Fetch Subordinate Statement from authority
90
+ subordinate_statement = fetch_subordinate_statement(
91
+ issuer: authority_id,
92
+ subject: current_entity_id
93
+ )
94
+
95
+ # Validate Subordinate Statement
96
+ issuer_config = fetch_entity_configuration(authority_id)
97
+ validate_entity_statement(subordinate_statement, issuer_config)
98
+
99
+ # Add to chain
100
+ @resolved_statements << subordinate_statement
101
+ @visited_entities.add(authority_id)
102
+
103
+ # Continue with issuer as next entity
104
+ current_entity_id = authority_id
105
+ current_config = issuer_config
106
+ found_next = true
107
+ break
108
+ rescue ValidationError, FetchError => e
109
+ OmniauthOpenidFederation::Logger.warn("[TrustChainResolver] Failed to resolve via #{authority_id}: #{e.message}")
110
+ # Instrument trust chain validation failure
111
+ OmniauthOpenidFederation::Instrumentation.notify_trust_chain_validation_failed(
112
+ entity_id: current_entity_id,
113
+ trust_anchor: authority_id,
114
+ validation_step: "subordinate_statement_validation",
115
+ error_message: e.message,
116
+ error_class: e.class.name
117
+ )
118
+ next
119
+ end
120
+ end
121
+
122
+ unless found_next
123
+ raise ValidationError, "Could not resolve trust chain from #{current_entity_id}: no valid authority found"
124
+ end
125
+ end
126
+
127
+ # Step 3: Verify we reached a Trust Anchor
128
+ unless is_trust_anchor?(current_config)
129
+ error_msg = "Trust chain did not terminate at a configured Trust Anchor"
130
+ # Instrument trust chain validation failure
131
+ OmniauthOpenidFederation::Instrumentation.notify_trust_chain_validation_failed(
132
+ entity_id: @leaf_entity_id,
133
+ trust_anchor: current_entity_id,
134
+ validation_step: "trust_anchor_verification",
135
+ error_message: error_msg
136
+ )
137
+ raise ValidationError, error_msg
138
+ end
139
+
140
+ OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Trust chain resolved: #{@resolved_statements.length} statements")
141
+ @resolved_statements
142
+ end
143
+
144
+ private
145
+
146
+ def normalize_trust_anchors(trust_anchors)
147
+ trust_anchors.map do |ta|
148
+ {
149
+ entity_id: ta[:entity_id] || ta["entity_id"],
150
+ jwks: ta[:jwks] || ta["jwks"]
151
+ }
152
+ end
153
+ end
154
+
155
+ def fetch_entity_configuration(entity_id)
156
+ entity_statement_url = OmniauthOpenidFederation::Utils.build_entity_statement_url(entity_id)
157
+ OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Fetching Entity Configuration from: #{entity_statement_url}")
158
+
159
+ begin
160
+ EntityStatement.fetch!(entity_statement_url, timeout: @timeout)
161
+ rescue OmniauthOpenidFederation::NetworkError => e
162
+ raise FetchError, "Failed to fetch entity configuration from #{entity_statement_url}: #{e.message}"
163
+ end
164
+ end
165
+
166
+ def fetch_subordinate_statement(issuer:, subject:)
167
+ # Try to get fetch endpoint from issuer's Entity Configuration
168
+ issuer_config = fetch_entity_configuration(issuer)
169
+ fetch_endpoint = extract_fetch_endpoint(issuer_config)
170
+
171
+ unless fetch_endpoint
172
+ raise FetchError, "Issuer #{issuer} does not provide a fetch endpoint"
173
+ end
174
+
175
+ # Build fetch URL with iss and sub parameters
176
+ fetch_url = "#{fetch_endpoint}?iss=#{CGI.escape(issuer)}&sub=#{CGI.escape(subject)}"
177
+ OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Fetching Subordinate Statement from: #{fetch_url}")
178
+
179
+ begin
180
+ response = HttpClient.get(fetch_url, timeout: @timeout)
181
+ unless response.status.success?
182
+ raise FetchError, "Failed to fetch subordinate statement from #{fetch_url}: HTTP #{response.status}"
183
+ end
184
+
185
+ subordinate_statement_jwt = response.body.to_s
186
+ EntityStatement.new(subordinate_statement_jwt)
187
+ rescue OmniauthOpenidFederation::NetworkError => e
188
+ raise FetchError, "Failed to fetch subordinate statement from #{fetch_url}: #{e.message}"
189
+ end
190
+ end
191
+
192
+ def extract_fetch_endpoint(entity_config)
193
+ # Fetch endpoint is typically at /.well-known/openid-federation/fetch
194
+ # or can be specified in metadata
195
+ parsed = entity_config.parse
196
+ issuer = parsed[:issuer] || parsed[:iss] || parsed["issuer"] || parsed["iss"]
197
+ return nil unless issuer
198
+
199
+ "#{issuer}/.well-known/openid-federation/fetch"
200
+ end
201
+
202
+ def extract_authority_hints(entity_config)
203
+ parsed = entity_config.parse
204
+ parsed[:authority_hints] || parsed["authority_hints"]
205
+ end
206
+
207
+ def validate_entity_statement(statement, issuer_config)
208
+ validator = EntityStatementValidator.new(
209
+ jwt_string: statement.entity_statement,
210
+ issuer_entity_configuration: issuer_config
211
+ )
212
+ validator.validate!
213
+ end
214
+
215
+ def is_trust_anchor?(entity_config)
216
+ parsed = entity_config.parse
217
+ entity_id = parsed[:issuer] || parsed[:iss] || parsed["issuer"] || parsed["iss"]
218
+
219
+ @trust_anchors.any? do |ta|
220
+ ta[:entity_id] == entity_id
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end