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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +120 -14
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +3 -3
- data/config/polyrun_coverage.yml +15 -0
- data/config/polyrun_spec_quality.yml +29 -0
- data/config/routes.rb +20 -10
- data/examples/README_INTEGRATION_TESTING.md +3 -0
- data/examples/integration_test_flow.rb +10 -8
- data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
- data/examples/jobs/federation_files_generation_job.rb.example +3 -2
- data/examples/mock_op_server.rb +4 -5
- data/examples/mock_rp_server.rb +3 -3
- data/examples/standalone_multiple_endpoints_example.rb +494 -0
- data/lib/omniauth_openid_federation/access_token.rb +91 -450
- data/lib/omniauth_openid_federation/cache.rb +16 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
- data/lib/omniauth_openid_federation/constants.rb +3 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +8 -91
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +54 -21
- data/lib/omniauth_openid_federation/federation_endpoint.rb +40 -172
- data/lib/omniauth_openid_federation/http_client.rb +145 -32
- data/lib/omniauth_openid_federation/http_errors.rb +14 -0
- data/lib/omniauth_openid_federation/id_token.rb +19 -0
- data/lib/omniauth_openid_federation/jwe.rb +26 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
- data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
- data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
- data/lib/omniauth_openid_federation/jws.rb +3 -11
- data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
- data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
- data/lib/omniauth_openid_federation/rack.rb +2 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +21 -9
- data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
- data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
- data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
- data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
- data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
- data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
- data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
- data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
- data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
- data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
- data/lib/omniauth_openid_federation/strategy.rb +27 -1910
- data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
- data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
- data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
- data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
- data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +28 -790
- data/lib/omniauth_openid_federation/time_helpers.rb +67 -0
- data/lib/omniauth_openid_federation/user_info.rb +15 -0
- data/lib/omniauth_openid_federation/utils.rb +14 -9
- data/lib/omniauth_openid_federation/validators.rb +55 -44
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +10 -3
- data/lib/tasks/omniauth_openid_federation.rake +5 -5
- data/sig/omniauth_openid_federation.rbs +8 -1
- data/sig/strategy.rbs +6 -6
- 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
|