omniauth_openid_federation 1.2.2 → 1.3.2
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 +20 -1
- data/README.md +210 -708
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +14 -1
- data/config/routes.rb +20 -10
- data/examples/config/initializers/devise.rb.example +44 -55
- data/examples/config/initializers/federation_endpoint.rb.example +2 -2
- data/examples/config/open_id_connect_config.rb.example +12 -15
- data/examples/config/routes.rb.example +9 -5
- data/examples/integration_test_flow.rb +4 -4
- data/examples/mock_op_server.rb +3 -3
- data/examples/mock_rp_server.rb +3 -3
- data/lib/omniauth_openid_federation/configuration.rb +8 -0
- data/lib/omniauth_openid_federation/constants.rb +5 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
- 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 +6 -87
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +3 -15
- data/lib/omniauth_openid_federation/federation_endpoint.rb +39 -193
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -15
- data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
- data/lib/omniauth_openid_federation/jws.rb +23 -20
- data/lib/omniauth_openid_federation/rack_endpoint.rb +30 -5
- data/lib/omniauth_openid_federation/strategy.rb +143 -194
- data/lib/omniauth_openid_federation/tasks_helper.rb +501 -2
- data/lib/omniauth_openid_federation/time_helpers.rb +60 -0
- data/lib/omniauth_openid_federation/utils.rb +4 -7
- data/lib/omniauth_openid_federation/validators.rb +294 -8
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +1 -0
- data/lib/tasks/omniauth_openid_federation.rake +301 -2
- data/sig/federation.rbs +0 -8
- data/sig/jwks.rbs +0 -6
- data/sig/omniauth_openid_federation.rbs +6 -1
- data/sig/strategy.rbs +0 -2
- metadata +100 -1
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
# Input validation utilities for omniauth_openid_federation
|
|
2
|
+
require_relative "constants"
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
require_relative "string_helpers"
|
|
5
|
+
|
|
2
6
|
module OmniauthOpenidFederation
|
|
3
7
|
module Validators
|
|
4
8
|
# Validate that a private key is present and valid
|
|
@@ -10,7 +14,6 @@ module OmniauthOpenidFederation
|
|
|
10
14
|
raise ConfigurationError, "Private key is required for signed request objects"
|
|
11
15
|
end
|
|
12
16
|
|
|
13
|
-
# Try to parse if it's a string
|
|
14
17
|
if private_key.is_a?(String)
|
|
15
18
|
begin
|
|
16
19
|
OpenSSL::PKey::RSA.new(private_key)
|
|
@@ -71,17 +74,16 @@ module OmniauthOpenidFederation
|
|
|
71
74
|
true
|
|
72
75
|
end
|
|
73
76
|
|
|
74
|
-
# Validate client options hash
|
|
77
|
+
# Validate client options hash (for configuration validation only)
|
|
78
|
+
# Note: This validates configuration structure, not security (config is trusted)
|
|
75
79
|
#
|
|
76
80
|
# @param client_options [Hash] The client options to validate
|
|
77
81
|
# @raise [ConfigurationError] If required options are missing
|
|
78
82
|
def self.validate_client_options!(client_options)
|
|
79
83
|
client_options ||= {}
|
|
80
84
|
|
|
81
|
-
# Normalize hash keys to symbols
|
|
82
85
|
normalized = normalize_hash(client_options)
|
|
83
86
|
|
|
84
|
-
# Validate required fields
|
|
85
87
|
if StringHelpers.blank?(normalized[:identifier])
|
|
86
88
|
raise ConfigurationError, "Client identifier is required"
|
|
87
89
|
end
|
|
@@ -90,13 +92,17 @@ module OmniauthOpenidFederation
|
|
|
90
92
|
raise ConfigurationError, "Redirect URI is required"
|
|
91
93
|
end
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
begin
|
|
96
|
+
parsed = URI.parse(normalized[:redirect_uri].to_s)
|
|
97
|
+
unless parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS)
|
|
98
|
+
raise ConfigurationError, "Redirect URI must be HTTP or HTTPS: #{normalized[:redirect_uri]}"
|
|
99
|
+
end
|
|
100
|
+
rescue URI::InvalidURIError => e
|
|
101
|
+
raise ConfigurationError, "Invalid redirect URI format: #{e.message}"
|
|
102
|
+
end
|
|
95
103
|
|
|
96
|
-
# Validate private key
|
|
97
104
|
validate_private_key!(normalized[:private_key])
|
|
98
105
|
|
|
99
|
-
# Validate endpoints if provided
|
|
100
106
|
%i[authorization_endpoint token_endpoint jwks_uri].each do |endpoint|
|
|
101
107
|
if normalized.key?(endpoint) && !StringHelpers.blank?(normalized[endpoint])
|
|
102
108
|
# Endpoints can be paths or full URLs
|
|
@@ -122,5 +128,285 @@ module OmniauthOpenidFederation
|
|
|
122
128
|
result[key] = v
|
|
123
129
|
end
|
|
124
130
|
end
|
|
131
|
+
|
|
132
|
+
# Validate and sanitize user input from HTTP requests only (not config values)
|
|
133
|
+
# Prevents URI exploitation, ReDoS, string overflow, and control character attacks
|
|
134
|
+
# Default max_length uses Configuration.config.max_string_length (8KB default) - large enough for legitimate use, prevents DoS attacks
|
|
135
|
+
def self.sanitize_request_param(value, max_length: nil, allow_control_chars: false)
|
|
136
|
+
max_length ||= ::OmniauthOpenidFederation::Configuration.config.max_string_length
|
|
137
|
+
return nil if value.nil?
|
|
138
|
+
|
|
139
|
+
str = value.to_s.strip
|
|
140
|
+
return nil if str.length > max_length
|
|
141
|
+
|
|
142
|
+
unless allow_control_chars
|
|
143
|
+
str = str.gsub(/[^\x20-\x7E]/, "")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
str.empty? ? nil : str
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Validate URI for user input only (not config values)
|
|
150
|
+
# Prevents URI gem exploitation and validates scheme/length
|
|
151
|
+
def self.validate_uri_safe!(uri_str, max_length: nil, allowed_schemes: ["http", "https"])
|
|
152
|
+
max_length ||= ::OmniauthOpenidFederation::Configuration.config.max_string_length
|
|
153
|
+
raise SecurityError, "URI cannot be nil" if uri_str.nil?
|
|
154
|
+
|
|
155
|
+
original_str = uri_str.to_s
|
|
156
|
+
sanitized = original_str.gsub(/[^\x20-\x7E]/, "")
|
|
157
|
+
raise SecurityError, "URI contains invalid characters (only printable ASCII allowed)" if sanitized != original_str
|
|
158
|
+
|
|
159
|
+
str = sanitized.strip
|
|
160
|
+
raise SecurityError, "URI cannot be empty" if str.empty?
|
|
161
|
+
raise SecurityError, "URI exceeds maximum length of #{max_length} characters" if str.length > max_length
|
|
162
|
+
|
|
163
|
+
begin
|
|
164
|
+
parsed = URI.parse(str)
|
|
165
|
+
rescue URI::InvalidURIError => e
|
|
166
|
+
raise SecurityError, "Invalid URI format: #{e.message}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
unless parsed.scheme && allowed_schemes.include?(parsed.scheme.downcase)
|
|
170
|
+
raise SecurityError, "URI scheme must be one of: #{allowed_schemes.join(", ")}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
unless parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS)
|
|
174
|
+
raise SecurityError, "URI must be HTTP or HTTPS"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
raise SecurityError, "URI host cannot be empty" if StringHelpers.blank?(parsed.host)
|
|
178
|
+
|
|
179
|
+
parsed
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Validate and normalize acr_values parameter per OIDC Core 1.0 spec
|
|
183
|
+
# acr_values is a space-separated string of ACR values
|
|
184
|
+
# Security: Uses allowed characters approach - only allows printable ASCII characters
|
|
185
|
+
#
|
|
186
|
+
# @param acr_values [String, Array, nil] ACR values in any format
|
|
187
|
+
# @param max_length [Integer] Maximum total length (default: Configuration.config.max_string_length)
|
|
188
|
+
# @param skip_sanitization [Boolean] Skip sanitization if values are already sanitized (default: false)
|
|
189
|
+
# @return [String, nil] Normalized space-separated string or nil
|
|
190
|
+
def self.normalize_acr_values(acr_values, max_length: nil, skip_sanitization: false)
|
|
191
|
+
max_length ||= ::OmniauthOpenidFederation::Configuration.config.max_string_length
|
|
192
|
+
return nil if StringHelpers.blank?(acr_values)
|
|
193
|
+
|
|
194
|
+
case acr_values
|
|
195
|
+
when Array
|
|
196
|
+
values = acr_values.map(&:to_s).map(&:strip).reject { |v| StringHelpers.blank?(v) }
|
|
197
|
+
when String
|
|
198
|
+
trimmed = acr_values.strip
|
|
199
|
+
values = trimmed.split(" ").map(&:strip).reject { |v| StringHelpers.blank?(v) }
|
|
200
|
+
else
|
|
201
|
+
str = acr_values.to_s.strip
|
|
202
|
+
return nil if str.length > max_length
|
|
203
|
+
values = str.split(" ").map(&:strip).reject { |v| StringHelpers.blank?(v) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
unless skip_sanitization
|
|
207
|
+
values = values.map { |v| sanitize_request_param(v) }.compact
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
return nil if values.empty?
|
|
211
|
+
|
|
212
|
+
result = values.join(" ")
|
|
213
|
+
return nil if result.length > max_length
|
|
214
|
+
|
|
215
|
+
result
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Validate and sanitize client_id per OIDC Core 1.0 spec
|
|
219
|
+
# client_id is REQUIRED and must be a valid string
|
|
220
|
+
#
|
|
221
|
+
# @param client_id [String, nil] Client identifier
|
|
222
|
+
# @return [String] Sanitized client_id
|
|
223
|
+
# @raise [ConfigurationError] If client_id is invalid
|
|
224
|
+
def self.validate_client_id!(client_id)
|
|
225
|
+
if StringHelpers.blank?(client_id)
|
|
226
|
+
raise ConfigurationError, "client_id is REQUIRED per OIDC Core 1.0 spec"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
str = client_id.to_s.strip
|
|
230
|
+
if str.empty?
|
|
231
|
+
raise ConfigurationError, "client_id cannot be empty after trimming"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
sanitized = sanitize_request_param(str)
|
|
235
|
+
if StringHelpers.blank?(sanitized)
|
|
236
|
+
raise ConfigurationError, "client_id contains invalid characters"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
sanitized
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Validate and sanitize redirect_uri per OIDC Core 1.0 spec
|
|
243
|
+
# redirect_uri is REQUIRED and must be a valid absolute URI
|
|
244
|
+
#
|
|
245
|
+
# @param redirect_uri [String, nil] Redirect URI
|
|
246
|
+
# @return [String] Validated redirect_uri
|
|
247
|
+
# @raise [ConfigurationError] If redirect_uri is invalid
|
|
248
|
+
def self.validate_redirect_uri!(redirect_uri)
|
|
249
|
+
if StringHelpers.blank?(redirect_uri)
|
|
250
|
+
raise ConfigurationError, "redirect_uri is REQUIRED per OIDC Core 1.0 spec"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
str = redirect_uri.to_s.strip
|
|
254
|
+
if str.empty?
|
|
255
|
+
raise ConfigurationError, "redirect_uri cannot be empty after trimming"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
validated = validate_uri_safe!(str, allowed_schemes: ["http", "https"])
|
|
259
|
+
validated.to_s
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Validate and sanitize scope per OIDC Core 1.0 spec
|
|
263
|
+
# scope is space-delimited, case-sensitive list of ASCII string values
|
|
264
|
+
# MUST include "openid" scope value
|
|
265
|
+
#
|
|
266
|
+
# @param scope [String, Array, nil] Scope value(s)
|
|
267
|
+
# @param require_openid [Boolean] Whether to require "openid" scope (default: true)
|
|
268
|
+
# @return [String] Normalized space-separated scope string
|
|
269
|
+
# @raise [ConfigurationError] If scope is invalid
|
|
270
|
+
def self.validate_scope!(scope, require_openid: true)
|
|
271
|
+
if StringHelpers.blank?(scope)
|
|
272
|
+
if require_openid
|
|
273
|
+
raise ConfigurationError, "scope is REQUIRED and MUST include 'openid' per OIDC Core 1.0 spec"
|
|
274
|
+
end
|
|
275
|
+
return nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
scopes = case scope
|
|
279
|
+
when Array
|
|
280
|
+
scope.map(&:to_s).map(&:strip).reject { |s| StringHelpers.blank?(s) }
|
|
281
|
+
when String
|
|
282
|
+
scope.strip.split(" ").map(&:strip).reject { |s| StringHelpers.blank?(s) }
|
|
283
|
+
else
|
|
284
|
+
scope.to_s.strip.split(" ").map(&:strip).reject { |s| StringHelpers.blank?(s) }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
scopes = scopes.map { |s| sanitize_request_param(s) }.compact
|
|
288
|
+
|
|
289
|
+
if scopes.empty?
|
|
290
|
+
raise ConfigurationError, "scope cannot be empty after validation"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
if require_openid && !scopes.include?("openid")
|
|
294
|
+
raise ConfigurationError, "scope MUST include 'openid' per OIDC Core 1.0 spec"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
result = scopes.join(" ")
|
|
298
|
+
max_length = ::OmniauthOpenidFederation::Configuration.config.max_string_length
|
|
299
|
+
if result.length > max_length
|
|
300
|
+
raise ConfigurationError, "scope exceeds maximum length of #{max_length} characters"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
result
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Validate and sanitize state parameter
|
|
307
|
+
# state is REQUIRED for CSRF protection
|
|
308
|
+
#
|
|
309
|
+
# @param state [String, nil] State value
|
|
310
|
+
# @return [String] Sanitized state value
|
|
311
|
+
# @raise [ConfigurationError] If state is invalid
|
|
312
|
+
def self.validate_state!(state)
|
|
313
|
+
if StringHelpers.blank?(state)
|
|
314
|
+
raise ConfigurationError, "state is REQUIRED for CSRF protection"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
str = state.to_s.strip
|
|
318
|
+
if str.empty?
|
|
319
|
+
raise ConfigurationError, "state cannot be empty after trimming"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
sanitized = sanitize_request_param(str)
|
|
323
|
+
if StringHelpers.blank?(sanitized)
|
|
324
|
+
raise ConfigurationError, "state contains invalid characters"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
sanitized
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Validate and sanitize nonce parameter
|
|
331
|
+
# nonce is REQUIRED for Implicit and Hybrid flows, RECOMMENDED for Authorization Code flow
|
|
332
|
+
#
|
|
333
|
+
# @param nonce [String, nil] Nonce value
|
|
334
|
+
# @param required [Boolean] Whether nonce is required (default: false)
|
|
335
|
+
# @return [String, nil] Sanitized nonce value or nil
|
|
336
|
+
# @raise [ConfigurationError] If nonce is required but invalid
|
|
337
|
+
def self.validate_nonce(nonce, required: false)
|
|
338
|
+
return nil unless nonce
|
|
339
|
+
|
|
340
|
+
str = nonce.to_s.strip
|
|
341
|
+
if str.empty?
|
|
342
|
+
if required
|
|
343
|
+
raise ConfigurationError, "nonce is REQUIRED but is empty after trimming"
|
|
344
|
+
end
|
|
345
|
+
return nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
sanitized = sanitize_request_param(str)
|
|
349
|
+
if StringHelpers.blank?(sanitized)
|
|
350
|
+
if required
|
|
351
|
+
raise ConfigurationError, "nonce contains invalid characters"
|
|
352
|
+
end
|
|
353
|
+
return nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
sanitized
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Validate and sanitize response_type per OIDC Core 1.0 spec
|
|
360
|
+
# response_type is REQUIRED and must be a valid value
|
|
361
|
+
#
|
|
362
|
+
# @param response_type [String, nil] Response type
|
|
363
|
+
# @return [String] Validated response_type
|
|
364
|
+
# @raise [ConfigurationError] If response_type is invalid
|
|
365
|
+
def self.validate_response_type!(response_type)
|
|
366
|
+
if StringHelpers.blank?(response_type)
|
|
367
|
+
raise ConfigurationError, "response_type is REQUIRED per OIDC Core 1.0 spec"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
str = response_type.to_s.strip
|
|
371
|
+
if str.empty?
|
|
372
|
+
raise ConfigurationError, "response_type cannot be empty after trimming"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
sanitized = sanitize_request_param(str)
|
|
376
|
+
if StringHelpers.blank?(sanitized)
|
|
377
|
+
raise ConfigurationError, "response_type contains invalid characters"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
valid_types = ["code", "id_token", "token", "id_token token", "code id_token", "code token", "code id_token token"]
|
|
381
|
+
types = sanitized.split(" ").map(&:strip)
|
|
382
|
+
unless types.all? { |t| valid_types.include?(t) || t.match?(/^[a-z_]+$/) }
|
|
383
|
+
raise ConfigurationError, "response_type contains invalid value: #{sanitized}"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
sanitized
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Validate entity identifier per OpenID Federation 1.0 spec
|
|
390
|
+
# Entity identifiers are URIs that identify entities in the federation
|
|
391
|
+
#
|
|
392
|
+
# @param entity_id [String, nil] Entity identifier
|
|
393
|
+
# @param max_length [Integer] Maximum length (default: Configuration.config.max_string_length)
|
|
394
|
+
# @return [String] Validated and trimmed entity identifier
|
|
395
|
+
# @raise [SecurityError] If entity identifier is invalid
|
|
396
|
+
def self.validate_entity_identifier!(entity_id, max_length: nil)
|
|
397
|
+
max_length ||= ::OmniauthOpenidFederation::Configuration.config.max_string_length
|
|
398
|
+
if StringHelpers.blank?(entity_id)
|
|
399
|
+
raise SecurityError, "Entity identifier cannot be nil or empty"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
str = entity_id.to_s.strip
|
|
403
|
+
if str.empty?
|
|
404
|
+
raise SecurityError, "Entity identifier cannot be empty after trimming"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
validate_uri_safe!(str, max_length: max_length, allowed_schemes: ["http", "https"])
|
|
408
|
+
|
|
409
|
+
str
|
|
410
|
+
end
|
|
125
411
|
end
|
|
126
412
|
end
|
|
@@ -28,6 +28,7 @@ end
|
|
|
28
28
|
|
|
29
29
|
require_relative "omniauth_openid_federation/version"
|
|
30
30
|
require_relative "omniauth_openid_federation/string_helpers"
|
|
31
|
+
require_relative "omniauth_openid_federation/time_helpers"
|
|
31
32
|
require_relative "omniauth_openid_federation/logger"
|
|
32
33
|
require_relative "omniauth_openid_federation/configuration"
|
|
33
34
|
require_relative "omniauth_openid_federation/errors"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Rake tasks for OmniAuth OpenID Federation
|
|
2
2
|
# Thin wrappers around TasksHelper for CLI interface
|
|
3
|
+
require_relative "../omniauth_openid_federation/time_helpers"
|
|
3
4
|
|
|
4
5
|
namespace :openid_federation do
|
|
5
6
|
desc "Fetch entity statement from OpenID Federation provider"
|
|
@@ -262,8 +263,8 @@ namespace :openid_federation do
|
|
|
262
263
|
end
|
|
263
264
|
puts " Issuer: #{metadata[:issuer]}"
|
|
264
265
|
puts " Subject: #{metadata[:sub]}"
|
|
265
|
-
puts " Expires: #{
|
|
266
|
-
puts " Issued At: #{
|
|
266
|
+
puts " Expires: #{OmniauthOpenidFederation::TimeHelpers.at(metadata[:exp])}" if metadata[:exp]
|
|
267
|
+
puts " Issued At: #{OmniauthOpenidFederation::TimeHelpers.at(metadata[:iat])}" if metadata[:iat]
|
|
267
268
|
puts
|
|
268
269
|
|
|
269
270
|
# Key status information
|
|
@@ -373,4 +374,302 @@ namespace :openid_federation do
|
|
|
373
374
|
exit 1
|
|
374
375
|
end
|
|
375
376
|
end
|
|
377
|
+
|
|
378
|
+
desc "Test full OpenID Federation authentication flow with a real provider"
|
|
379
|
+
task :test_authentication_flow, [:login_page_url, :base_url, :provider_acr] => :environment do |_t, args|
|
|
380
|
+
require "omniauth_openid_federation"
|
|
381
|
+
require "cgi"
|
|
382
|
+
require "base64"
|
|
383
|
+
require "openssl"
|
|
384
|
+
require "uri"
|
|
385
|
+
require "time"
|
|
386
|
+
|
|
387
|
+
login_page_url = args[:login_page_url] || ENV["LOGIN_PAGE_URL"]
|
|
388
|
+
base_url = args[:base_url] || ENV["BASE_URL"] || (login_page_url ? URI.parse(login_page_url).tap { |u|
|
|
389
|
+
u.path = ""
|
|
390
|
+
u.query = nil
|
|
391
|
+
u.fragment = nil
|
|
392
|
+
}.to_s : "http://localhost:3000")
|
|
393
|
+
provider_acr = args[:provider_acr] || ENV["PROVIDER_ACR"]
|
|
394
|
+
|
|
395
|
+
# Required configuration from environment
|
|
396
|
+
client_id = ENV["CLIENT_ID"]
|
|
397
|
+
redirect_uri = ENV["REDIRECT_URI"] || "#{base_url}/users/auth/openid_federation/callback"
|
|
398
|
+
private_key_pem = ENV["PRIVATE_KEY"] || (ENV["PRIVATE_KEY_BASE64"] ? Base64.decode64(ENV["PRIVATE_KEY_BASE64"]) : nil)
|
|
399
|
+
private_key_path = ENV["PRIVATE_KEY_PATH"]
|
|
400
|
+
|
|
401
|
+
# Entity statement configuration
|
|
402
|
+
entity_statement_url = ENV["ENTITY_STATEMENT_URL"]
|
|
403
|
+
entity_statement_path = ENV["ENTITY_STATEMENT_PATH"]
|
|
404
|
+
client_entity_statement_url = ENV["CLIENT_ENTITY_STATEMENT_URL"]
|
|
405
|
+
client_entity_statement_path = ENV["CLIENT_ENTITY_STATEMENT_PATH"]
|
|
406
|
+
|
|
407
|
+
puts "=" * 80
|
|
408
|
+
puts "OpenID Federation Authentication Flow Test"
|
|
409
|
+
puts "=" * 80
|
|
410
|
+
puts
|
|
411
|
+
puts "Login Page URL: #{login_page_url || "Not provided"}"
|
|
412
|
+
puts "Base URL: #{base_url}"
|
|
413
|
+
puts "Provider ACR: #{provider_acr || "Not specified (will use default)"}"
|
|
414
|
+
puts
|
|
415
|
+
|
|
416
|
+
# Validate required parameters
|
|
417
|
+
unless login_page_url
|
|
418
|
+
puts "❌ LOGIN_PAGE_URL is required"
|
|
419
|
+
puts " Set it as an environment variable or pass as first argument:"
|
|
420
|
+
puts " rake openid_federation:test_authentication_flow[https://example.com/login]"
|
|
421
|
+
exit 1
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Load private key
|
|
425
|
+
private_key = nil
|
|
426
|
+
if private_key_pem
|
|
427
|
+
begin
|
|
428
|
+
private_key = OpenSSL::PKey::RSA.new(private_key_pem)
|
|
429
|
+
rescue => e
|
|
430
|
+
puts "❌ Failed to parse private key from PRIVATE_KEY or PRIVATE_KEY_BASE64: #{e.message}"
|
|
431
|
+
exit 1
|
|
432
|
+
end
|
|
433
|
+
elsif private_key_path
|
|
434
|
+
begin
|
|
435
|
+
private_key = OpenSSL::PKey::RSA.new(File.read(private_key_path))
|
|
436
|
+
rescue => e
|
|
437
|
+
puts "❌ Failed to load private key from #{private_key_path}: #{e.message}"
|
|
438
|
+
exit 1
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
unless private_key
|
|
443
|
+
puts "❌ Private key is required"
|
|
444
|
+
puts " Set one of: PRIVATE_KEY, PRIVATE_KEY_BASE64, or PRIVATE_KEY_PATH"
|
|
445
|
+
exit 1
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
unless client_id
|
|
449
|
+
puts "❌ CLIENT_ID is required"
|
|
450
|
+
puts " Set it as an environment variable: CLIENT_ID=your_client_id"
|
|
451
|
+
exit 1
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Try to resolve entity statement if not provided
|
|
455
|
+
unless entity_statement_url || entity_statement_path
|
|
456
|
+
# Try to fetch from well-known endpoint
|
|
457
|
+
begin
|
|
458
|
+
well_known_url = "#{base_url}/.well-known/openid-federation"
|
|
459
|
+
puts "📥 Attempting to fetch entity statement from: #{well_known_url}"
|
|
460
|
+
require "http"
|
|
461
|
+
response = HTTP.timeout(connect: 5, read: 5).get(well_known_url)
|
|
462
|
+
if response.status.success?
|
|
463
|
+
entity_statement_path = "/tmp/entity_statement_#{Time.now.to_i}.json"
|
|
464
|
+
File.write(entity_statement_path, response.body.to_s)
|
|
465
|
+
puts " ✅ Entity statement cached to: #{entity_statement_path}"
|
|
466
|
+
end
|
|
467
|
+
rescue => e
|
|
468
|
+
puts " ⚠️ Could not fetch entity statement: #{e.message}"
|
|
469
|
+
puts " Set ENTITY_STATEMENT_URL or ENTITY_STATEMENT_PATH manually"
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Display configuration status
|
|
474
|
+
puts "📋 Configuration Status:"
|
|
475
|
+
puts " ✅ Client ID: #{client_id}"
|
|
476
|
+
puts " ✅ Redirect URI: #{redirect_uri}"
|
|
477
|
+
puts " ✅ Private Key: Loaded"
|
|
478
|
+
if entity_statement_url
|
|
479
|
+
puts " ✅ Provider Entity Statement URL: #{entity_statement_url}"
|
|
480
|
+
elsif entity_statement_path
|
|
481
|
+
puts " ✅ Provider Entity Statement Path: #{entity_statement_path}"
|
|
482
|
+
else
|
|
483
|
+
puts " ⚠️ Provider Entity Statement: Not configured"
|
|
484
|
+
end
|
|
485
|
+
if client_entity_statement_url
|
|
486
|
+
puts " ✅ Client Entity Statement URL: #{client_entity_statement_url}"
|
|
487
|
+
elsif client_entity_statement_path
|
|
488
|
+
puts " ✅ Client Entity Statement Path: #{client_entity_statement_path}"
|
|
489
|
+
end
|
|
490
|
+
puts
|
|
491
|
+
|
|
492
|
+
begin
|
|
493
|
+
# Step 1: Request authorization URL
|
|
494
|
+
puts "=" * 80
|
|
495
|
+
puts "📋 Step 1: Requesting Authorization URL"
|
|
496
|
+
puts "-" * 80
|
|
497
|
+
puts
|
|
498
|
+
|
|
499
|
+
result = OmniauthOpenidFederation::TasksHelper.test_authentication_flow(
|
|
500
|
+
login_page_url: login_page_url,
|
|
501
|
+
base_url: base_url,
|
|
502
|
+
provider_acr: provider_acr
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
if result[:errors].any?
|
|
506
|
+
puts "❌ Errors occurred:"
|
|
507
|
+
result[:errors].each { |error| puts " - #{error}" }
|
|
508
|
+
exit 1
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
puts "✅ CSRF token extracted: #{result[:csrf_token][0..20]}..." if result[:csrf_token]
|
|
512
|
+
puts "✅ Cookies received: #{result[:cookies].length} cookie(s)"
|
|
513
|
+
puts
|
|
514
|
+
puts "✅ Authorization URL received"
|
|
515
|
+
puts
|
|
516
|
+
puts "🔗 Authorization URL:"
|
|
517
|
+
puts result[:authorization_url]
|
|
518
|
+
puts
|
|
519
|
+
puts "📋 Instructions:"
|
|
520
|
+
result[:instructions].each { |instruction| puts " #{instruction}" }
|
|
521
|
+
puts
|
|
522
|
+
|
|
523
|
+
# Step 2: Get callback URL from user
|
|
524
|
+
puts "=" * 80
|
|
525
|
+
puts "📥 Step 2: Waiting for Callback URL"
|
|
526
|
+
puts "-" * 80
|
|
527
|
+
puts
|
|
528
|
+
print "Paste the callback URL here (or press Enter to skip to manual code entry): "
|
|
529
|
+
callback_url = $stdin.gets.chomp
|
|
530
|
+
|
|
531
|
+
if callback_url.empty?
|
|
532
|
+
puts
|
|
533
|
+
print "Enter authorization code manually: "
|
|
534
|
+
auth_code = $stdin.gets.chomp
|
|
535
|
+
|
|
536
|
+
if auth_code.empty?
|
|
537
|
+
puts "❌ No authorization code provided"
|
|
538
|
+
exit 1
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Build callback URL with code
|
|
542
|
+
callback_url = "#{base_url}/users/auth/openid_federation/callback?code=#{CGI.escape(auth_code)}"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Step 3: Process callback and validate
|
|
546
|
+
puts
|
|
547
|
+
puts "=" * 80
|
|
548
|
+
puts "🔄 Step 3: Processing Callback and Validating"
|
|
549
|
+
puts "-" * 80
|
|
550
|
+
puts
|
|
551
|
+
|
|
552
|
+
callback_result = OmniauthOpenidFederation::TasksHelper.process_callback_and_validate(
|
|
553
|
+
callback_url: callback_url,
|
|
554
|
+
base_url: base_url,
|
|
555
|
+
entity_statement_url: entity_statement_url,
|
|
556
|
+
entity_statement_path: entity_statement_path,
|
|
557
|
+
client_id: client_id,
|
|
558
|
+
redirect_uri: redirect_uri,
|
|
559
|
+
private_key: private_key,
|
|
560
|
+
provider_acr: provider_acr,
|
|
561
|
+
client_entity_statement_url: client_entity_statement_url,
|
|
562
|
+
client_entity_statement_path: client_entity_statement_path
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
if callback_result[:errors].any?
|
|
566
|
+
puts "❌ Errors occurred:"
|
|
567
|
+
callback_result[:errors].each { |error| puts " - #{error}" }
|
|
568
|
+
exit 1
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
puts "✅ Authorization code extracted"
|
|
572
|
+
puts "✅ Strategy initialized"
|
|
573
|
+
puts "✅ Tokens received"
|
|
574
|
+
puts
|
|
575
|
+
puts "📋 Token Information:"
|
|
576
|
+
callback_result[:token_info].each do |key, value|
|
|
577
|
+
if value
|
|
578
|
+
label = key.to_s.split("_").map(&:capitalize).join(" ")
|
|
579
|
+
puts " #{label}: #{value}"
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
puts
|
|
583
|
+
|
|
584
|
+
# Step 4: ID Token validation
|
|
585
|
+
puts "=" * 80
|
|
586
|
+
puts "🔐 Step 4: ID Token Validation"
|
|
587
|
+
puts "-" * 80
|
|
588
|
+
puts
|
|
589
|
+
|
|
590
|
+
if callback_result[:id_token_valid]
|
|
591
|
+
puts "✅ ID token decrypted and validated"
|
|
592
|
+
puts
|
|
593
|
+
puts "📋 ID Token Claims:"
|
|
594
|
+
callback_result[:id_token_claims].each do |key, value|
|
|
595
|
+
if value
|
|
596
|
+
if [:exp, :iat, :auth_time].include?(key)
|
|
597
|
+
time_value = begin
|
|
598
|
+
OmniauthOpenidFederation::TimeHelpers.at(value)
|
|
599
|
+
rescue
|
|
600
|
+
value
|
|
601
|
+
end
|
|
602
|
+
puts " #{key}: #{value} (#{time_value})"
|
|
603
|
+
else
|
|
604
|
+
puts " #{key}: #{value}"
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
puts
|
|
609
|
+
puts "✅ All required claims present"
|
|
610
|
+
else
|
|
611
|
+
puts "❌ ID token validation failed"
|
|
612
|
+
end
|
|
613
|
+
puts
|
|
614
|
+
|
|
615
|
+
# Step 5: OpenID Federation Compliance
|
|
616
|
+
puts "=" * 80
|
|
617
|
+
puts "✅ Step 5: OpenID Federation Compliance Check"
|
|
618
|
+
puts "-" * 80
|
|
619
|
+
puts
|
|
620
|
+
|
|
621
|
+
callback_result[:compliance_checks].each do |check_name, check_data|
|
|
622
|
+
status_icon = check_data[:verified] ? "✅" : "❌"
|
|
623
|
+
puts "#{status_icon} #{check_name}"
|
|
624
|
+
puts " Status: #{check_data[:status]}"
|
|
625
|
+
puts " Description: #{check_data[:description]}"
|
|
626
|
+
puts
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
all_verified = callback_result[:all_compliance_verified]
|
|
630
|
+
|
|
631
|
+
if all_verified
|
|
632
|
+
puts "✅ All OpenID Federation requirements verified"
|
|
633
|
+
else
|
|
634
|
+
puts "⚠️ Some requirements not verified"
|
|
635
|
+
end
|
|
636
|
+
puts
|
|
637
|
+
|
|
638
|
+
# Step 6: Summary
|
|
639
|
+
puts "=" * 80
|
|
640
|
+
puts "📊 Test Summary"
|
|
641
|
+
puts "=" * 80
|
|
642
|
+
puts
|
|
643
|
+
puts "Provider ACR: #{provider_acr || "Default"}"
|
|
644
|
+
if callback_result[:id_token_claims][:sub]
|
|
645
|
+
puts "Subject (sub): #{callback_result[:id_token_claims][:sub]}"
|
|
646
|
+
end
|
|
647
|
+
if callback_result[:id_token_claims][:iss]
|
|
648
|
+
puts "Issuer (iss): #{callback_result[:id_token_claims][:iss]}"
|
|
649
|
+
end
|
|
650
|
+
if callback_result[:id_token_claims][:aud]
|
|
651
|
+
puts "Audience (aud): #{callback_result[:id_token_claims][:aud]}"
|
|
652
|
+
end
|
|
653
|
+
if callback_result[:id_token_claims][:acr]
|
|
654
|
+
puts "Authentication Context (acr): #{callback_result[:id_token_claims][:acr]}"
|
|
655
|
+
end
|
|
656
|
+
puts
|
|
657
|
+
puts "OpenID Federation Compliance: #{all_verified ? "✅ PASS" : "❌ FAIL"}"
|
|
658
|
+
puts
|
|
659
|
+
puts "=" * 80
|
|
660
|
+
|
|
661
|
+
if all_verified
|
|
662
|
+
puts "✅ All tests passed! Implementation is compliant."
|
|
663
|
+
exit 0
|
|
664
|
+
else
|
|
665
|
+
puts "⚠️ Some checks failed. Review the output above."
|
|
666
|
+
exit 1
|
|
667
|
+
end
|
|
668
|
+
rescue => e
|
|
669
|
+
puts "❌ Test failed: #{e.message}"
|
|
670
|
+
puts " #{e.class}"
|
|
671
|
+
puts " #{e.backtrace.first(10).join("\n ")}"
|
|
672
|
+
exit 1
|
|
673
|
+
end
|
|
674
|
+
end
|
|
376
675
|
end
|
data/sig/federation.rbs
CHANGED
|
@@ -152,14 +152,6 @@ module OmniauthOpenidFederation
|
|
|
152
152
|
def self.generate_signed_jwks: () -> String
|
|
153
153
|
def self.current_jwks: () -> Hash[String, untyped]
|
|
154
154
|
def self.rack_app: () -> RackEndpoint
|
|
155
|
-
def self.mount_routes: (
|
|
156
|
-
untyped router,
|
|
157
|
-
?entity_statement_path: String,
|
|
158
|
-
?fetch_path: String,
|
|
159
|
-
?jwks_path: String,
|
|
160
|
-
?signed_jwks_path: String,
|
|
161
|
-
?as: Symbol
|
|
162
|
-
) -> void
|
|
163
155
|
|
|
164
156
|
def self.generate_fresh_keys: (
|
|
165
157
|
entity_statement_path: String,
|