omniauth_openid_federation 2.0.0 → 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 +19 -0
- data/README.md +19 -4
- data/SECURITY.md +11 -14
- data/lib/omniauth_openid_federation/cache.rb +26 -4
- data/lib/omniauth_openid_federation/constants.rb +3 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +31 -17
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +4 -1
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +64 -44
- data/lib/omniauth_openid_federation/federation/trust_chain_signature.rb +62 -0
- data/lib/omniauth_openid_federation/http_client.rb +39 -2
- data/lib/omniauth_openid_federation/jwt_response_decoder.rb +4 -6
- data/lib/omniauth_openid_federation/strategy/callback_handling.rb +18 -20
- data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +6 -1
- data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +8 -9
- data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +32 -38
- data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +3 -2
- data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +32 -0
- data/lib/omniauth_openid_federation/strategy.rb +3 -1
- data/lib/omniauth_openid_federation/utils.rb +1 -1
- data/lib/omniauth_openid_federation/version.rb +1 -1
- metadata +22 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: da45b4e4b8bb24f3b4191c0a3c297e23dad26f3d89ffefaceebc0b225117dc59
|
|
4
|
+
data.tar.gz: 1f8f747bc2f5815fc0d9b2005fa21a2c542dc08f6b3f223072cc7b9e95f0f089
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16d2a33e35399bc802f47e6f7d69e7995a20781024bfb22e7d938f8035069dc5f4bfb4a62c0de5aa4a1a42842472e7c19d4bacea342c5cad9cd92d81d59f0d90
|
|
7
|
+
data.tar.gz: efec81bf4ec61d381ee7c03f2dc5e598c9d4d47a7768847f4353c746849cc141b339f301f9f99c3e5f8758d43248bd2682fc7c70fa2df612620f4f6805e878da
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 2.1.0 (2026-10-29)
|
|
6
|
+
|
|
7
|
+
- BREAKING: Require Ruby `>= 3.4` (gemspec); drop Rails 6 and TruffleRuby appraisal targets from the tested matrix
|
|
8
|
+
- Fix trust chain resolution to bind fetched entity configurations to the requested entity id and verify trust-anchor statements with pinned `trust_anchors[].jwks`
|
|
9
|
+
- Fix `EntityStatementValidator` authority_hints checks to use the subject entity configuration (`subject_entity_configuration:`)
|
|
10
|
+
- Fix UserInfo merge so `sub` must match the ID token and identity claims (`iss`, `sub`, `aud`, `exp`, `iat`) stay from the ID token
|
|
11
|
+
- Fix signed JWKS handling to fail closed on signature, validation, and fetch errors instead of falling back to unsigned `jwks_uri`
|
|
12
|
+
- Fix signed JWKS cache keys to include the verifying `entity_jwks` digest
|
|
13
|
+
- Fix OAuth callback to validate `state` before handling authorization `error` responses
|
|
14
|
+
- Fix configured `entity_statement_path` missing-file handling to raise `ConfigurationError` instead of silent network fallback
|
|
15
|
+
- Fix endpoint resolution so configured entity-statement files under `Dir.tmpdir` are not deleted after resolve
|
|
16
|
+
- Fix `client_entity_statement_url` so `load_client_entity_statement` fetches from the URL
|
|
17
|
+
- Fix `Utils.validate_file_path!` allowed-directory checks to require a path-separator boundary
|
|
18
|
+
- Stop streaming HTTP responses at the configured byte limit and close oversized responses before buffering the remaining body
|
|
19
|
+
- Add `rfcs/` starter pack: process (RFC 0001), positioning (RFC 0002), and Standards Track design RFCs 0003–0006 for strategy, trust chain, token verification, and federation endpoint
|
|
20
|
+
- Document Ruby `>= 3.4` in README requirements
|
|
21
|
+
|
|
3
22
|
## 2.0.0 (2026-07-06)
|
|
4
23
|
|
|
5
24
|
- BREAKING: Replace `openid_connect` and `json-jwt` with `OmniauthOpenidFederation::OidcClient`, `AccessToken`, and `IdToken` built on `oauth2` and `jwt`
|
data/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# omniauth_openid_federation
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/omniauth_openid_federation) [](https://github.com/amkisko/omniauth_openid_federation.rb/actions/workflows/test.yml) [](https://codecov.io/
|
|
3
|
+
[](https://badge.fury.io/rb/omniauth_openid_federation) [](https://github.com/amkisko/omniauth_openid_federation.rb/actions/workflows/test.yml) [](https://app.codecov.io/github/amkisko/omniauth_openid_federation.rb) [](https://sonarcloud.io/project/overview?id=amkisko_omniauth_openid_federation.rb)
|
|
4
4
|
|
|
5
5
|
OmniAuth strategy for OpenID Federation providers with comprehensive security features, supporting signed request objects, ID token encryption, and full OpenID Federation 1.0 compliance.
|
|
6
6
|
|
|
7
|
+
Design changes go through `rfcs/` (RFC 0001).
|
|
8
|
+
|
|
7
9
|
## Installation
|
|
8
10
|
|
|
9
11
|
```ruby
|
|
@@ -471,8 +473,8 @@ rake openid_federation:test_authentication_flow[
|
|
|
471
473
|
|
|
472
474
|
## Requirements
|
|
473
475
|
|
|
474
|
-
- Ruby >= 3.
|
|
475
|
-
- Rails >=
|
|
476
|
+
- Ruby >= 3.4
|
|
477
|
+
- Rails >= 7.2 (or compatible Rack application; see Appraisal gemfiles)
|
|
476
478
|
- OpenSSL (for RSA key operations)
|
|
477
479
|
|
|
478
480
|
## Example Files
|
|
@@ -492,7 +494,7 @@ bundle install
|
|
|
492
494
|
bundle exec polyrun parallel-rspec --workers 5 -c polyrun.yml
|
|
493
495
|
```
|
|
494
496
|
|
|
495
|
-
Same flow as CI; optional wrappers [`bin/rspec_parallel`](bin/rspec_parallel) and [`bin/appraisals`](bin/appraisals) (runs **`polyrun parallel-rspec`** per Appraisal gemfile). Details: **[
|
|
497
|
+
Same flow as CI; optional wrappers [`bin/rspec_parallel`](bin/rspec_parallel) and [`bin/appraisals`](bin/appraisals) (runs **`polyrun parallel-rspec`** per Appraisal gemfile). Details: **[spec/README.md](spec/README.md)** and **`polyrun.yml`**.
|
|
496
498
|
|
|
497
499
|
## Contributing
|
|
498
500
|
|
|
@@ -517,6 +519,19 @@ Contributions welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidel
|
|
|
517
519
|
- [anyway_config](https://github.com/palkan/anyway_config) - Configuration management
|
|
518
520
|
- [action_reporter](https://github.com/basecamp/action_reporter) - Error reporting
|
|
519
521
|
|
|
522
|
+
## Links
|
|
523
|
+
|
|
524
|
+
- [GitHub](https://github.com/amkisko/omniauth_openid_federation.rb)
|
|
525
|
+
- [GitLab](https://gitlab.com/amkisko/omniauth_openid_federation.rb)
|
|
526
|
+
- [RubyGems](https://rubygems.org/gems/omniauth_openid_federation)
|
|
527
|
+
- [Versions Atom](https://rubygems.org/gems/omniauth_openid_federation/versions.atom) (feed id `10007301350098`)
|
|
528
|
+
- [libraries.io](https://libraries.io/rubygems/omniauth_openid_federation)
|
|
529
|
+
- [Deps.dev](https://deps.dev/rubygems/omniauth_openid_federation)
|
|
530
|
+
- [SonarCloud](https://sonarcloud.io/project/overview?id=amkisko_omniauth_openid_federation.rb)
|
|
531
|
+
- [Snyk](https://snyk.io/test/github/amkisko/omniauth_openid_federation.rb)
|
|
532
|
+
- [Codecov](https://app.codecov.io/github/amkisko/omniauth_openid_federation.rb)
|
|
533
|
+
- [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/amkisko/omniauth_openid_federation.rb)
|
|
534
|
+
|
|
520
535
|
## License
|
|
521
536
|
|
|
522
537
|
MIT License. See [LICENSE.md](LICENSE.md) for details.
|
data/SECURITY.md
CHANGED
|
@@ -2,27 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## Reporting a Vulnerability
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Email security details to: security@kiskolabs.com
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Include: description, steps to reproduce, potential impact, and suggested fix (if available).
|
|
7
|
+
Send the report only to that address. Include description, steps to reproduce, potential impact, and a suggested fix when you have one.
|
|
10
8
|
|
|
11
9
|
### Response Timeline
|
|
12
10
|
|
|
13
|
-
- We will acknowledge receipt of your report
|
|
14
|
-
- We will provide an initial assessment
|
|
15
|
-
- We will keep you informed of our progress and resolution timeline
|
|
11
|
+
- We will acknowledge receipt of your report.
|
|
12
|
+
- We will provide an initial assessment.
|
|
13
|
+
- We will keep you informed of our progress and resolution timeline.
|
|
16
14
|
|
|
17
15
|
### Disclosure Policy
|
|
18
16
|
|
|
19
|
-
- We will work with you to understand and resolve the issue
|
|
20
|
-
- We will credit you for the discovery
|
|
21
|
-
- We will publish a security advisory after the vulnerability is patched
|
|
22
|
-
- We will coordinate public disclosure with you
|
|
17
|
+
- We will work with you to understand and resolve the issue.
|
|
18
|
+
- We will credit you for the discovery unless you prefer to remain anonymous.
|
|
19
|
+
- We will publish a security advisory after the vulnerability is patched.
|
|
20
|
+
- We will coordinate public disclosure with you.
|
|
23
21
|
|
|
24
22
|
## Automation Security
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
* **Supply Chain:** All automated dependencies must be verified.
|
|
24
|
+
- Keep production credentials, API keys, and personal data out of prompts sent to third-party models or automation services.
|
|
25
|
+
- Verify automated dependencies.
|
|
@@ -15,11 +15,32 @@ module OmniauthOpenidFederation
|
|
|
15
15
|
# Generate cache key for signed JWKS
|
|
16
16
|
#
|
|
17
17
|
# @param signed_jwks_uri [String] The signed JWKS URI
|
|
18
|
+
# @param entity_jwks [Hash, Array, nil] Entity JWKS used to verify the signed set
|
|
18
19
|
# @return [String] Cache key
|
|
19
|
-
def self.key_for_signed_jwks(signed_jwks_uri)
|
|
20
|
-
"
|
|
20
|
+
def self.key_for_signed_jwks(signed_jwks_uri, entity_jwks: nil)
|
|
21
|
+
material = "#{signed_jwks_uri}:#{canonical_jwks_digest(entity_jwks)}"
|
|
22
|
+
"omniauth_openid_federation:signed_jwks:#{Digest::SHA256.hexdigest(material)}"
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
def self.canonical_jwks_digest(entity_jwks)
|
|
26
|
+
return "none" if entity_jwks.nil?
|
|
27
|
+
|
|
28
|
+
require "json"
|
|
29
|
+
Digest::SHA256.hexdigest(JSON.generate(deep_sort_keys(entity_jwks)))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.deep_sort_keys(value)
|
|
33
|
+
case value
|
|
34
|
+
when Hash
|
|
35
|
+
value.keys.map(&:to_s).sort.index_with { |key| deep_sort_keys(value[key] || value[key.to_sym]) }
|
|
36
|
+
when Array
|
|
37
|
+
value.map { |item| deep_sort_keys(item) }
|
|
38
|
+
else
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
private_class_method :canonical_jwks_digest, :deep_sort_keys
|
|
43
|
+
|
|
23
44
|
# Generate cache key for locally served JWKS (federation endpoint)
|
|
24
45
|
#
|
|
25
46
|
# @param issuer [String] Federation endpoint issuer entity identifier
|
|
@@ -47,9 +68,10 @@ module OmniauthOpenidFederation
|
|
|
47
68
|
# Delete signed JWKS cache
|
|
48
69
|
#
|
|
49
70
|
# @param signed_jwks_uri [String] The signed JWKS URI
|
|
50
|
-
|
|
71
|
+
# @param entity_jwks [Hash, Array, nil] Entity JWKS used when the entry was cached
|
|
72
|
+
def self.delete_signed_jwks(signed_jwks_uri, entity_jwks: nil)
|
|
51
73
|
return unless CacheAdapter.available?
|
|
52
|
-
CacheAdapter.delete(key_for_signed_jwks(signed_jwks_uri))
|
|
74
|
+
CacheAdapter.delete(key_for_signed_jwks(signed_jwks_uri, entity_jwks: entity_jwks))
|
|
53
75
|
end
|
|
54
76
|
end
|
|
55
77
|
end
|
|
@@ -17,5 +17,8 @@ module OmniauthOpenidFederation
|
|
|
17
17
|
|
|
18
18
|
# Minimum RSA modulus size for OIDC signing and encryption keys (FTN and common federation profiles)
|
|
19
19
|
MIN_RSA_KEY_BITS = 2048
|
|
20
|
+
|
|
21
|
+
# Maximum HTTP response body size for federation/JWKS fetches (1 MiB)
|
|
22
|
+
MAX_HTTP_RESPONSE_BODY_BYTES = 1_048_576
|
|
20
23
|
end
|
|
21
24
|
end
|
|
@@ -39,9 +39,13 @@ module OmniauthOpenidFederation
|
|
|
39
39
|
# @param issuer_entity_configuration [Hash, EntityStatement, nil] Optional: Entity Configuration of the issuer
|
|
40
40
|
# Required for validating Subordinate Statements (when iss != sub)
|
|
41
41
|
# @param clock_skew_tolerance [Integer, nil] Clock skew tolerance in seconds (default: from config)
|
|
42
|
-
def initialize(jwt_string:, issuer_entity_configuration: nil, clock_skew_tolerance: nil)
|
|
42
|
+
def initialize(jwt_string:, issuer_entity_configuration: nil, subject_entity_configuration: nil, clock_skew_tolerance: nil)
|
|
43
43
|
@jwt_string = jwt_string
|
|
44
44
|
@issuer_entity_configuration = issuer_entity_configuration
|
|
45
|
+
# Subject Entity Configuration supplies authority_hints for Subordinate Statements.
|
|
46
|
+
# When omitted, issuer_entity_configuration is used for backward compatibility with
|
|
47
|
+
# callers that historically passed the subject config under that name.
|
|
48
|
+
@subject_entity_configuration = subject_entity_configuration
|
|
45
49
|
@clock_skew_tolerance = clock_skew_tolerance || OmniauthOpenidFederation.config.clock_skew_tolerance
|
|
46
50
|
@header = nil
|
|
47
51
|
@payload = nil
|
|
@@ -153,32 +157,42 @@ module OmniauthOpenidFederation
|
|
|
153
157
|
@is_subordinate_statement = !@is_entity_configuration
|
|
154
158
|
end
|
|
155
159
|
|
|
156
|
-
# Step 7: For Subordinate Statements, validate iss matches authority_hints
|
|
160
|
+
# Step 7: For Subordinate Statements, validate iss matches subject's authority_hints
|
|
157
161
|
def validate_authority_hints
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
OmniauthOpenidFederation::Logger.warn("[EntityStatementValidator] Cannot validate authority_hints: issuer entity configuration not provided")
|
|
162
|
+
subject_source = @subject_entity_configuration || @issuer_entity_configuration
|
|
163
|
+
unless subject_source
|
|
164
|
+
OmniauthOpenidFederation::Logger.warn("[EntityStatementValidator] Cannot validate authority_hints: subject entity configuration not provided")
|
|
162
165
|
return
|
|
163
166
|
end
|
|
164
167
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
elsif @issuer_entity_configuration.respond_to?(:parse)
|
|
168
|
-
@issuer_entity_configuration.parse
|
|
169
|
-
else
|
|
170
|
-
raise ValidationError, "Invalid issuer entity configuration format"
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
authority_hints = issuer_config[:claims]&.fetch("authority_hints", []) ||
|
|
174
|
-
issuer_config["claims"]&.fetch("authority_hints", []) ||
|
|
175
|
-
issuer_config.fetch("authority_hints", [])
|
|
168
|
+
subject_config = normalize_entity_configuration_hash(subject_source)
|
|
169
|
+
authority_hints = extract_authority_hints_from_config(subject_config)
|
|
176
170
|
|
|
177
171
|
unless authority_hints.is_a?(Array) && authority_hints.include?(@payload["iss"])
|
|
178
172
|
raise ValidationError, "Subordinate Statement issuer '#{@payload["iss"]}' MUST be listed in the authority_hints array of the subject's Entity Configuration"
|
|
179
173
|
end
|
|
180
174
|
end
|
|
181
175
|
|
|
176
|
+
def normalize_entity_configuration_hash(config)
|
|
177
|
+
if config.is_a?(Hash)
|
|
178
|
+
config
|
|
179
|
+
elsif config.respond_to?(:parse)
|
|
180
|
+
config.parse
|
|
181
|
+
else
|
|
182
|
+
raise ValidationError, "Invalid entity configuration format"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def extract_authority_hints_from_config(config)
|
|
187
|
+
claims = config[:claims] || config["claims"]
|
|
188
|
+
if claims.is_a?(Hash)
|
|
189
|
+
hints = claims[:authority_hints] || claims["authority_hints"]
|
|
190
|
+
return hints if hints
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
config[:authority_hints] || config["authority_hints"] || []
|
|
194
|
+
end
|
|
195
|
+
|
|
182
196
|
def validate_iat_claim
|
|
183
197
|
iat = @payload["iat"]
|
|
184
198
|
if iat.nil?
|
|
@@ -60,7 +60,10 @@ module OmniauthOpenidFederation
|
|
|
60
60
|
# @raise [FetchError] If fetching fails
|
|
61
61
|
# @raise [ValidationError] If validation fails
|
|
62
62
|
def self.fetch!(signed_jwks_uri, entity_jwks, cache_key: nil, cache_ttl: nil, force_refresh: false)
|
|
63
|
-
cache_key ||= OmniauthOpenidFederation::Cache.key_for_signed_jwks(
|
|
63
|
+
cache_key ||= OmniauthOpenidFederation::Cache.key_for_signed_jwks(
|
|
64
|
+
signed_jwks_uri,
|
|
65
|
+
entity_jwks: entity_jwks
|
|
66
|
+
)
|
|
64
67
|
config = OmniauthOpenidFederation::Configuration.config
|
|
65
68
|
cache_ttl ||= config.cache_ttl
|
|
66
69
|
rotate_on_errors = config.rotate_on_errors
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "trust_chain_signature"
|
|
2
|
+
|
|
1
3
|
require "cgi"
|
|
2
4
|
require "json"
|
|
3
5
|
require "base64"
|
|
@@ -37,6 +39,8 @@ module OmniauthOpenidFederation
|
|
|
37
39
|
#
|
|
38
40
|
# Resolves and validates trust chains from a Leaf Entity to a Trust Anchor.
|
|
39
41
|
class TrustChainResolver
|
|
42
|
+
include TrustChainSignature
|
|
43
|
+
|
|
40
44
|
# Initialize resolver
|
|
41
45
|
#
|
|
42
46
|
# @param leaf_entity_id [String] Entity Identifier of the Leaf Entity
|
|
@@ -72,7 +76,7 @@ module OmniauthOpenidFederation
|
|
|
72
76
|
current_entity_id = @leaf_entity_id
|
|
73
77
|
current_config = leaf_config
|
|
74
78
|
|
|
75
|
-
while current_config && !
|
|
79
|
+
while current_config && !trust_anchor_for(current_entity_id)
|
|
76
80
|
authority_hints = extract_authority_hints(current_config)
|
|
77
81
|
|
|
78
82
|
if StringHelpers.blank?(authority_hints)
|
|
@@ -89,13 +93,21 @@ module OmniauthOpenidFederation
|
|
|
89
93
|
|
|
90
94
|
begin
|
|
91
95
|
issuer_config = fetch_entity_configuration(authority_id)
|
|
96
|
+
validate_entity_statement(issuer_config, nil)
|
|
97
|
+
|
|
92
98
|
subordinate_statement = fetch_subordinate_statement(
|
|
93
99
|
issuer: authority_id,
|
|
94
100
|
subject: current_entity_id,
|
|
95
101
|
issuer_config: issuer_config
|
|
96
102
|
)
|
|
97
103
|
|
|
98
|
-
validate_entity_statement(
|
|
104
|
+
validate_entity_statement(
|
|
105
|
+
subordinate_statement,
|
|
106
|
+
issuer_config,
|
|
107
|
+
subject_config: current_config,
|
|
108
|
+
expected_issuer: authority_id,
|
|
109
|
+
expected_subject: current_entity_id
|
|
110
|
+
)
|
|
99
111
|
|
|
100
112
|
@resolved_statements << subordinate_statement
|
|
101
113
|
@visited_entities.add(authority_id)
|
|
@@ -104,7 +116,8 @@ module OmniauthOpenidFederation
|
|
|
104
116
|
current_config = issuer_config
|
|
105
117
|
found_next = true
|
|
106
118
|
break
|
|
107
|
-
rescue ValidationError, FetchError => e
|
|
119
|
+
rescue ValidationError, FetchError, SignatureError => e
|
|
120
|
+
@visited_entities.add(authority_id)
|
|
108
121
|
OmniauthOpenidFederation::Logger.warn("[TrustChainResolver] Failed to resolve via #{authority_id}: #{e.message}")
|
|
109
122
|
OmniauthOpenidFederation::Instrumentation.notify_trust_chain_validation_failed(
|
|
110
123
|
entity_id: current_entity_id,
|
|
@@ -122,7 +135,7 @@ module OmniauthOpenidFederation
|
|
|
122
135
|
end
|
|
123
136
|
end
|
|
124
137
|
|
|
125
|
-
unless
|
|
138
|
+
unless trust_anchor_for(current_entity_id)
|
|
126
139
|
error_msg = "Trust chain did not terminate at a configured Trust Anchor"
|
|
127
140
|
OmniauthOpenidFederation::Instrumentation.notify_trust_chain_validation_failed(
|
|
128
141
|
entity_id: @leaf_entity_id,
|
|
@@ -156,12 +169,24 @@ module OmniauthOpenidFederation
|
|
|
156
169
|
|
|
157
170
|
begin
|
|
158
171
|
config = EntityStatement.fetch!(entity_statement_url, timeout: @timeout)
|
|
172
|
+
assert_entity_configuration_ids!(config, entity_id)
|
|
159
173
|
@entity_configurations[entity_id] = config
|
|
160
174
|
rescue OmniauthOpenidFederation::NetworkError => e
|
|
161
175
|
raise FetchError, "Failed to fetch entity configuration from #{entity_statement_url}: #{e.message}"
|
|
162
176
|
end
|
|
163
177
|
end
|
|
164
178
|
|
|
179
|
+
def assert_entity_configuration_ids!(config, expected_entity_id)
|
|
180
|
+
parsed = config.parse
|
|
181
|
+
iss = entity_issuer(parsed)
|
|
182
|
+
sub = field(parsed, :sub)
|
|
183
|
+
|
|
184
|
+
unless iss == expected_entity_id && sub == expected_entity_id
|
|
185
|
+
raise ValidationError,
|
|
186
|
+
"Entity configuration iss/sub must equal requested entity id '#{expected_entity_id}' (iss=#{iss}, sub=#{sub})"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
165
190
|
def fetch_subordinate_statement(issuer:, subject:, issuer_config:)
|
|
166
191
|
fetch_endpoint = extract_fetch_endpoint(issuer_config)
|
|
167
192
|
|
|
@@ -186,10 +211,17 @@ module OmniauthOpenidFederation
|
|
|
186
211
|
end
|
|
187
212
|
|
|
188
213
|
def extract_fetch_endpoint(entity_config)
|
|
189
|
-
# Fetch endpoint is typically at /.well-known/openid-federation/fetch
|
|
190
|
-
# or can be specified in metadata
|
|
191
214
|
parsed = entity_config.parse
|
|
192
|
-
|
|
215
|
+
payload = entity_config.decode_payload
|
|
216
|
+
metadata = field(payload, :metadata) || {}
|
|
217
|
+
federation_entity = field(metadata, :federation_entity) || {}
|
|
218
|
+
openid_provider = field(metadata, :openid_provider) || {}
|
|
219
|
+
|
|
220
|
+
endpoint = field(federation_entity, :federation_fetch_endpoint) || field(openid_provider, :federation_fetch_endpoint)
|
|
221
|
+
|
|
222
|
+
return endpoint if StringHelpers.present?(endpoint)
|
|
223
|
+
|
|
224
|
+
issuer = entity_issuer(parsed)
|
|
193
225
|
return nil unless issuer
|
|
194
226
|
|
|
195
227
|
"#{issuer}/.well-known/openid-federation/fetch"
|
|
@@ -200,58 +232,46 @@ module OmniauthOpenidFederation
|
|
|
200
232
|
parsed[:authority_hints] || parsed["authority_hints"]
|
|
201
233
|
end
|
|
202
234
|
|
|
203
|
-
def validate_entity_statement(statement, issuer_config)
|
|
235
|
+
def validate_entity_statement(statement, issuer_config, subject_config: nil, expected_issuer: nil, expected_subject: nil)
|
|
204
236
|
validator = EntityStatementValidator.new(
|
|
205
237
|
jwt_string: statement.entity_statement,
|
|
206
|
-
issuer_entity_configuration: issuer_config
|
|
238
|
+
issuer_entity_configuration: issuer_config,
|
|
239
|
+
subject_entity_configuration: subject_config
|
|
207
240
|
)
|
|
208
241
|
validator.validate!
|
|
209
|
-
verify_entity_statement_signature!(statement, issuer_config)
|
|
210
|
-
end
|
|
211
242
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
raise ValidationError, "Invalid JWT format: expected #{EntityStatementValidator::JWT_PARTS_COUNT} parts, got #{parts.length}"
|
|
217
|
-
end
|
|
243
|
+
if expected_issuer || expected_subject
|
|
244
|
+
parsed = statement.parse
|
|
245
|
+
iss = entity_issuer(parsed)
|
|
246
|
+
sub = parsed[:sub] || parsed["sub"]
|
|
218
247
|
|
|
219
|
-
|
|
220
|
-
|
|
248
|
+
if expected_issuer && iss != expected_issuer
|
|
249
|
+
raise ValidationError, "Subordinate statement iss '#{iss}' does not match expected issuer '#{expected_issuer}'"
|
|
250
|
+
end
|
|
251
|
+
if expected_subject && sub != expected_subject
|
|
252
|
+
raise ValidationError, "Subordinate statement sub '#{sub}' does not match expected subject '#{expected_subject}'"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
221
255
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
signing_key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
|
|
256
|
+
verify_entity_statement_signature!(statement, issuer_config)
|
|
257
|
+
end
|
|
225
258
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
259
|
+
def normalize_jwks(jwks)
|
|
260
|
+
return {} if jwks.nil?
|
|
261
|
+
return jwks if jwks.is_a?(Hash)
|
|
262
|
+
return {"keys" => jwks} if jwks.is_a?(Array)
|
|
229
263
|
|
|
230
|
-
|
|
231
|
-
::JWT.decode(jwt_string, public_key, true, {algorithm: "RS256"})
|
|
232
|
-
rescue ::JWT::DecodeError, ::JWT::VerificationError => e
|
|
233
|
-
error_msg = "Entity statement signature validation failed: #{e.class} - #{e.message}"
|
|
234
|
-
OmniauthOpenidFederation::Logger.error("[TrustChainResolver] #{error_msg}")
|
|
235
|
-
raise SignatureError, error_msg, e.backtrace
|
|
264
|
+
{}
|
|
236
265
|
end
|
|
237
266
|
|
|
238
|
-
def
|
|
239
|
-
|
|
240
|
-
parsed = statement.parse
|
|
241
|
-
parsed[:jwks] || parsed["jwks"] || {}
|
|
242
|
-
else
|
|
243
|
-
issuer_parsed = issuer_config.parse
|
|
244
|
-
issuer_parsed[:jwks] || issuer_parsed["jwks"] || {}
|
|
245
|
-
end
|
|
267
|
+
def trust_anchor_for(entity_id)
|
|
268
|
+
@trust_anchors.find { |ta| ta[:entity_id] == entity_id }
|
|
246
269
|
end
|
|
247
270
|
|
|
248
271
|
def is_trust_anchor?(entity_config)
|
|
249
272
|
parsed = entity_config.parse
|
|
250
|
-
entity_id = parsed
|
|
251
|
-
|
|
252
|
-
@trust_anchors.any? do |ta|
|
|
253
|
-
ta[:entity_id] == entity_id
|
|
254
|
-
end
|
|
273
|
+
entity_id = entity_issuer(parsed)
|
|
274
|
+
!trust_anchor_for(entity_id).nil?
|
|
255
275
|
end
|
|
256
276
|
end
|
|
257
277
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module OmniauthOpenidFederation
|
|
2
|
+
module Federation
|
|
3
|
+
module TrustChainSignature
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def verify_entity_statement_signature!(statement, issuer_config)
|
|
7
|
+
jwt_string = statement.entity_statement
|
|
8
|
+
kid, algorithm = signature_header(jwt_string)
|
|
9
|
+
public_key = signing_public_key(statement, issuer_config, kid)
|
|
10
|
+
::JWT.decode(jwt_string, public_key, true, {algorithm: algorithm})
|
|
11
|
+
rescue ::JWT::DecodeError, ::JWT::VerificationError => e
|
|
12
|
+
error_msg = "Entity statement signature validation failed: #{e.class} - #{e.message}"
|
|
13
|
+
OmniauthOpenidFederation::Logger.error("[TrustChainResolver] #{error_msg}")
|
|
14
|
+
raise SignatureError, error_msg, e.backtrace
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def signature_header(jwt_string)
|
|
18
|
+
parts = jwt_string.split(".")
|
|
19
|
+
if parts.length != EntityStatementValidator::JWT_PARTS_COUNT
|
|
20
|
+
raise ValidationError, "Invalid JWT format: expected #{EntityStatementValidator::JWT_PARTS_COUNT} parts, got #{parts.length}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
|
|
24
|
+
kid = field(header, :kid)
|
|
25
|
+
algorithm = field(header, :alg) || "RS256"
|
|
26
|
+
|
|
27
|
+
unless EntityStatementValidator::SUPPORTED_ALGORITHMS.include?(algorithm)
|
|
28
|
+
raise ValidationError, "Unsupported entity statement algorithm: #{algorithm}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
[kid, algorithm]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def signing_public_key(statement, issuer_config, kid)
|
|
35
|
+
signing_keys = issuer_signing_keys(statement, issuer_config)
|
|
36
|
+
keys = field(signing_keys, :keys) || []
|
|
37
|
+
signing_key_data = keys.find { |key| field(key, :kid) == kid }
|
|
38
|
+
unless signing_key_data
|
|
39
|
+
raise ValidationError, "Signing key with kid '#{kid}' not found in issuer JWKS for signature verification"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def issuer_signing_keys(statement, issuer_config)
|
|
46
|
+
parsed = (issuer_config || statement).parse
|
|
47
|
+
trust_anchor = trust_anchor_for(entity_issuer(parsed))
|
|
48
|
+
return normalize_jwks(trust_anchor[:jwks]) if trust_anchor
|
|
49
|
+
|
|
50
|
+
field(parsed, :jwks) || {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def entity_issuer(parsed)
|
|
54
|
+
parsed[:issuer] || parsed[:iss] || parsed["issuer"] || parsed["iss"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def field(hash, key)
|
|
58
|
+
hash[key] || hash[key.to_s]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -62,12 +62,49 @@ module OmniauthOpenidFederation
|
|
|
62
62
|
next
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
return response
|
|
65
|
+
return enforce_body_limit!(response)
|
|
66
66
|
rescue *RETRYABLE_ERRORS => error
|
|
67
67
|
retries = handle_network_error(method, uri, error, retries, max_retries, max_attempts, retry_delay)
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
def self.enforce_body_limit!(response)
|
|
72
|
+
max_bytes = Constants::MAX_HTTP_RESPONSE_BODY_BYTES
|
|
73
|
+
content_length = response.headers&.[]("Content-Length") || response.headers&.[]("content-length")
|
|
74
|
+
if content_length && content_length.to_i > max_bytes
|
|
75
|
+
raise OmniauthOpenidFederation::NetworkError,
|
|
76
|
+
"HTTP response Content-Length exceeds limit (#{max_bytes} bytes)"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
response = buffer_streamed_response(response, max_bytes) if response.body.is_a?(HTTP::Response::Body)
|
|
80
|
+
body = response.body
|
|
81
|
+
size = if body.respond_to?(:bytesize)
|
|
82
|
+
body.bytesize
|
|
83
|
+
else
|
|
84
|
+
body.to_s.bytesize
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if size > max_bytes
|
|
88
|
+
raise OmniauthOpenidFederation::NetworkError,
|
|
89
|
+
"HTTP response body exceeds limit (#{max_bytes} bytes)"
|
|
90
|
+
end
|
|
91
|
+
response
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.buffer_streamed_response(response, max_bytes)
|
|
95
|
+
contents = String.new(encoding: response.body.encoding)
|
|
96
|
+
response.body.each do |chunk|
|
|
97
|
+
if contents.bytesize + chunk.bytesize > max_bytes
|
|
98
|
+
response.connection.close
|
|
99
|
+
raise OmniauthOpenidFederation::NetworkError, "HTTP response body exceeds limit (#{max_bytes} bytes)"
|
|
100
|
+
end
|
|
101
|
+
contents << chunk
|
|
102
|
+
end
|
|
103
|
+
HTTP::Response.new(status: response.status.code, version: response.version,
|
|
104
|
+
headers: response.headers, proxy_headers: response.proxy_headers,
|
|
105
|
+
request: response.request, body: contents)
|
|
106
|
+
end
|
|
107
|
+
|
|
71
108
|
def self.perform_request(http_client, headers, method, uri, form)
|
|
72
109
|
client = http_client
|
|
73
110
|
client = client.headers(headers) unless headers.empty?
|
|
@@ -178,6 +215,6 @@ module OmniauthOpenidFederation
|
|
|
178
215
|
|
|
179
216
|
private_class_method :request, :perform_request, :should_retry_for_status?, :retry_after_status,
|
|
180
217
|
:handle_network_error, :build_http_client, :build_http_options_hash, :ssl_verify_mode, :ssl_ca_file,
|
|
181
|
-
:max_retries_for, :resolve_timeout, :retryable_http_status?, :retry_delay_for
|
|
218
|
+
:max_retries_for, :resolve_timeout, :retryable_http_status?, :retry_delay_for, :enforce_body_limit!, :buffer_streamed_response
|
|
182
219
|
end
|
|
183
220
|
end
|
|
@@ -176,12 +176,10 @@ module OmniauthOpenidFederation
|
|
|
176
176
|
return nil if OmniauthOpenidFederation::StringHelpers.blank?(signed_jwks_uri)
|
|
177
177
|
|
|
178
178
|
OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(signed_jwks_uri, parsed[:entity_jwks])
|
|
179
|
-
rescue OmniauthOpenidFederation::SecurityError
|
|
180
|
-
OmniauthOpenidFederation::
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
OmniauthOpenidFederation::Logger.warn("[JwtResponseDecoder] Failed to fetch signed JWKS, falling back to standard JWKS")
|
|
184
|
-
nil
|
|
179
|
+
rescue OmniauthOpenidFederation::SecurityError, OmniauthOpenidFederation::ValidationError,
|
|
180
|
+
OmniauthOpenidFederation::FetchError => error
|
|
181
|
+
OmniauthOpenidFederation::Logger.error("[JwtResponseDecoder] Signed JWKS failure (fail closed): #{error.message}")
|
|
182
|
+
raise
|
|
185
183
|
end
|
|
186
184
|
|
|
187
185
|
def load_entity_statement_keys_for_jwks_validation(strategy_options)
|
|
@@ -12,30 +12,13 @@ module OmniauthOpenidFederation
|
|
|
12
12
|
code_param = code_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(code_param_raw) : nil
|
|
13
13
|
error_param = error_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(error_param_raw) : nil
|
|
14
14
|
error_description_param = error_description_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(error_description_raw) : nil
|
|
15
|
-
if error_param
|
|
16
|
-
error_msg = "Authorization error: #{error_param}"
|
|
17
|
-
error_msg += " - #{error_description_param}" if error_description_param
|
|
18
|
-
OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
|
|
19
|
-
stage: "callback_phase",
|
|
20
|
-
error_message: error_msg,
|
|
21
|
-
error_class: "AuthorizationError",
|
|
22
|
-
request_info: {
|
|
23
|
-
remote_ip: request.env["REMOTE_ADDR"],
|
|
24
|
-
user_agent: request.env["HTTP_USER_AGENT"],
|
|
25
|
-
path: request.path
|
|
26
|
-
}
|
|
27
|
-
)
|
|
28
|
-
env["omniauth_openid_federation.instrumented"] = true
|
|
29
|
-
return fail!(:authorization_error, OmniauthOpenidFederation::ValidationError.new(error_msg))
|
|
30
|
-
end
|
|
31
15
|
|
|
32
|
-
# CSRF protection: constant-time state comparison
|
|
16
|
+
# CSRF protection: constant-time state comparison before any OAuth outcome
|
|
33
17
|
state_session = session["omniauth.state"]
|
|
34
18
|
|
|
35
19
|
if OmniauthOpenidFederation::StringHelpers.blank?(state_param) ||
|
|
36
20
|
state_session.nil? ||
|
|
37
21
|
!OmniauthOpenidFederation::SecureCompare.secure_compare(state_param.to_s, state_session.to_s)
|
|
38
|
-
# Instrument CSRF detection
|
|
39
22
|
OmniauthOpenidFederation::Instrumentation.notify_csrf_detected(
|
|
40
23
|
state_param: state_param ? "[PRESENT]" : "[MISSING]",
|
|
41
24
|
state_session: state_session ? "[PRESENT]" : "[MISSING]",
|
|
@@ -45,14 +28,29 @@ module OmniauthOpenidFederation
|
|
|
45
28
|
path: request.path
|
|
46
29
|
}
|
|
47
30
|
)
|
|
48
|
-
# Mark as instrumented to prevent double instrumentation in fail!
|
|
49
31
|
env["omniauth_openid_federation.instrumented"] = true
|
|
50
32
|
return fail!(:csrf_detected, OmniauthOpenidFederation::SecurityError.new("CSRF detected"))
|
|
51
33
|
end
|
|
52
34
|
|
|
53
|
-
# Clear state from session
|
|
54
35
|
session.delete("omniauth.state")
|
|
55
36
|
|
|
37
|
+
if error_param
|
|
38
|
+
error_msg = "Authorization error: #{error_param}"
|
|
39
|
+
error_msg += " - #{error_description_param}" if error_description_param
|
|
40
|
+
OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
|
|
41
|
+
stage: "callback_phase",
|
|
42
|
+
error_message: error_msg,
|
|
43
|
+
error_class: "AuthorizationError",
|
|
44
|
+
request_info: {
|
|
45
|
+
remote_ip: request.env["REMOTE_ADDR"],
|
|
46
|
+
user_agent: request.env["HTTP_USER_AGENT"],
|
|
47
|
+
path: request.path
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
env["omniauth_openid_federation.instrumented"] = true
|
|
51
|
+
return fail!(:authorization_error, OmniauthOpenidFederation::ValidationError.new(error_msg))
|
|
52
|
+
end
|
|
53
|
+
|
|
56
54
|
if OmniauthOpenidFederation::StringHelpers.blank?(code_param)
|
|
57
55
|
# Instrument unexpected authentication break
|
|
58
56
|
OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
|
|
@@ -9,7 +9,12 @@ module OmniauthOpenidFederation
|
|
|
9
9
|
return load_client_entity_statement_from_file(entity_statement_path)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
# Priority 2:
|
|
12
|
+
# Priority 2: Fetch from configured URL
|
|
13
|
+
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
|
|
14
|
+
return load_client_entity_statement_from_url(entity_statement_url)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Priority 3: Check cache (if Rails.cache is available)
|
|
13
18
|
# This respects background job cache refresh and key rotation
|
|
14
19
|
if defined?(Rails) && Rails.cache
|
|
15
20
|
cache_key = "federation:entity_statement"
|
|
@@ -22,14 +22,15 @@ module OmniauthOpenidFederation
|
|
|
22
22
|
|
|
23
23
|
begin
|
|
24
24
|
# Resolve endpoints from entity statement
|
|
25
|
-
# Use temporary file if we have content but no path
|
|
25
|
+
# Use temporary file if we have content but no readable configured path
|
|
26
|
+
created_temp_entity_statement = false
|
|
26
27
|
entity_statement_path = if options.entity_statement_path && File.exist?(resolve_entity_statement_path(options.entity_statement_path))
|
|
27
28
|
resolve_entity_statement_path(options.entity_statement_path)
|
|
28
29
|
else
|
|
29
|
-
# Create temporary file for EndpointResolver
|
|
30
30
|
temp_file = Tempfile.new(["entity_statement", ".jwt"])
|
|
31
31
|
temp_file.write(entity_statement_content)
|
|
32
32
|
temp_file.close
|
|
33
|
+
created_temp_entity_statement = true
|
|
33
34
|
temp_file.path
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -38,8 +39,7 @@ module OmniauthOpenidFederation
|
|
|
38
39
|
config: {} # Don't pass client_options here - we want entity statement values
|
|
39
40
|
)
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
if entity_statement_path.start_with?(Dir.tmpdir)
|
|
42
|
+
if created_temp_entity_statement
|
|
43
43
|
begin
|
|
44
44
|
File.unlink(entity_statement_path)
|
|
45
45
|
rescue
|
|
@@ -175,13 +175,14 @@ module OmniauthOpenidFederation
|
|
|
175
175
|
|
|
176
176
|
if entity_statement_content
|
|
177
177
|
begin
|
|
178
|
-
|
|
178
|
+
created_temp_entity_statement = false
|
|
179
179
|
entity_statement_path = if options.entity_statement_path && File.exist?(resolve_entity_statement_path(options.entity_statement_path))
|
|
180
180
|
resolve_entity_statement_path(options.entity_statement_path)
|
|
181
181
|
else
|
|
182
182
|
temp_file = Tempfile.new(["entity_statement", ".jwt"])
|
|
183
183
|
temp_file.write(entity_statement_content)
|
|
184
184
|
temp_file.close
|
|
185
|
+
created_temp_entity_statement = true
|
|
185
186
|
temp_file.path
|
|
186
187
|
end
|
|
187
188
|
|
|
@@ -193,8 +194,7 @@ module OmniauthOpenidFederation
|
|
|
193
194
|
|
|
194
195
|
if resolved[:audience] && OmniauthOpenidFederation::StringHelpers.present?(resolved[:audience])
|
|
195
196
|
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved audience from entity statement: #{resolved[:audience]}")
|
|
196
|
-
|
|
197
|
-
if entity_statement_path.start_with?(Dir.tmpdir)
|
|
197
|
+
if created_temp_entity_statement
|
|
198
198
|
begin
|
|
199
199
|
File.unlink(entity_statement_path)
|
|
200
200
|
rescue
|
|
@@ -217,8 +217,7 @@ module OmniauthOpenidFederation
|
|
|
217
217
|
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get entity issuer from entity statement: #{e.message}")
|
|
218
218
|
end
|
|
219
219
|
|
|
220
|
-
|
|
221
|
-
if entity_statement_path.start_with?(Dir.tmpdir)
|
|
220
|
+
if created_temp_entity_statement
|
|
222
221
|
begin
|
|
223
222
|
File.unlink(entity_statement_path)
|
|
224
223
|
rescue
|
|
@@ -116,49 +116,43 @@ module OmniauthOpenidFederation
|
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
# 2. Try signed JWKS (if entity statement has signed_jwks_uri)
|
|
120
|
-
#
|
|
119
|
+
# 2. Try signed JWKS (if entity statement has signed_jwks_uri).
|
|
120
|
+
# When signed_jwks_uri is advertised, signature/validation failures fail closed
|
|
121
|
+
# instead of falling back to an unsigned jwks_uri.
|
|
121
122
|
if entity_statement_content
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
|
|
124
|
+
entity_statement_content
|
|
125
|
+
)
|
|
126
|
+
if parsed && parsed[:signed_jwks_uri] && parsed[:entity_jwks]
|
|
127
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching signed JWKS for ID token validation (kid: #{kid})")
|
|
128
|
+
signed_jwks = OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(
|
|
129
|
+
parsed[:signed_jwks_uri],
|
|
130
|
+
parsed[:entity_jwks]
|
|
125
131
|
)
|
|
126
|
-
if parsed && parsed[:signed_jwks_uri] && parsed[:entity_jwks]
|
|
127
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching signed JWKS for ID token validation (kid: #{kid})")
|
|
128
|
-
signed_jwks = OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(
|
|
129
|
-
parsed[:signed_jwks_uri],
|
|
130
|
-
parsed[:entity_jwks]
|
|
131
|
-
)
|
|
132
|
-
# Ensure it's in the format expected by JWT.decode
|
|
133
|
-
jwks_hash = if signed_jwks.is_a?(Hash) && signed_jwks.key?("keys")
|
|
134
|
-
signed_jwks
|
|
135
|
-
elsif signed_jwks.is_a?(Hash) && signed_jwks.key?(:keys)
|
|
136
|
-
{"keys" => signed_jwks[:keys]}
|
|
137
|
-
elsif signed_jwks.is_a?(Array)
|
|
138
|
-
{"keys" => signed_jwks}
|
|
139
|
-
end
|
|
140
132
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
133
|
+
jwks_hash = if signed_jwks.is_a?(Hash) && signed_jwks.key?("keys")
|
|
134
|
+
signed_jwks
|
|
135
|
+
elsif signed_jwks.is_a?(Hash) && signed_jwks.key?(:keys)
|
|
136
|
+
{"keys" => signed_jwks[:keys]}
|
|
137
|
+
elsif signed_jwks.is_a?(Array)
|
|
138
|
+
{"keys" => signed_jwks}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
keys = jwks_hash&.dig("keys")
|
|
142
|
+
if keys&.is_a?(Array) && !keys.empty?
|
|
143
|
+
first_valid_jwks ||= jwks_hash
|
|
144
|
+
if kid.nil?
|
|
145
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Kid is nil, returning signed JWKS for validation attempt")
|
|
146
|
+
return jwks_hash
|
|
147
|
+
end
|
|
148
|
+
key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
|
|
149
|
+
if key_data
|
|
150
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Found kid '#{kid}' in signed JWKS")
|
|
151
|
+
return jwks_hash
|
|
152
|
+
else
|
|
153
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in signed JWKS, trying standard JWKS URI")
|
|
158
154
|
end
|
|
159
155
|
end
|
|
160
|
-
rescue => e
|
|
161
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not fetch signed JWKS: #{e.message}")
|
|
162
156
|
end
|
|
163
157
|
end
|
|
164
158
|
|
|
@@ -20,9 +20,10 @@ module OmniauthOpenidFederation
|
|
|
20
20
|
if File.exist?(path)
|
|
21
21
|
OmniauthOpenidFederation::Logger.debug("[Strategy] Loading provider entity statement from file: #{path}")
|
|
22
22
|
return File.read(path).strip
|
|
23
|
-
else
|
|
24
|
-
OmniauthOpenidFederation::Logger.warn("[Strategy] Provider entity statement file not found: #{path}, will try to fetch from URL")
|
|
25
23
|
end
|
|
24
|
+
|
|
25
|
+
raise OmniauthOpenidFederation::ConfigurationError,
|
|
26
|
+
"Provider entity statement file not found: #{path}"
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# Priority 2: Fetch from URL if provided
|
|
@@ -1,8 +1,40 @@
|
|
|
1
|
+
require_relative "../secure_compare"
|
|
2
|
+
require_relative "../string_helpers"
|
|
3
|
+
|
|
1
4
|
module OmniauthOpenidFederation
|
|
2
5
|
module Strategy
|
|
3
6
|
module UserinfoDecoding
|
|
7
|
+
IDENTITY_CLAIM_KEYS = %w[iss sub aud exp iat].freeze
|
|
8
|
+
|
|
4
9
|
private
|
|
5
10
|
|
|
11
|
+
def merge_userinfo_with_id_token_claims(id_token_claims, userinfo_hash)
|
|
12
|
+
normalized_id = stringify_claim_keys(id_token_claims)
|
|
13
|
+
normalized_userinfo = stringify_claim_keys(userinfo_hash)
|
|
14
|
+
|
|
15
|
+
id_sub = normalized_id["sub"]
|
|
16
|
+
userinfo_sub = normalized_userinfo["sub"]
|
|
17
|
+
|
|
18
|
+
if OmniauthOpenidFederation::StringHelpers.blank?(userinfo_sub) ||
|
|
19
|
+
OmniauthOpenidFederation::StringHelpers.blank?(id_sub) ||
|
|
20
|
+
!OmniauthOpenidFederation::SecureCompare.secure_compare(userinfo_sub.to_s, id_sub.to_s)
|
|
21
|
+
raise OmniauthOpenidFederation::ValidationError,
|
|
22
|
+
"UserInfo sub must match ID token sub"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
merged = normalized_id.merge(normalized_userinfo)
|
|
26
|
+
IDENTITY_CLAIM_KEYS.each do |claim|
|
|
27
|
+
merged[claim] = normalized_id[claim] if normalized_id.key?(claim)
|
|
28
|
+
end
|
|
29
|
+
merged
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stringify_claim_keys(claims)
|
|
33
|
+
(claims || {}).each_with_object({}) do |(key, value), hash|
|
|
34
|
+
hash[key.to_s] = value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
6
38
|
def decode_userinfo(userinfo)
|
|
7
39
|
if userinfo.is_a?(String)
|
|
8
40
|
if encrypted_token?(userinfo)
|
|
@@ -162,7 +162,9 @@ module OmniAuth
|
|
|
162
162
|
begin
|
|
163
163
|
userinfo = access_token.userinfo!
|
|
164
164
|
userinfo_hash = decode_userinfo(userinfo)
|
|
165
|
-
id_token_claims
|
|
165
|
+
merge_userinfo_with_id_token_claims(id_token_claims, userinfo_hash)
|
|
166
|
+
rescue OmniauthOpenidFederation::ValidationError
|
|
167
|
+
raise
|
|
166
168
|
rescue => e
|
|
167
169
|
error_msg = "Failed to fetch or decode userinfo: #{e.class} - #{e.message}"
|
|
168
170
|
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
@@ -95,7 +95,7 @@ module OmniauthOpenidFederation
|
|
|
95
95
|
if allowed_dirs && !allowed_dirs.empty?
|
|
96
96
|
allowed = allowed_dirs.any? do |dir|
|
|
97
97
|
expanded_dir = File.expand_path(dir)
|
|
98
|
-
resolved.start_with?(expanded_dir)
|
|
98
|
+
resolved == expanded_dir || resolved.start_with?(expanded_dir + File::SEPARATOR)
|
|
99
99
|
end
|
|
100
100
|
unless allowed
|
|
101
101
|
raise SecurityError, "File path outside allowed directories: #{sanitize_path(path)}"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: omniauth_openid_federation
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Makarov
|
|
@@ -99,14 +99,28 @@ dependencies:
|
|
|
99
99
|
requirements:
|
|
100
100
|
- - ">="
|
|
101
101
|
- !ruby/object:Gem::Version
|
|
102
|
-
version: 2.
|
|
102
|
+
version: 2.2.0
|
|
103
103
|
type: :development
|
|
104
104
|
prerelease: false
|
|
105
105
|
version_requirements: !ruby/object:Gem::Requirement
|
|
106
106
|
requirements:
|
|
107
107
|
- - ">="
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
|
-
version: 2.
|
|
109
|
+
version: 2.2.0
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: prosopite
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '2.0'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '2.0'
|
|
110
124
|
- !ruby/object:Gem::Dependency
|
|
111
125
|
name: webmock
|
|
112
126
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -301,14 +315,14 @@ dependencies:
|
|
|
301
315
|
requirements:
|
|
302
316
|
- - "~>"
|
|
303
317
|
- !ruby/object:Gem::Version
|
|
304
|
-
version: '
|
|
318
|
+
version: '4.0'
|
|
305
319
|
type: :development
|
|
306
320
|
prerelease: false
|
|
307
321
|
version_requirements: !ruby/object:Gem::Requirement
|
|
308
322
|
requirements:
|
|
309
323
|
- - "~>"
|
|
310
324
|
- !ruby/object:Gem::Version
|
|
311
|
-
version: '
|
|
325
|
+
version: '4.0'
|
|
312
326
|
- !ruby/object:Gem::Dependency
|
|
313
327
|
name: webrick
|
|
314
328
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -490,6 +504,7 @@ files:
|
|
|
490
504
|
- lib/omniauth_openid_federation/federation/metadata_policy_merger.rb
|
|
491
505
|
- lib/omniauth_openid_federation/federation/signed_jwks.rb
|
|
492
506
|
- lib/omniauth_openid_federation/federation/trust_chain_resolver.rb
|
|
507
|
+
- lib/omniauth_openid_federation/federation/trust_chain_signature.rb
|
|
493
508
|
- lib/omniauth_openid_federation/federation_endpoint.rb
|
|
494
509
|
- lib/omniauth_openid_federation/http_client.rb
|
|
495
510
|
- lib/omniauth_openid_federation/http_errors.rb
|
|
@@ -556,14 +571,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
556
571
|
requirements:
|
|
557
572
|
- - ">="
|
|
558
573
|
- !ruby/object:Gem::Version
|
|
559
|
-
version: '3.
|
|
574
|
+
version: '3.4'
|
|
560
575
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
561
576
|
requirements:
|
|
562
577
|
- - ">="
|
|
563
578
|
- !ruby/object:Gem::Version
|
|
564
579
|
version: 3.3.0
|
|
565
580
|
requirements: []
|
|
566
|
-
rubygems_version: 4.0.
|
|
581
|
+
rubygems_version: 4.0.6
|
|
567
582
|
specification_version: 4
|
|
568
583
|
summary: OmniAuth strategy for OpenID Federation providers with signed request objects
|
|
569
584
|
and ID token encryption.
|