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