omniauth_openid_federation 1.3.0 → 2.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 +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +120 -14
  4. data/app/controllers/omniauth_openid_federation/federation_controller.rb +3 -3
  5. data/config/polyrun_coverage.yml +15 -0
  6. data/config/polyrun_spec_quality.yml +29 -0
  7. data/config/routes.rb +20 -10
  8. data/examples/README_INTEGRATION_TESTING.md +3 -0
  9. data/examples/integration_test_flow.rb +10 -8
  10. data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
  11. data/examples/jobs/federation_files_generation_job.rb.example +3 -2
  12. data/examples/mock_op_server.rb +4 -5
  13. data/examples/mock_rp_server.rb +3 -3
  14. data/examples/standalone_multiple_endpoints_example.rb +494 -0
  15. data/lib/omniauth_openid_federation/access_token.rb +91 -450
  16. data/lib/omniauth_openid_federation/cache.rb +16 -0
  17. data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
  18. data/lib/omniauth_openid_federation/constants.rb +3 -0
  19. data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
  20. data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
  21. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
  22. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
  23. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +8 -91
  24. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
  25. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +54 -21
  26. data/lib/omniauth_openid_federation/federation_endpoint.rb +40 -172
  27. data/lib/omniauth_openid_federation/http_client.rb +145 -32
  28. data/lib/omniauth_openid_federation/http_errors.rb +14 -0
  29. data/lib/omniauth_openid_federation/id_token.rb +19 -0
  30. data/lib/omniauth_openid_federation/jwe.rb +26 -0
  31. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
  32. data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
  33. data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
  34. data/lib/omniauth_openid_federation/jws.rb +3 -11
  35. data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
  36. data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
  37. data/lib/omniauth_openid_federation/rack.rb +2 -0
  38. data/lib/omniauth_openid_federation/rack_endpoint.rb +21 -9
  39. data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
  40. data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
  41. data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
  42. data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
  43. data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
  44. data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
  45. data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
  46. data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
  47. data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
  48. data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
  49. data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
  50. data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
  51. data/lib/omniauth_openid_federation/strategy.rb +27 -1910
  52. data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
  53. data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
  54. data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
  55. data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
  56. data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
  57. data/lib/omniauth_openid_federation/tasks_helper.rb +28 -790
  58. data/lib/omniauth_openid_federation/time_helpers.rb +67 -0
  59. data/lib/omniauth_openid_federation/user_info.rb +15 -0
  60. data/lib/omniauth_openid_federation/utils.rb +14 -9
  61. data/lib/omniauth_openid_federation/validators.rb +55 -44
  62. data/lib/omniauth_openid_federation/version.rb +1 -1
  63. data/lib/omniauth_openid_federation.rb +10 -3
  64. data/lib/tasks/omniauth_openid_federation.rake +5 -5
  65. data/sig/omniauth_openid_federation.rbs +8 -1
  66. data/sig/strategy.rbs +6 -6
  67. metadata +252 -43
@@ -0,0 +1,220 @@
1
+ module OmniauthOpenidFederation
2
+ module Strategy
3
+ module AuthorizationRequest
4
+ def authorize_uri
5
+ validate_provider_trust_configuration!
6
+
7
+ request_params = request.params
8
+
9
+ # Security: Only validate user input from HTTP requests, not config values
10
+ # Note: Rack params can return arrays for multi-value parameters
11
+ sanitized_params = {}
12
+ request_params.each do |key, value|
13
+ next unless value
14
+ key_str = key.to_s
15
+ next if key_str.length > 256
16
+ # For arrays (multi-value params), sanitize each element and limit size
17
+ if value.is_a?(Array)
18
+ # Prevent DoS: limit array size
19
+ if value.length > 100
20
+ next
21
+ end
22
+ # Sanitize each element
23
+ sanitized_array = value.map { |v| OmniauthOpenidFederation::Validators.sanitize_request_param(v) }.compact
24
+ next if sanitized_array.empty?
25
+ # Keep as array for acr_values (handled by normalize_acr_values)
26
+ # Convert to space-separated string for other parameters (ui_locales, claims_locales)
27
+ sanitized_params[key_str] = if key_str == "acr_values"
28
+ sanitized_array
29
+ else
30
+ sanitized_array.join(" ")
31
+ end
32
+ else
33
+ sanitized = OmniauthOpenidFederation::Validators.sanitize_request_param(value)
34
+ sanitized_params[key_str] = sanitized if sanitized
35
+ end
36
+ end
37
+ request_params = sanitized_params
38
+
39
+ if options.default_request_object_claims.is_a?(Hash)
40
+ options.default_request_object_claims.each do |key, value|
41
+ key_str = key.to_s
42
+ next if key_str.length > 256
43
+ next if OmniauthOpenidFederation::StringHelpers.blank?(value)
44
+ request_params[key_str] ||= value
45
+ end
46
+ end
47
+
48
+ # Apply custom proc to modify params before adding to signed request object
49
+ if options.prepare_request_object_params.respond_to?(:call)
50
+ request_params = options.prepare_request_object_params.call(request_params.dup) || request_params
51
+ request_params = {} unless request_params.is_a?(Hash)
52
+ end
53
+
54
+ # Enforce signed request objects (RFC 9101) - unsigned requests are not allowed
55
+ client_options_hash = options.client_options || {}
56
+ normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
57
+ private_key = normalized_options[:private_key]
58
+ OmniauthOpenidFederation::Validators.validate_private_key!(private_key)
59
+
60
+ resolved_issuer = options.issuer
61
+ unless OmniauthOpenidFederation::StringHelpers.present?(resolved_issuer)
62
+ resolved_issuer = resolve_issuer_from_metadata
63
+ options.issuer = resolved_issuer if resolved_issuer
64
+ end
65
+
66
+ audience_value = resolve_audience(client_options_hash, resolved_issuer)
67
+
68
+ unless OmniauthOpenidFederation::StringHelpers.present?(audience_value)
69
+ error_msg = "Audience is required for signed request objects. " \
70
+ "Set audience option, provide entity statement with provider issuer, or configure token_endpoint"
71
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
72
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
73
+ end
74
+
75
+ state_value = new_state
76
+ nonce_value = options.send_nonce ? new_nonce : nil
77
+ configured_redirect_uri = normalized_options[:redirect_uri] || callback_url
78
+
79
+ # Automatic registration uses entity identifier as client_id (OpenID Federation Section 12.1)
80
+ client_registration_type = options.client_registration_type || :explicit
81
+ client_id_for_request = normalized_options[:identifier]
82
+ client_entity_statement = nil
83
+
84
+ if client_registration_type == :automatic
85
+ client_entity_statement = load_client_entity_statement(
86
+ options.client_entity_statement_path,
87
+ options.client_entity_statement_url
88
+ )
89
+ entity_identifier = extract_entity_identifier_from_statement(client_entity_statement, options.client_entity_identifier)
90
+ unless OmniauthOpenidFederation::StringHelpers.present?(entity_identifier)
91
+ error_msg = "Failed to extract entity identifier from client entity statement. " \
92
+ "Set client_entity_identifier option or ensure entity statement has 'sub' claim"
93
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
94
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
95
+ end
96
+ client_id_for_request = entity_identifier
97
+ if client.respond_to?(:identifier=)
98
+ client.identifier = entity_identifier
99
+ elsif client.respond_to?(:client_id=)
100
+ client.client_id = entity_identifier
101
+ end
102
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using automatic registration with entity identifier: #{entity_identifier}")
103
+ end
104
+
105
+ signing_key_source = options.signing_key_source || options.key_source || :local
106
+ jwks = normalized_options[:jwks] || normalized_options["jwks"]
107
+
108
+ # Extract already-sanitized user input params (sanitized above)
109
+ validated_state = state_value.to_s.strip
110
+ validated_nonce = nonce_value&.to_s&.strip
111
+ validated_login_hint = request_params["login_hint"]
112
+ validated_ui_locales = request_params["ui_locales"]
113
+ validated_claims_locales = request_params["claims_locales"]
114
+
115
+ # Config values are trusted (no sanitization needed)
116
+ validated_client_id = client_id_for_request.to_s.strip
117
+ validated_redirect_uri = configured_redirect_uri.to_s.strip
118
+ validated_scope = Array(options.scope).join(" ").strip
119
+ validated_response_type = options.response_type.to_s.strip
120
+ validated_prompt = options.prompt&.to_s&.strip
121
+ validated_hd = options.hd&.to_s&.strip
122
+ validated_response_mode = options.response_mode&.to_s&.strip
123
+ validated_issuer = (resolved_issuer || options.issuer)&.to_s&.strip
124
+ validated_audience = audience_value&.to_s&.strip
125
+ normalized_acr_values = OmniauthOpenidFederation::Validators.normalize_acr_values(request_params["acr_values"], skip_sanitization: true) || nil
126
+
127
+ request_object_claims = request_params.dup
128
+ request_object_claims["acr_values"] = normalized_acr_values if normalized_acr_values
129
+ request_object_claims["ui_locales"] = validated_ui_locales if validated_ui_locales
130
+ request_object_claims["claims_locales"] = validated_claims_locales if validated_claims_locales
131
+ request_object_claims["login_hint"] = validated_login_hint if validated_login_hint
132
+ OmniauthOpenidFederation::Validators.validate_required_request_object_claims!(
133
+ request_object_claims,
134
+ options.required_request_object_claims
135
+ )
136
+
137
+ jws_builder = OmniauthOpenidFederation::Jws.new(
138
+ client_id: validated_client_id,
139
+ redirect_uri: validated_redirect_uri,
140
+ scope: validated_scope,
141
+ issuer: validated_issuer,
142
+ audience: validated_audience,
143
+ state: validated_state,
144
+ nonce: validated_nonce,
145
+ response_type: validated_response_type,
146
+ response_mode: validated_response_mode,
147
+ login_hint: validated_login_hint,
148
+ ui_locales: validated_ui_locales,
149
+ claims_locales: validated_claims_locales,
150
+ prompt: validated_prompt,
151
+ hd: validated_hd,
152
+ acr_values: normalized_acr_values,
153
+ extra_params: options.extra_authorize_params || {},
154
+ private_key: normalized_options[:private_key],
155
+ jwks: jwks,
156
+ entity_statement_path: options.entity_statement_path,
157
+ key_source: signing_key_source,
158
+ client_entity_statement: client_entity_statement
159
+ )
160
+
161
+ # Add dynamic request object params from HTTP request (already sanitized above)
162
+ options.request_object_params&.each do |key|
163
+ key_str = key.to_s
164
+ next if key_str.length > 256
165
+ value = request_params[key_str]
166
+ jws_builder.add_claim(key_str.to_sym, value) if value
167
+ end
168
+
169
+ # RFC 9101: Only 'request' parameter in query, all params in JWT
170
+ provider_metadata = load_provider_metadata_for_encryption
171
+ signed_request_object = jws_builder.sign(
172
+ provider_metadata: provider_metadata,
173
+ always_encrypt: options.always_encrypt_request_object
174
+ )
175
+ unless OmniauthOpenidFederation::StringHelpers.present?(signed_request_object)
176
+ error_msg = "Failed to generate signed request object - authentication cannot proceed without signed request"
177
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
178
+ raise OmniauthOpenidFederation::SecurityError, error_msg
179
+ end
180
+
181
+ # Build URL manually to ensure RFC 9101 compliance (only 'request' param in query)
182
+ auth_endpoint = client.authorization_endpoint
183
+ unless OmniauthOpenidFederation::StringHelpers.present?(auth_endpoint)
184
+ error_msg = "Authorization endpoint not configured. Provide authorization_endpoint in client_options or entity statement"
185
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
186
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
187
+ end
188
+
189
+ begin
190
+ uri = URI.parse(auth_endpoint)
191
+ rescue URI::InvalidURIError => e
192
+ error_msg = "Invalid authorization endpoint URI format: #{e.message}"
193
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
194
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
195
+ end
196
+
197
+ max_string_length = ::OmniauthOpenidFederation::Configuration.config.max_string_length
198
+ if signed_request_object.length > max_string_length
199
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Request object exceeds maximum length")
200
+ end
201
+
202
+ uri.query = URI.encode_www_form(request: signed_request_object)
203
+ uri.to_s
204
+ end
205
+
206
+ def validate_provider_trust_configuration!
207
+ return unless options.require_entity_statement_fingerprint
208
+
209
+ using_remote_source = OmniauthOpenidFederation::StringHelpers.present?(options.entity_statement_url) ||
210
+ OmniauthOpenidFederation::StringHelpers.present?(options.issuer)
211
+ return unless using_remote_source
212
+
213
+ if OmniauthOpenidFederation::StringHelpers.blank?(options.entity_statement_fingerprint)
214
+ raise OmniauthOpenidFederation::ConfigurationError,
215
+ "entity_statement_fingerprint is required when require_entity_statement_fingerprint is enabled"
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,135 @@
1
+ module OmniauthOpenidFederation
2
+ module Strategy
3
+ module CallbackHandling
4
+ def callback_phase
5
+ # Security: Validate user input from HTTP request
6
+ state_param_raw = request.params["state"]
7
+ code_param_raw = request.params["code"]
8
+ error_param_raw = request.params["error"]
9
+ error_description_raw = request.params["error_description"]
10
+
11
+ state_param = state_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(state_param_raw) : nil
12
+ code_param = code_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(code_param_raw) : nil
13
+ error_param = error_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(error_param_raw) : nil
14
+ error_description_param = error_description_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(error_description_raw) : nil
15
+ if error_param
16
+ error_msg = "Authorization error: #{error_param}"
17
+ error_msg += " - #{error_description_param}" if error_description_param
18
+ OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
19
+ stage: "callback_phase",
20
+ error_message: error_msg,
21
+ error_class: "AuthorizationError",
22
+ request_info: {
23
+ remote_ip: request.env["REMOTE_ADDR"],
24
+ user_agent: request.env["HTTP_USER_AGENT"],
25
+ path: request.path
26
+ }
27
+ )
28
+ env["omniauth_openid_federation.instrumented"] = true
29
+ return fail!(:authorization_error, OmniauthOpenidFederation::ValidationError.new(error_msg))
30
+ end
31
+
32
+ # CSRF protection: constant-time state comparison
33
+ state_session = session["omniauth.state"]
34
+
35
+ if OmniauthOpenidFederation::StringHelpers.blank?(state_param) ||
36
+ state_session.nil? ||
37
+ !OmniauthOpenidFederation::SecureCompare.secure_compare(state_param.to_s, state_session.to_s)
38
+ # Instrument CSRF detection
39
+ OmniauthOpenidFederation::Instrumentation.notify_csrf_detected(
40
+ state_param: state_param ? "[PRESENT]" : "[MISSING]",
41
+ state_session: state_session ? "[PRESENT]" : "[MISSING]",
42
+ request_info: {
43
+ remote_ip: request.env["REMOTE_ADDR"],
44
+ user_agent: request.env["HTTP_USER_AGENT"],
45
+ path: request.path
46
+ }
47
+ )
48
+ # Mark as instrumented to prevent double instrumentation in fail!
49
+ env["omniauth_openid_federation.instrumented"] = true
50
+ return fail!(:csrf_detected, OmniauthOpenidFederation::SecurityError.new("CSRF detected"))
51
+ end
52
+
53
+ # Clear state from session
54
+ session.delete("omniauth.state")
55
+
56
+ if OmniauthOpenidFederation::StringHelpers.blank?(code_param)
57
+ # Instrument unexpected authentication break
58
+ OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
59
+ stage: "callback_phase",
60
+ error_message: "Missing authorization code",
61
+ error_class: "ValidationError",
62
+ request_info: {
63
+ remote_ip: request.env["REMOTE_ADDR"],
64
+ user_agent: request.env["HTTP_USER_AGENT"],
65
+ path: request.path
66
+ }
67
+ )
68
+ # Mark as instrumented to prevent double instrumentation in fail!
69
+ env["omniauth_openid_federation.instrumented"] = true
70
+ return fail!(:missing_code, OmniauthOpenidFederation::ValidationError.new("Missing authorization code"))
71
+ end
72
+
73
+ begin
74
+ @access_token = exchange_authorization_code(code_param)
75
+ rescue => e
76
+ # Instrument unexpected authentication break
77
+ OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
78
+ stage: "token_exchange",
79
+ error_message: e.message,
80
+ error_class: e.class.name,
81
+ request_info: {
82
+ remote_ip: request.env["REMOTE_ADDR"],
83
+ user_agent: request.env["HTTP_USER_AGENT"],
84
+ path: request.path
85
+ }
86
+ )
87
+ # Mark as instrumented to prevent double instrumentation in fail!
88
+ env["omniauth_openid_federation.instrumented"] = true
89
+ return fail!(:token_exchange_error, e)
90
+ end
91
+
92
+ env["omniauth.auth"] = auth_hash
93
+ call_app!
94
+ end
95
+
96
+ private
97
+
98
+ def exchange_authorization_code(authorization_code)
99
+ client_options_hash = options.client_options || {}
100
+ normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
101
+ configured_redirect_uri = normalized_options[:redirect_uri] || callback_url
102
+
103
+ oidc_client.authorization_code = authorization_code
104
+ oidc_client.redirect_uri = configured_redirect_uri
105
+
106
+ begin
107
+ oidc_client.access_token!(
108
+ options.client_auth_method || :jwt_bearer
109
+ )
110
+ rescue => e
111
+ error_msg = "Failed to exchange authorization code for access token: #{e.class} - #{e.message}"
112
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
113
+ raise OmniauthOpenidFederation::NetworkError, error_msg, e.backtrace
114
+ end
115
+ end
116
+
117
+ def new_state
118
+ # Generate a random state value and store it in the session
119
+ state = SecureRandom.hex(self.class::STATE_BYTES)
120
+ session["omniauth.state"] = state
121
+ state
122
+ end
123
+
124
+ def new_nonce
125
+ nonce = SecureRandom.hex(self.class::NONCE_BYTES)
126
+ session["omniauth.nonce"] = nonce if options.send_nonce
127
+ nonce
128
+ end
129
+
130
+ def omniauth_rack_session
131
+ env.is_a?(Hash) ? env["rack.session"] : nil
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,101 @@
1
+ module OmniauthOpenidFederation
2
+ module Strategy
3
+ module ClientConstruction
4
+ def client_jwk_signing_key
5
+ # Return manually configured value if present (allows override)
6
+ configured_value = options.client_jwk_signing_key
7
+ return configured_value if OmniauthOpenidFederation::StringHelpers.present?(configured_value)
8
+
9
+ # Automatically extract from client entity statement if available
10
+ extracted_value = extract_client_jwk_signing_key
11
+ return extracted_value if OmniauthOpenidFederation::StringHelpers.present?(extracted_value)
12
+
13
+ # Return nil if not available (allows fallback to other authentication methods)
14
+ nil
15
+ end
16
+
17
+ def options
18
+ opts = super
19
+ # Dynamically set client_jwk_signing_key if not already set and we can extract it
20
+ if opts[:client_jwk_signing_key].nil? && (opts[:client_entity_statement_path] || opts[:client_entity_statement_url])
21
+ extracted = extract_client_jwk_signing_key
22
+ opts[:client_jwk_signing_key] = extracted if OmniauthOpenidFederation::StringHelpers.present?(extracted)
23
+ end
24
+ opts
25
+ end
26
+
27
+ def client
28
+ @client ||= begin
29
+ client_options_hash = options.client_options || {}
30
+
31
+ # Automatically resolve endpoints, issuer, scheme, and host from entity statement metadata if available
32
+ # This allows endpoints and issuer to be discovered from entity statement without manual configuration
33
+ # client_options still takes precedence for overrides
34
+ resolved_endpoints = resolve_endpoints_from_metadata(client_options_hash)
35
+
36
+ # Merge resolved endpoints with client_options (client_options takes precedence)
37
+ # resolved_endpoints may contain: endpoints, issuer, scheme, host
38
+ # client_options will override any resolved values
39
+ merged_options = resolved_endpoints.merge(client_options_hash)
40
+
41
+ # Build base URL from scheme, host, and port
42
+ base_url = build_base_url(merged_options)
43
+
44
+ # For automatic registration, identifier is the entity identifier (determined at request time)
45
+ # For explicit registration, identifier comes from client_options
46
+ # Note: For automatic registration, the actual entity identifier will be extracted
47
+ # in authorize_uri and used in the request object. The client identifier here is
48
+ # used for client assertion at the token endpoint, which should also use the entity identifier.
49
+ # However, since the client is cached, we'll handle this in authorize_uri by updating
50
+ # the client's identifier if needed.
51
+ client_identifier = merged_options[:identifier] || merged_options["identifier"]
52
+
53
+ # Create OpenID Connect client (extends OAuth2::Client, so compatible with OmniAuth::Strategies::OAuth2)
54
+ # Build endpoints - use resolved values or nil if not available
55
+ auth_endpoint = build_endpoint(base_url, merged_options[:authorization_endpoint] || merged_options["authorization_endpoint"])
56
+ token_endpoint = build_endpoint(base_url, merged_options[:token_endpoint] || merged_options["token_endpoint"])
57
+ userinfo_endpoint = build_endpoint(base_url, merged_options[:userinfo_endpoint] || merged_options["userinfo_endpoint"])
58
+ jwks_uri_endpoint = build_endpoint(base_url, merged_options[:jwks_uri] || merged_options["jwks_uri"])
59
+
60
+ # Validate that at least authorization_endpoint is present (required)
61
+ unless OmniauthOpenidFederation::StringHelpers.present?(auth_endpoint)
62
+ error_msg = "Authorization endpoint not configured. Provide authorization_endpoint in client_options or entity statement"
63
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
64
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
65
+ end
66
+
67
+ oidc_client = OmniauthOpenidFederation::OidcClient.new(
68
+ identifier: client_identifier,
69
+ secret: nil, # We use private_key_jwt, so no secret needed
70
+ redirect_uri: merged_options[:redirect_uri] || merged_options["redirect_uri"],
71
+ authorization_endpoint: auth_endpoint,
72
+ token_endpoint: token_endpoint,
73
+ userinfo_endpoint: userinfo_endpoint,
74
+ jwks_uri: jwks_uri_endpoint
75
+ )
76
+
77
+ # Store private key for client assertion (private_key_jwt authentication)
78
+ oidc_client.private_key = merged_options[:private_key] || merged_options["private_key"]
79
+
80
+ # Store strategy options on client for AccessToken to access later
81
+ # This allows AccessToken to get configuration without relying on Devise
82
+ # Ensure all entity statement options are included (to_h might not include all options)
83
+ strategy_options_hash = options.to_h.dup
84
+ # Explicitly include entity statement options that AccessToken needs
85
+ strategy_options_hash[:entity_statement_path] = options.entity_statement_path if options.entity_statement_path
86
+ strategy_options_hash[:entity_statement_url] = options.entity_statement_url if options.entity_statement_url
87
+ strategy_options_hash[:entity_statement_fingerprint] = options.entity_statement_fingerprint if options.entity_statement_fingerprint
88
+ strategy_options_hash[:issuer] = options.issuer if options.issuer
89
+ oidc_client.instance_variable_set(:@strategy_options, strategy_options_hash)
90
+
91
+ # OidcClient extends OAuth2::Client, so it's compatible with OmniAuth::Strategies::OAuth2
92
+ oidc_client
93
+ end
94
+ end
95
+
96
+ def oidc_client
97
+ client
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,211 @@
1
+ module OmniauthOpenidFederation
2
+ module Strategy
3
+ module ClientEntityStatement
4
+ private
5
+
6
+ def load_client_entity_statement(entity_statement_path = nil, entity_statement_url = nil)
7
+ # Priority 1: Use file path if provided (for manual cache, development, debugging)
8
+ if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
9
+ return load_client_entity_statement_from_file(entity_statement_path)
10
+ end
11
+
12
+ # Priority 2: Check cache (if Rails.cache is available)
13
+ # This respects background job cache refresh and key rotation
14
+ if defined?(Rails) && Rails.cache
15
+ cache_key = "federation:entity_statement"
16
+ config = OmniauthOpenidFederation::FederationEndpoint.configuration
17
+
18
+ # Use cache TTL based on entity statement expiration or default to 1 hour
19
+ # The entity statement JWT itself has an expiration, but we cache it for performance
20
+ # Cache TTL should be shorter than JWT expiration to ensure fresh keys
21
+ cache_ttl = config.jwks_cache_ttl || 3600 # Default to 1 hour, same as JWKS cache
22
+
23
+ begin
24
+ cached_statement = Rails.cache.fetch(cache_key, expires_in: cache_ttl) do
25
+ # Generate and cache if not in cache
26
+ entity_statement = OmniauthOpenidFederation::FederationEndpoint.generate_entity_statement
27
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Generated and cached client entity statement")
28
+ entity_statement
29
+ end
30
+
31
+ if cached_statement
32
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using cached client entity statement")
33
+ return cached_statement
34
+ end
35
+ rescue => e
36
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Cache fetch failed, generating fresh entity statement: #{e.message}")
37
+ # Fall through to generate dynamically
38
+ end
39
+ end
40
+
41
+ # Priority 3: Generate dynamically (always available)
42
+ # The entity statement is always generated via FederationEndpoint
43
+ begin
44
+ entity_statement = OmniauthOpenidFederation::FederationEndpoint.generate_entity_statement
45
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Generated client entity statement dynamically")
46
+ entity_statement
47
+ rescue OmniauthOpenidFederation::ConfigurationError => e
48
+ # FederationEndpoint not configured - provide helpful error message
49
+ error_msg = "Failed to generate client entity statement: #{e.message}. " \
50
+ "Either configure OmniauthOpenidFederation::FederationEndpoint.configure " \
51
+ "or provide client_entity_statement_path for manual cache/dev/debug."
52
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
53
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
54
+ rescue => e
55
+ error_msg = "Failed to generate client entity statement: #{e.message}"
56
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
57
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
58
+ end
59
+ end
60
+
61
+ def load_client_entity_statement_from_file(entity_statement_path)
62
+ # Resolve path (relative to Rails root if available)
63
+ path = if entity_statement_path.start_with?("/")
64
+ entity_statement_path
65
+ elsif defined?(Rails) && Rails.root
66
+ Rails.root.join(entity_statement_path).to_s
67
+ else
68
+ File.expand_path(entity_statement_path)
69
+ end
70
+
71
+ unless File.exist?(path)
72
+ error_msg = "Client entity statement file not found: #{path}"
73
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
74
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
75
+ end
76
+
77
+ entity_statement = File.read(path)
78
+ unless OmniauthOpenidFederation::StringHelpers.present?(entity_statement)
79
+ error_msg = "Client entity statement file is empty: #{path}"
80
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
81
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
82
+ end
83
+
84
+ # Validate it's a JWT (has 3 parts)
85
+ jwt_parts = entity_statement.strip.split(".")
86
+ unless jwt_parts.length == 3
87
+ error_msg = "Client entity statement is not a valid JWT (expected 3 parts, got #{jwt_parts.length}): #{path}"
88
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
89
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
90
+ end
91
+
92
+ entity_statement.strip
93
+ end
94
+
95
+ def load_client_entity_statement_from_url(entity_statement_url)
96
+ response = HttpClient.get(entity_statement_url)
97
+ unless response.status.success?
98
+ error_msg = "Failed to fetch client entity statement from #{entity_statement_url}: HTTP #{response.status}"
99
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
100
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
101
+ end
102
+
103
+ entity_statement = response.body.to_s
104
+ unless OmniauthOpenidFederation::StringHelpers.present?(entity_statement)
105
+ error_msg = "Client entity statement from URL is empty: #{entity_statement_url}"
106
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
107
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
108
+ end
109
+
110
+ # Validate it's a JWT (has 3 parts)
111
+ jwt_parts = entity_statement.strip.split(".")
112
+ unless jwt_parts.length == 3
113
+ error_msg = "Client entity statement from URL is not a valid JWT (expected 3 parts, got #{jwt_parts.length}): #{entity_statement_url}"
114
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
115
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
116
+ end
117
+
118
+ entity_statement.strip
119
+ rescue OmniauthOpenidFederation::NetworkError => e
120
+ error_msg = "Failed to fetch client entity statement from #{entity_statement_url}: #{e.message}"
121
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
122
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
123
+ end
124
+
125
+ def extract_client_jwk_signing_key
126
+ # Access raw options hash to avoid recursion (don't call options method which triggers extraction)
127
+ raw_opts = @options || {}
128
+
129
+ # If explicit JWKS is provided, use it
130
+ return raw_opts[:client_jwk_signing_key] if OmniauthOpenidFederation::StringHelpers.present?(raw_opts[:client_jwk_signing_key])
131
+
132
+ # Entity statement is always available (either from file or generated dynamically)
133
+ begin
134
+ entity_statement_content = load_client_entity_statement(
135
+ raw_opts[:client_entity_statement_path],
136
+ raw_opts[:client_entity_statement_url]
137
+ )
138
+ return nil unless OmniauthOpenidFederation::StringHelpers.present?(entity_statement_content)
139
+
140
+ # Extract JWKS from client entity statement
141
+ jwt_parts = entity_statement_content.split(".")
142
+ return nil if jwt_parts.length != 3
143
+
144
+ payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
145
+ entity_jwks = payload.fetch("jwks", {})
146
+ return nil if entity_jwks.empty?
147
+
148
+ # Return JWKS as JSON string (format expected by OAuth2/OIDC clients)
149
+ JSON.dump(entity_jwks)
150
+ rescue => e
151
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Failed to extract client JWKS from entity statement: #{e.message}")
152
+ nil
153
+ end
154
+ end
155
+
156
+ def extract_entity_identifier_from_statement(entity_statement, configured_identifier = nil)
157
+ # Use configured identifier if provided
158
+ return configured_identifier if OmniauthOpenidFederation::StringHelpers.present?(configured_identifier)
159
+
160
+ # Extract from entity statement
161
+ begin
162
+ jwt_parts = entity_statement.split(".")
163
+ payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
164
+ entity_identifier = payload["sub"] || payload[:sub]
165
+ return entity_identifier if OmniauthOpenidFederation::StringHelpers.present?(entity_identifier)
166
+
167
+ # Fallback to issuer if sub is not present
168
+ entity_identifier = payload["iss"] || payload[:iss]
169
+ return entity_identifier if OmniauthOpenidFederation::StringHelpers.present?(entity_identifier)
170
+
171
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Could not extract entity identifier from entity statement (no 'sub' or 'iss' claim)")
172
+ nil
173
+ rescue => e
174
+ OmniauthOpenidFederation::Logger.error("[Strategy] Failed to extract entity identifier from entity statement: #{e.message}")
175
+ nil
176
+ end
177
+ end
178
+
179
+ def load_provider_metadata_for_encryption
180
+ entity_statement_content = load_provider_entity_statement
181
+ return nil unless entity_statement_content
182
+
183
+ begin
184
+ # Decode entity statement payload to get all provider metadata fields
185
+ # EntityStatement.parse only extracts specific fields, so we need to access raw payload
186
+ jwt_parts = entity_statement_content.split(".")
187
+ return nil if jwt_parts.length != 3
188
+
189
+ payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
190
+ metadata_section = payload.fetch("metadata", {})
191
+ provider_metadata = metadata_section.fetch("openid_provider", {})
192
+ entity_jwks = payload.fetch("jwks", {})
193
+
194
+ # Combine provider metadata with entity JWKS for encryption
195
+ # Note: Provider's encryption requirements would be in their discovery document,
196
+ # but we can also check client metadata as a fallback
197
+ {
198
+ "request_object_encryption_alg" => provider_metadata["request_object_encryption_alg"] ||
199
+ provider_metadata[:request_object_encryption_alg],
200
+ "request_object_encryption_enc" => provider_metadata["request_object_encryption_enc"] ||
201
+ provider_metadata[:request_object_encryption_enc],
202
+ "jwks" => entity_jwks
203
+ }
204
+ rescue => e
205
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not load provider metadata for encryption: #{e.message}")
206
+ nil
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end