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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -1
  3. data/README.md +210 -708
  4. data/app/controllers/omniauth_openid_federation/federation_controller.rb +14 -1
  5. data/config/routes.rb +20 -10
  6. data/examples/config/initializers/devise.rb.example +44 -55
  7. data/examples/config/initializers/federation_endpoint.rb.example +2 -2
  8. data/examples/config/open_id_connect_config.rb.example +12 -15
  9. data/examples/config/routes.rb.example +9 -5
  10. data/examples/integration_test_flow.rb +4 -4
  11. data/examples/mock_op_server.rb +3 -3
  12. data/examples/mock_rp_server.rb +3 -3
  13. data/lib/omniauth_openid_federation/configuration.rb +8 -0
  14. data/lib/omniauth_openid_federation/constants.rb +5 -0
  15. data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
  16. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
  17. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
  18. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +6 -87
  19. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +3 -15
  20. data/lib/omniauth_openid_federation/federation_endpoint.rb +39 -193
  21. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -15
  22. data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
  23. data/lib/omniauth_openid_federation/jws.rb +23 -20
  24. data/lib/omniauth_openid_federation/rack_endpoint.rb +30 -5
  25. data/lib/omniauth_openid_federation/strategy.rb +143 -194
  26. data/lib/omniauth_openid_federation/tasks_helper.rb +501 -2
  27. data/lib/omniauth_openid_federation/time_helpers.rb +60 -0
  28. data/lib/omniauth_openid_federation/utils.rb +4 -7
  29. data/lib/omniauth_openid_federation/validators.rb +294 -8
  30. data/lib/omniauth_openid_federation/version.rb +1 -1
  31. data/lib/omniauth_openid_federation.rb +1 -0
  32. data/lib/tasks/omniauth_openid_federation.rake +301 -2
  33. data/sig/federation.rbs +0 -8
  34. data/sig/jwks.rbs +0 -6
  35. data/sig/omniauth_openid_federation.rbs +6 -1
  36. data/sig/strategy.rbs +0 -2
  37. metadata +100 -1
@@ -38,28 +38,53 @@ module OmniauthOpenidFederation
38
38
  def self.run(jwks_uri, entity_statement_path: nil)
39
39
  if entity_statement_path
40
40
  # Validate file path to prevent path traversal
41
- begin
42
- # Determine allowed directories for file path validation
43
- config = Configuration.config
44
- allowed_dirs = if defined?(Rails) && Rails.root
45
- [Rails.root.join("config").to_s]
46
- elsif config.root_path
47
- [File.join(config.root_path, "config")]
48
- end
41
+ # Allow absolute paths that exist (for temp files in tests) to skip directory validation
42
+ # For absolute paths that don't exist, still validate they're not path traversal, then check existence
43
+ path_str = entity_statement_path.to_s
44
+ is_absolute = path_str.start_with?("/", "~")
49
45
 
50
- validated_path = Utils.validate_file_path!(
51
- entity_statement_path,
52
- allowed_dirs: allowed_dirs
53
- )
54
- rescue SecurityError => e
55
- Logger.error("[Jwks::Rotate] #{e.message}")
56
- raise SecurityError, e.message
57
- end
46
+ if is_absolute && File.exist?(entity_statement_path)
47
+ validated_path = entity_statement_path
48
+ else
49
+ # For absolute paths, validate path traversal but allow outside allowed_dirs
50
+ # For relative paths, validate against allowed directories
51
+ if is_absolute
52
+ # Validate path traversal for absolute paths, but don't require it to be in allowed_dirs
53
+ begin
54
+ validated_path = Utils.validate_file_path!(
55
+ entity_statement_path,
56
+ allowed_dirs: nil # Allow absolute paths outside config directory
57
+ )
58
+ rescue SecurityError => e
59
+ # Path traversal attempt - raise SecurityError
60
+ Logger.error("[Jwks::Rotate] #{e.message}")
61
+ raise SecurityError, e.message
62
+ end
63
+ else
64
+ # Relative path - must be in allowed directories
65
+ begin
66
+ config = Configuration.config
67
+ allowed_dirs = if defined?(Rails) && Rails.root
68
+ [Rails.root.join("config").to_s]
69
+ elsif config.root_path
70
+ [File.join(config.root_path, "config")]
71
+ end
58
72
 
59
- unless File.exist?(validated_path)
60
- sanitized_path = Utils.sanitize_path(validated_path)
61
- Logger.warn("[Jwks::Rotate] Entity statement file not found: #{sanitized_path}")
62
- raise ConfigurationError, "Entity statement file not found: #{sanitized_path}"
73
+ validated_path = Utils.validate_file_path!(
74
+ entity_statement_path,
75
+ allowed_dirs: allowed_dirs
76
+ )
77
+ rescue SecurityError => e
78
+ Logger.error("[Jwks::Rotate] #{e.message}")
79
+ raise SecurityError, e.message
80
+ end
81
+ end
82
+
83
+ unless File.exist?(validated_path)
84
+ sanitized_path = Utils.sanitize_path(validated_path)
85
+ Logger.warn("[Jwks::Rotate] Entity statement file not found: #{sanitized_path}")
86
+ raise ConfigurationError, "Entity statement file not found: #{sanitized_path}"
87
+ end
63
88
  end
64
89
 
65
90
  # Try to use signed JWKS if entity statement is available
@@ -3,6 +3,7 @@ require "jwe"
3
3
  require "securerandom"
4
4
  require "base64"
5
5
  require_relative "string_helpers"
6
+ require_relative "time_helpers"
6
7
  require_relative "logger"
7
8
  require_relative "errors"
8
9
  require_relative "validators"
@@ -63,10 +64,6 @@ module OmniauthOpenidFederation
63
64
  STATE_BYTES = 16 # Number of hex bytes for state parameter
64
65
 
65
66
  attr_accessor :private_key, :state, :nonce
66
- # Provider-specific extension parameters (outside JWT)
67
- # Some providers may require additional parameters that are not part of the JWT
68
- # @deprecated Use request_object_params option in strategy instead (adds params to the JWT request object)
69
- attr_accessor :ftn_spname
70
67
 
71
68
  # Initialize JWT request object builder
72
69
  #
@@ -114,21 +111,27 @@ module OmniauthOpenidFederation
114
111
  key_source: :local,
115
112
  client_entity_statement: nil
116
113
  )
117
- @client_id = client_id
118
- @redirect_uri = redirect_uri
119
- @scope = scope
120
- @issuer = issuer
121
- @audience = audience
122
- @state = state || SecureRandom.hex(STATE_BYTES)
123
- @nonce = nonce
124
- @response_type = response_type
125
- @response_mode = response_mode
126
- @login_hint = login_hint
127
- @ui_locales = ui_locales
128
- @claims_locales = claims_locales
129
- @prompt = prompt
130
- @hd = hd
131
- @acr_values = acr_values
114
+ # Security: User input parameters are validated and sanitized before reaching here
115
+ # Configuration parameters are trusted and only trimmed for consistency
116
+ # Store trimmed values to ensure consistency
117
+ @client_id = client_id.to_s.strip
118
+ @redirect_uri = redirect_uri.to_s.strip
119
+ @scope = scope.to_s.strip
120
+ @issuer = issuer&.to_s&.strip
121
+ @audience = audience&.to_s&.strip
122
+ @state = state&.to_s&.strip || SecureRandom.hex(STATE_BYTES)
123
+ @nonce = nonce&.to_s&.strip
124
+ @response_type = response_type.to_s.strip
125
+ @response_mode = response_mode&.to_s&.strip
126
+ # User input parameters (already sanitized)
127
+ @login_hint = login_hint&.to_s&.strip
128
+ @ui_locales = ui_locales&.to_s&.strip
129
+ @claims_locales = claims_locales&.to_s&.strip
130
+ # Configuration parameters (trusted, only trimmed)
131
+ @prompt = prompt&.to_s&.strip
132
+ @hd = hd&.to_s&.strip
133
+ # User input parameter (already sanitized)
134
+ @acr_values = acr_values&.to_s&.strip
132
135
  @extra_params = extra_params
133
136
  @jwks = jwks
134
137
  @entity_statement_path = entity_statement_path
@@ -220,7 +223,7 @@ module OmniauthOpenidFederation
220
223
  response_type: @response_type,
221
224
  scope: @scope,
222
225
  state: state,
223
- exp: (Time.now + (defined?(ActiveSupport) ? REQUEST_OBJECT_EXPIRATION_MINUTES.minutes : REQUEST_OBJECT_EXPIRATION_SECONDS)).to_i,
226
+ exp: (TimeHelpers.now + (defined?(ActiveSupport) ? REQUEST_OBJECT_EXPIRATION_MINUTES.minutes : REQUEST_OBJECT_EXPIRATION_SECONDS)).to_i,
224
227
  jti: SecureRandom.uuid # JWT ID to prevent replay
225
228
  }
226
229
 
@@ -25,9 +25,11 @@
25
25
  require "rack"
26
26
  require "json"
27
27
  require "digest"
28
+ require "uri"
28
29
  require_relative "cache_adapter"
29
30
  require_relative "federation_endpoint"
30
31
  require_relative "logger"
32
+ require_relative "constants"
31
33
 
32
34
  module OmniauthOpenidFederation
33
35
  class RackEndpoint
@@ -114,20 +116,31 @@ module OmniauthOpenidFederation
114
116
  subject_entity_id = request.params["sub"]
115
117
 
116
118
  unless subject_entity_id
117
- return error_response(400, {error: "invalid_request", error_description: "Missing required parameter: sub"}.to_json)
119
+ return error_response(400, {error: "invalid_request", error_description: "Missing required parameter: sub"})
120
+ end
121
+
122
+ # Security: Validate entity identifier per OpenID Federation 1.0 spec
123
+ # Entity identifiers must be valid HTTP/HTTPS URIs
124
+ begin
125
+ # Validate and get trimmed value
126
+ subject_entity_id = OmniauthOpenidFederation::Validators.validate_entity_identifier!(subject_entity_id)
127
+ rescue SecurityError => e
128
+ return error_response(400, {error: "invalid_request", error_description: "Invalid subject entity ID: #{e.message}"})
129
+ rescue => e
130
+ return error_response(400, {error: "invalid_request", error_description: "Subject entity ID validation failed: #{e.message}"})
118
131
  end
119
132
 
120
133
  # Validate that subject is not the issuer (invalid request per spec)
121
134
  config = OmniauthOpenidFederation::FederationEndpoint.configuration
122
135
  if subject_entity_id == config.issuer
123
- return error_response(400, {error: "invalid_request", error_description: "Subject cannot be the issuer"}.to_json)
136
+ return error_response(400, {error: "invalid_request", error_description: "Subject cannot be the issuer"})
124
137
  end
125
138
 
126
139
  # Get Subordinate Statement
127
140
  subordinate_statement = OmniauthOpenidFederation::FederationEndpoint.get_subordinate_statement(subject_entity_id)
128
141
 
129
142
  unless subordinate_statement
130
- return error_response(404, {error: "not_found", error_description: "Subordinate Statement not found for subject: #{subject_entity_id}"}.to_json)
143
+ return error_response(404, {error: "not_found", error_description: "Subordinate Statement not found for subject: #{subject_entity_id}"})
131
144
  end
132
145
 
133
146
  headers = {
@@ -175,11 +188,23 @@ module OmniauthOpenidFederation
175
188
  # Return error response
176
189
  #
177
190
  # @param status [Integer] HTTP status code
178
- # @param message [String] Error message
191
+ # @param message [String, Hash] Error message (string) or error hash (will be converted to JSON)
179
192
  # @return [Array] Rack response
180
193
  def error_response(status, message)
181
194
  content_type = (status == 503) ? "text/plain" : "application/json"
182
- body = (status == 503) ? message : {error: message}.to_json
195
+ body = if status == 503
196
+ message
197
+ elsif message.is_a?(Hash)
198
+ message.to_json
199
+ else
200
+ # If message is already JSON string, parse and re-encode to ensure proper format
201
+ begin
202
+ parsed = JSON.parse(message)
203
+ parsed.to_json
204
+ rescue JSON::ParserError
205
+ {error: message}.to_json
206
+ end
207
+ end
183
208
 
184
209
  [status, {"Content-Type" => content_type}, [body]]
185
210
  end