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,775 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Enhanced Mock OpenID Provider (OP) Server for OpenID Federation Testing
5
+ #
6
+ # This is a comprehensive standalone Webrick server that acts as a mock OP 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
+ # - Authorization Endpoint (/auth) with trust chain resolution and request object validation
11
+ # - Token Endpoint (/token) with ID Token signing
12
+ # - Request object validation (signed and optionally encrypted)
13
+ # - Error injection modes for testing failure scenarios
14
+ #
15
+ # Configuration:
16
+ # - Load from YAML file: config/mock_op.yml
17
+ # - Or set environment variables
18
+ #
19
+ # Usage:
20
+ # ruby examples/mock_op_server.rb
21
+ #
22
+ # Access:
23
+ # http://localhost:9292/.well-known/openid-federation
24
+ # http://localhost:9292/auth?request=<signed_jwt>
25
+ # http://localhost:9292/token (POST with code)
26
+ #
27
+ # Error Injection Modes (via query param ?error_mode=<mode>):
28
+ # - invalid_statement: Return invalid entity statement
29
+ # - wrong_keys: Return wrong JWKS keys
30
+ # - invalid_request: Reject request object
31
+ # - invalid_signature: Return invalid signature
32
+ # - expired_statement: Return expired entity statement
33
+ # - missing_metadata: Return statement without metadata
34
+
35
+ require "bundler/setup"
36
+ require "webrick"
37
+ require "yaml"
38
+ require "json"
39
+ require "jwt"
40
+ require "jwe"
41
+ require "openssl"
42
+ require "base64"
43
+ require "uri"
44
+ require "cgi"
45
+ require "securerandom"
46
+
47
+ # Add the gem to the load path
48
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
49
+ require "omniauth_openid_federation"
50
+
51
+ class MockOPServer
52
+ # Load configuration from YAML or environment
53
+ def self.load_config
54
+ config_path = File.expand_path("../config/mock_op.yml", __dir__)
55
+ if File.exist?(config_path)
56
+ YAML.load_file(config_path)
57
+ else
58
+ # Fall back to environment variables
59
+ {
60
+ "entity_id" => ENV["OP_ENTITY_ID"] || "http://localhost:9292",
61
+ "server_host" => ENV["OP_SERVER_HOST"] || "localhost:9292",
62
+ "signing_key" => ENV["OP_SIGNING_KEY"],
63
+ "encryption_key" => ENV["OP_ENCRYPTION_KEY"],
64
+ "trust_anchors" => parse_trust_anchors(ENV["OP_TRUST_ANCHORS"]),
65
+ "authority_hints" => parse_array(ENV["OP_AUTHORITY_HINTS"]),
66
+ "op_metadata" => parse_json(ENV["OP_METADATA"]) || default_op_metadata,
67
+ "require_request_encryption" => ENV["OP_REQUIRE_ENCRYPTION"] == "true",
68
+ "validate_request_objects" => ENV["OP_VALIDATE_REQUESTS"] != "false"
69
+ }
70
+ end
71
+ end
72
+
73
+ def self.parse_trust_anchors(str)
74
+ return [] unless str
75
+ JSON.parse(str)
76
+ rescue JSON::ParserError
77
+ []
78
+ end
79
+
80
+ def self.parse_array(str)
81
+ return [] unless str
82
+ str.split(",").map(&:strip)
83
+ end
84
+
85
+ def self.parse_json(str)
86
+ return nil unless str
87
+ JSON.parse(str)
88
+ rescue JSON::ParserError
89
+ nil
90
+ end
91
+
92
+ def self.default_op_metadata
93
+ {
94
+ "issuer" => "http://localhost:9292",
95
+ "authorization_endpoint" => "http://localhost:9292/auth",
96
+ "token_endpoint" => "http://localhost:9292/token",
97
+ "userinfo_endpoint" => "http://localhost:9292/userinfo",
98
+ "jwks_uri" => "http://localhost:9292/.well-known/jwks.json",
99
+ "signed_jwks_uri" => "http://localhost:9292/.well-known/signed-jwks.json",
100
+ "client_registration_types_supported" => ["automatic", "explicit"],
101
+ "response_types_supported" => ["code"],
102
+ "grant_types_supported" => ["authorization_code"],
103
+ "id_token_signing_alg_values_supported" => ["RS256"],
104
+ "id_token_encryption_alg_values_supported" => ["RSA-OAEP"],
105
+ "id_token_encryption_enc_values_supported" => ["A128CBC-HS256"],
106
+ "request_object_signing_alg_values_supported" => ["RS256"],
107
+ "request_object_encryption_alg_values_supported" => ["RSA-OAEP"],
108
+ "request_object_encryption_enc_values_supported" => ["A128CBC-HS256"],
109
+ "scopes_supported" => ["openid", "profile", "email"]
110
+ }
111
+ end
112
+
113
+ def self.load_signing_key(key_data)
114
+ if key_data.nil? || key_data.empty?
115
+ # Generate a new key for testing
116
+ OpenSSL::PKey::RSA.new(2048)
117
+ elsif key_data.is_a?(String)
118
+ if key_data.include?("BEGIN")
119
+ OpenSSL::PKey::RSA.new(key_data)
120
+ else
121
+ OpenSSL::PKey::RSA.new(Base64.decode64(key_data))
122
+ end
123
+ else
124
+ raise "Invalid signing key format"
125
+ end
126
+ end
127
+
128
+ def self.load_encryption_key(key_data)
129
+ return nil if key_data.nil? || key_data.empty?
130
+ load_signing_key(key_data)
131
+ end
132
+
133
+ def self.normalize_trust_anchors(trust_anchors)
134
+ trust_anchors.map do |ta|
135
+ {
136
+ entity_id: ta["entity_id"] || ta[:entity_id],
137
+ jwks: ta["jwks"] || ta[:jwks]
138
+ }
139
+ end
140
+ end
141
+
142
+ # Initialize configuration
143
+ CONFIG = load_config
144
+ ENTITY_ID = CONFIG["entity_id"] || "http://localhost:9292"
145
+ SERVER_HOST = CONFIG["server_host"] || "localhost:9292"
146
+ SIGNING_KEY = load_signing_key(CONFIG["signing_key"])
147
+ ENCRYPTION_KEY = load_encryption_key(CONFIG["encryption_key"]) || SIGNING_KEY
148
+ TRUST_ANCHORS = CONFIG["trust_anchors"] || []
149
+ AUTHORITY_HINTS = CONFIG["authority_hints"] || []
150
+ OP_METADATA = CONFIG["op_metadata"] || default_op_metadata
151
+ REQUIRE_REQUEST_ENCRYPTION = CONFIG["require_request_encryption"] || false
152
+ VALIDATE_REQUEST_OBJECTS = CONFIG["validate_request_objects"] != false
153
+
154
+ # Store for authorization codes (in production, use a database)
155
+ AUTHORIZATION_CODES = {}
156
+
157
+ # Store for registered RPs (for testing)
158
+ REGISTERED_RPS = {}
159
+
160
+ # Configure FederationEndpoint (deferred until server starts)
161
+ def self.configure_federation_endpoint
162
+ base_url = base_url_static
163
+ # Use localhost URLs if entity_id is localhost (for isolation)
164
+ if ENTITY_ID.include?("localhost")
165
+ # Override metadata to use localhost URLs
166
+ metadata = OP_METADATA.dup
167
+ metadata["issuer"] = ENTITY_ID
168
+ metadata["authorization_endpoint"] = "#{base_url}/auth"
169
+ metadata["token_endpoint"] = "#{base_url}/token"
170
+ metadata["userinfo_endpoint"] = "#{base_url}/userinfo"
171
+ metadata["jwks_uri"] = "#{base_url}/.well-known/jwks.json"
172
+ metadata["signed_jwks_uri"] = "#{base_url}/.well-known/signed-jwks.json"
173
+ else
174
+ metadata = OP_METADATA.merge(
175
+ "issuer" => ENTITY_ID,
176
+ "authorization_endpoint" => "#{base_url}/auth",
177
+ "token_endpoint" => "#{base_url}/token",
178
+ "userinfo_endpoint" => "#{base_url}/userinfo",
179
+ "jwks_uri" => "#{base_url}/.well-known/jwks.json",
180
+ "signed_jwks_uri" => "#{base_url}/.well-known/signed-jwks.json"
181
+ )
182
+ end
183
+
184
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
185
+ issuer: ENTITY_ID,
186
+ private_key: SIGNING_KEY,
187
+ metadata: {
188
+ openid_provider: metadata
189
+ }
190
+ )
191
+
192
+ # Set authority_hints if provided (must be done after auto_configure)
193
+ if AUTHORITY_HINTS.any?
194
+ OmniauthOpenidFederation::FederationEndpoint.configure do |config|
195
+ config.authority_hints = AUTHORITY_HINTS
196
+ end
197
+ end
198
+ end
199
+
200
+ # Configure subordinate statements if provided
201
+ def self.configure_subordinate_statements
202
+ if CONFIG["subordinate_statements"]
203
+ OmniauthOpenidFederation::FederationEndpoint.configure do |config|
204
+ config.subordinate_statements = CONFIG["subordinate_statements"]
205
+ end
206
+ end
207
+ end
208
+
209
+ def self.base_url_static
210
+ "http://#{SERVER_HOST}"
211
+ end
212
+
213
+ # Webrick servlet to handle requests
214
+ class Servlet < WEBrick::HTTPServlet::AbstractServlet
215
+ def service(req, res)
216
+ path = req.path
217
+ method = req.request_method
218
+ params = parse_query(req.query_string)
219
+ error_mode = params["error_mode"] || req.header["x-error-mode"]&.first
220
+
221
+ # Parse body for POST requests
222
+ body_params = {}
223
+ if method == "POST" && req.body && !req.body.empty?
224
+ body_params = if req.content_type&.include?("application/json")
225
+ begin
226
+ JSON.parse(req.body)
227
+ rescue
228
+ {}
229
+ end
230
+ else
231
+ parse_form_data(req.body)
232
+ end
233
+ end
234
+
235
+ # Merge query params and body params
236
+ all_params = params.merge(body_params)
237
+
238
+ case [method, path]
239
+ when ["GET", "/"]
240
+ handle_health_check(req, res)
241
+ when ["GET", "/.well-known/openid-federation"]
242
+ handle_entity_configuration(req, res, error_mode)
243
+ when ["GET", "/.well-known/openid-federation/fetch"]
244
+ handle_fetch(req, res, params, error_mode)
245
+ when ["GET", "/.well-known/jwks.json"]
246
+ handle_jwks(req, res, error_mode)
247
+ when ["GET", "/.well-known/signed-jwks.json"]
248
+ handle_signed_jwks(req, res, error_mode)
249
+ when ["GET", "/auth"]
250
+ handle_authorization(req, res, all_params, error_mode)
251
+ when ["POST", "/token"]
252
+ handle_token(req, res, all_params)
253
+ when ["GET", "/userinfo"]
254
+ handle_userinfo(req, res)
255
+ else
256
+ res.status = 404
257
+ res.content_type = "application/json"
258
+ res.body = {error: "not_found", error_description: "Endpoint not found"}.to_json
259
+ end
260
+ end
261
+
262
+ private
263
+
264
+ def parse_query(query_string)
265
+ return {} unless query_string
266
+ CGI.parse(query_string).transform_values { |v| v.first }
267
+ end
268
+
269
+ def parse_form_data(body)
270
+ return {} unless body
271
+ CGI.parse(body).transform_values { |v| v.first }
272
+ end
273
+
274
+ def base_url(req)
275
+ scheme = req.request_uri.scheme || "http"
276
+ host = req.host
277
+ port = req.port
278
+ port_str = ((port == 80 && scheme == "http") || (port == 443 && scheme == "https")) ? "" : ":#{port}"
279
+ "#{scheme}://#{host}#{port_str}"
280
+ end
281
+
282
+ def json_response(res, data, status: 200)
283
+ res.status = status
284
+ res.content_type = "application/json"
285
+ res.body = data.to_json
286
+ end
287
+
288
+ def error_response(res, error, description, status: 400)
289
+ json_response(res, {error: error, error_description: description}, status: status)
290
+ end
291
+
292
+ def handle_health_check(req, res)
293
+ json_response(res, {
294
+ status: "ok",
295
+ entity_id: MockOPServer::ENTITY_ID,
296
+ endpoints: {
297
+ entity_configuration: "#{base_url(req)}/.well-known/openid-federation",
298
+ fetch: "#{base_url(req)}/.well-known/openid-federation/fetch",
299
+ authorization: "#{base_url(req)}/auth",
300
+ token: "#{base_url(req)}/token",
301
+ userinfo: "#{base_url(req)}/userinfo"
302
+ },
303
+ error_modes: [
304
+ "invalid_statement",
305
+ "wrong_keys",
306
+ "invalid_request",
307
+ "invalid_signature",
308
+ "expired_statement",
309
+ "missing_metadata"
310
+ ]
311
+ })
312
+ end
313
+
314
+ def handle_entity_configuration(req, res, error_mode)
315
+ if error_mode == "invalid_statement"
316
+ res.status = 200
317
+ res.content_type = "application/jwt"
318
+ res.body = "invalid.entity.statement"
319
+ return
320
+ end
321
+
322
+ if error_mode == "expired_statement"
323
+ res.status = 200
324
+ res.content_type = "application/jwt"
325
+ res.body = generate_expired_entity_statement
326
+ return
327
+ end
328
+
329
+ if error_mode == "wrong_keys"
330
+ res.status = 200
331
+ res.content_type = "application/jwt"
332
+ res.body = generate_entity_statement_with_wrong_keys
333
+ return
334
+ end
335
+
336
+ if error_mode == "missing_metadata"
337
+ res.status = 200
338
+ res.content_type = "application/jwt"
339
+ res.body = generate_entity_statement_without_metadata
340
+ return
341
+ end
342
+
343
+ entity_statement = OmniauthOpenidFederation::FederationEndpoint.generate_entity_statement
344
+ res.status = 200
345
+ res.content_type = "application/jwt"
346
+ res["Cache-Control"] = "public, max-age=3600"
347
+ res.body = entity_statement
348
+ end
349
+
350
+ def handle_fetch(req, res, params, error_mode)
351
+ subject_entity_id = params["sub"]
352
+
353
+ unless subject_entity_id
354
+ return error_response(res, "invalid_request", "Missing required parameter: sub")
355
+ end
356
+
357
+ if subject_entity_id == MockOPServer::ENTITY_ID
358
+ return error_response(res, "invalid_request", "Subject cannot be the issuer")
359
+ end
360
+
361
+ if error_mode == "invalid_statement"
362
+ res.status = 200
363
+ res.content_type = "application/entity-statement+jwt"
364
+ res.body = "invalid.subordinate.statement"
365
+ return
366
+ end
367
+
368
+ subordinate_statement = OmniauthOpenidFederation::FederationEndpoint.get_subordinate_statement(subject_entity_id)
369
+
370
+ unless subordinate_statement
371
+ return error_response(res, "not_found", "Subordinate Statement not found for subject: #{subject_entity_id}", status: 404)
372
+ end
373
+
374
+ res.status = 200
375
+ res.content_type = "application/entity-statement+jwt"
376
+ res["Cache-Control"] = "public, max-age=3600"
377
+ res.body = subordinate_statement
378
+ end
379
+
380
+ def handle_jwks(req, res, error_mode)
381
+ if error_mode == "wrong_keys"
382
+ wrong_key = OpenSSL::PKey::RSA.new(2048)
383
+ jwk = JWT::JWK.new(wrong_key.public_key)
384
+ jwks = {keys: [jwk.export]}
385
+ return json_response(res, jwks)
386
+ end
387
+
388
+ jwks = OmniauthOpenidFederation::FederationEndpoint.current_jwks
389
+ res.status = 200
390
+ res.content_type = "application/json"
391
+ res["Cache-Control"] = "public, max-age=3600"
392
+ res.body = jwks.to_json
393
+ end
394
+
395
+ def handle_signed_jwks(req, res, error_mode)
396
+ if error_mode == "invalid_signature"
397
+ res.status = 200
398
+ res.content_type = "application/jwt"
399
+ res.body = generate_invalid_signed_jwks
400
+ return
401
+ end
402
+
403
+ signed_jwks = OmniauthOpenidFederation::FederationEndpoint.generate_signed_jwks
404
+ res.status = 200
405
+ res.content_type = "application/jwt"
406
+ res["Cache-Control"] = "public, max-age=3600"
407
+ res.body = signed_jwks
408
+ end
409
+
410
+ def handle_authorization(req, res, params, error_mode)
411
+ request_object = params["request"]
412
+ client_id = params["client_id"]
413
+ redirect_uri = params["redirect_uri"]
414
+ state = params["state"]
415
+ nonce = params["nonce"]
416
+
417
+ # Validate request object if present
418
+ if request_object
419
+ if error_mode == "invalid_request"
420
+ return error_response(res, "invalid_request_object", "Request object validation failed")
421
+ end
422
+
423
+ begin
424
+ validated_request = validate_request_object(request_object, error_mode)
425
+ client_id = validated_request[:client_id] || validated_request["client_id"] || client_id
426
+ redirect_uri = validated_request[:redirect_uri] || validated_request["redirect_uri"] || redirect_uri
427
+ state = validated_request[:state] || validated_request["state"] || state
428
+ nonce = validated_request[:nonce] || validated_request["nonce"] || nonce
429
+ rescue OmniauthOpenidFederation::DecryptionError => e
430
+ return error_response(res, "invalid_request_object", "Request object decryption failed: #{e.message}")
431
+ rescue => e
432
+ return error_response(res, "invalid_request_object", "Request object validation failed: #{e.message}")
433
+ end
434
+ end
435
+
436
+ unless client_id && redirect_uri
437
+ return error_response(res, "invalid_request", "Missing required parameters: client_id, redirect_uri")
438
+ end
439
+
440
+ # Resolve RP's trust chain if client_id is an Entity ID
441
+ rp_effective_metadata = nil
442
+ if is_entity_id?(client_id) && MockOPServer::TRUST_ANCHORS.any?
443
+ begin
444
+ resolver = OmniauthOpenidFederation::Federation::TrustChainResolver.new(
445
+ leaf_entity_id: client_id,
446
+ trust_anchors: MockOPServer.normalize_trust_anchors(MockOPServer::TRUST_ANCHORS)
447
+ )
448
+ trust_chain = resolver.resolve!
449
+
450
+ # Extract RP metadata from trust chain
451
+ leaf_statement = trust_chain.first
452
+ leaf_parsed = leaf_statement.is_a?(Hash) ? leaf_statement : leaf_statement.parse
453
+ leaf_metadata = extract_metadata_from_parsed(leaf_parsed)
454
+
455
+ # Merge metadata policies
456
+ merger = OmniauthOpenidFederation::Federation::MetadataPolicyMerger.new(trust_chain: trust_chain)
457
+ rp_effective_metadata = merger.merge_and_apply(leaf_metadata)
458
+ rescue => e
459
+ return error_response(res, "invalid_client", "Failed to resolve client trust chain: #{e.message}")
460
+ end
461
+ end
462
+
463
+ # Extract redirect_uri from effective metadata if available
464
+ if rp_effective_metadata
465
+ rp_metadata = rp_effective_metadata[:openid_relying_party] || rp_effective_metadata["openid_relying_party"]
466
+ if rp_metadata
467
+ allowed_redirect_uris = rp_metadata[:redirect_uris] || rp_metadata["redirect_uris"] || []
468
+ unless allowed_redirect_uris.include?(redirect_uri)
469
+ return error_response(res, "invalid_request", "redirect_uri not in client's allowed redirect_uris")
470
+ end
471
+ end
472
+ end
473
+
474
+ # Generate authorization code
475
+ code = SecureRandom.hex(32)
476
+ MockOPServer::AUTHORIZATION_CODES[code] = {
477
+ client_id: client_id,
478
+ redirect_uri: redirect_uri,
479
+ state: state,
480
+ nonce: nonce,
481
+ created_at: Time.now
482
+ }
483
+
484
+ # Redirect back to RP with authorization code
485
+ redirect_uri_with_code = URI.parse(redirect_uri)
486
+ query_params = redirect_uri_with_code.query ? CGI.parse(redirect_uri_with_code.query) : {}
487
+ query_params["code"] = [code]
488
+ query_params["state"] = [state] if state
489
+ query_params["iss"] = [MockOPServer::ENTITY_ID]
490
+
491
+ redirect_uri_with_code.query = URI.encode_www_form(query_params.flatten.map { |k, v| [k, v] }.flatten)
492
+ res.status = 302
493
+ res["Location"] = redirect_uri_with_code.to_s
494
+ end
495
+
496
+ def handle_token(req, res, params)
497
+ grant_type = params["grant_type"]
498
+ code = params["code"]
499
+ redirect_uri = params["redirect_uri"]
500
+ params["client_id"]
501
+
502
+ unless grant_type == "authorization_code"
503
+ return error_response(res, "unsupported_grant_type", "Only authorization_code grant type is supported")
504
+ end
505
+
506
+ unless code
507
+ return error_response(res, "invalid_request", "Missing authorization code")
508
+ end
509
+
510
+ code_data = MockOPServer::AUTHORIZATION_CODES.delete(code)
511
+ unless code_data
512
+ return error_response(res, "invalid_grant", "Invalid or expired authorization code", status: 401)
513
+ end
514
+
515
+ # Validate redirect_uri matches
516
+ if redirect_uri && redirect_uri != code_data[:redirect_uri]
517
+ return error_response(res, "invalid_grant", "redirect_uri mismatch", status: 401)
518
+ end
519
+
520
+ # Generate ID Token
521
+ id_token = generate_id_token(
522
+ client_id: code_data[:client_id],
523
+ nonce: code_data[:nonce]
524
+ )
525
+
526
+ # Generate Access Token (mock)
527
+ access_token = SecureRandom.hex(32)
528
+
529
+ json_response(res, {
530
+ access_token: access_token,
531
+ token_type: "Bearer",
532
+ expires_in: 3600,
533
+ id_token: id_token
534
+ })
535
+ end
536
+
537
+ def handle_userinfo(req, res)
538
+ json_response(res, {
539
+ sub: "user123",
540
+ name: "Test User",
541
+ email: "test@example.com"
542
+ })
543
+ end
544
+
545
+ def is_entity_id?(str)
546
+ str.is_a?(String) && str.start_with?("http://", "https://")
547
+ end
548
+
549
+ def extract_metadata_from_parsed(parsed)
550
+ metadata = parsed[:metadata] || parsed["metadata"] || {}
551
+ result = {}
552
+ metadata.each do |entity_type, entity_metadata|
553
+ result[entity_type.to_sym] = entity_metadata
554
+ end
555
+ result
556
+ end
557
+
558
+ def validate_request_object(request_jwt, error_mode = nil)
559
+ # Check if it's encrypted (JWE - 5 parts)
560
+ parts_count = request_jwt.split(".").length
561
+ if parts_count == 5
562
+ # Error injection: malformed encrypted request (triggered by error_mode)
563
+ if error_mode == "malformed_encryption"
564
+ raise OmniauthOpenidFederation::DecryptionError, "Malformed encrypted request object"
565
+ end
566
+
567
+ # Try to decrypt - will fail if wrong key was used
568
+ begin
569
+ request_jwt = JWE.decrypt(request_jwt, MockOPServer::ENCRYPTION_KEY)
570
+ rescue => e
571
+ raise OmniauthOpenidFederation::DecryptionError, "Failed to decrypt request object: #{e.message}"
572
+ end
573
+ elsif parts_count != 3 && error_mode == "malformed_encryption"
574
+ # Not a valid JWT or JWE format
575
+ raise OmniauthOpenidFederation::DecryptionError, "Malformed encrypted request object"
576
+ end
577
+
578
+ # Decode and validate the request object
579
+ parts = request_jwt.split(".")
580
+ raise OmniauthOpenidFederation::ValidationError, "Invalid JWT format" if parts.length != 3
581
+
582
+ header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
583
+ payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
584
+
585
+ # Validate algorithm
586
+ alg = header["alg"] || header[:alg]
587
+ unless alg == "RS256"
588
+ raise OmniauthOpenidFederation::ValidationError, "Unsupported algorithm: #{alg}"
589
+ end
590
+
591
+ # Extract client_id to get their JWKS
592
+ client_id = payload["client_id"] || payload[:client_id]
593
+ unless client_id
594
+ raise OmniauthOpenidFederation::ValidationError, "Missing client_id in request object"
595
+ end
596
+
597
+ # Fetch client's entity statement to get their JWKS
598
+ if is_entity_id?(client_id)
599
+ begin
600
+ # If client_id is localhost, use direct localhost URL (for isolation)
601
+ client_url = if client_id.include?("localhost")
602
+ port = client_id.match(/:(\d+)/)&.captures&.first || "9293"
603
+ "http://localhost:#{port}/.well-known/openid-federation"
604
+ else
605
+ "#{client_id}/.well-known/openid-federation"
606
+ end
607
+
608
+ client_statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
609
+ client_url
610
+ )
611
+ client_parsed = client_statement.parse
612
+ client_jwks = client_parsed[:jwks] || client_parsed["jwks"] || {}
613
+ client_keys = client_jwks[:keys] || client_jwks["keys"] || []
614
+
615
+ # Find the key used for signing
616
+ kid = header["kid"] || header[:kid]
617
+ signing_key_data = client_keys.find { |k| (k["kid"] || k[:kid]) == kid }
618
+
619
+ unless signing_key_data
620
+ raise OmniauthOpenidFederation::ValidationError, "Signing key not found in client JWKS"
621
+ end
622
+
623
+ # Convert JWK to OpenSSL key
624
+ public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
625
+
626
+ # Verify signature
627
+ JWT.decode(request_jwt, public_key, true, {algorithm: "RS256"})
628
+ rescue => e
629
+ raise OmniauthOpenidFederation::ValidationError, "Request object validation failed: #{e.message}"
630
+ end
631
+ end
632
+
633
+ payload
634
+ end
635
+
636
+ def generate_id_token(client_id:, nonce: nil)
637
+ now = Time.now.to_i
638
+ jwks = OmniauthOpenidFederation::FederationEndpoint.current_jwks
639
+ signing_key = OmniauthOpenidFederation::FederationEndpoint.configuration.private_key
640
+ kid = jwks["keys"]&.first&.dig("kid") || jwks[:keys]&.first&.dig(:kid)
641
+
642
+ payload = {
643
+ iss: MockOPServer::ENTITY_ID,
644
+ sub: "user123",
645
+ aud: client_id,
646
+ exp: now + 3600,
647
+ iat: now,
648
+ nonce: nonce
649
+ }
650
+
651
+ header = {
652
+ alg: "RS256",
653
+ typ: "JWT",
654
+ kid: kid
655
+ }
656
+
657
+ JWT.encode(payload, signing_key, "RS256", header)
658
+ end
659
+
660
+ def generate_expired_entity_statement
661
+ jwk = JWT::JWK.new(MockOPServer::SIGNING_KEY.public_key)
662
+ jwk_export = jwk.export
663
+ jwk_export[:kid] = jwk_export[:kid] || SecureRandom.hex(16)
664
+
665
+ payload = {
666
+ iss: MockOPServer::ENTITY_ID,
667
+ sub: MockOPServer::ENTITY_ID,
668
+ iat: Time.now.to_i - 7200,
669
+ exp: Time.now.to_i - 3600,
670
+ jwks: {keys: [jwk_export]},
671
+ metadata: {
672
+ openid_provider: MockOPServer::OP_METADATA
673
+ }
674
+ }
675
+
676
+ header = {alg: "RS256", typ: "entity-statement+jwt", kid: jwk_export[:kid]}
677
+ JWT.encode(payload, MockOPServer::SIGNING_KEY, "RS256", header)
678
+ end
679
+
680
+ def generate_entity_statement_with_wrong_keys
681
+ wrong_key = OpenSSL::PKey::RSA.new(2048)
682
+ jwk = JWT::JWK.new(wrong_key.public_key)
683
+ jwk_export = jwk.export
684
+ jwk_export[:kid] = jwk_export[:kid] || SecureRandom.hex(16)
685
+
686
+ payload = {
687
+ iss: MockOPServer::ENTITY_ID,
688
+ sub: MockOPServer::ENTITY_ID,
689
+ iat: Time.now.to_i,
690
+ exp: Time.now.to_i + 3600,
691
+ jwks: {keys: [jwk_export]},
692
+ metadata: {
693
+ openid_provider: MockOPServer::OP_METADATA
694
+ }
695
+ }
696
+
697
+ header = {alg: "RS256", typ: "entity-statement+jwt", kid: jwk_export[:kid]}
698
+ JWT.encode(payload, MockOPServer::SIGNING_KEY, "RS256", header)
699
+ end
700
+
701
+ def generate_entity_statement_without_metadata
702
+ jwk = JWT::JWK.new(MockOPServer::SIGNING_KEY.public_key)
703
+ jwk_export = jwk.export
704
+ jwk_export[:kid] = jwk_export[:kid] || SecureRandom.hex(16)
705
+
706
+ payload = {
707
+ iss: MockOPServer::ENTITY_ID,
708
+ sub: MockOPServer::ENTITY_ID,
709
+ iat: Time.now.to_i,
710
+ exp: Time.now.to_i + 3600,
711
+ jwks: {keys: [jwk_export]}
712
+ }
713
+
714
+ header = {alg: "RS256", typ: "entity-statement+jwt", kid: jwk_export[:kid]}
715
+ JWT.encode(payload, MockOPServer::SIGNING_KEY, "RS256", header)
716
+ end
717
+
718
+ def generate_invalid_signed_jwks
719
+ wrong_key = OpenSSL::PKey::RSA.new(2048)
720
+ jwk = JWT::JWK.new(wrong_key.public_key)
721
+ jwk_export = jwk.export
722
+
723
+ payload = {
724
+ keys: [jwk_export]
725
+ }
726
+
727
+ header = {alg: "RS256", typ: "JWT", kid: jwk_export[:kid]}
728
+ JWT.encode(payload, wrong_key, "RS256", header)
729
+ end
730
+ end
731
+
732
+ def self.run!
733
+ port = ENV["PORT"]&.to_i || 9292
734
+ bind = ENV["BIND"] || "localhost"
735
+
736
+ server = WEBrick::HTTPServer.new(Port: port, BindAddress: bind)
737
+ server.mount("/", Servlet)
738
+
739
+ trap("INT") { server.shutdown }
740
+ trap("TERM") { server.shutdown }
741
+
742
+ server.start
743
+ end
744
+ end
745
+
746
+ # Run the server
747
+ if __FILE__ == $0
748
+ # Configure federation endpoint before starting
749
+ MockOPServer.configure_federation_endpoint
750
+ MockOPServer.configure_subordinate_statements
751
+
752
+ puts "Starting Enhanced Mock OP Server..."
753
+ puts "Entity ID: #{MockOPServer::ENTITY_ID}"
754
+ puts "Server: http://#{MockOPServer::SERVER_HOST}"
755
+ puts ""
756
+ puts "Endpoints:"
757
+ puts " GET /.well-known/openid-federation - Entity Configuration"
758
+ puts " GET /.well-known/openid-federation/fetch?sub=<entity_id> - Fetch Subordinate Statement"
759
+ puts " GET /.well-known/jwks.json - JWKS"
760
+ puts " GET /.well-known/signed-jwks.json - Signed JWKS"
761
+ puts " GET /auth?request=<signed_jwt> - Authorization (with request object validation)"
762
+ puts " POST /token - Token Exchange"
763
+ puts " GET /userinfo - UserInfo (mock)"
764
+ puts ""
765
+ puts "Error Injection Modes (add ?error_mode=<mode> to any endpoint):"
766
+ puts " - invalid_statement: Return invalid entity statement"
767
+ puts " - wrong_keys: Return wrong JWKS keys"
768
+ puts " - invalid_request: Reject request object"
769
+ puts " - invalid_signature: Return invalid signature"
770
+ puts " - expired_statement: Return expired entity statement"
771
+ puts " - missing_metadata: Return statement without metadata"
772
+ puts ""
773
+
774
+ MockOPServer.run!
775
+ end