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,1334 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Integration Test Flow for OpenID Federation
5
+ #
6
+ # This script tests the complete OpenID Federation flow:
7
+ # 1. Provider exposes entity statement with JWKS
8
+ # 2. Client exposes entity statement with JWKS
9
+ # 3. Client fetches provider statement with keys
10
+ # 4. Client sends login request (with signed request object)
11
+ # 5. Provider fetches client statement and keys
12
+ # 6. Exchange and authenticated login
13
+ #
14
+ # It also tests error scenarios:
15
+ # - Wrong statements
16
+ # - Wrong keys
17
+ # - Wrong request encryption, validation failure
18
+ # - Other edge cases
19
+ #
20
+ # Usage:
21
+ # ruby examples/integration_test_flow.rb
22
+ #
23
+ # Environment Variables:
24
+ # OP_URL - OP server URL (default: http://localhost:9292)
25
+ # RP_URL - RP server URL (default: http://localhost:9293)
26
+ # OP_PORT - OP server port (default: 9292)
27
+ # RP_PORT - RP server port (default: 9293)
28
+ # OP_ENTITY_ID - OP entity ID (default: https://op.example.com)
29
+ # RP_ENTITY_ID - RP entity ID (default: https://rp.example.com)
30
+ # TMP_DIR - Temporary directory for keys/configs (default: tmp/integration_test)
31
+ # AUTO_START_SERVERS - Auto-start servers (default: true)
32
+ # CLEANUP_ON_EXIT - Clean up tmp dirs on exit (default: true)
33
+ # KEY_TYPE - Key type: 'single' or 'separate' (default: separate)
34
+
35
+ require "bundler/setup"
36
+ require "net/http"
37
+ require "uri"
38
+ require "json"
39
+ require "base64"
40
+ require "openssl"
41
+ require "securerandom"
42
+ require "fileutils"
43
+ require "timeout"
44
+ require "open3"
45
+ require "jwe"
46
+ require "jwt"
47
+
48
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
49
+ require "omniauth_openid_federation"
50
+
51
+ class IntegrationTestFlow
52
+ OP_URL = ENV["OP_URL"] || "http://localhost:9292"
53
+ RP_URL = ENV["RP_URL"] || "http://localhost:9293"
54
+ OP_PORT = ENV["OP_PORT"]&.to_i || 9292
55
+ RP_PORT = ENV["RP_PORT"]&.to_i || 9293
56
+ # Use localhost URLs as entity IDs for complete isolation
57
+ OP_ENTITY_ID = ENV["OP_ENTITY_ID"] || "http://localhost:9292"
58
+ RP_ENTITY_ID = ENV["RP_ENTITY_ID"] || "http://localhost:9293"
59
+ TMP_DIR = ENV["TMP_DIR"] || File.join(Dir.pwd, "tmp", "integration_test")
60
+ AUTO_START_SERVERS = ENV["AUTO_START_SERVERS"] != "false"
61
+ CLEANUP_ON_EXIT = ENV["CLEANUP_ON_EXIT"] != "false"
62
+ KEY_TYPE = ENV["KEY_TYPE"] || "separate"
63
+
64
+ def initialize
65
+ @test_results = []
66
+ @tmp_dir = File.expand_path(TMP_DIR)
67
+ @op_pid = nil
68
+ @rp_pid = nil
69
+ @op_keys_dir = File.join(@tmp_dir, "op_keys")
70
+ @rp_keys_dir = File.join(@tmp_dir, "rp_keys")
71
+ @op_config_dir = File.join(@tmp_dir, "op_config")
72
+ @rp_config_dir = File.join(@tmp_dir, "rp_config")
73
+ end
74
+
75
+ def run
76
+ setup_directories
77
+ generate_keys
78
+ configure_servers
79
+ start_servers if AUTO_START_SERVERS
80
+ wait_for_servers
81
+ run_all_tests
82
+ ensure
83
+ cleanup if CLEANUP_ON_EXIT
84
+ end
85
+
86
+ def run_all_tests
87
+ puts "=" * 80
88
+ puts "OpenID Federation Integration Tests"
89
+ puts "=" * 80
90
+ puts ""
91
+ puts "Configuration:"
92
+ puts " OP URL: #{OP_URL}"
93
+ puts " RP URL: #{RP_URL}"
94
+ puts " OP Entity ID: #{OP_ENTITY_ID} (localhost - no DNS needed)"
95
+ puts " RP Entity ID: #{RP_ENTITY_ID} (localhost - no DNS needed)"
96
+ puts " Tmp Dir: #{@tmp_dir}"
97
+ puts " Isolation: Complete localhost isolation, no external dependencies"
98
+ puts ""
99
+
100
+ test_suite("Happy Path Flow") do
101
+ test_provider_exposes_entity_statement
102
+ test_client_exposes_entity_statement
103
+ test_client_fetches_provider_statement
104
+ test_client_sends_login_request
105
+ test_provider_fetches_client_statement
106
+ test_exchange_and_authenticated_login
107
+ end
108
+
109
+ test_suite("Error Scenarios") do
110
+ test_wrong_entity_statement
111
+ test_wrong_jwks_keys
112
+ test_invalid_request_object
113
+ test_expired_entity_statement
114
+ test_missing_metadata
115
+ end
116
+
117
+ test_suite("Request Object Encryption") do
118
+ test_encrypted_request_object
119
+ test_invalid_encryption_key
120
+ test_malformed_encrypted_request
121
+ end
122
+
123
+ test_suite("ID Token Validation") do
124
+ test_id_token_validation_with_trust_chain
125
+ test_invalid_id_token_signature
126
+ test_expired_id_token
127
+ test_id_token_wrong_audience
128
+ end
129
+
130
+ test_suite("Entity Statement Validation") do
131
+ test_invalid_entity_statement_signature
132
+ test_wrong_algorithm_entity_statement
133
+ test_missing_required_claims_entity_statement
134
+ test_invalid_jwt_typ_entity_statement
135
+ end
136
+
137
+ test_suite("Signed JWKS Endpoint") do
138
+ test_signed_jwks_endpoint
139
+ test_invalid_signed_jwks_signature
140
+ end
141
+
142
+ test_suite("Request Object Validation Details") do
143
+ test_request_object_missing_required_claims
144
+ test_request_object_invalid_nonce
145
+ test_request_object_expiration
146
+ end
147
+
148
+ print_summary
149
+ end
150
+
151
+ private
152
+
153
+ def setup_directories
154
+ puts "Setting up directories..."
155
+ [@tmp_dir, @op_keys_dir, @rp_keys_dir, @op_config_dir, @rp_config_dir].each do |dir|
156
+ FileUtils.mkdir_p(dir)
157
+ end
158
+ puts " Created: #{@tmp_dir}"
159
+ end
160
+
161
+ def generate_keys
162
+ puts "Generating keys..."
163
+ puts " Key type: #{KEY_TYPE}"
164
+
165
+ # Generate OP keys
166
+ op_result = if KEY_TYPE == "separate"
167
+ OmniauthOpenidFederation::TasksHelper.prepare_client_keys(
168
+ key_type: "separate",
169
+ output_dir: @op_keys_dir
170
+ )
171
+ else
172
+ OmniauthOpenidFederation::TasksHelper.prepare_client_keys(
173
+ key_type: "single",
174
+ output_dir: @op_keys_dir
175
+ )
176
+ end
177
+
178
+ # Generate RP keys
179
+ rp_result = if KEY_TYPE == "separate"
180
+ OmniauthOpenidFederation::TasksHelper.prepare_client_keys(
181
+ key_type: "separate",
182
+ output_dir: @rp_keys_dir
183
+ )
184
+ else
185
+ OmniauthOpenidFederation::TasksHelper.prepare_client_keys(
186
+ key_type: "single",
187
+ output_dir: @rp_keys_dir
188
+ )
189
+ end
190
+
191
+ # Handle both single and separate key types
192
+ @op_signing_key_path = op_result[:signing_key_path] || op_result[:private_key_path]
193
+ @op_encryption_key_path = op_result[:encryption_key_path] || op_result[:private_key_path] || @op_signing_key_path
194
+ @rp_signing_key_path = rp_result[:signing_key_path] || rp_result[:private_key_path]
195
+ @rp_encryption_key_path = rp_result[:encryption_key_path] || rp_result[:private_key_path] || @rp_signing_key_path
196
+
197
+ puts " OP keys: #{@op_signing_key_path}"
198
+ puts " RP keys: #{@rp_signing_key_path}"
199
+ end
200
+
201
+ def configure_servers
202
+ puts "Configuring servers..."
203
+
204
+ # Load keys
205
+ @op_signing_key = OpenSSL::PKey::RSA.new(File.read(@op_signing_key_path))
206
+ @op_encryption_key = OpenSSL::PKey::RSA.new(File.read(@op_encryption_key_path))
207
+ @rp_signing_key = OpenSSL::PKey::RSA.new(File.read(@rp_signing_key_path))
208
+ @rp_encryption_key = OpenSSL::PKey::RSA.new(File.read(@rp_encryption_key_path))
209
+
210
+ # Set environment variables for servers
211
+ # Use localhost URLs to ensure complete isolation
212
+ ENV["OP_ENTITY_ID"] = OP_ENTITY_ID
213
+ ENV["OP_SERVER_HOST"] = "localhost:#{OP_PORT}"
214
+ ENV["OP_SIGNING_KEY"] = @op_signing_key.to_pem
215
+ ENV["OP_ENCRYPTION_KEY"] = @op_encryption_key.to_pem
216
+ ENV["PORT"] = OP_PORT.to_s
217
+ # Override default metadata to use localhost URLs
218
+ op_metadata = {
219
+ "issuer" => OP_ENTITY_ID,
220
+ "authorization_endpoint" => "#{OP_URL}/auth",
221
+ "token_endpoint" => "#{OP_URL}/token",
222
+ "userinfo_endpoint" => "#{OP_URL}/userinfo",
223
+ "jwks_uri" => "#{OP_URL}/.well-known/jwks.json",
224
+ "signed_jwks_uri" => "#{OP_URL}/.well-known/signed-jwks.json",
225
+ "client_registration_types_supported" => ["automatic", "explicit"],
226
+ "response_types_supported" => ["code"],
227
+ "grant_types_supported" => ["authorization_code"],
228
+ "id_token_signing_alg_values_supported" => ["RS256"],
229
+ "id_token_encryption_alg_values_supported" => ["RSA-OAEP"],
230
+ "id_token_encryption_enc_values_supported" => ["A128CBC-HS256"],
231
+ "request_object_signing_alg_values_supported" => ["RS256"],
232
+ "request_object_encryption_alg_values_supported" => ["RSA-OAEP"],
233
+ "request_object_encryption_enc_values_supported" => ["A128CBC-HS256"],
234
+ "scopes_supported" => ["openid", "profile", "email"]
235
+ }
236
+ ENV["OP_METADATA"] = op_metadata.to_json
237
+
238
+ ENV["RP_ENTITY_ID"] = RP_ENTITY_ID
239
+ ENV["RP_SERVER_HOST"] = "localhost:#{RP_PORT}"
240
+ ENV["RP_SIGNING_KEY"] = @rp_signing_key.to_pem
241
+ ENV["RP_ENCRYPTION_KEY"] = @rp_encryption_key.to_pem
242
+ ENV["RP_PORT"] = RP_PORT.to_s
243
+ ENV["RP_REDIRECT_URIS"] = "#{RP_URL}/callback"
244
+
245
+ puts " Environment variables configured"
246
+ puts " OP Entity ID: #{OP_ENTITY_ID}"
247
+ puts " RP Entity ID: #{RP_ENTITY_ID}"
248
+ end
249
+
250
+ def start_servers
251
+ puts "Starting servers..."
252
+
253
+ op_script = File.expand_path("../examples/mock_op_server.rb", __dir__)
254
+ rp_script = File.expand_path("../examples/mock_rp_server.rb", __dir__)
255
+
256
+ # Start OP server
257
+ puts " Starting OP server on port #{OP_PORT}..."
258
+ @op_pid = spawn_server(op_script, "OP", OP_PORT)
259
+
260
+ # Start RP server
261
+ puts " Starting RP server on port #{RP_PORT}..."
262
+ @rp_pid = spawn_server(rp_script, "RP", RP_PORT)
263
+
264
+ puts " Servers started (OP PID: #{@op_pid}, RP PID: #{@rp_pid})"
265
+ end
266
+
267
+ def spawn_server(script, name, port)
268
+ # Use spawn to run server in background with bundle exec
269
+ log_file = File.join(@tmp_dir, "#{name.downcase}_server.log")
270
+ err_file = File.join(@tmp_dir, "#{name.downcase}_server_error.log")
271
+
272
+ script_path = File.expand_path(script, __dir__)
273
+ project_root = File.expand_path("..", __dir__)
274
+
275
+ # Use bundle exec to ensure all dependencies (jwt, jwe, etc.) are available
276
+ pid = Process.spawn(
277
+ {
278
+ "RUBYOPT" => "-W0", # Suppress warnings
279
+ "BUNDLE_GEMFILE" => File.join(project_root, "Gemfile")
280
+ },
281
+ "bundle", "exec", "ruby", script_path,
282
+ out: [log_file, "w"],
283
+ err: [err_file, "w"],
284
+ chdir: project_root # Run from project root
285
+ )
286
+ Process.detach(pid)
287
+
288
+ # Give the process a moment to start
289
+ sleep 0.2
290
+
291
+ # Verify process is still running
292
+ begin
293
+ Process.kill(0, pid)
294
+ rescue Errno::ESRCH
295
+ # Process died immediately - check error log
296
+ sleep 0.1 # Give it a moment to write error log
297
+ if File.exist?(err_file) && File.size(err_file) > 0
298
+ error_content = File.read(err_file)
299
+ raise "Server #{name} failed to start. Error: #{error_content}"
300
+ else
301
+ raise "Server #{name} failed to start (process died immediately). Check #{err_file}"
302
+ end
303
+ end
304
+
305
+ pid
306
+ end
307
+
308
+ def wait_for_servers
309
+ return unless AUTO_START_SERVERS
310
+
311
+ puts "Waiting for servers to be ready..."
312
+ # Give servers a moment to start binding to ports
313
+ sleep 0.5
314
+
315
+ max_attempts = 20 # Reduced from 30
316
+ check_interval = 0.2 # Check every 200ms instead of 500ms
317
+
318
+ [OP_URL, RP_URL].each do |url|
319
+ attempt = 0
320
+ ready = false
321
+ server_name = url.include?("9292") ? "OP" : "RP"
322
+ start_time = Time.now
323
+
324
+ while attempt < max_attempts && !ready
325
+ begin
326
+ uri = URI.parse("#{url}/")
327
+ http = Net::HTTP.new(uri.host, uri.port)
328
+ http.open_timeout = 0.5 # Reduced timeout
329
+ http.read_timeout = 0.5
330
+ response = http.get(uri.path)
331
+
332
+ if response.code == "200"
333
+ elapsed = (Time.now - start_time).round(1)
334
+ puts " ✓ #{url} is ready (#{elapsed}s)"
335
+ ready = true
336
+ end
337
+ rescue
338
+ # Unexpected error - ignore for now
339
+ end
340
+
341
+ unless ready
342
+ attempt += 1
343
+ # Show progress every 5 attempts (1 second)
344
+ if attempt % 5 == 0
345
+ elapsed = (Time.now - start_time).round(1)
346
+ print "."
347
+ end
348
+ sleep check_interval
349
+ end
350
+ end
351
+
352
+ unless ready
353
+ elapsed = (Time.now - start_time).round(1)
354
+ puts "\n ✗ #{server_name} server at #{url} did not become ready in time (#{elapsed}s)"
355
+ err_log = File.join(@tmp_dir, "#{server_name.downcase}_server_error.log")
356
+ log_file = File.join(@tmp_dir, "#{server_name.downcase}_server.log")
357
+
358
+ if File.exist?(err_log) && File.size(err_log) > 0
359
+ puts " Error log:"
360
+ File.readlines(err_log).last(5).each { |line| puts " #{line.chomp}" }
361
+ end
362
+ if File.exist?(log_file) && File.size(log_file) > 0
363
+ puts " Last log entries:"
364
+ File.readlines(log_file).last(5).each { |line| puts " #{line.chomp}" }
365
+ end
366
+
367
+ raise "#{server_name} server at #{url} did not become ready after #{elapsed} seconds"
368
+ end
369
+ end
370
+ puts ""
371
+ end
372
+
373
+ def test_suite(name)
374
+ puts "Testing: #{name}"
375
+ puts "-" * 80
376
+ yield
377
+ puts ""
378
+ end
379
+
380
+ def test(name)
381
+ print " ✓ #{name}... "
382
+ begin
383
+ result = yield
384
+ if result
385
+ puts "PASS"
386
+ @test_results << {name: name, status: :pass}
387
+ else
388
+ puts "FAIL"
389
+ @test_results << {name: name, status: :fail}
390
+ end
391
+ rescue => e
392
+ puts "ERROR: #{e.message}"
393
+ @test_results << {name: name, status: :error, error: e.message}
394
+ end
395
+ end
396
+
397
+ def test_provider_exposes_entity_statement
398
+ test("Provider exposes entity statement with JWKS") do
399
+ uri = URI.parse("#{OP_URL}/.well-known/openid-federation")
400
+ response = Net::HTTP.get_response(uri)
401
+
402
+ return false unless response.code == "200"
403
+ return false unless response.content_type == "application/jwt"
404
+
405
+ jwt = response.body
406
+ parts = jwt.split(".")
407
+ return false unless parts.length == 3
408
+
409
+ payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
410
+ return false unless payload["iss"]
411
+ return false unless payload["jwks"]
412
+ return false unless payload["jwks"]["keys"]
413
+
414
+ true
415
+ end
416
+ end
417
+
418
+ def test_client_exposes_entity_statement
419
+ test("Client exposes entity statement with JWKS") do
420
+ uri = URI.parse("#{RP_URL}/.well-known/openid-federation")
421
+ response = Net::HTTP.get_response(uri)
422
+
423
+ return false unless response.code == "200"
424
+ return false unless response.content_type == "application/jwt"
425
+
426
+ jwt = response.body
427
+ parts = jwt.split(".")
428
+ return false unless parts.length == 3
429
+
430
+ payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
431
+ return false unless payload["iss"]
432
+ return false unless payload["jwks"]
433
+ return false unless payload["jwks"]["keys"]
434
+
435
+ true
436
+ end
437
+ end
438
+
439
+ def test_client_fetches_provider_statement
440
+ test("Client fetches provider statement with keys") do
441
+ # Use localhost URL directly (no DNS needed)
442
+ uri = URI.parse("#{OP_URL}/.well-known/openid-federation")
443
+ response = Net::HTTP.get_response(uri)
444
+
445
+ return false unless response.code == "200"
446
+
447
+ statement = OmniauthOpenidFederation::Federation::EntityStatement.new(response.body)
448
+ metadata = statement.parse
449
+
450
+ return false unless metadata[:issuer]
451
+ return false unless metadata[:jwks]
452
+ # Verify issuer uses localhost (no external DNS)
453
+ return false unless metadata[:issuer].include?("localhost")
454
+
455
+ true
456
+ end
457
+ end
458
+
459
+ def test_client_sends_login_request
460
+ test("Client sends login request with signed request object") do
461
+ redirect_uri = "#{RP_URL}/callback"
462
+ client_id = RP_ENTITY_ID
463
+ provider_entity_id = OP_ENTITY_ID
464
+
465
+ # Verify we're using localhost URLs (no DNS needed)
466
+ return false unless client_id.include?("localhost")
467
+ return false unless provider_entity_id.include?("localhost")
468
+
469
+ jws = OmniauthOpenidFederation::Jws.new(
470
+ client_id: client_id,
471
+ redirect_uri: redirect_uri,
472
+ scope: "openid",
473
+ audience: provider_entity_id,
474
+ state: SecureRandom.hex(32),
475
+ nonce: SecureRandom.hex(32),
476
+ private_key: @rp_signing_key
477
+ )
478
+
479
+ request_object = jws.sign
480
+
481
+ parts = request_object.split(".")
482
+ return false unless parts.length == 3
483
+
484
+ header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
485
+ payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
486
+
487
+ return false unless header["alg"] == "RS256"
488
+ return false unless payload["client_id"] == client_id
489
+ return false unless payload["redirect_uri"] == redirect_uri
490
+
491
+ true
492
+ end
493
+ end
494
+
495
+ def test_provider_fetches_client_statement
496
+ test("Provider fetches client statement and keys") do
497
+ uri = URI.parse("#{RP_URL}/.well-known/openid-federation")
498
+ response = Net::HTTP.get_response(uri)
499
+
500
+ return false unless response.code == "200"
501
+
502
+ statement = OmniauthOpenidFederation::Federation::EntityStatement.new(response.body)
503
+ metadata = statement.parse
504
+
505
+ return false unless metadata[:issuer]
506
+ return false unless metadata[:jwks]
507
+
508
+ true
509
+ end
510
+ end
511
+
512
+ def test_exchange_and_authenticated_login
513
+ test("Exchange and authenticated login") do
514
+ uri = URI.parse("#{OP_URL}/token")
515
+ http = Net::HTTP.new(uri.host, uri.port)
516
+ request = Net::HTTP::Post.new(uri.path)
517
+ request.set_form_data({
518
+ "grant_type" => "authorization_code",
519
+ "code" => "test_code"
520
+ })
521
+
522
+ response = http.request(request)
523
+ return false unless response.code.to_i.between?(400, 500)
524
+
525
+ body = JSON.parse(response.body)
526
+ return false unless body["error"]
527
+
528
+ true
529
+ end
530
+ end
531
+
532
+ def test_wrong_entity_statement
533
+ test("Wrong entity statement (invalid format)") do
534
+ uri = URI.parse("#{OP_URL}/.well-known/openid-federation?error_mode=invalid_statement")
535
+ response = Net::HTTP.get_response(uri)
536
+
537
+ return false unless response.code == "200"
538
+
539
+ jwt = response.body
540
+ parts = jwt.split(".")
541
+ return false unless parts.length != 3
542
+
543
+ true
544
+ end
545
+ end
546
+
547
+ def test_wrong_jwks_keys
548
+ test("Wrong JWKS keys") do
549
+ uri = URI.parse("#{OP_URL}/.well-known/jwks.json?error_mode=wrong_keys")
550
+ response = Net::HTTP.get_response(uri)
551
+
552
+ return false unless response.code == "200"
553
+
554
+ jwks = JSON.parse(response.body)
555
+ return false unless jwks["keys"]
556
+
557
+ true
558
+ end
559
+ end
560
+
561
+ def test_invalid_request_object
562
+ test("Invalid request object validation") do
563
+ uri = URI.parse("#{OP_URL}/auth?error_mode=invalid_request")
564
+ response = Net::HTTP.get_response(uri)
565
+
566
+ return false unless response.code.to_i >= 400
567
+
568
+ true
569
+ end
570
+ end
571
+
572
+ def test_expired_entity_statement
573
+ test("Expired entity statement") do
574
+ uri = URI.parse("#{OP_URL}/.well-known/openid-federation?error_mode=expired_statement")
575
+ response = Net::HTTP.get_response(uri)
576
+
577
+ return false unless response.code == "200"
578
+
579
+ jwt = response.body
580
+ parts = jwt.split(".")
581
+ return false unless parts.length == 3
582
+
583
+ payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
584
+ exp = payload["exp"]
585
+ return false unless exp < Time.now.to_i
586
+
587
+ true
588
+ end
589
+ end
590
+
591
+ def test_missing_metadata
592
+ test("Missing metadata in entity statement") do
593
+ uri = URI.parse("#{OP_URL}/.well-known/openid-federation?error_mode=missing_metadata")
594
+ response = Net::HTTP.get_response(uri)
595
+
596
+ return false unless response.code == "200"
597
+
598
+ jwt = response.body
599
+ parts = jwt.split(".")
600
+ return false unless parts.length == 3
601
+
602
+ payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
603
+ return false if payload["metadata"]
604
+
605
+ true
606
+ end
607
+ end
608
+
609
+ def test_encrypted_request_object
610
+ test("Encrypted request object") do
611
+ # Fetch provider metadata to get encryption keys
612
+ provider_uri = URI.parse("#{OP_URL}/.well-known/openid-federation")
613
+ provider_response = Net::HTTP.get_response(provider_uri)
614
+ return false unless provider_response.code == "200"
615
+
616
+ provider_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(provider_response.body)
617
+ provider_metadata = provider_statement.parse
618
+ op_metadata = provider_metadata[:metadata][:openid_provider] || provider_metadata["metadata"]["openid_provider"]
619
+
620
+ # Get provider JWKS for encryption
621
+ jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
622
+ jwks_response = Net::HTTP.get_response(jwks_uri)
623
+ return false unless jwks_response.code == "200"
624
+
625
+ provider_jwks = JSON.parse(jwks_response.body)
626
+ encryption_key_data = provider_jwks["keys"]&.find { |k| (k["use"] || k[:use]) == "enc" } || provider_jwks["keys"]&.first
627
+ return false unless encryption_key_data
628
+
629
+ OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(encryption_key_data)
630
+
631
+ # Create signed request object
632
+ redirect_uri = "#{RP_URL}/callback"
633
+ jws = OmniauthOpenidFederation::Jws.new(
634
+ client_id: RP_ENTITY_ID,
635
+ redirect_uri: redirect_uri,
636
+ scope: "openid",
637
+ audience: OP_ENTITY_ID,
638
+ state: SecureRandom.hex(32),
639
+ nonce: SecureRandom.hex(32),
640
+ private_key: @rp_signing_key
641
+ )
642
+
643
+ # Encrypt the request object
644
+ request_object = jws.sign(provider_metadata: op_metadata, always_encrypt: true)
645
+
646
+ # Verify it's encrypted (JWE has 5 parts)
647
+ parts = request_object.split(".")
648
+ return false unless parts.length == 5
649
+
650
+ # Try to send it to the provider (should succeed)
651
+ auth_uri = URI.parse("#{OP_URL}/auth")
652
+ auth_uri.query = URI.encode_www_form({"request" => request_object})
653
+ auth_response = Net::HTTP.get_response(auth_uri)
654
+
655
+ # Should redirect (302) or return error if validation fails, but not 500
656
+ return false if auth_response.code.to_i >= 500
657
+
658
+ true
659
+ end
660
+ end
661
+
662
+ def test_invalid_encryption_key
663
+ test("Invalid encryption key") do
664
+ # Create an encrypted request object encrypted with a wrong key
665
+ # The provider will try to decrypt with its own key and fail
666
+ wrong_key = OpenSSL::PKey::RSA.new(2048)
667
+
668
+ # Create signed request object
669
+ redirect_uri = "#{RP_URL}/callback"
670
+ jws = OmniauthOpenidFederation::Jws.new(
671
+ client_id: RP_ENTITY_ID,
672
+ redirect_uri: redirect_uri,
673
+ scope: "openid",
674
+ audience: OP_ENTITY_ID,
675
+ state: SecureRandom.hex(32),
676
+ nonce: SecureRandom.hex(32),
677
+ private_key: @rp_signing_key
678
+ )
679
+
680
+ # Encrypt with wrong key (provider won't be able to decrypt with its key)
681
+ signed_jwt = jws.sign
682
+ encrypted_request = JWE.encrypt(signed_jwt, wrong_key)
683
+
684
+ # Verify it's encrypted (JWE has 5 parts)
685
+ parts = encrypted_request.split(".")
686
+ return false unless parts.length == 5
687
+
688
+ # Send to provider - should fail to decrypt
689
+ auth_uri = URI.parse("#{OP_URL}/auth")
690
+ auth_uri.query = URI.encode_www_form({"request" => encrypted_request})
691
+ auth_response = Net::HTTP.get_response(auth_uri)
692
+
693
+ # Should return error (400 or 401) due to decryption failure
694
+ return false unless auth_response.code.to_i.between?(400, 499)
695
+
696
+ body = begin
697
+ JSON.parse(auth_response.body)
698
+ rescue
699
+ {}
700
+ end
701
+ # Should have error about decryption failure
702
+ return false unless body["error"] || body["error_description"]
703
+
704
+ true
705
+ end
706
+ end
707
+
708
+ def test_malformed_encrypted_request
709
+ test("Malformed encrypted request object") do
710
+ # Create a malformed encrypted request (invalid JWE format - not 5 parts)
711
+ malformed_request = "invalid.jwe.format.not.5.parts"
712
+
713
+ # Send to provider
714
+ auth_uri = URI.parse("#{OP_URL}/auth?error_mode=malformed_encryption")
715
+ auth_uri.query = URI.encode_www_form({"request" => malformed_request})
716
+ auth_response = Net::HTTP.get_response(auth_uri)
717
+
718
+ # Should return error (400 or 401)
719
+ return false unless auth_response.code.to_i.between?(400, 499)
720
+
721
+ body = begin
722
+ JSON.parse(auth_response.body)
723
+ rescue
724
+ {}
725
+ end
726
+ return false unless body["error"] || body["error_description"]
727
+
728
+ true
729
+ end
730
+ end
731
+
732
+ # ID Token Validation Tests
733
+ def test_id_token_validation_with_trust_chain
734
+ test("ID token validation with trust chain") do
735
+ # Get ID token from provider (via token exchange)
736
+ # First, we need to get an authorization code, but for testing we'll use the mock
737
+ # In a real scenario, we'd validate the ID token signature using provider's JWKS
738
+
739
+ # Fetch provider JWKS
740
+ jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
741
+ jwks_response = Net::HTTP.get_response(jwks_uri)
742
+ return false unless jwks_response.code == "200"
743
+
744
+ provider_jwks = JSON.parse(jwks_response.body)
745
+ return false unless provider_jwks["keys"]&.any?
746
+
747
+ # Verify we can decode a JWT using the JWKS (simulating ID token validation)
748
+ # Create a test ID token
749
+ now = Time.now.to_i
750
+ test_payload = {
751
+ iss: OP_ENTITY_ID,
752
+ sub: "user123",
753
+ aud: RP_ENTITY_ID,
754
+ exp: now + 3600,
755
+ iat: now,
756
+ nonce: SecureRandom.hex(32)
757
+ }
758
+
759
+ # Use OP's private key directly (we have it from setup)
760
+ # Get kid from JWKS
761
+ signing_key_data = provider_jwks["keys"].find { |k| (k["use"] || k[:use]) == "sig" || !k["use"] }
762
+ return false unless signing_key_data
763
+
764
+ kid = signing_key_data["kid"] || signing_key_data[:kid]
765
+
766
+ # Sign the token using OP's private key
767
+ header = {alg: "RS256", typ: "JWT", kid: kid}
768
+ id_token = JWT.encode(test_payload, @op_signing_key, "RS256", header)
769
+
770
+ # Validate using JWKS
771
+ begin
772
+ decoded = OmniauthOpenidFederation::Jwks::Decode.jwt(id_token, "#{OP_URL}/.well-known/jwks.json")
773
+ payload = decoded.first
774
+
775
+ # Verify claims
776
+ return false unless payload["iss"] == OP_ENTITY_ID
777
+ return false unless payload["aud"] == RP_ENTITY_ID
778
+ return false unless payload["sub"]
779
+ return false unless payload["exp"] > Time.now.to_i
780
+
781
+ true
782
+ rescue
783
+ false
784
+ end
785
+ end
786
+ end
787
+
788
+ def test_invalid_id_token_signature
789
+ test("Invalid ID token signature") do
790
+ # Create ID token signed with wrong key
791
+ wrong_key = OpenSSL::PKey::RSA.new(2048)
792
+ now = Time.now.to_i
793
+ test_payload = {
794
+ iss: OP_ENTITY_ID,
795
+ sub: "user123",
796
+ aud: RP_ENTITY_ID,
797
+ exp: now + 3600,
798
+ iat: now
799
+ }
800
+
801
+ id_token = JWT.encode(test_payload, wrong_key, "RS256")
802
+
803
+ # Try to validate - should fail
804
+ begin
805
+ OmniauthOpenidFederation::Jwks::Decode.jwt(id_token, "#{OP_URL}/.well-known/jwks.json")
806
+ false # Should have raised an error
807
+ rescue OmniauthOpenidFederation::SignatureError, JWT::VerificationError, JWT::DecodeError
808
+ true
809
+ rescue
810
+ # Other errors are also acceptable
811
+ true
812
+ end
813
+ end
814
+ end
815
+
816
+ def test_expired_id_token
817
+ test("Expired ID token") do
818
+ # Fetch provider JWKS
819
+ jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
820
+ jwks_response = Net::HTTP.get_response(jwks_uri)
821
+ return false unless jwks_response.code == "200"
822
+
823
+ provider_jwks = JSON.parse(jwks_response.body)
824
+ signing_key_data = provider_jwks["keys"].find { |k| (k["use"] || k[:use]) == "sig" || !k["use"] }
825
+ return false unless signing_key_data
826
+
827
+ kid = signing_key_data["kid"] || signing_key_data[:kid]
828
+
829
+ # Create expired ID token using OP's private key
830
+ now = Time.now.to_i
831
+ expired_payload = {
832
+ iss: OP_ENTITY_ID,
833
+ sub: "user123",
834
+ aud: RP_ENTITY_ID,
835
+ exp: now - 3600, # Expired 1 hour ago
836
+ iat: now - 7200
837
+ }
838
+
839
+ header = {alg: "RS256", typ: "JWT", kid: kid}
840
+ id_token = JWT.encode(expired_payload, @op_signing_key, "RS256", header)
841
+
842
+ # Try to validate - should fail due to expiration
843
+ begin
844
+ decoded = OmniauthOpenidFederation::Jwks::Decode.jwt(id_token, "#{OP_URL}/.well-known/jwks.json")
845
+ payload = decoded.first
846
+ # Check if exp validation is working
847
+ payload["exp"] < Time.now.to_i
848
+ rescue JWT::ExpiredSignature, OmniauthOpenidFederation::ValidationError
849
+ true
850
+ rescue
851
+ false
852
+ end
853
+ end
854
+ end
855
+
856
+ def test_id_token_wrong_audience
857
+ test("ID token with wrong audience") do
858
+ # Fetch provider JWKS
859
+ jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
860
+ jwks_response = Net::HTTP.get_response(jwks_uri)
861
+ return false unless jwks_response.code == "200"
862
+
863
+ provider_jwks = JSON.parse(jwks_response.body)
864
+ signing_key_data = provider_jwks["keys"].find { |k| (k["use"] || k[:use]) == "sig" || !k["use"] }
865
+ return false unless signing_key_data
866
+
867
+ kid = signing_key_data["kid"] || signing_key_data[:kid]
868
+
869
+ # Create ID token with wrong audience using OP's private key
870
+ now = Time.now.to_i
871
+ wrong_aud_payload = {
872
+ iss: OP_ENTITY_ID,
873
+ sub: "user123",
874
+ aud: "wrong-client-id", # Wrong audience
875
+ exp: now + 3600,
876
+ iat: now
877
+ }
878
+
879
+ header = {alg: "RS256", typ: "JWT", kid: kid}
880
+ id_token = JWT.encode(wrong_aud_payload, @op_signing_key, "RS256", header)
881
+
882
+ # Validate - signature should be valid but audience should be wrong
883
+ begin
884
+ decoded = OmniauthOpenidFederation::Jwks::Decode.jwt(id_token, "#{OP_URL}/.well-known/jwks.json")
885
+ payload = decoded.first
886
+ # Audience should not match
887
+ payload["aud"] != RP_ENTITY_ID
888
+ rescue
889
+ # If validation fails due to audience, that's also acceptable
890
+ true
891
+ end
892
+ end
893
+ end
894
+
895
+ # Entity Statement Validation Tests
896
+ def test_invalid_entity_statement_signature
897
+ test("Invalid signature on entity statement") do
898
+ # Get a valid entity statement to extract payload
899
+ uri = URI.parse("#{OP_URL}/.well-known/openid-federation")
900
+ response = Net::HTTP.get_response(uri)
901
+ return false unless response.code == "200"
902
+
903
+ valid_statement = response.body
904
+ parts = valid_statement.split(".")
905
+ return false unless parts.length == 3
906
+
907
+ # Extract header and payload
908
+ header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
909
+ payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
910
+
911
+ # Verify payload has JWKS with keys (needed for signature validation)
912
+ jwks = payload["jwks"] || payload[:jwks] || {}
913
+ keys = jwks["keys"] || jwks[:keys] || []
914
+ return false unless keys.any?
915
+
916
+ # Verify kid in header matches a key in JWKS
917
+ kid = header["kid"] || header[:kid]
918
+ matching_key = keys.find { |k| (k["kid"] || k[:kid]) == kid }
919
+ return false unless matching_key
920
+
921
+ # Create a statement with wrong signature (sign with different key)
922
+ # The JWKS in payload still has original keys, so signature validation will fail
923
+ wrong_key = OpenSSL::PKey::RSA.new(2048)
924
+ # Keep the same header and payload but sign with wrong key
925
+ invalid_statement = JWT.encode(payload, wrong_key, "RS256", header)
926
+
927
+ # Try to parse with signature validation - should fail
928
+ # The signature was created with wrong key, but JWKS in payload has original keys
929
+ # So when we validate using keys from JWKS, it will fail
930
+ begin
931
+ OmniauthOpenidFederation::Federation::EntityStatementParser.parse(
932
+ invalid_statement,
933
+ validate_signature: true,
934
+ validate_full: true
935
+ )
936
+ # If parsing succeeds, the signature validation didn't catch the error
937
+ false
938
+ rescue OmniauthOpenidFederation::SignatureError
939
+ true
940
+ rescue JWT::VerificationError
941
+ true
942
+ rescue OmniauthOpenidFederation::ValidationError => e
943
+ # ValidationError might be raised if signature validation fails during full validation
944
+ # Check if it's a signature-related error
945
+ error_msg = e.message.downcase
946
+ error_msg.include?("signature") || error_msg.include?("verification") || error_msg.include?("key")
947
+ rescue => e
948
+ # Check if it's a signature-related error in the message
949
+ error_msg = e.message.downcase
950
+ error_msg.include?("signature") || error_msg.include?("verification")
951
+ end
952
+ end
953
+ end
954
+
955
+ def test_wrong_algorithm_entity_statement
956
+ test("Wrong algorithm in entity statement") do
957
+ # Create entity statement with wrong algorithm (HS256 instead of RS256)
958
+ # HS256 requires a string key, not RSA
959
+ hmac_key = SecureRandom.hex(32)
960
+ now = Time.now.to_i
961
+ payload = {
962
+ iss: OP_ENTITY_ID,
963
+ sub: OP_ENTITY_ID,
964
+ iat: now,
965
+ exp: now + 3600,
966
+ jwks: {keys: []},
967
+ metadata: {openid_provider: {}}
968
+ }
969
+
970
+ # Sign with wrong algorithm (HS256 instead of RS256)
971
+ header = {alg: "HS256", typ: "entity-statement+jwt"}
972
+ invalid_statement = JWT.encode(payload, hmac_key, "HS256", header)
973
+
974
+ # Try to parse with full validation - should fail due to wrong algorithm
975
+ begin
976
+ OmniauthOpenidFederation::Federation::EntityStatementParser.parse(
977
+ invalid_statement,
978
+ validate_signature: false, # Can't validate HS256 signature with RS256 keys
979
+ validate_full: true
980
+ )
981
+ false # Should have raised an error
982
+ rescue OmniauthOpenidFederation::ValidationError, JWT::IncorrectAlgorithm, JWT::DecodeError
983
+ true
984
+ rescue
985
+ false
986
+ end
987
+ end
988
+ end
989
+
990
+ def test_missing_required_claims_entity_statement
991
+ test("Missing required claims in entity statement") do
992
+ # Test missing 'iss' claim
993
+ signing_key = @op_signing_key
994
+ now = Time.now.to_i
995
+ payload_missing_iss = {
996
+ # Missing iss
997
+ sub: OP_ENTITY_ID,
998
+ iat: now,
999
+ exp: now + 3600,
1000
+ jwks: {keys: []}
1001
+ }
1002
+
1003
+ header = {alg: "RS256", typ: "entity-statement+jwt"}
1004
+ invalid_statement = JWT.encode(payload_missing_iss, signing_key, "RS256", header)
1005
+
1006
+ # Try to parse with full validation - should fail due to missing iss claim
1007
+ begin
1008
+ OmniauthOpenidFederation::Federation::EntityStatementParser.parse(
1009
+ invalid_statement,
1010
+ validate_signature: true,
1011
+ validate_full: true
1012
+ )
1013
+ false # Should have raised an error
1014
+ rescue OmniauthOpenidFederation::ValidationError
1015
+ true
1016
+ rescue
1017
+ false
1018
+ end
1019
+ end
1020
+ end
1021
+
1022
+ def test_invalid_jwt_typ_entity_statement
1023
+ test("Invalid JWT typ claim in entity statement") do
1024
+ signing_key = @op_signing_key
1025
+ now = Time.now.to_i
1026
+ payload = {
1027
+ iss: OP_ENTITY_ID,
1028
+ sub: OP_ENTITY_ID,
1029
+ iat: now,
1030
+ exp: now + 3600,
1031
+ jwks: {keys: []},
1032
+ metadata: {openid_provider: {}}
1033
+ }
1034
+
1035
+ # Wrong typ value
1036
+ header = {alg: "RS256", typ: "JWT"} # Should be "entity-statement+jwt"
1037
+ invalid_statement = JWT.encode(payload, signing_key, "RS256", header)
1038
+
1039
+ # Try to parse with full validation - should fail due to wrong typ
1040
+ begin
1041
+ OmniauthOpenidFederation::Federation::EntityStatementParser.parse(
1042
+ invalid_statement,
1043
+ validate_signature: true,
1044
+ validate_full: true
1045
+ )
1046
+ false # Should have raised an error
1047
+ rescue OmniauthOpenidFederation::ValidationError
1048
+ true
1049
+ rescue
1050
+ false
1051
+ end
1052
+ end
1053
+ end
1054
+
1055
+ # Signed JWKS Endpoint Tests
1056
+ def test_signed_jwks_endpoint
1057
+ test("Signed JWKS endpoint") do
1058
+ uri = URI.parse("#{OP_URL}/.well-known/signed-jwks.json")
1059
+ response = Net::HTTP.get_response(uri)
1060
+
1061
+ return false unless response.code == "200"
1062
+ return false unless response.content_type == "application/jwt"
1063
+
1064
+ signed_jwks = response.body
1065
+ parts = signed_jwks.split(".")
1066
+ return false unless parts.length == 3
1067
+
1068
+ # Try to decode and validate
1069
+ begin
1070
+ # Get OP's signing key for validation
1071
+ jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
1072
+ jwks_response = Net::HTTP.get_response(jwks_uri)
1073
+ return false unless jwks_response.code == "200"
1074
+
1075
+ provider_jwks = JSON.parse(jwks_response.body)
1076
+ signing_key_data = provider_jwks["keys"].find { |k| (k["use"] || k[:use]) == "sig" || !k["use"] }
1077
+ return false unless signing_key_data
1078
+
1079
+ public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
1080
+ # KeyExtractor returns a public key, use it directly
1081
+ decoded = JWT.decode(signed_jwks, public_key, true, {algorithm: "RS256"})
1082
+
1083
+ payload = decoded.first
1084
+ return false unless payload["keys"]
1085
+ return false unless payload["keys"].is_a?(Array)
1086
+
1087
+ true
1088
+ rescue
1089
+ false
1090
+ end
1091
+ end
1092
+ end
1093
+
1094
+ def test_invalid_signed_jwks_signature
1095
+ test("Invalid signed JWKS signature") do
1096
+ # Get signed JWKS with invalid signature error mode
1097
+ uri = URI.parse("#{OP_URL}/.well-known/signed-jwks.json?error_mode=invalid_signature")
1098
+ response = Net::HTTP.get_response(uri)
1099
+
1100
+ return false unless response.code == "200"
1101
+
1102
+ signed_jwks = response.body
1103
+ parts = signed_jwks.split(".")
1104
+ return false unless parts.length == 3
1105
+
1106
+ # Try to validate - should fail
1107
+ begin
1108
+ jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
1109
+ jwks_response = Net::HTTP.get_response(jwks_uri)
1110
+ return false unless jwks_response.code == "200"
1111
+
1112
+ provider_jwks = JSON.parse(jwks_response.body)
1113
+ signing_key_data = provider_jwks["keys"].find { |k| (k["use"] || k[:use]) == "sig" || !k["use"] }
1114
+ return false unless signing_key_data
1115
+
1116
+ public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
1117
+ # KeyExtractor returns a public key, use it directly
1118
+ JWT.decode(signed_jwks, public_key, true, {algorithm: "RS256"})
1119
+ false # Should have raised an error
1120
+ rescue JWT::VerificationError, OmniauthOpenidFederation::SignatureError
1121
+ true
1122
+ rescue
1123
+ false
1124
+ end
1125
+ end
1126
+ end
1127
+
1128
+ # Request Object Validation Details Tests
1129
+ def test_request_object_missing_required_claims
1130
+ test("Request object with missing required claims") do
1131
+ # Create request object missing client_id
1132
+ signing_key = @rp_signing_key
1133
+ now = Time.now.to_i
1134
+ payload = {
1135
+ # Missing client_id
1136
+ redirect_uri: "#{RP_URL}/callback",
1137
+ response_type: "code",
1138
+ scope: "openid",
1139
+ state: SecureRandom.hex(32),
1140
+ nonce: SecureRandom.hex(32),
1141
+ iat: now,
1142
+ exp: now + 300
1143
+ }
1144
+
1145
+ header = {alg: "RS256", typ: "JWT"}
1146
+ request_object = JWT.encode(payload, signing_key, "RS256", header)
1147
+
1148
+ # Send to provider - should fail validation
1149
+ auth_uri = URI.parse("#{OP_URL}/auth")
1150
+ auth_uri.query = URI.encode_www_form({"request" => request_object})
1151
+ auth_response = Net::HTTP.get_response(auth_uri)
1152
+
1153
+ # Should return error
1154
+ return false unless auth_response.code.to_i.between?(400, 499)
1155
+
1156
+ body = begin
1157
+ JSON.parse(auth_response.body)
1158
+ rescue
1159
+ {}
1160
+ end
1161
+ return false unless body["error"] || body["error_description"]
1162
+
1163
+ true
1164
+ end
1165
+ end
1166
+
1167
+ def test_request_object_invalid_nonce
1168
+ test("Request object with invalid nonce") do
1169
+ # This test verifies that nonce validation works
1170
+ # In a real flow, the nonce in request object should match the nonce in ID token
1171
+ # For this test, we'll create a valid request object and verify it has a nonce
1172
+ redirect_uri = "#{RP_URL}/callback"
1173
+ jws = OmniauthOpenidFederation::Jws.new(
1174
+ client_id: RP_ENTITY_ID,
1175
+ redirect_uri: redirect_uri,
1176
+ scope: "openid",
1177
+ audience: OP_ENTITY_ID,
1178
+ state: SecureRandom.hex(32),
1179
+ nonce: SecureRandom.hex(32),
1180
+ private_key: @rp_signing_key
1181
+ )
1182
+
1183
+ request_object = jws.sign
1184
+
1185
+ # Verify request object has nonce
1186
+ parts = request_object.split(".")
1187
+ return false unless parts.length == 3
1188
+
1189
+ payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
1190
+ return false unless payload["nonce"]
1191
+ return false if payload["nonce"].empty?
1192
+
1193
+ # The actual nonce validation happens during token exchange
1194
+ # This test verifies the nonce is present in the request object
1195
+ true
1196
+ end
1197
+ end
1198
+
1199
+ def test_request_object_expiration
1200
+ test("Request object expiration") do
1201
+ # Create expired request object
1202
+ signing_key = @rp_signing_key
1203
+ now = Time.now.to_i
1204
+ expired_payload = {
1205
+ client_id: RP_ENTITY_ID,
1206
+ redirect_uri: "#{RP_URL}/callback",
1207
+ response_type: "code",
1208
+ scope: "openid",
1209
+ state: SecureRandom.hex(32),
1210
+ nonce: SecureRandom.hex(32),
1211
+ iat: now - 600,
1212
+ exp: now - 300 # Expired 5 minutes ago
1213
+ }
1214
+
1215
+ header = {alg: "RS256", typ: "JWT"}
1216
+ expired_request = JWT.encode(expired_payload, signing_key, "RS256", header)
1217
+
1218
+ # Send to provider - should fail due to expiration
1219
+ auth_uri = URI.parse("#{OP_URL}/auth")
1220
+ auth_uri.query = URI.encode_www_form({"request" => expired_request})
1221
+ auth_response = Net::HTTP.get_response(auth_uri)
1222
+
1223
+ # Should return error
1224
+ return false unless auth_response.code.to_i.between?(400, 499)
1225
+
1226
+ body = begin
1227
+ JSON.parse(auth_response.body)
1228
+ rescue
1229
+ {}
1230
+ end
1231
+ return false unless body["error"] || body["error_description"]
1232
+
1233
+ true
1234
+ end
1235
+ end
1236
+
1237
+ def print_summary
1238
+ puts "=" * 80
1239
+ puts "Test Summary"
1240
+ puts "=" * 80
1241
+
1242
+ passed = @test_results.count { |r| r[:status] == :pass }
1243
+ failed = @test_results.count { |r| r[:status] == :fail }
1244
+ errors = @test_results.count { |r| r[:status] == :error }
1245
+
1246
+ puts "Total: #{@test_results.length}"
1247
+ puts "Passed: #{passed}"
1248
+ puts "Failed: #{failed}"
1249
+ puts "Errors: #{errors}"
1250
+ puts ""
1251
+
1252
+ if failed > 0 || errors > 0
1253
+ puts "Failed/Error Tests:"
1254
+ @test_results.each do |result|
1255
+ if result[:status] != :pass
1256
+ puts " - #{result[:name]}: #{result[:status]}"
1257
+ puts " #{result[:error]}" if result[:error]
1258
+ end
1259
+ end
1260
+ end
1261
+
1262
+ puts ""
1263
+ puts (passed == @test_results.length) ? "✅ All tests passed!" : "❌ Some tests failed."
1264
+ end
1265
+
1266
+ def cleanup
1267
+ puts ""
1268
+ puts "Cleaning up..."
1269
+
1270
+ # Kill servers
1271
+ if @op_pid
1272
+ begin
1273
+ begin
1274
+ Process.kill("TERM", @op_pid) if Process.kill(0, @op_pid)
1275
+ rescue
1276
+ nil
1277
+ end
1278
+ rescue Errno::ESRCH, Errno::EPERM
1279
+ # Process already dead or permission denied
1280
+ end
1281
+ end
1282
+
1283
+ if @rp_pid
1284
+ begin
1285
+ begin
1286
+ Process.kill("TERM", @rp_pid) if Process.kill(0, @rp_pid)
1287
+ rescue
1288
+ nil
1289
+ end
1290
+ rescue Errno::ESRCH, Errno::EPERM
1291
+ # Process already dead or permission denied
1292
+ end
1293
+ end
1294
+
1295
+ # Wait a bit for processes to terminate
1296
+ sleep 1
1297
+
1298
+ # Remove tmp directory if cleanup enabled
1299
+ if CLEANUP_ON_EXIT && File.directory?(@tmp_dir)
1300
+ FileUtils.rm_rf(@tmp_dir)
1301
+ puts " Removed: #{@tmp_dir}"
1302
+ else
1303
+ puts " Tmp directory preserved: #{@tmp_dir}"
1304
+ end
1305
+ end
1306
+ end
1307
+
1308
+ # Run tests if executed directly
1309
+ if __FILE__ == $0
1310
+ puts "OpenID Federation Integration Test Flow"
1311
+ puts "=" * 80
1312
+ puts ""
1313
+ puts "Environment Variables:"
1314
+ puts " OP_URL - OP server URL (default: #{IntegrationTestFlow::OP_URL})"
1315
+ puts " RP_URL - RP server URL (default: #{IntegrationTestFlow::RP_URL})"
1316
+ puts " OP_PORT - OP server port (default: #{IntegrationTestFlow::OP_PORT})"
1317
+ puts " RP_PORT - RP server port (default: #{IntegrationTestFlow::RP_PORT})"
1318
+ puts " OP_ENTITY_ID - OP entity ID (default: #{IntegrationTestFlow::OP_ENTITY_ID} - localhost)"
1319
+ puts " RP_ENTITY_ID - RP entity ID (default: #{IntegrationTestFlow::RP_ENTITY_ID} - localhost)"
1320
+ puts " TMP_DIR - Temporary directory (default: tmp/integration_test)"
1321
+ puts " AUTO_START_SERVERS - Auto-start servers (default: true)"
1322
+ puts " CLEANUP_ON_EXIT - Clean up on exit (default: true)"
1323
+ puts " KEY_TYPE - Key type: 'single' or 'separate' (default: separate)"
1324
+ puts ""
1325
+ puts "Note: Default entity IDs use localhost URLs for complete isolation."
1326
+ puts " No DNS resolution or external dependencies required."
1327
+ puts ""
1328
+ puts "Example:"
1329
+ puts " KEY_TYPE=single ruby examples/integration_test_flow.rb"
1330
+ puts ""
1331
+
1332
+ test_flow = IntegrationTestFlow.new
1333
+ test_flow.run
1334
+ end