omniauth_openid_federation 1.3.2 → 2.1.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 +51 -1
- data/README.md +136 -15
- data/SECURITY.md +11 -14
- 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 +42 -4
- data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
- data/lib/omniauth_openid_federation/constants.rb +6 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +33 -21
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +4 -2
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +85 -20
- data/lib/omniauth_openid_federation/federation/trust_chain_signature.rb +62 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
- data/lib/omniauth_openid_federation/http_client.rb +182 -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 +288 -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 +133 -0
- data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
- data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +216 -0
- data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +432 -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 +262 -0
- data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +135 -0
- data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +93 -0
- data/lib/omniauth_openid_federation/strategy.rb +30 -1911
- 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 +11 -3
- 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 +188 -63
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "jwe"
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module OmniauthOpenidFederation
|
|
6
|
+
# Compact-serialization JWE helpers for nested JWTs (sign then encrypt).
|
|
7
|
+
module Jwe
|
|
8
|
+
PARTS_COUNT = 5
|
|
9
|
+
|
|
10
|
+
def self.encrypted?(token)
|
|
11
|
+
token.to_s.count(".") + 1 == PARTS_COUNT
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.encrypt(plaintext, public_key, alg:, enc:)
|
|
15
|
+
::JWE.encrypt(plaintext.to_s, public_key, alg: alg.to_s, enc: enc.to_s)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.decrypt(ciphertext, private_key)
|
|
19
|
+
::JWE.decrypt(ciphertext.to_s, private_key)
|
|
20
|
+
rescue ::JWE::DecodeError, ::JWE::InvalidData, ::JWE::BadCEK, ::JWE::NotImplementedError, ArgumentError => error
|
|
21
|
+
raise OmniauthOpenidFederation::DecryptionError,
|
|
22
|
+
"Failed to decrypt JWE: #{error.message}",
|
|
23
|
+
error.backtrace
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -113,19 +113,6 @@ module OmniauthOpenidFederation
|
|
|
113
113
|
else
|
|
114
114
|
raise e
|
|
115
115
|
end
|
|
116
|
-
rescue => e
|
|
117
|
-
# Other errors might be due to key rotation
|
|
118
|
-
if retried
|
|
119
|
-
# If already re-tried to fetch, raise error
|
|
120
|
-
error_msg = "JWT decode failed after cache refresh: #{e.class} - #{e.message}"
|
|
121
|
-
OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
|
|
122
|
-
raise ValidationError, error_msg, e.backtrace
|
|
123
|
-
else
|
|
124
|
-
OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] JWT decode error (clearing cache and retrying - possible key rotation): #{e.class} - #{e.message}")
|
|
125
|
-
# Reset cache to force re-fetching of keys
|
|
126
|
-
OmniauthOpenidFederation::Cache.delete_jwks(jwks_uri)
|
|
127
|
-
run(encoded_jwt, jwks_uri, retried: true, entity_statement_keys: entity_statement_keys, &block)
|
|
128
|
-
end
|
|
129
116
|
end
|
|
130
117
|
end
|
|
131
118
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
require "http"
|
|
2
1
|
require "jwt"
|
|
3
2
|
require "openssl"
|
|
4
3
|
require_relative "../logger"
|
|
@@ -44,52 +43,30 @@ module OmniauthOpenidFederation
|
|
|
44
43
|
cache_ttl ||= config.cache_ttl
|
|
45
44
|
rotate_on_errors = config.rotate_on_errors
|
|
46
45
|
|
|
47
|
-
# Use cache adapter if available, otherwise fetch directly
|
|
48
46
|
if CacheAdapter.available?
|
|
49
|
-
if force_refresh
|
|
50
|
-
# Force refresh: clear cache and fetch fresh
|
|
51
|
-
CacheAdapter.delete(cache_key)
|
|
52
|
-
end
|
|
47
|
+
CacheAdapter.delete(cache_key) if force_refresh
|
|
53
48
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
begin
|
|
57
|
-
CacheAdapter.fetch(cache_key, expires_in: nil) do
|
|
58
|
-
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
59
|
-
end
|
|
60
|
-
rescue KeyRelatedError => e
|
|
61
|
-
# Rotate on key-related errors if configured
|
|
62
|
-
if rotate_on_errors
|
|
63
|
-
OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
|
|
64
|
-
CacheAdapter.delete(cache_key)
|
|
65
|
-
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
66
|
-
else
|
|
67
|
-
raise
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
else
|
|
71
|
-
# TTL-based cache: expires after cache_ttl seconds
|
|
72
|
-
# Rotate on errors if configured
|
|
73
|
-
begin
|
|
74
|
-
CacheAdapter.fetch(cache_key, expires_in: cache_ttl) do
|
|
75
|
-
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
76
|
-
end
|
|
77
|
-
rescue KeyRelatedError => e
|
|
78
|
-
# Rotate on key-related errors if configured
|
|
79
|
-
if rotate_on_errors
|
|
80
|
-
OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
|
|
81
|
-
CacheAdapter.delete(cache_key)
|
|
82
|
-
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
83
|
-
else
|
|
84
|
-
raise
|
|
85
|
-
end
|
|
86
|
-
end
|
|
49
|
+
fetch_from_cache(cache_key, cache_ttl: cache_ttl, rotate_on_errors: rotate_on_errors) do
|
|
50
|
+
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
87
51
|
end
|
|
88
52
|
else
|
|
89
53
|
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
90
54
|
end
|
|
91
55
|
end
|
|
92
56
|
|
|
57
|
+
def self.fetch_from_cache(cache_key, cache_ttl:, rotate_on_errors:)
|
|
58
|
+
CacheAdapter.fetch(cache_key, expires_in: cache_ttl) { yield }
|
|
59
|
+
rescue KeyRelatedError => error
|
|
60
|
+
if rotate_on_errors
|
|
61
|
+
OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{error.message}")
|
|
62
|
+
CacheAdapter.delete(cache_key)
|
|
63
|
+
yield
|
|
64
|
+
else
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
private_class_method :fetch_from_cache
|
|
69
|
+
|
|
93
70
|
def self.fetch_jwks(jwks_uri, entity_statement_keys)
|
|
94
71
|
# Rate limiting to prevent DoS
|
|
95
72
|
unless RateLimiter.allow?(jwks_uri)
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
require "jwt"
|
|
2
|
-
require "jwe"
|
|
3
2
|
require "securerandom"
|
|
4
3
|
require "base64"
|
|
5
4
|
require_relative "string_helpers"
|
|
@@ -56,10 +55,6 @@ module OmniauthOpenidFederation
|
|
|
56
55
|
# )
|
|
57
56
|
# signed_jwt = jws.sign
|
|
58
57
|
class Jws
|
|
59
|
-
# Request object expiration constants
|
|
60
|
-
REQUEST_OBJECT_EXPIRATION_SECONDS = 600 # 10 minutes in seconds
|
|
61
|
-
REQUEST_OBJECT_EXPIRATION_MINUTES = 10
|
|
62
|
-
|
|
63
58
|
# State generation constants
|
|
64
59
|
STATE_BYTES = 16 # Number of hex bytes for state parameter
|
|
65
60
|
|
|
@@ -223,7 +218,7 @@ module OmniauthOpenidFederation
|
|
|
223
218
|
response_type: @response_type,
|
|
224
219
|
scope: @scope,
|
|
225
220
|
state: state,
|
|
226
|
-
exp: (TimeHelpers.now +
|
|
221
|
+
exp: (TimeHelpers.now + TimeHelpers.request_object_expiration_offset).to_i,
|
|
227
222
|
jti: SecureRandom.uuid # JWT ID to prevent replay
|
|
228
223
|
}
|
|
229
224
|
|
|
@@ -393,11 +388,7 @@ module OmniauthOpenidFederation
|
|
|
393
388
|
# Convert JWK to OpenSSL public key
|
|
394
389
|
public_key = KeyExtractor.jwk_to_openssl_key(encryption_key_data)
|
|
395
390
|
|
|
396
|
-
|
|
397
|
-
# For JWE, we encrypt the signed JWT string as plaintext
|
|
398
|
-
# The pattern is: sign first, then encrypt (nested JWT)
|
|
399
|
-
# JWE.encrypt(plaintext, key, alg: "RSA-OAEP", enc: "A128CBC-HS256")
|
|
400
|
-
JWE.encrypt(
|
|
391
|
+
OmniauthOpenidFederation::Jwe.encrypt(
|
|
401
392
|
signed_jwt,
|
|
402
393
|
public_key,
|
|
403
394
|
alg: encryption_alg,
|
|
@@ -0,0 +1,288 @@
|
|
|
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, OmniauthOpenidFederation::ValidationError,
|
|
180
|
+
OmniauthOpenidFederation::FetchError => error
|
|
181
|
+
OmniauthOpenidFederation::Logger.error("[JwtResponseDecoder] Signed JWKS failure (fail closed): #{error.message}")
|
|
182
|
+
raise
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def load_entity_statement_keys_for_jwks_validation(strategy_options)
|
|
186
|
+
entity_statement_content = load_entity_statement_content(strategy_options)
|
|
187
|
+
return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
|
|
188
|
+
|
|
189
|
+
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
190
|
+
parsed = entity_statement.parse
|
|
191
|
+
entity_jwks = parsed[:jwks] if parsed
|
|
192
|
+
|
|
193
|
+
keys = if entity_jwks.is_a?(Hash) && entity_jwks.key?("keys")
|
|
194
|
+
entity_jwks["keys"]
|
|
195
|
+
elsif entity_jwks.is_a?(Hash) && entity_jwks.key?(:keys)
|
|
196
|
+
entity_jwks[:keys]
|
|
197
|
+
elsif entity_jwks.is_a?(Array)
|
|
198
|
+
entity_jwks
|
|
199
|
+
else
|
|
200
|
+
[]
|
|
201
|
+
end
|
|
202
|
+
if keys.empty?
|
|
203
|
+
OmniauthOpenidFederation::Logger.warn("[JwtResponseDecoder] No keys found in entity statement")
|
|
204
|
+
return nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
OmniauthOpenidFederation::Utils.to_indifferent_hash(
|
|
208
|
+
keys: keys.map { |jwk| jwk.is_a?(Hash) ? jwk : JSON.parse(jwk.to_json) }
|
|
209
|
+
)
|
|
210
|
+
rescue => error
|
|
211
|
+
OmniauthOpenidFederation::Logger.error(
|
|
212
|
+
"[JwtResponseDecoder] Failed to load entity statement keys for JWKS validation: #{error.message}"
|
|
213
|
+
)
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def resolve_jwks_uri_from_entity_statement(strategy_options)
|
|
218
|
+
entity_statement_content = load_entity_statement_content(strategy_options, log_fetch_errors: :debug)
|
|
219
|
+
return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
|
|
220
|
+
|
|
221
|
+
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
222
|
+
parsed = entity_statement.parse
|
|
223
|
+
return nil unless parsed
|
|
224
|
+
|
|
225
|
+
jwks_uri = parsed.dig(:metadata, :openid_provider, :jwks_uri) ||
|
|
226
|
+
parsed.dig("metadata", "openid_provider", "jwks_uri")
|
|
227
|
+
OmniauthOpenidFederation::StringHelpers.present?(jwks_uri) ? jwks_uri : nil
|
|
228
|
+
rescue => error
|
|
229
|
+
OmniauthOpenidFederation::Logger.debug(
|
|
230
|
+
"[JwtResponseDecoder] Could not extract JWKS URI from entity statement: #{error.message}"
|
|
231
|
+
)
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def load_entity_statement_content(strategy_options, log_fetch_errors: :warn)
|
|
236
|
+
normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(strategy_options)
|
|
237
|
+
entity_statement_path = normalized_options[:entity_statement_path]
|
|
238
|
+
entity_statement_url = normalized_options[:entity_statement_url]
|
|
239
|
+
issuer = normalized_options[:issuer]
|
|
240
|
+
entity_statement_fingerprint = normalized_options[:entity_statement_fingerprint]
|
|
241
|
+
|
|
242
|
+
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
|
|
243
|
+
begin
|
|
244
|
+
validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
|
|
245
|
+
entity_statement_path,
|
|
246
|
+
allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
|
|
247
|
+
)
|
|
248
|
+
return File.read(validated_path) if File.exist?(validated_path)
|
|
249
|
+
rescue OmniauthOpenidFederation::SecurityError => error
|
|
250
|
+
OmniauthOpenidFederation::Logger.error("[JwtResponseDecoder] #{error.message}")
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
|
|
255
|
+
begin
|
|
256
|
+
return OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
|
|
257
|
+
entity_statement_url,
|
|
258
|
+
fingerprint: entity_statement_fingerprint
|
|
259
|
+
).entity_statement
|
|
260
|
+
rescue => error
|
|
261
|
+
log_entity_statement_fetch_failure(log_fetch_errors, "URL", error)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
if OmniauthOpenidFederation::StringHelpers.present?(issuer)
|
|
266
|
+
begin
|
|
267
|
+
return OmniauthOpenidFederation::Federation::EntityStatement.fetch_from_issuer!(
|
|
268
|
+
issuer,
|
|
269
|
+
fingerprint: entity_statement_fingerprint
|
|
270
|
+
).entity_statement
|
|
271
|
+
rescue => error
|
|
272
|
+
log_entity_statement_fetch_failure(log_fetch_errors, "issuer", error)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def log_entity_statement_fetch_failure(level, source, error)
|
|
280
|
+
message = "[JwtResponseDecoder] Failed to fetch entity statement from #{source}: #{error.message}"
|
|
281
|
+
if level == :debug
|
|
282
|
+
OmniauthOpenidFederation::Logger.debug(message)
|
|
283
|
+
else
|
|
284
|
+
OmniauthOpenidFederation::Logger.warn(message)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
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
|