omniauth_openid_federation 1.3.2 → 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 +32 -1
- data/README.md +119 -13
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
- data/config/polyrun_coverage.yml +15 -0
- data/config/polyrun_spec_quality.yml +29 -0
- 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/federation/entity_statement.rb +0 -4
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +2 -4
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +51 -6
- data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
- 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/jws.rb +2 -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 +2 -2
- 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 -808
- data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
- data/lib/omniauth_openid_federation/user_info.rb +15 -0
- data/lib/omniauth_openid_federation/utils.rb +10 -2
- data/lib/omniauth_openid_federation/validators.rb +43 -8
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +9 -3
- data/lib/tasks/omniauth_openid_federation.rake +1 -2
- data/sig/omniauth_openid_federation.rbs +2 -1
- data/sig/strategy.rbs +6 -6
- metadata +181 -71
|
@@ -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?
|
|
@@ -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?
|
|
@@ -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
|