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,67 @@
|
|
|
1
|
+
# Time helper utilities for compatibility with ActiveSupport
|
|
2
|
+
# Provides time methods that work with or without Time.zone
|
|
3
|
+
module OmniauthOpenidFederation
|
|
4
|
+
module TimeHelpers
|
|
5
|
+
# Get current time, using Time.zone if available, otherwise Time
|
|
6
|
+
#
|
|
7
|
+
# @return [Time] Current time
|
|
8
|
+
def self.now
|
|
9
|
+
if time_zone_available?
|
|
10
|
+
Time.zone.now
|
|
11
|
+
else
|
|
12
|
+
# rubocop:disable Rails/TimeZone
|
|
13
|
+
Time.now
|
|
14
|
+
# rubocop:enable Rails/TimeZone
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Convert a timestamp to Time, using Time.zone if available, otherwise Time
|
|
19
|
+
#
|
|
20
|
+
# @param timestamp [Integer, Float] Unix timestamp
|
|
21
|
+
# @return [Time] Time object representing the timestamp
|
|
22
|
+
def self.at(timestamp)
|
|
23
|
+
if time_zone_available?
|
|
24
|
+
Time.zone.at(timestamp)
|
|
25
|
+
else
|
|
26
|
+
# rubocop:disable Rails/TimeZone
|
|
27
|
+
Time.at(timestamp)
|
|
28
|
+
# rubocop:enable Rails/TimeZone
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Parse a time string, using Time.zone if available, otherwise Time
|
|
33
|
+
#
|
|
34
|
+
# @param time_string [String] Time string to parse
|
|
35
|
+
# @return [Time] Parsed time object
|
|
36
|
+
def self.parse(time_string)
|
|
37
|
+
if time_zone_available?
|
|
38
|
+
Time.zone.parse(time_string)
|
|
39
|
+
else
|
|
40
|
+
# rubocop:disable Rails/TimeZone
|
|
41
|
+
Time.parse(time_string)
|
|
42
|
+
# rubocop:enable Rails/TimeZone
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if Time.zone is available and configured
|
|
47
|
+
#
|
|
48
|
+
# @return [Boolean] true if Time.zone is available and not nil
|
|
49
|
+
def self.time_zone_available?
|
|
50
|
+
return false unless defined?(ActiveSupport)
|
|
51
|
+
return false unless Time.respond_to?(:zone)
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
!Time.zone.nil?
|
|
55
|
+
rescue
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Seconds to add to {TimeHelpers.now} for JWT request object expiration.
|
|
61
|
+
#
|
|
62
|
+
# @return [Integer]
|
|
63
|
+
def self.request_object_expiration_offset
|
|
64
|
+
Constants::REQUEST_OBJECT_EXPIRATION_SECONDS
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module OmniauthOpenidFederation
|
|
2
|
+
class UserInfo
|
|
3
|
+
attr_reader :raw_attributes
|
|
4
|
+
|
|
5
|
+
def initialize(raw_attributes)
|
|
6
|
+
@raw_attributes = raw_attributes.each_with_object({}) do |(key, value), claims|
|
|
7
|
+
claims[key.to_sym] = value
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def as_json(_options = {})
|
|
12
|
+
raw_attributes.transform_keys(&:to_s)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
# Utility functions for omniauth_openid_federation
|
|
2
|
+
require_relative "string_helpers"
|
|
3
|
+
|
|
2
4
|
module OmniauthOpenidFederation
|
|
3
5
|
module Utils
|
|
4
6
|
# Convert hash to HashWithIndifferentAccess if available
|
|
5
7
|
#
|
|
6
8
|
# @param hash [Hash, Object] The hash to convert
|
|
7
9
|
# @return [Hash, HashWithIndifferentAccess] Converted hash
|
|
8
|
-
def self.to_indifferent_hash(
|
|
10
|
+
def self.to_indifferent_hash(value)
|
|
11
|
+
hash = if value.is_a?(Hash)
|
|
12
|
+
value
|
|
13
|
+
elsif value.respond_to?(:to_h)
|
|
14
|
+
value.to_h
|
|
15
|
+
else
|
|
16
|
+
{}
|
|
17
|
+
end
|
|
18
|
+
|
|
9
19
|
if defined?(ActiveSupport::HashWithIndifferentAccess)
|
|
10
20
|
ActiveSupport::HashWithIndifferentAccess.new(hash)
|
|
11
21
|
else
|
|
12
|
-
hash
|
|
22
|
+
hash
|
|
13
23
|
end
|
|
14
24
|
end
|
|
15
25
|
|
|
@@ -18,7 +28,7 @@ module OmniauthOpenidFederation
|
|
|
18
28
|
# @param path [String, nil] The file path
|
|
19
29
|
# @return [String] Sanitized path (filename only)
|
|
20
30
|
def self.sanitize_path(path)
|
|
21
|
-
return "[REDACTED]" if
|
|
31
|
+
return "[REDACTED]" if StringHelpers.blank?(path)
|
|
22
32
|
File.basename(path)
|
|
23
33
|
end
|
|
24
34
|
|
|
@@ -27,7 +37,7 @@ module OmniauthOpenidFederation
|
|
|
27
37
|
# @param uri [String, nil] The URI
|
|
28
38
|
# @return [String] Sanitized URI
|
|
29
39
|
def self.sanitize_uri(uri)
|
|
30
|
-
return "[REDACTED]" if
|
|
40
|
+
return "[REDACTED]" if StringHelpers.blank?(uri)
|
|
31
41
|
begin
|
|
32
42
|
parsed = URI.parse(uri)
|
|
33
43
|
"#{parsed.scheme}://#{parsed.host}/[REDACTED]"
|
|
@@ -70,16 +80,13 @@ module OmniauthOpenidFederation
|
|
|
70
80
|
def self.validate_file_path!(path, allowed_dirs: nil)
|
|
71
81
|
raise SecurityError, "File path cannot be nil" if path.nil?
|
|
72
82
|
|
|
73
|
-
# Convert Pathname to string if needed
|
|
74
83
|
path_str = path.to_s
|
|
75
84
|
raise SecurityError, "File path cannot be empty" if path_str.empty?
|
|
76
85
|
|
|
77
|
-
# Check for path traversal attempts
|
|
78
86
|
if path_str.include?("..") || path_str.include?("~")
|
|
79
87
|
raise SecurityError, "Path traversal detected in: #{sanitize_path(path_str)}"
|
|
80
88
|
end
|
|
81
89
|
|
|
82
|
-
# Resolve to absolute path
|
|
83
90
|
resolved = File.expand_path(path_str)
|
|
84
91
|
|
|
85
92
|
# Validate it's within allowed directories if specified
|
|
@@ -120,7 +127,6 @@ module OmniauthOpenidFederation
|
|
|
120
127
|
n = Base64.urlsafe_encode64(key.n.to_s(2), padding: false)
|
|
121
128
|
e = Base64.urlsafe_encode64(key.e.to_s(2), padding: false)
|
|
122
129
|
|
|
123
|
-
# Generate kid (key ID) from public key
|
|
124
130
|
public_key_pem = key.public_key.to_pem
|
|
125
131
|
kid = Digest::SHA256.hexdigest(public_key_pem)[0, 16]
|
|
126
132
|
|
|
@@ -131,7 +137,6 @@ module OmniauthOpenidFederation
|
|
|
131
137
|
e: e
|
|
132
138
|
}
|
|
133
139
|
|
|
134
|
-
# Add use field if specified
|
|
135
140
|
jwk[:use] = use if use
|
|
136
141
|
|
|
137
142
|
jwk
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Input validation utilities for omniauth_openid_federation
|
|
2
2
|
require_relative "constants"
|
|
3
3
|
require_relative "configuration"
|
|
4
|
+
require_relative "string_helpers"
|
|
4
5
|
|
|
5
6
|
module OmniauthOpenidFederation
|
|
6
7
|
module Validators
|
|
@@ -8,20 +9,54 @@ module OmniauthOpenidFederation
|
|
|
8
9
|
#
|
|
9
10
|
# @param private_key [OpenSSL::PKey::RSA, String, nil] The private key to validate
|
|
10
11
|
# @raise [ConfigurationError] If private key is missing or invalid
|
|
11
|
-
def self.validate_private_key!(private_key)
|
|
12
|
+
def self.validate_private_key!(private_key, min_bits: Constants::MIN_RSA_KEY_BITS)
|
|
12
13
|
if private_key.nil?
|
|
13
14
|
raise ConfigurationError, "Private key is required for signed request objects"
|
|
14
15
|
end
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
normalized_key =
|
|
18
|
+
if private_key.is_a?(String)
|
|
19
|
+
begin
|
|
20
|
+
OpenSSL::PKey::RSA.new(private_key)
|
|
21
|
+
rescue => error
|
|
22
|
+
raise ConfigurationError, "Invalid private key format: #{error.message}"
|
|
23
|
+
end
|
|
24
|
+
elsif private_key.is_a?(OpenSSL::PKey::RSA)
|
|
25
|
+
private_key
|
|
26
|
+
else
|
|
27
|
+
raise ConfigurationError, "Private key must be an OpenSSL::PKey::RSA instance or PEM string"
|
|
22
28
|
end
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
|
|
30
|
+
if min_bits.to_i.positive? && normalized_key.n.num_bits < min_bits.to_i
|
|
31
|
+
raise ConfigurationError,
|
|
32
|
+
"RSA private key must be at least #{min_bits} bits (got #{normalized_key.n.num_bits} bits)"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.validate_required_request_object_claims!(claims, required_names)
|
|
39
|
+
required = Array(required_names).map(&:to_s).uniq
|
|
40
|
+
return true if required.empty?
|
|
41
|
+
|
|
42
|
+
normalized_claims = (claims || {}).each_with_object({}) do |(key, value), hash|
|
|
43
|
+
hash[key.to_s] = value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
missing = required.reject { |name| StringHelpers.present?(normalized_claims[name]) }
|
|
47
|
+
return true if missing.empty?
|
|
48
|
+
|
|
49
|
+
raise ConfigurationError, "Missing required request object claims: #{missing.join(", ")}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.validate_allowed_acr_value!(acr_value, allowed_acr_values)
|
|
53
|
+
allowed = Array(allowed_acr_values).map(&:to_s).map(&:strip).reject { |value| StringHelpers.blank?(value) }
|
|
54
|
+
return true if allowed.empty?
|
|
55
|
+
|
|
56
|
+
token_acr = acr_value.to_s.strip
|
|
57
|
+
if StringHelpers.blank?(token_acr) || !allowed.include?(token_acr)
|
|
58
|
+
raise ValidationError,
|
|
59
|
+
"ID token acr mismatch: expected one of [#{allowed.join(", ")}], got '#{acr_value}'"
|
|
25
60
|
end
|
|
26
61
|
|
|
27
62
|
true
|
|
@@ -82,10 +117,8 @@ module OmniauthOpenidFederation
|
|
|
82
117
|
def self.validate_client_options!(client_options)
|
|
83
118
|
client_options ||= {}
|
|
84
119
|
|
|
85
|
-
# Normalize hash keys to symbols
|
|
86
120
|
normalized = normalize_hash(client_options)
|
|
87
121
|
|
|
88
|
-
# Validate required fields (structure validation, not security)
|
|
89
122
|
if StringHelpers.blank?(normalized[:identifier])
|
|
90
123
|
raise ConfigurationError, "Client identifier is required"
|
|
91
124
|
end
|
|
@@ -94,8 +127,6 @@ module OmniauthOpenidFederation
|
|
|
94
127
|
raise ConfigurationError, "Redirect URI is required"
|
|
95
128
|
end
|
|
96
129
|
|
|
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
130
|
begin
|
|
100
131
|
parsed = URI.parse(normalized[:redirect_uri].to_s)
|
|
101
132
|
unless parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS)
|
|
@@ -105,10 +136,8 @@ module OmniauthOpenidFederation
|
|
|
105
136
|
raise ConfigurationError, "Invalid redirect URI format: #{e.message}"
|
|
106
137
|
end
|
|
107
138
|
|
|
108
|
-
# Validate private key (required for security)
|
|
109
139
|
validate_private_key!(normalized[:private_key])
|
|
110
140
|
|
|
111
|
-
# Basic format check for endpoints (config validation, not security)
|
|
112
141
|
%i[authorization_endpoint token_endpoint jwks_uri].each do |endpoint|
|
|
113
142
|
if normalized.key?(endpoint) && !StringHelpers.blank?(normalized[endpoint])
|
|
114
143
|
# Endpoints can be paths or full URLs
|
|
@@ -145,7 +174,6 @@ module OmniauthOpenidFederation
|
|
|
145
174
|
str = value.to_s.strip
|
|
146
175
|
return nil if str.length > max_length
|
|
147
176
|
|
|
148
|
-
# Only allow printable ASCII (whitelist approach)
|
|
149
177
|
unless allow_control_chars
|
|
150
178
|
str = str.gsub(/[^\x20-\x7E]/, "")
|
|
151
179
|
end
|
|
@@ -159,15 +187,16 @@ module OmniauthOpenidFederation
|
|
|
159
187
|
max_length ||= ::OmniauthOpenidFederation::Configuration.config.max_string_length
|
|
160
188
|
raise SecurityError, "URI cannot be nil" if uri_str.nil?
|
|
161
189
|
|
|
162
|
-
|
|
190
|
+
original_str = uri_str.to_s
|
|
191
|
+
sanitized = original_str.gsub(/[^\x20-\x7E]/, "")
|
|
192
|
+
raise SecurityError, "URI contains invalid characters (only printable ASCII allowed)" if sanitized != original_str
|
|
193
|
+
|
|
194
|
+
str = sanitized.strip
|
|
163
195
|
raise SecurityError, "URI cannot be empty" if str.empty?
|
|
164
196
|
raise SecurityError, "URI exceeds maximum length of #{max_length} characters" if str.length > max_length
|
|
165
197
|
|
|
166
|
-
sanitized = str.gsub(/[^\x20-\x7E]/, "")
|
|
167
|
-
raise SecurityError, "URI contains invalid characters (only printable ASCII allowed)" if sanitized != str
|
|
168
|
-
|
|
169
198
|
begin
|
|
170
|
-
parsed = URI.parse(
|
|
199
|
+
parsed = URI.parse(str)
|
|
171
200
|
rescue URI::InvalidURIError => e
|
|
172
201
|
raise SecurityError, "Invalid URI format: #{e.message}"
|
|
173
202
|
end
|
|
@@ -180,7 +209,7 @@ module OmniauthOpenidFederation
|
|
|
180
209
|
raise SecurityError, "URI must be HTTP or HTTPS"
|
|
181
210
|
end
|
|
182
211
|
|
|
183
|
-
raise SecurityError, "URI host cannot be empty" if
|
|
212
|
+
raise SecurityError, "URI host cannot be empty" if StringHelpers.blank?(parsed.host)
|
|
184
213
|
|
|
185
214
|
parsed
|
|
186
215
|
end
|
|
@@ -199,22 +228,16 @@ module OmniauthOpenidFederation
|
|
|
199
228
|
|
|
200
229
|
case acr_values
|
|
201
230
|
when Array
|
|
202
|
-
# Filter out blanks (arrays may already be sanitized)
|
|
203
231
|
values = acr_values.map(&:to_s).map(&:strip).reject { |v| StringHelpers.blank?(v) }
|
|
204
232
|
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
233
|
trimmed = acr_values.strip
|
|
208
234
|
values = trimmed.split(" ").map(&:strip).reject { |v| StringHelpers.blank?(v) }
|
|
209
235
|
else
|
|
210
|
-
# Convert to string, trim and split
|
|
211
236
|
str = acr_values.to_s.strip
|
|
212
237
|
return nil if str.length > max_length
|
|
213
|
-
# Security: Use simple space split (no regexp to avoid ReDoS)
|
|
214
238
|
values = str.split(" ").map(&:strip).reject { |v| StringHelpers.blank?(v) }
|
|
215
239
|
end
|
|
216
240
|
|
|
217
|
-
# Sanitize each value unless already sanitized
|
|
218
241
|
unless skip_sanitization
|
|
219
242
|
values = values.map { |v| sanitize_request_param(v) }.compact
|
|
220
243
|
end
|
|
@@ -243,9 +266,8 @@ module OmniauthOpenidFederation
|
|
|
243
266
|
raise ConfigurationError, "client_id cannot be empty after trimming"
|
|
244
267
|
end
|
|
245
268
|
|
|
246
|
-
# Sanitize using allowed characters (printable ASCII only)
|
|
247
269
|
sanitized = sanitize_request_param(str)
|
|
248
|
-
if
|
|
270
|
+
if StringHelpers.blank?(sanitized)
|
|
249
271
|
raise ConfigurationError, "client_id contains invalid characters"
|
|
250
272
|
end
|
|
251
273
|
|
|
@@ -268,7 +290,6 @@ module OmniauthOpenidFederation
|
|
|
268
290
|
raise ConfigurationError, "redirect_uri cannot be empty after trimming"
|
|
269
291
|
end
|
|
270
292
|
|
|
271
|
-
# Validate as URI (includes allowed characters validation)
|
|
272
293
|
validated = validate_uri_safe!(str, allowed_schemes: ["http", "https"])
|
|
273
294
|
validated.to_s
|
|
274
295
|
end
|
|
@@ -289,7 +310,6 @@ module OmniauthOpenidFederation
|
|
|
289
310
|
return nil
|
|
290
311
|
end
|
|
291
312
|
|
|
292
|
-
# Normalize to array
|
|
293
313
|
scopes = case scope
|
|
294
314
|
when Array
|
|
295
315
|
scope.map(&:to_s).map(&:strip).reject { |s| StringHelpers.blank?(s) }
|
|
@@ -299,14 +319,12 @@ module OmniauthOpenidFederation
|
|
|
299
319
|
scope.to_s.strip.split(" ").map(&:strip).reject { |s| StringHelpers.blank?(s) }
|
|
300
320
|
end
|
|
301
321
|
|
|
302
|
-
# Validate each scope value (allowed: printable ASCII)
|
|
303
322
|
scopes = scopes.map { |s| sanitize_request_param(s) }.compact
|
|
304
323
|
|
|
305
324
|
if scopes.empty?
|
|
306
325
|
raise ConfigurationError, "scope cannot be empty after validation"
|
|
307
326
|
end
|
|
308
327
|
|
|
309
|
-
# Check for "openid" scope if required
|
|
310
328
|
if require_openid && !scopes.include?("openid")
|
|
311
329
|
raise ConfigurationError, "scope MUST include 'openid' per OIDC Core 1.0 spec"
|
|
312
330
|
end
|
|
@@ -336,9 +354,8 @@ module OmniauthOpenidFederation
|
|
|
336
354
|
raise ConfigurationError, "state cannot be empty after trimming"
|
|
337
355
|
end
|
|
338
356
|
|
|
339
|
-
# Sanitize using allowed characters (printable ASCII only)
|
|
340
357
|
sanitized = sanitize_request_param(str)
|
|
341
|
-
if
|
|
358
|
+
if StringHelpers.blank?(sanitized)
|
|
342
359
|
raise ConfigurationError, "state contains invalid characters"
|
|
343
360
|
end
|
|
344
361
|
|
|
@@ -363,10 +380,8 @@ module OmniauthOpenidFederation
|
|
|
363
380
|
return nil
|
|
364
381
|
end
|
|
365
382
|
|
|
366
|
-
# Sanitize using allowed characters (printable ASCII only)
|
|
367
|
-
# OIDC Core 1.0: nonce value is a case-sensitive string
|
|
368
383
|
sanitized = sanitize_request_param(str)
|
|
369
|
-
if
|
|
384
|
+
if StringHelpers.blank?(sanitized)
|
|
370
385
|
if required
|
|
371
386
|
raise ConfigurationError, "nonce contains invalid characters"
|
|
372
387
|
end
|
|
@@ -392,13 +407,11 @@ module OmniauthOpenidFederation
|
|
|
392
407
|
raise ConfigurationError, "response_type cannot be empty after trimming"
|
|
393
408
|
end
|
|
394
409
|
|
|
395
|
-
# Sanitize using allowed characters (printable ASCII only)
|
|
396
410
|
sanitized = sanitize_request_param(str)
|
|
397
|
-
if
|
|
411
|
+
if StringHelpers.blank?(sanitized)
|
|
398
412
|
raise ConfigurationError, "response_type contains invalid characters"
|
|
399
413
|
end
|
|
400
414
|
|
|
401
|
-
# Validate it's a known response type (space-separated list)
|
|
402
415
|
valid_types = ["code", "id_token", "token", "id_token token", "code id_token", "code token", "code id_token token"]
|
|
403
416
|
types = sanitized.split(" ").map(&:strip)
|
|
404
417
|
unless types.all? { |t| valid_types.include?(t) || t.match?(/^[a-z_]+$/) }
|
|
@@ -426,10 +439,8 @@ module OmniauthOpenidFederation
|
|
|
426
439
|
raise SecurityError, "Entity identifier cannot be empty after trimming"
|
|
427
440
|
end
|
|
428
441
|
|
|
429
|
-
# Validate as URI (includes allowed characters and length validation)
|
|
430
442
|
validate_uri_safe!(str, max_length: max_length, allowed_schemes: ["http", "https"])
|
|
431
443
|
|
|
432
|
-
# Return trimmed and validated value
|
|
433
444
|
str
|
|
434
445
|
end
|
|
435
446
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# OmniAuth OpenID Federation
|
|
2
2
|
#
|
|
3
|
-
# Custom OmniAuth strategy for OpenID Federation providers
|
|
4
|
-
#
|
|
3
|
+
# Custom OmniAuth strategy for OpenID Federation providers supporting signed request objects,
|
|
4
|
+
# ID token encryption, and OpenID Federation.
|
|
5
5
|
#
|
|
6
6
|
# @see https://openid.net/specs/openid-federation-1_0.html
|
|
7
7
|
module OmniauthOpenidFederation
|
|
@@ -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"
|
|
@@ -38,7 +39,13 @@ require_relative "omniauth_openid_federation/constants"
|
|
|
38
39
|
require_relative "omniauth_openid_federation/cache"
|
|
39
40
|
require_relative "omniauth_openid_federation/cache_adapter"
|
|
40
41
|
require_relative "omniauth_openid_federation/utils"
|
|
42
|
+
require_relative "omniauth_openid_federation/jwe"
|
|
41
43
|
require_relative "omniauth_openid_federation/jws"
|
|
44
|
+
require_relative "omniauth_openid_federation/http_errors"
|
|
45
|
+
require_relative "omniauth_openid_federation/id_token"
|
|
46
|
+
require_relative "omniauth_openid_federation/user_info"
|
|
47
|
+
require_relative "omniauth_openid_federation/secure_compare"
|
|
48
|
+
require_relative "omniauth_openid_federation/oidc_client"
|
|
42
49
|
require_relative "omniauth_openid_federation/jwks/normalizer"
|
|
43
50
|
require_relative "omniauth_openid_federation/jwks/fetch"
|
|
44
51
|
require_relative "omniauth_openid_federation/jwks/decode"
|
|
@@ -55,12 +62,12 @@ require_relative "omniauth_openid_federation/federation/trust_chain_resolver"
|
|
|
55
62
|
require_relative "omniauth_openid_federation/federation/metadata_policy_merger"
|
|
56
63
|
require_relative "omniauth_openid_federation/federation/signed_jwks"
|
|
57
64
|
require_relative "omniauth_openid_federation/federation_endpoint"
|
|
58
|
-
require_relative "omniauth_openid_federation/rack_endpoint"
|
|
59
65
|
require_relative "omniauth_openid_federation/entity_statement_reader"
|
|
60
66
|
require_relative "omniauth_openid_federation/tasks_helper"
|
|
61
67
|
require_relative "omniauth_openid_federation/endpoint_resolver"
|
|
62
68
|
require_relative "omniauth_openid_federation/strategy"
|
|
63
69
|
require_relative "omniauth_openid_federation/access_token"
|
|
70
|
+
require_relative "omniauth_openid_federation/jwt_response_decoder"
|
|
64
71
|
|
|
65
72
|
module OmniauthOpenidFederation
|
|
66
73
|
# Rotate JWKS cache for a provider
|
|
@@ -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
|
|
@@ -456,8 +457,7 @@ namespace :openid_federation do
|
|
|
456
457
|
begin
|
|
457
458
|
well_known_url = "#{base_url}/.well-known/openid-federation"
|
|
458
459
|
puts "📥 Attempting to fetch entity statement from: #{well_known_url}"
|
|
459
|
-
|
|
460
|
-
response = HTTP.timeout(connect: 5, read: 5).get(well_known_url)
|
|
460
|
+
response = OmniauthOpenidFederation::HttpClient.get(well_known_url, timeout: 5, max_retries: 0)
|
|
461
461
|
if response.status.success?
|
|
462
462
|
entity_statement_path = "/tmp/entity_statement_#{Time.now.to_i}.json"
|
|
463
463
|
File.write(entity_statement_path, response.body.to_s)
|
|
@@ -594,7 +594,7 @@ namespace :openid_federation do
|
|
|
594
594
|
if value
|
|
595
595
|
if [:exp, :iat, :auth_time].include?(key)
|
|
596
596
|
time_value = begin
|
|
597
|
-
|
|
597
|
+
OmniauthOpenidFederation::TimeHelpers.at(value)
|
|
598
598
|
rescue
|
|
599
599
|
value
|
|
600
600
|
end
|
|
@@ -71,7 +71,8 @@ module OmniauthOpenidFederation
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
class HttpClient
|
|
74
|
-
def self.get: (String uri, ?
|
|
74
|
+
def self.get: (String uri, ?Hash[Symbol, untyped] options) -> untyped
|
|
75
|
+
def self.post: (String uri, ?Hash[Symbol, untyped] options) -> untyped
|
|
75
76
|
end
|
|
76
77
|
|
|
77
78
|
class Logger
|
|
@@ -183,6 +184,12 @@ module OmniauthOpenidFederation
|
|
|
183
184
|
def self.blank?: (untyped value) -> bool
|
|
184
185
|
end
|
|
185
186
|
|
|
187
|
+
module TimeHelpers
|
|
188
|
+
def self.now: () -> Time
|
|
189
|
+
def self.at: (Integer | Float timestamp) -> Time
|
|
190
|
+
def self.parse: (String time_string) -> Time
|
|
191
|
+
end
|
|
192
|
+
|
|
186
193
|
module Validators
|
|
187
194
|
def self.validate_private_key!: (untyped private_key) -> void
|
|
188
195
|
def self.normalize_hash: (untyped hash) -> Hash[Symbol, untyped]
|
data/sig/strategy.rbs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module OmniAuth
|
|
2
2
|
module Strategies
|
|
3
3
|
class OpenIDFederation < OmniAuth::Strategies::OAuth2
|
|
4
|
-
def client: () ->
|
|
5
|
-
def oidc_client: () ->
|
|
4
|
+
def client: () -> OmniauthOpenidFederation::OidcClient
|
|
5
|
+
def oidc_client: () -> OmniauthOpenidFederation::OidcClient
|
|
6
6
|
def request_phase: () -> untyped
|
|
7
7
|
def callback_phase: () -> untyped
|
|
8
8
|
def auth_hash: () -> OmniAuth::AuthHash
|
|
@@ -10,7 +10,7 @@ module OmniAuth
|
|
|
10
10
|
def raw_info: () -> Hash[String, untyped]
|
|
11
11
|
def client_jwk_signing_key: () -> untyped
|
|
12
12
|
def options: () -> untyped
|
|
13
|
-
def exchange_authorization_code: (String authorization_code) ->
|
|
13
|
+
def exchange_authorization_code: (String authorization_code) -> OmniauthOpenidFederation::AccessToken
|
|
14
14
|
def new_state: () -> String
|
|
15
15
|
def new_nonce: () -> String
|
|
16
16
|
def resolve_endpoints_from_metadata: (Hash[Symbol, untyped] client_options_hash) -> Hash[Symbol, String?]
|
|
@@ -21,7 +21,7 @@ module OmniAuth
|
|
|
21
21
|
def resolve_jwks_uri: (Hash[Symbol, untyped] normalized_options) -> String?
|
|
22
22
|
def build_base_url: (Hash[Symbol, untyped] client_options_hash) -> String
|
|
23
23
|
def build_endpoint: (String base_url, String path) -> String
|
|
24
|
-
def decode_id_token: (String id_token) ->
|
|
24
|
+
def decode_id_token: (String id_token) -> OmniauthOpenidFederation::IdToken
|
|
25
25
|
def encrypted_token?: (String token) -> bool
|
|
26
26
|
def decode_userinfo: (untyped userinfo) -> Hash[String, untyped]
|
|
27
27
|
def load_provider_entity_statement: () -> Hash[Symbol, untyped]?
|
|
@@ -45,9 +45,10 @@ module OmniAuth
|
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
module
|
|
48
|
+
module OmniauthOpenidFederation
|
|
49
49
|
class AccessToken
|
|
50
50
|
def resource_request: () { () -> untyped } -> untyped
|
|
51
|
+
def userinfo!: () -> untyped
|
|
51
52
|
def get_strategy_options: () -> Hash[Symbol, untyped]
|
|
52
53
|
def extract_encryption_key_for_decryption: () -> untyped
|
|
53
54
|
def fetch_signed_jwks: (Hash[Symbol, untyped] strategy_options) -> Hash[String, untyped]?
|
|
@@ -55,4 +56,3 @@ module OpenIDConnect
|
|
|
55
56
|
def resolve_jwks_uri_from_entity_statement: (Hash[Symbol, untyped] strategy_options) -> String?
|
|
56
57
|
end
|
|
57
58
|
end
|
|
58
|
-
|