omniauth_openid_federation 1.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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.md +22 -0
- data/README.md +822 -0
- data/SECURITY.md +129 -0
- data/examples/README_INTEGRATION_TESTING.md +399 -0
- data/examples/README_MOCK_OP.md +243 -0
- data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
- data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
- data/examples/app/models/user.rb.example +39 -0
- data/examples/config/initializers/devise.rb.example +97 -0
- data/examples/config/initializers/federation_endpoint.rb.example +206 -0
- data/examples/config/mock_op.yml.example +83 -0
- data/examples/config/open_id_connect_config.rb.example +210 -0
- data/examples/config/routes.rb.example +12 -0
- data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
- data/examples/integration_test_flow.rb +1334 -0
- data/examples/jobs/README.md +194 -0
- data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
- data/examples/jobs/federation_files_generation_job.rb.example +87 -0
- data/examples/mock_op_server.rb +775 -0
- data/examples/mock_rp_server.rb +435 -0
- data/lib/omniauth_openid_federation/access_token.rb +504 -0
- data/lib/omniauth_openid_federation/cache.rb +39 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
- data/lib/omniauth_openid_federation/configuration.rb +135 -0
- data/lib/omniauth_openid_federation/constants.rb +13 -0
- data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
- data/lib/omniauth_openid_federation/errors.rb +52 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
- data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
- data/lib/omniauth_openid_federation/http_client.rb +70 -0
- data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
- data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
- data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
- data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
- data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
- data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
- data/lib/omniauth_openid_federation/jws.rb +416 -0
- data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
- data/lib/omniauth_openid_federation/logger.rb +99 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
- data/lib/omniauth_openid_federation/railtie.rb +29 -0
- data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
- data/lib/omniauth_openid_federation/strategy.rb +2029 -0
- data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
- data/lib/omniauth_openid_federation/utils.rb +166 -0
- data/lib/omniauth_openid_federation/validators.rb +126 -0
- data/lib/omniauth_openid_federation/version.rb +3 -0
- data/lib/omniauth_openid_federation.rb +98 -0
- data/lib/tasks/omniauth_openid_federation.rake +376 -0
- data/sig/federation.rbs +218 -0
- data/sig/jwks.rbs +63 -0
- data/sig/omniauth_openid_federation.rbs +254 -0
- data/sig/strategy.rbs +60 -0
- metadata +352 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
require_relative "entity_statement"
|
|
2
|
+
require_relative "entity_statement_validator"
|
|
3
|
+
require_relative "../http_client"
|
|
4
|
+
require_relative "../logger"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../utils"
|
|
7
|
+
require "set"
|
|
8
|
+
require "cgi"
|
|
9
|
+
|
|
10
|
+
# Trust Chain Resolver for OpenID Federation 1.0
|
|
11
|
+
# @see https://openid.net/specs/openid-federation-1_0.html#section-10 Section 10: Trust Chain Resolution
|
|
12
|
+
#
|
|
13
|
+
# Resolves trust chains from a Leaf Entity up to a Trust Anchor by:
|
|
14
|
+
# 1. Fetching the Leaf Entity's Entity Configuration
|
|
15
|
+
# 2. Following authority_hints to fetch Subordinate Statements
|
|
16
|
+
# 3. Validating each statement in the chain
|
|
17
|
+
# 4. Continuing until a Trust Anchor is reached
|
|
18
|
+
#
|
|
19
|
+
# @example Resolve a trust chain
|
|
20
|
+
# resolver = TrustChainResolver.new(
|
|
21
|
+
# leaf_entity_id: "https://rp.example.com",
|
|
22
|
+
# trust_anchors: [
|
|
23
|
+
# {
|
|
24
|
+
# entity_id: "https://ta.example.com",
|
|
25
|
+
# jwks: trust_anchor_jwks
|
|
26
|
+
# }
|
|
27
|
+
# ]
|
|
28
|
+
# )
|
|
29
|
+
# trust_chain = resolver.resolve!
|
|
30
|
+
module OmniauthOpenidFederation
|
|
31
|
+
module Federation
|
|
32
|
+
# Trust Chain Resolver for OpenID Federation 1.0
|
|
33
|
+
#
|
|
34
|
+
# Resolves and validates trust chains from a Leaf Entity to a Trust Anchor.
|
|
35
|
+
class TrustChainResolver
|
|
36
|
+
# Initialize resolver
|
|
37
|
+
#
|
|
38
|
+
# @param leaf_entity_id [String] Entity Identifier of the Leaf Entity
|
|
39
|
+
# @param trust_anchors [Array<Hash>] Array of Trust Anchor configurations
|
|
40
|
+
# Each hash must have:
|
|
41
|
+
# - :entity_id or "entity_id" - Trust Anchor Entity Identifier
|
|
42
|
+
# - :jwks or "jwks" - Trust Anchor JWKS for validation
|
|
43
|
+
# @param max_chain_length [Integer] Maximum chain length to prevent infinite loops (default: 10)
|
|
44
|
+
# @param timeout [Integer] HTTP request timeout in seconds (default: 10)
|
|
45
|
+
def initialize(leaf_entity_id:, trust_anchors:, max_chain_length: 10, timeout: 10)
|
|
46
|
+
@leaf_entity_id = leaf_entity_id
|
|
47
|
+
@trust_anchors = normalize_trust_anchors(trust_anchors)
|
|
48
|
+
@max_chain_length = max_chain_length
|
|
49
|
+
@timeout = timeout
|
|
50
|
+
@resolved_statements = []
|
|
51
|
+
@visited_entities = Set.new
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve the trust chain
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<Hash>] Array of validated entity statements in order (Leaf to Trust Anchor)
|
|
57
|
+
# @raise [ValidationError] If trust chain resolution fails
|
|
58
|
+
# @raise [FetchError] If fetching entity statements fails
|
|
59
|
+
def resolve!
|
|
60
|
+
OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Starting trust chain resolution for: #{@leaf_entity_id}")
|
|
61
|
+
|
|
62
|
+
# Step 1: Fetch Leaf Entity's Entity Configuration
|
|
63
|
+
leaf_config = fetch_entity_configuration(@leaf_entity_id)
|
|
64
|
+
validate_entity_statement(leaf_config, nil) # No issuer for Entity Configuration
|
|
65
|
+
@resolved_statements << leaf_config
|
|
66
|
+
@visited_entities.add(@leaf_entity_id)
|
|
67
|
+
|
|
68
|
+
# Step 2: Follow authority_hints to build the chain
|
|
69
|
+
current_entity_id = @leaf_entity_id
|
|
70
|
+
current_config = leaf_config
|
|
71
|
+
|
|
72
|
+
while current_config && !is_trust_anchor?(current_config)
|
|
73
|
+
authority_hints = extract_authority_hints(current_config)
|
|
74
|
+
|
|
75
|
+
if authority_hints.nil? || authority_hints.empty?
|
|
76
|
+
raise ValidationError, "Entity #{current_entity_id} has no authority_hints and is not a Trust Anchor"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Try each authority hint until we find a valid chain
|
|
80
|
+
found_next = false
|
|
81
|
+
authority_hints.each do |authority_id|
|
|
82
|
+
next if @visited_entities.include?(authority_id)
|
|
83
|
+
|
|
84
|
+
if @resolved_statements.length >= @max_chain_length
|
|
85
|
+
raise ValidationError, "Trust chain length exceeds maximum (#{@max_chain_length})"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
# Fetch Subordinate Statement from authority
|
|
90
|
+
subordinate_statement = fetch_subordinate_statement(
|
|
91
|
+
issuer: authority_id,
|
|
92
|
+
subject: current_entity_id
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Validate Subordinate Statement
|
|
96
|
+
issuer_config = fetch_entity_configuration(authority_id)
|
|
97
|
+
validate_entity_statement(subordinate_statement, issuer_config)
|
|
98
|
+
|
|
99
|
+
# Add to chain
|
|
100
|
+
@resolved_statements << subordinate_statement
|
|
101
|
+
@visited_entities.add(authority_id)
|
|
102
|
+
|
|
103
|
+
# Continue with issuer as next entity
|
|
104
|
+
current_entity_id = authority_id
|
|
105
|
+
current_config = issuer_config
|
|
106
|
+
found_next = true
|
|
107
|
+
break
|
|
108
|
+
rescue ValidationError, FetchError => e
|
|
109
|
+
OmniauthOpenidFederation::Logger.warn("[TrustChainResolver] Failed to resolve via #{authority_id}: #{e.message}")
|
|
110
|
+
# Instrument trust chain validation failure
|
|
111
|
+
OmniauthOpenidFederation::Instrumentation.notify_trust_chain_validation_failed(
|
|
112
|
+
entity_id: current_entity_id,
|
|
113
|
+
trust_anchor: authority_id,
|
|
114
|
+
validation_step: "subordinate_statement_validation",
|
|
115
|
+
error_message: e.message,
|
|
116
|
+
error_class: e.class.name
|
|
117
|
+
)
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
unless found_next
|
|
123
|
+
raise ValidationError, "Could not resolve trust chain from #{current_entity_id}: no valid authority found"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Step 3: Verify we reached a Trust Anchor
|
|
128
|
+
unless is_trust_anchor?(current_config)
|
|
129
|
+
error_msg = "Trust chain did not terminate at a configured Trust Anchor"
|
|
130
|
+
# Instrument trust chain validation failure
|
|
131
|
+
OmniauthOpenidFederation::Instrumentation.notify_trust_chain_validation_failed(
|
|
132
|
+
entity_id: @leaf_entity_id,
|
|
133
|
+
trust_anchor: current_entity_id,
|
|
134
|
+
validation_step: "trust_anchor_verification",
|
|
135
|
+
error_message: error_msg
|
|
136
|
+
)
|
|
137
|
+
raise ValidationError, error_msg
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Trust chain resolved: #{@resolved_statements.length} statements")
|
|
141
|
+
@resolved_statements
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def normalize_trust_anchors(trust_anchors)
|
|
147
|
+
trust_anchors.map do |ta|
|
|
148
|
+
{
|
|
149
|
+
entity_id: ta[:entity_id] || ta["entity_id"],
|
|
150
|
+
jwks: ta[:jwks] || ta["jwks"]
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def fetch_entity_configuration(entity_id)
|
|
156
|
+
entity_statement_url = OmniauthOpenidFederation::Utils.build_entity_statement_url(entity_id)
|
|
157
|
+
OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Fetching Entity Configuration from: #{entity_statement_url}")
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
EntityStatement.fetch!(entity_statement_url, timeout: @timeout)
|
|
161
|
+
rescue OmniauthOpenidFederation::NetworkError => e
|
|
162
|
+
raise FetchError, "Failed to fetch entity configuration from #{entity_statement_url}: #{e.message}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def fetch_subordinate_statement(issuer:, subject:)
|
|
167
|
+
# Try to get fetch endpoint from issuer's Entity Configuration
|
|
168
|
+
issuer_config = fetch_entity_configuration(issuer)
|
|
169
|
+
fetch_endpoint = extract_fetch_endpoint(issuer_config)
|
|
170
|
+
|
|
171
|
+
unless fetch_endpoint
|
|
172
|
+
raise FetchError, "Issuer #{issuer} does not provide a fetch endpoint"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Build fetch URL with iss and sub parameters
|
|
176
|
+
fetch_url = "#{fetch_endpoint}?iss=#{CGI.escape(issuer)}&sub=#{CGI.escape(subject)}"
|
|
177
|
+
OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Fetching Subordinate Statement from: #{fetch_url}")
|
|
178
|
+
|
|
179
|
+
begin
|
|
180
|
+
response = HttpClient.get(fetch_url, timeout: @timeout)
|
|
181
|
+
unless response.status.success?
|
|
182
|
+
raise FetchError, "Failed to fetch subordinate statement from #{fetch_url}: HTTP #{response.status}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
subordinate_statement_jwt = response.body.to_s
|
|
186
|
+
EntityStatement.new(subordinate_statement_jwt)
|
|
187
|
+
rescue OmniauthOpenidFederation::NetworkError => e
|
|
188
|
+
raise FetchError, "Failed to fetch subordinate statement from #{fetch_url}: #{e.message}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def extract_fetch_endpoint(entity_config)
|
|
193
|
+
# Fetch endpoint is typically at /.well-known/openid-federation/fetch
|
|
194
|
+
# or can be specified in metadata
|
|
195
|
+
parsed = entity_config.parse
|
|
196
|
+
issuer = parsed[:issuer] || parsed[:iss] || parsed["issuer"] || parsed["iss"]
|
|
197
|
+
return nil unless issuer
|
|
198
|
+
|
|
199
|
+
"#{issuer}/.well-known/openid-federation/fetch"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def extract_authority_hints(entity_config)
|
|
203
|
+
parsed = entity_config.parse
|
|
204
|
+
parsed[:authority_hints] || parsed["authority_hints"]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def validate_entity_statement(statement, issuer_config)
|
|
208
|
+
validator = EntityStatementValidator.new(
|
|
209
|
+
jwt_string: statement.entity_statement,
|
|
210
|
+
issuer_entity_configuration: issuer_config
|
|
211
|
+
)
|
|
212
|
+
validator.validate!
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def is_trust_anchor?(entity_config)
|
|
216
|
+
parsed = entity_config.parse
|
|
217
|
+
entity_id = parsed[:issuer] || parsed[:iss] || parsed["issuer"] || parsed["iss"]
|
|
218
|
+
|
|
219
|
+
@trust_anchors.any? do |ta|
|
|
220
|
+
ta[:entity_id] == entity_id
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|