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,435 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Mock Relying Party (RP) Server for OpenID Federation Testing
5
+ #
6
+ # This is a standalone Webrick server that acts as a mock RP server
7
+ # for testing OpenID Federation flows. It supports:
8
+ # - Entity Configuration endpoint (/.well-known/openid-federation)
9
+ # - Fetch Endpoint (/.well-known/openid-federation/fetch)
10
+ # - Callback endpoint for authorization responses
11
+ # - Full OpenID Federation flow simulation
12
+ #
13
+ # Usage:
14
+ # ruby examples/mock_rp_server.rb
15
+ #
16
+ # Access:
17
+ # http://localhost:9293/.well-known/openid-federation
18
+ # http://localhost:9293/login?provider=<op_entity_id>
19
+ # http://localhost:9293/callback - Authorization callback
20
+
21
+ require "bundler/setup"
22
+ require "webrick"
23
+ require "yaml"
24
+ require "json"
25
+ require "jwt"
26
+ require "openssl"
27
+ require "base64"
28
+ require "uri"
29
+ require "cgi"
30
+ require "securerandom"
31
+ require "net/http"
32
+
33
+ # Add the gem to the load path
34
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
35
+ require "omniauth_openid_federation"
36
+
37
+ class MockRPServer
38
+ # Load configuration
39
+ def self.load_config
40
+ config_path = File.expand_path("../config/mock_rp.yml", __dir__)
41
+ if File.exist?(config_path)
42
+ YAML.load_file(config_path)
43
+ else
44
+ {
45
+ "entity_id" => ENV["RP_ENTITY_ID"] || "http://localhost:9293",
46
+ "server_host" => ENV["RP_SERVER_HOST"] || "localhost:9293",
47
+ "signing_key" => ENV["RP_SIGNING_KEY"],
48
+ "encryption_key" => ENV["RP_ENCRYPTION_KEY"],
49
+ "trust_anchors" => parse_trust_anchors(ENV["RP_TRUST_ANCHORS"]),
50
+ "authority_hints" => parse_array(ENV["RP_AUTHORITY_HINTS"]),
51
+ "redirect_uris" => parse_array(ENV["RP_REDIRECT_URIS"]) || ["http://localhost:9293/callback"]
52
+ }
53
+ end
54
+ end
55
+
56
+ def self.parse_trust_anchors(str)
57
+ return [] unless str
58
+ JSON.parse(str)
59
+ rescue JSON::ParserError
60
+ []
61
+ end
62
+
63
+ def self.parse_array(str)
64
+ return [] unless str
65
+ str.split(",").map(&:strip)
66
+ end
67
+
68
+ def self.load_signing_key(key_data)
69
+ if key_data.nil? || key_data.empty?
70
+ OpenSSL::PKey::RSA.new(2048)
71
+ elsif key_data.is_a?(String)
72
+ if key_data.include?("BEGIN")
73
+ OpenSSL::PKey::RSA.new(key_data)
74
+ else
75
+ OpenSSL::PKey::RSA.new(Base64.decode64(key_data))
76
+ end
77
+ else
78
+ raise "Invalid signing key format"
79
+ end
80
+ end
81
+
82
+ def self.load_encryption_key(key_data)
83
+ return nil if key_data.nil? || key_data.empty?
84
+ load_signing_key(key_data)
85
+ end
86
+
87
+ # Initialize configuration
88
+ CONFIG = load_config
89
+ ENTITY_ID = CONFIG["entity_id"] || "http://localhost:9293"
90
+ SERVER_HOST = CONFIG["server_host"] || "localhost:9293"
91
+ SIGNING_KEY = load_signing_key(CONFIG["signing_key"])
92
+ ENCRYPTION_KEY = load_encryption_key(CONFIG["encryption_key"]) || SIGNING_KEY
93
+ TRUST_ANCHORS = CONFIG["trust_anchors"] || []
94
+ AUTHORITY_HINTS = CONFIG["authority_hints"] || []
95
+ REDIRECT_URIS = CONFIG["redirect_uris"] || ["http://localhost:9293/callback"]
96
+
97
+ # Store for authorization state
98
+ AUTHORIZATION_STATE = {}
99
+
100
+ # Configure FederationEndpoint (deferred until server starts)
101
+ def self.configure_federation_endpoint
102
+ base_url = base_url_static
103
+ # Use localhost URLs if entity_id is localhost (for isolation)
104
+ redirect_uris = if ENTITY_ID.include?("localhost")
105
+ REDIRECT_URIS.map { |uri| uri.include?("localhost") ? uri : "#{base_url}/callback" }
106
+ else
107
+ REDIRECT_URIS.map { |uri| uri.gsub("https://rp.example.com", base_url) }
108
+ end
109
+
110
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
111
+ issuer: ENTITY_ID,
112
+ private_key: SIGNING_KEY,
113
+ metadata: {
114
+ openid_relying_party: {
115
+ "redirect_uris" => redirect_uris,
116
+ "client_name" => "Mock RP Server",
117
+ "application_type" => "web"
118
+ }
119
+ }
120
+ )
121
+
122
+ # Set authority_hints if provided (must be done after auto_configure)
123
+ if AUTHORITY_HINTS.any?
124
+ OmniauthOpenidFederation::FederationEndpoint.configure do |config|
125
+ config.authority_hints = AUTHORITY_HINTS
126
+ end
127
+ end
128
+ end
129
+
130
+ def self.base_url_static
131
+ "http://#{SERVER_HOST}"
132
+ end
133
+
134
+ # Webrick servlet to handle requests
135
+ class Servlet < WEBrick::HTTPServlet::AbstractServlet
136
+ def service(req, res)
137
+ path = req.path
138
+ method = req.request_method
139
+ params = parse_query(req.query_string)
140
+
141
+ case [method, path]
142
+ when ["GET", "/"]
143
+ handle_health_check(req, res)
144
+ when ["GET", "/.well-known/openid-federation"]
145
+ handle_entity_configuration(req, res)
146
+ when ["GET", "/.well-known/openid-federation/fetch"]
147
+ handle_fetch(req, res, params)
148
+ when ["GET", "/.well-known/jwks.json"]
149
+ handle_jwks(req, res)
150
+ when ["GET", "/login"]
151
+ handle_login(req, res, params)
152
+ when ["GET", "/callback"]
153
+ handle_callback(req, res, params)
154
+ else
155
+ res.status = 404
156
+ res.content_type = "application/json"
157
+ res.body = {error: "not_found", error_description: "Endpoint not found"}.to_json
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def parse_query(query_string)
164
+ return {} unless query_string
165
+ CGI.parse(query_string).transform_values { |v| v.first }
166
+ end
167
+
168
+ def base_url(req)
169
+ scheme = req.request_uri.scheme || "http"
170
+ host = req.host
171
+ port = req.port
172
+ port_str = ((port == 80 && scheme == "http") || (port == 443 && scheme == "https")) ? "" : ":#{port}"
173
+ "#{scheme}://#{host}#{port_str}"
174
+ end
175
+
176
+ def json_response(res, data, status: 200)
177
+ res.status = status
178
+ res.content_type = "application/json"
179
+ res.body = data.to_json
180
+ end
181
+
182
+ def handle_health_check(req, res)
183
+ json_response(res, {
184
+ status: "ok",
185
+ entity_id: MockRPServer::ENTITY_ID,
186
+ endpoints: {
187
+ entity_configuration: "#{base_url(req)}/.well-known/openid-federation",
188
+ login: "#{base_url(req)}/login?provider=<op_entity_id>",
189
+ callback: "#{base_url(req)}/callback"
190
+ }
191
+ })
192
+ end
193
+
194
+ def handle_entity_configuration(req, res)
195
+ entity_statement = OmniauthOpenidFederation::FederationEndpoint.generate_entity_statement
196
+ res.status = 200
197
+ res.content_type = "application/jwt"
198
+ res["Cache-Control"] = "public, max-age=3600"
199
+ res.body = entity_statement
200
+ end
201
+
202
+ def handle_fetch(req, res, params)
203
+ subject_entity_id = params["sub"]
204
+ unless subject_entity_id
205
+ return json_response(res, {error: "invalid_request", error_description: "Missing required parameter: sub"}, status: 400)
206
+ end
207
+
208
+ subordinate_statement = OmniauthOpenidFederation::FederationEndpoint.get_subordinate_statement(subject_entity_id)
209
+ unless subordinate_statement
210
+ return json_response(res, {error: "not_found", error_description: "Subordinate Statement not found"}, status: 404)
211
+ end
212
+
213
+ res.status = 200
214
+ res.content_type = "application/entity-statement+jwt"
215
+ res["Cache-Control"] = "public, max-age=3600"
216
+ res.body = subordinate_statement
217
+ end
218
+
219
+ def handle_jwks(req, res)
220
+ jwks = OmniauthOpenidFederation::FederationEndpoint.current_jwks
221
+ res.status = 200
222
+ res.content_type = "application/json"
223
+ res["Cache-Control"] = "public, max-age=3600"
224
+ res.body = jwks.to_json
225
+ end
226
+
227
+ def handle_login(req, res, params)
228
+ # Default to localhost OP if not specified (for isolation)
229
+ provider_entity_id = params["provider"] || ENV["OP_ENTITY_ID"] || "http://localhost:9292"
230
+ redirect_uri = "#{base_url(req)}/callback"
231
+
232
+ # Step 1: Fetch provider's entity statement
233
+ provider_url = if provider_entity_id.include?("localhost")
234
+ port = provider_entity_id.match(/:(\d+)/)&.captures&.first || "9292"
235
+ "http://localhost:#{port}/.well-known/openid-federation"
236
+ else
237
+ "#{provider_entity_id}/.well-known/openid-federation"
238
+ end
239
+
240
+ begin
241
+ provider_statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
242
+ provider_url
243
+ )
244
+ provider_metadata = provider_statement.parse
245
+ rescue => e
246
+ return json_response(res, {error: "provider_error", error_description: "Failed to fetch provider entity statement: #{e.message}"}, status: 500)
247
+ end
248
+
249
+ # Step 2: Extract authorization endpoint
250
+ op_metadata = provider_metadata[:metadata][:openid_provider] || provider_metadata["metadata"]["openid_provider"]
251
+ authorization_endpoint = op_metadata[:authorization_endpoint] || op_metadata["authorization_endpoint"]
252
+
253
+ unless authorization_endpoint
254
+ return json_response(res, {error: "provider_error", error_description: "Provider metadata missing authorization_endpoint"}, status: 500)
255
+ end
256
+
257
+ # Step 3: Build signed request object
258
+ state = SecureRandom.hex(32)
259
+ nonce = SecureRandom.hex(32)
260
+
261
+ begin
262
+ jws = OmniauthOpenidFederation::Jws.new(
263
+ client_id: MockRPServer::ENTITY_ID,
264
+ redirect_uri: redirect_uri,
265
+ scope: "openid profile email",
266
+ audience: provider_entity_id,
267
+ state: state,
268
+ nonce: nonce,
269
+ private_key: MockRPServer::SIGNING_KEY
270
+ )
271
+
272
+ # Check if provider requires encryption
273
+ request_object_encryption_alg = op_metadata[:request_object_encryption_alg] || op_metadata["request_object_encryption_alg"]
274
+ if request_object_encryption_alg
275
+ provider_jwks_uri = op_metadata[:jwks_uri] || op_metadata["jwks_uri"]
276
+ if provider_jwks_uri
277
+ provider_jwks = OmniauthOpenidFederation::Jwks::Fetch.run(provider_jwks_uri)
278
+ encryption_key_data = provider_jwks["keys"]&.find { |k| (k["use"] || k[:use]) == "enc" } || provider_jwks["keys"]&.first
279
+ if encryption_key_data
280
+ OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(encryption_key_data)
281
+ request_object = jws.sign(provider_metadata: op_metadata, always_encrypt: true)
282
+ else
283
+ request_object = jws.sign(provider_metadata: op_metadata)
284
+ end
285
+ else
286
+ request_object = jws.sign(provider_metadata: op_metadata)
287
+ end
288
+ else
289
+ request_object = jws.sign(provider_metadata: op_metadata)
290
+ end
291
+ rescue => e
292
+ return json_response(res, {error: "request_error", error_description: "Failed to generate request object: #{e.message}"}, status: 500)
293
+ end
294
+
295
+ # Step 4: Store state
296
+ MockRPServer::AUTHORIZATION_STATE[state] = {
297
+ provider_entity_id: provider_entity_id,
298
+ redirect_uri: redirect_uri,
299
+ nonce: nonce,
300
+ created_at: Time.now
301
+ }
302
+
303
+ # Step 5: Redirect to provider
304
+ auth_url = URI.parse(authorization_endpoint)
305
+ auth_url.query = URI.encode_www_form({
306
+ "request" => request_object
307
+ })
308
+
309
+ res.status = 302
310
+ res["Location"] = auth_url.to_s
311
+ end
312
+
313
+ def handle_callback(req, res, params)
314
+ code = params["code"]
315
+ state = params["state"]
316
+ error = params["error"]
317
+ error_description = params["error_description"]
318
+
319
+ if error
320
+ return json_response(res, {error: error, error_description: error_description}, status: 400)
321
+ end
322
+
323
+ unless code && state
324
+ return json_response(res, {error: "invalid_request", error_description: "Missing code or state"}, status: 400)
325
+ end
326
+
327
+ state_data = MockRPServer::AUTHORIZATION_STATE.delete(state)
328
+ unless state_data
329
+ return json_response(res, {error: "invalid_state", error_description: "Invalid or expired state"}, status: 400)
330
+ end
331
+
332
+ provider_entity_id = state_data[:provider_entity_id]
333
+
334
+ # Step 6: Exchange authorization code for tokens
335
+ begin
336
+ provider_url = if provider_entity_id.include?("localhost")
337
+ port = provider_entity_id.match(/:(\d+)/)&.captures&.first || "9292"
338
+ "http://localhost:#{port}/.well-known/openid-federation"
339
+ else
340
+ "#{provider_entity_id}/.well-known/openid-federation"
341
+ end
342
+
343
+ provider_statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
344
+ provider_url
345
+ )
346
+ provider_metadata = provider_statement.parse
347
+ op_metadata = provider_metadata[:metadata][:openid_provider] || provider_metadata["metadata"]["openid_provider"]
348
+ token_endpoint = op_metadata[:token_endpoint] || op_metadata["token_endpoint"]
349
+
350
+ # Exchange code for tokens
351
+ uri = URI.parse(token_endpoint)
352
+ http = Net::HTTP.new(uri.host, uri.port)
353
+ request = Net::HTTP::Post.new(uri.path)
354
+ request.set_form_data({
355
+ "grant_type" => "authorization_code",
356
+ "code" => code,
357
+ "redirect_uri" => state_data[:redirect_uri]
358
+ })
359
+
360
+ response = http.request(request)
361
+ token_response = JSON.parse(response.body)
362
+
363
+ if response.code != "200"
364
+ return json_response(res, token_response, status: response.code.to_i)
365
+ end
366
+
367
+ id_token = token_response["id_token"]
368
+
369
+ # Step 7: Validate ID token
370
+ provider_jwks_uri = op_metadata[:jwks_uri] || op_metadata["jwks_uri"]
371
+ OmniauthOpenidFederation::Jwks::Fetch.run(provider_jwks_uri)
372
+
373
+ decoded = OmniauthOpenidFederation::Jwks::Decode.jwt(id_token, provider_jwks_uri)
374
+ id_token_payload = decoded.first
375
+
376
+ # Validate claims
377
+ unless id_token_payload["iss"] == provider_entity_id
378
+ return json_response(res, {error: "invalid_token", error_description: "Invalid issuer"}, status: 400)
379
+ end
380
+
381
+ unless id_token_payload["aud"] == MockRPServer::ENTITY_ID
382
+ return json_response(res, {error: "invalid_token", error_description: "Invalid audience"}, status: 400)
383
+ end
384
+
385
+ unless id_token_payload["nonce"] == state_data[:nonce]
386
+ return json_response(res, {error: "invalid_token", error_description: "Invalid nonce"}, status: 400)
387
+ end
388
+
389
+ json_response(res, {
390
+ status: "success",
391
+ user: {
392
+ sub: id_token_payload["sub"],
393
+ iss: id_token_payload["iss"]
394
+ },
395
+ id_token: id_token_payload
396
+ })
397
+ rescue => e
398
+ json_response(res, {error: "token_error", error_description: "Failed to exchange code or validate token: #{e.message}"}, status: 500)
399
+ end
400
+ end
401
+ end
402
+
403
+ def self.run!
404
+ port = ENV["RP_PORT"]&.to_i || 9293
405
+ bind = ENV["RP_BIND"] || "localhost"
406
+
407
+ server = WEBrick::HTTPServer.new(Port: port, BindAddress: bind)
408
+ server.mount("/", Servlet)
409
+
410
+ trap("INT") { server.shutdown }
411
+ trap("TERM") { server.shutdown }
412
+
413
+ server.start
414
+ end
415
+ end
416
+
417
+ # Run the server
418
+ if __FILE__ == $0
419
+ # Configure federation endpoint before starting
420
+ MockRPServer.configure_federation_endpoint
421
+
422
+ puts "Starting Mock RP Server..."
423
+ puts "Entity ID: #{MockRPServer::ENTITY_ID}"
424
+ puts "Server: http://#{MockRPServer::SERVER_HOST}"
425
+ puts ""
426
+ puts "Endpoints:"
427
+ puts " GET /.well-known/openid-federation - Entity Configuration"
428
+ puts " GET /.well-known/openid-federation/fetch?sub=<entity_id> - Fetch Subordinate Statement"
429
+ puts " GET /.well-known/jwks.json - JWKS"
430
+ puts " GET /login?provider=<op_entity_id> - Initiate login flow"
431
+ puts " GET /callback - Authorization callback"
432
+ puts ""
433
+
434
+ MockRPServer.run!
435
+ end