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,290 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "json"
|
|
3
|
+
require "jwt"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "string_helpers"
|
|
7
|
+
require_relative "logger"
|
|
8
|
+
require_relative "validators"
|
|
9
|
+
require_relative "utils"
|
|
10
|
+
require_relative "key_extractor"
|
|
11
|
+
require_relative "jwe"
|
|
12
|
+
require_relative "jwks/decode"
|
|
13
|
+
require_relative "federation/entity_statement"
|
|
14
|
+
require_relative "federation/entity_statement_helper"
|
|
15
|
+
require_relative "federation/signed_jwks"
|
|
16
|
+
|
|
17
|
+
module OmniauthOpenidFederation
|
|
18
|
+
# Decrypts and verifies compact JWE/JWS response bodies (nested JWTs).
|
|
19
|
+
class JwtResponseDecoder
|
|
20
|
+
COMPACT_TOKEN_PATTERN = /\A[\w\-.]+\z/
|
|
21
|
+
|
|
22
|
+
def initialize(strategy_options:, client: nil)
|
|
23
|
+
@strategy_options = strategy_options
|
|
24
|
+
@client = client
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def decode(body)
|
|
28
|
+
body_text = body.to_s.strip
|
|
29
|
+
return {} if OmniauthOpenidFederation::StringHelpers.blank?(body_text)
|
|
30
|
+
|
|
31
|
+
unless compact_token?(body_text)
|
|
32
|
+
return OmniauthOpenidFederation::Utils.to_indifferent_hash(JSON.parse(body_text))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
signed_jwt = resolve_signed_jwt(body_text)
|
|
36
|
+
return OmniauthOpenidFederation::Utils.to_indifferent_hash(signed_jwt) if signed_jwt.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
payload = verify_signed_jwt(signed_jwt)
|
|
39
|
+
OmniauthOpenidFederation::Utils.to_indifferent_hash(payload)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def compact_token?(body_text)
|
|
45
|
+
COMPACT_TOKEN_PATTERN.match?(body_text) && body_text.count(".") >= 2
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resolve_signed_jwt(body_text)
|
|
49
|
+
if OmniauthOpenidFederation::Jwe.encrypted?(body_text)
|
|
50
|
+
plain_text = OmniauthOpenidFederation::Jwe.decrypt(body_text, encryption_key)
|
|
51
|
+
return plain_text if plain_text.to_s.split(".").length == 3
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
parsed = JSON.parse(plain_text)
|
|
55
|
+
return parsed if parsed.is_a?(Hash)
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
# fall through to JWT verification path
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
plain_text
|
|
61
|
+
else
|
|
62
|
+
body_text
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def verify_signed_jwt(signed_jwt)
|
|
67
|
+
parts = signed_jwt.to_s.split(".")
|
|
68
|
+
raise ValidationError, "Signed response is not a valid JWT" if parts.empty?
|
|
69
|
+
|
|
70
|
+
header = JSON.parse(Base64.urlsafe_decode64(parts.first))
|
|
71
|
+
algorithm = header["alg"] || header[:alg]
|
|
72
|
+
|
|
73
|
+
if algorithm.nil? || algorithm.to_s.downcase == "none"
|
|
74
|
+
raise ValidationError, "JWT algorithm '#{algorithm}' is not permitted for signed responses"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
unless parts.length == 3
|
|
78
|
+
raise ValidationError, "Signed response is not a valid JWT"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
normalized_strategy_options = OmniauthOpenidFederation::Validators.normalize_hash(@strategy_options)
|
|
82
|
+
signed_jwks = fetch_signed_jwks(normalized_strategy_options)
|
|
83
|
+
|
|
84
|
+
if signed_jwks
|
|
85
|
+
payload, = ::JWT.decode(
|
|
86
|
+
signed_jwt,
|
|
87
|
+
nil,
|
|
88
|
+
true,
|
|
89
|
+
{algorithms: [algorithm], jwks: signed_jwks}
|
|
90
|
+
)
|
|
91
|
+
return payload
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
jwks_uri = resolve_jwks_uri(normalized_strategy_options)
|
|
95
|
+
unless jwks_uri
|
|
96
|
+
raise ConfigurationError,
|
|
97
|
+
"JWKS URI not available. Cannot verify JWT signature. Provide jwks_uri in client_options or entity statement."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
entity_statement_keys = load_entity_statement_keys_for_jwks_validation(normalized_strategy_options)
|
|
101
|
+
payload, = OmniauthOpenidFederation::Jwks::Decode.jwt(
|
|
102
|
+
signed_jwt,
|
|
103
|
+
jwks_uri.to_s,
|
|
104
|
+
entity_statement_keys: entity_statement_keys
|
|
105
|
+
)
|
|
106
|
+
payload
|
|
107
|
+
rescue ConfigurationError, ValidationError, SignatureError
|
|
108
|
+
raise
|
|
109
|
+
rescue => error
|
|
110
|
+
raise ValidationError, "Failed to verify JWT response: #{error.class} - #{error.message}", error.backtrace
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def encryption_key
|
|
114
|
+
raw_client_options = @strategy_options[:client_options] || @strategy_options["client_options"] || {}
|
|
115
|
+
client_options = OmniauthOpenidFederation::Validators.normalize_hash(raw_client_options)
|
|
116
|
+
|
|
117
|
+
private_key = client_options[:private_key] ||
|
|
118
|
+
((@client.respond_to?(:private_key) ? @client.private_key : nil))
|
|
119
|
+
jwks = client_options[:jwks] || client_options["jwks"]
|
|
120
|
+
|
|
121
|
+
metadata = nil
|
|
122
|
+
entity_statement_path = @strategy_options[:entity_statement_path] || @strategy_options["entity_statement_path"]
|
|
123
|
+
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
|
|
124
|
+
begin
|
|
125
|
+
validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
|
|
126
|
+
entity_statement_path,
|
|
127
|
+
allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
|
|
128
|
+
)
|
|
129
|
+
metadata = JSON.parse(File.read(validated_path)) if File.exist?(validated_path)
|
|
130
|
+
rescue => error
|
|
131
|
+
OmniauthOpenidFederation::Logger.debug("[JwtResponseDecoder] Could not load metadata for key extraction: #{error.message}")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
key = OmniauthOpenidFederation::KeyExtractor.extract_encryption_key(
|
|
136
|
+
jwks: jwks,
|
|
137
|
+
metadata: metadata,
|
|
138
|
+
private_key: private_key
|
|
139
|
+
) || private_key
|
|
140
|
+
|
|
141
|
+
OmniauthOpenidFederation::Validators.validate_private_key!(key)
|
|
142
|
+
key
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def resolve_jwks_uri(strategy_options)
|
|
146
|
+
raw_client_options = strategy_options[:client_options] || strategy_options["client_options"] || {}
|
|
147
|
+
client_options = OmniauthOpenidFederation::Validators.normalize_hash(raw_client_options)
|
|
148
|
+
jwks_uri_value = client_options[:jwks_uri] ||
|
|
149
|
+
((@client.respond_to?(:jwks_uri) ? @client.jwks_uri : nil))
|
|
150
|
+
|
|
151
|
+
if jwks_uri_value && %r{https?://.+}.match?(jwks_uri_value.to_s)
|
|
152
|
+
return URI.parse(jwks_uri_value.to_s)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if jwks_uri_value
|
|
156
|
+
return URI::HTTPS.build(
|
|
157
|
+
host: client_options[:host] || ((@client.respond_to?(:host) ? @client.host : nil)),
|
|
158
|
+
path: jwks_uri_value.to_s
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
remote_uri = resolve_jwks_uri_from_entity_statement(strategy_options)
|
|
163
|
+
remote_uri ? URI.parse(remote_uri.to_s) : nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def fetch_signed_jwks(strategy_options)
|
|
167
|
+
entity_statement_content = load_entity_statement_content(strategy_options)
|
|
168
|
+
return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
|
|
169
|
+
|
|
170
|
+
parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
|
|
171
|
+
entity_statement_content
|
|
172
|
+
)
|
|
173
|
+
return nil if parsed.nil?
|
|
174
|
+
|
|
175
|
+
signed_jwks_uri = parsed[:signed_jwks_uri]
|
|
176
|
+
return nil if OmniauthOpenidFederation::StringHelpers.blank?(signed_jwks_uri)
|
|
177
|
+
|
|
178
|
+
OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(signed_jwks_uri, parsed[:entity_jwks])
|
|
179
|
+
rescue OmniauthOpenidFederation::SecurityError => error
|
|
180
|
+
OmniauthOpenidFederation::Logger.error("[JwtResponseDecoder] Security error: #{error.message}")
|
|
181
|
+
nil
|
|
182
|
+
rescue
|
|
183
|
+
OmniauthOpenidFederation::Logger.warn("[JwtResponseDecoder] Failed to fetch signed JWKS, falling back to standard JWKS")
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def load_entity_statement_keys_for_jwks_validation(strategy_options)
|
|
188
|
+
entity_statement_content = load_entity_statement_content(strategy_options)
|
|
189
|
+
return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
|
|
190
|
+
|
|
191
|
+
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
192
|
+
parsed = entity_statement.parse
|
|
193
|
+
entity_jwks = parsed[:jwks] if parsed
|
|
194
|
+
|
|
195
|
+
keys = if entity_jwks.is_a?(Hash) && entity_jwks.key?("keys")
|
|
196
|
+
entity_jwks["keys"]
|
|
197
|
+
elsif entity_jwks.is_a?(Hash) && entity_jwks.key?(:keys)
|
|
198
|
+
entity_jwks[:keys]
|
|
199
|
+
elsif entity_jwks.is_a?(Array)
|
|
200
|
+
entity_jwks
|
|
201
|
+
else
|
|
202
|
+
[]
|
|
203
|
+
end
|
|
204
|
+
if keys.empty?
|
|
205
|
+
OmniauthOpenidFederation::Logger.warn("[JwtResponseDecoder] No keys found in entity statement")
|
|
206
|
+
return nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
OmniauthOpenidFederation::Utils.to_indifferent_hash(
|
|
210
|
+
keys: keys.map { |jwk| jwk.is_a?(Hash) ? jwk : JSON.parse(jwk.to_json) }
|
|
211
|
+
)
|
|
212
|
+
rescue => error
|
|
213
|
+
OmniauthOpenidFederation::Logger.error(
|
|
214
|
+
"[JwtResponseDecoder] Failed to load entity statement keys for JWKS validation: #{error.message}"
|
|
215
|
+
)
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def resolve_jwks_uri_from_entity_statement(strategy_options)
|
|
220
|
+
entity_statement_content = load_entity_statement_content(strategy_options, log_fetch_errors: :debug)
|
|
221
|
+
return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
|
|
222
|
+
|
|
223
|
+
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
224
|
+
parsed = entity_statement.parse
|
|
225
|
+
return nil unless parsed
|
|
226
|
+
|
|
227
|
+
jwks_uri = parsed.dig(:metadata, :openid_provider, :jwks_uri) ||
|
|
228
|
+
parsed.dig("metadata", "openid_provider", "jwks_uri")
|
|
229
|
+
OmniauthOpenidFederation::StringHelpers.present?(jwks_uri) ? jwks_uri : nil
|
|
230
|
+
rescue => error
|
|
231
|
+
OmniauthOpenidFederation::Logger.debug(
|
|
232
|
+
"[JwtResponseDecoder] Could not extract JWKS URI from entity statement: #{error.message}"
|
|
233
|
+
)
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def load_entity_statement_content(strategy_options, log_fetch_errors: :warn)
|
|
238
|
+
normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(strategy_options)
|
|
239
|
+
entity_statement_path = normalized_options[:entity_statement_path]
|
|
240
|
+
entity_statement_url = normalized_options[:entity_statement_url]
|
|
241
|
+
issuer = normalized_options[:issuer]
|
|
242
|
+
entity_statement_fingerprint = normalized_options[:entity_statement_fingerprint]
|
|
243
|
+
|
|
244
|
+
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
|
|
245
|
+
begin
|
|
246
|
+
validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
|
|
247
|
+
entity_statement_path,
|
|
248
|
+
allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
|
|
249
|
+
)
|
|
250
|
+
return File.read(validated_path) if File.exist?(validated_path)
|
|
251
|
+
rescue OmniauthOpenidFederation::SecurityError => error
|
|
252
|
+
OmniauthOpenidFederation::Logger.error("[JwtResponseDecoder] #{error.message}")
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
|
|
257
|
+
begin
|
|
258
|
+
return OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
|
|
259
|
+
entity_statement_url,
|
|
260
|
+
fingerprint: entity_statement_fingerprint
|
|
261
|
+
).entity_statement
|
|
262
|
+
rescue => error
|
|
263
|
+
log_entity_statement_fetch_failure(log_fetch_errors, "URL", error)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if OmniauthOpenidFederation::StringHelpers.present?(issuer)
|
|
268
|
+
begin
|
|
269
|
+
return OmniauthOpenidFederation::Federation::EntityStatement.fetch_from_issuer!(
|
|
270
|
+
issuer,
|
|
271
|
+
fingerprint: entity_statement_fingerprint
|
|
272
|
+
).entity_statement
|
|
273
|
+
rescue => error
|
|
274
|
+
log_entity_statement_fetch_failure(log_fetch_errors, "issuer", error)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def log_entity_statement_fetch_failure(level, source, error)
|
|
282
|
+
message = "[JwtResponseDecoder] Failed to fetch entity statement from #{source}: #{error.message}"
|
|
283
|
+
if level == :debug
|
|
284
|
+
OmniauthOpenidFederation::Logger.debug(message)
|
|
285
|
+
else
|
|
286
|
+
OmniauthOpenidFederation::Logger.warn(message)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require "oauth2"
|
|
2
|
+
require "jwt"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "access_token"
|
|
8
|
+
|
|
9
|
+
module OmniauthOpenidFederation
|
|
10
|
+
class OidcClient < OAuth2::Client
|
|
11
|
+
CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
12
|
+
CLIENT_ASSERTION_EXPIRATION_SECONDS = 180
|
|
13
|
+
|
|
14
|
+
attr_accessor :private_key, :authorization_code, :jwks_uri
|
|
15
|
+
attr_reader :authorization_endpoint, :token_endpoint, :userinfo_endpoint
|
|
16
|
+
|
|
17
|
+
def initialize(identifier:, redirect_uri:, authorization_endpoint:, token_endpoint:, secret: nil,
|
|
18
|
+
userinfo_endpoint: nil, jwks_uri: nil)
|
|
19
|
+
token_uri = URI.parse(token_endpoint.to_s)
|
|
20
|
+
site = build_site(token_uri)
|
|
21
|
+
|
|
22
|
+
super(
|
|
23
|
+
identifier,
|
|
24
|
+
secret,
|
|
25
|
+
site: site,
|
|
26
|
+
authorize_url: authorization_endpoint,
|
|
27
|
+
token_url: token_endpoint,
|
|
28
|
+
redirect_uri: redirect_uri,
|
|
29
|
+
auth_scheme: :private_key_jwt
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@authorization_endpoint = authorization_endpoint
|
|
33
|
+
@token_endpoint = token_endpoint
|
|
34
|
+
@userinfo_endpoint = userinfo_endpoint
|
|
35
|
+
@jwks_uri = jwks_uri
|
|
36
|
+
@authorization_code = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def identifier
|
|
40
|
+
id
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def identifier=(value)
|
|
44
|
+
@id = value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def redirect_uri
|
|
48
|
+
options[:redirect_uri]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def redirect_uri=(value)
|
|
52
|
+
options[:redirect_uri] = value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def host
|
|
56
|
+
URI.parse(site.to_s).host
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def access_token!(client_auth_method = :jwt_bearer)
|
|
60
|
+
unless client_auth_method == :jwt_bearer
|
|
61
|
+
raise ConfigurationError, "Unsupported client_auth_method: #{client_auth_method}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
OmniauthOpenidFederation::Validators.validate_private_key!(private_key)
|
|
65
|
+
|
|
66
|
+
oauth_token = auth_code.get_token(
|
|
67
|
+
authorization_code,
|
|
68
|
+
redirect_uri: redirect_uri,
|
|
69
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
70
|
+
client_assertion: build_client_assertion
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
OmniauthOpenidFederation::AccessToken.from_oauth2_token(
|
|
74
|
+
oauth_token,
|
|
75
|
+
strategy_options: instance_variable_get(:@strategy_options)
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def build_site(token_uri)
|
|
82
|
+
site = "#{token_uri.scheme}://#{token_uri.host}"
|
|
83
|
+
site += ":#{token_uri.port}" if token_uri.port && !token_uri.default_port
|
|
84
|
+
site
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_client_assertion
|
|
88
|
+
now = Time.now.to_i
|
|
89
|
+
payload = {
|
|
90
|
+
iss: identifier,
|
|
91
|
+
sub: identifier,
|
|
92
|
+
aud: token_endpoint.to_s,
|
|
93
|
+
jti: SecureRandom.hex(16),
|
|
94
|
+
iat: now,
|
|
95
|
+
exp: now + CLIENT_ASSERTION_EXPIRATION_SECONDS
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
JWT.encode(payload, private_key, "RS256")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -88,7 +88,7 @@ module OmniauthOpenidFederation
|
|
|
88
88
|
jwks_to_serve = OmniauthOpenidFederation::FederationEndpoint.current_jwks
|
|
89
89
|
|
|
90
90
|
# Apply server-side caching if available
|
|
91
|
-
cache_key =
|
|
91
|
+
cache_key = OmniauthOpenidFederation::Cache.key_for_served_jwks(config.issuer)
|
|
92
92
|
cache_ttl = config.jwks_cache_ttl || 3600
|
|
93
93
|
|
|
94
94
|
jwks_json = if CacheAdapter.available?
|
|
@@ -116,7 +116,7 @@ module OmniauthOpenidFederation
|
|
|
116
116
|
subject_entity_id = request.params["sub"]
|
|
117
117
|
|
|
118
118
|
unless subject_entity_id
|
|
119
|
-
return error_response(400, {error: "invalid_request", error_description: "Missing required parameter: sub"}
|
|
119
|
+
return error_response(400, {error: "invalid_request", error_description: "Missing required parameter: sub"})
|
|
120
120
|
end
|
|
121
121
|
|
|
122
122
|
# Security: Validate entity identifier per OpenID Federation 1.0 spec
|
|
@@ -125,22 +125,22 @@ module OmniauthOpenidFederation
|
|
|
125
125
|
# Validate and get trimmed value
|
|
126
126
|
subject_entity_id = OmniauthOpenidFederation::Validators.validate_entity_identifier!(subject_entity_id)
|
|
127
127
|
rescue SecurityError => e
|
|
128
|
-
return error_response(400, {error: "invalid_request", error_description: "Invalid subject entity ID: #{e.message}"}
|
|
128
|
+
return error_response(400, {error: "invalid_request", error_description: "Invalid subject entity ID: #{e.message}"})
|
|
129
129
|
rescue => e
|
|
130
|
-
return error_response(400, {error: "invalid_request", error_description: "Subject entity ID validation failed: #{e.message}"}
|
|
130
|
+
return error_response(400, {error: "invalid_request", error_description: "Subject entity ID validation failed: #{e.message}"})
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
# Validate that subject is not the issuer (invalid request per spec)
|
|
134
134
|
config = OmniauthOpenidFederation::FederationEndpoint.configuration
|
|
135
135
|
if subject_entity_id == config.issuer
|
|
136
|
-
return error_response(400, {error: "invalid_request", error_description: "Subject cannot be the issuer"}
|
|
136
|
+
return error_response(400, {error: "invalid_request", error_description: "Subject cannot be the issuer"})
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
# Get Subordinate Statement
|
|
140
140
|
subordinate_statement = OmniauthOpenidFederation::FederationEndpoint.get_subordinate_statement(subject_entity_id)
|
|
141
141
|
|
|
142
142
|
unless subordinate_statement
|
|
143
|
-
return error_response(404, {error: "not_found", error_description: "Subordinate Statement not found for subject: #{subject_entity_id}"}
|
|
143
|
+
return error_response(404, {error: "not_found", error_description: "Subordinate Statement not found for subject: #{subject_entity_id}"})
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
headers = {
|
|
@@ -159,7 +159,7 @@ module OmniauthOpenidFederation
|
|
|
159
159
|
signed_jwks_jwt = OmniauthOpenidFederation::FederationEndpoint.generate_signed_jwks
|
|
160
160
|
|
|
161
161
|
# Apply server-side caching if available
|
|
162
|
-
cache_key =
|
|
162
|
+
cache_key = OmniauthOpenidFederation::Cache.key_for_served_signed_jwks(config.issuer)
|
|
163
163
|
cache_ttl = config.jwks_cache_ttl || 3600
|
|
164
164
|
|
|
165
165
|
cached_jwt = if CacheAdapter.available?
|
|
@@ -188,11 +188,23 @@ module OmniauthOpenidFederation
|
|
|
188
188
|
# Return error response
|
|
189
189
|
#
|
|
190
190
|
# @param status [Integer] HTTP status code
|
|
191
|
-
# @param message [String] Error message
|
|
191
|
+
# @param message [String, Hash] Error message (string) or error hash (will be converted to JSON)
|
|
192
192
|
# @return [Array] Rack response
|
|
193
193
|
def error_response(status, message)
|
|
194
194
|
content_type = (status == 503) ? "text/plain" : "application/json"
|
|
195
|
-
body =
|
|
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
|
|
196
208
|
|
|
197
209
|
[status, {"Content-Type" => content_type}, [body]]
|
|
198
210
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "digest"
|
|
2
|
+
require_relative "cache_adapter"
|
|
2
3
|
require_relative "utils"
|
|
3
4
|
require_relative "logger"
|
|
4
5
|
|
|
@@ -11,34 +12,30 @@ module OmniauthOpenidFederation
|
|
|
11
12
|
|
|
12
13
|
# Check if request should be throttled
|
|
13
14
|
#
|
|
15
|
+
# Best-effort only: read-modify-write on the cache adapter is not atomic, so parallel
|
|
16
|
+
# workers can briefly exceed the limit. Treat this as a DoS soft guard, not a hard quota.
|
|
17
|
+
#
|
|
14
18
|
# @param key [String] Unique identifier for the rate limit (e.g., jwks_uri)
|
|
15
19
|
# @param max_requests [Integer] Maximum requests allowed in window (default: 10)
|
|
16
20
|
# @param window [Integer] Time window in seconds (default: 60)
|
|
17
21
|
# @return [Boolean] true if request should be allowed, false if throttled
|
|
18
22
|
def self.allow?(key, max_requests: DEFAULT_MAX_REQUESTS, window: DEFAULT_WINDOW_SECONDS)
|
|
19
|
-
return true unless
|
|
23
|
+
return true unless CacheAdapter.available?
|
|
20
24
|
|
|
21
25
|
cache_key = "omniauth_openid_federation:rate_limit:#{Digest::SHA256.hexdigest(key)}"
|
|
22
26
|
current_time = Time.now.to_i
|
|
23
27
|
window_start = current_time - window
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
timestamps = Rails.cache.read(cache_key) || []
|
|
27
|
-
|
|
28
|
-
# Filter out timestamps outside the current window
|
|
29
|
+
timestamps = CacheAdapter.read(cache_key) || []
|
|
29
30
|
timestamps = timestamps.select { |ts| ts > window_start }
|
|
30
31
|
|
|
31
|
-
# Check if we've exceeded the limit
|
|
32
32
|
if timestamps.length >= max_requests
|
|
33
33
|
OmniauthOpenidFederation::Logger.warn("[RateLimiter] Rate limit exceeded for #{Utils.sanitize_uri(key)}: #{timestamps.length}/#{max_requests} requests in #{window}s")
|
|
34
34
|
return false
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
# Add current request timestamp
|
|
38
37
|
timestamps << current_time
|
|
39
|
-
|
|
40
|
-
# Store updated timestamps (expires after window)
|
|
41
|
-
Rails.cache.write(cache_key, timestamps, expires_in: window)
|
|
38
|
+
CacheAdapter.write(cache_key, timestamps, expires_in: window)
|
|
42
39
|
|
|
43
40
|
true
|
|
44
41
|
end
|
|
@@ -47,9 +44,10 @@ module OmniauthOpenidFederation
|
|
|
47
44
|
#
|
|
48
45
|
# @param key [String] Unique identifier for the rate limit
|
|
49
46
|
def self.reset!(key)
|
|
50
|
-
return unless
|
|
47
|
+
return unless CacheAdapter.available?
|
|
48
|
+
|
|
51
49
|
cache_key = "omniauth_openid_federation:rate_limit:#{Digest::SHA256.hexdigest(key)}"
|
|
52
|
-
|
|
50
|
+
CacheAdapter.delete(cache_key)
|
|
53
51
|
end
|
|
54
52
|
end
|
|
55
53
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module OmniauthOpenidFederation
|
|
4
|
+
module SecureCompare
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def secure_compare(left, right)
|
|
8
|
+
left = left.to_s
|
|
9
|
+
right = right.to_s
|
|
10
|
+
return false unless left.bytesize == right.bytesize
|
|
11
|
+
|
|
12
|
+
OpenSSL.fixed_length_secure_compare(left, right)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|