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,331 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "jwt"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require_relative "../key_extractor"
|
|
8
|
+
require_relative "../logger"
|
|
9
|
+
require_relative "../errors"
|
|
10
|
+
require_relative "../configuration"
|
|
11
|
+
require_relative "../http_client"
|
|
12
|
+
require_relative "../utils"
|
|
13
|
+
require_relative "entity_statement_parser"
|
|
14
|
+
|
|
15
|
+
# Entity Statement implementation for OpenID Federation 1.0
|
|
16
|
+
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
17
|
+
# @see https://openid.github.io/federation/main.html OpenID Federation Documentation
|
|
18
|
+
#
|
|
19
|
+
# Entity Statements are self-signed JWTs that contain provider metadata and JWKS.
|
|
20
|
+
# This implementation supports:
|
|
21
|
+
# - Fetching entity statements from /.well-known/openid-federation endpoint (Section 9)
|
|
22
|
+
# - Fingerprint validation (SHA-256 hash) for integrity verification
|
|
23
|
+
# - Previous statement validation for update verification
|
|
24
|
+
# - Metadata extraction for OpenID Provider configuration
|
|
25
|
+
#
|
|
26
|
+
# Note: This implementation handles self-signed entity statements directly.
|
|
27
|
+
# Full trust chain resolution (Section 10) is not implemented as it's not required
|
|
28
|
+
# for direct entity statement validation use cases.
|
|
29
|
+
module OmniauthOpenidFederation
|
|
30
|
+
module Federation
|
|
31
|
+
# Entity Statement implementation for OpenID Federation 1.0
|
|
32
|
+
#
|
|
33
|
+
# @example Fetch and validate an entity statement from full URL
|
|
34
|
+
# statement = EntityStatement.fetch!(
|
|
35
|
+
# "https://provider.example.com/.well-known/openid-federation",
|
|
36
|
+
# fingerprint: "expected-fingerprint-hash"
|
|
37
|
+
# )
|
|
38
|
+
# metadata = statement.parse
|
|
39
|
+
#
|
|
40
|
+
# @example Fetch and validate an entity statement from issuer and endpoint
|
|
41
|
+
# statement = EntityStatement.fetch_from_issuer!(
|
|
42
|
+
# "https://provider.example.com",
|
|
43
|
+
# entity_statement_endpoint: "/.well-known/openid-federation",
|
|
44
|
+
# fingerprint: "expected-fingerprint-hash"
|
|
45
|
+
# )
|
|
46
|
+
# metadata = statement.parse
|
|
47
|
+
class EntityStatement
|
|
48
|
+
# Compatibility aliases for backward compatibility
|
|
49
|
+
FetchError = OmniauthOpenidFederation::FetchError
|
|
50
|
+
ValidationError = OmniauthOpenidFederation::ValidationError
|
|
51
|
+
attr_reader :entity_statement, :fingerprint, :metadata
|
|
52
|
+
|
|
53
|
+
# Fetch entity statement from URL
|
|
54
|
+
#
|
|
55
|
+
# @param url [String] The URL to fetch the entity statement from
|
|
56
|
+
# @param fingerprint [String, nil] Expected SHA-256 fingerprint for validation
|
|
57
|
+
# @param previous_statement [String, EntityStatement, Hash, nil] Previous statement for validation
|
|
58
|
+
# @param timeout [Integer] HTTP request timeout in seconds (default: 10)
|
|
59
|
+
# @return [EntityStatement] The fetched and validated entity statement
|
|
60
|
+
# @raise [FetchError] If fetching fails
|
|
61
|
+
# @raise [ValidationError] If validation fails
|
|
62
|
+
|
|
63
|
+
def initialize(entity_statement_content, fingerprint: nil)
|
|
64
|
+
@entity_statement = entity_statement_content
|
|
65
|
+
@fingerprint = fingerprint || calculate_fingerprint
|
|
66
|
+
@metadata = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Fetch entity statement from issuer and endpoint path
|
|
70
|
+
#
|
|
71
|
+
# @param issuer_uri [String, URI] Issuer URI (e.g., "https://provider.example.com")
|
|
72
|
+
# @param entity_statement_endpoint [String, nil] Entity statement endpoint path (defaults to "/.well-known/openid-federation")
|
|
73
|
+
# @param fingerprint [String, nil] Expected SHA-256 fingerprint for validation
|
|
74
|
+
# @param previous_statement [String, EntityStatement, Hash, nil] Previous statement for validation
|
|
75
|
+
# @param timeout [Integer] HTTP request timeout in seconds (default: 10)
|
|
76
|
+
# @return [EntityStatement] The fetched and validated entity statement
|
|
77
|
+
# @raise [FetchError] If fetching fails
|
|
78
|
+
# @raise [ValidationError] If validation fails
|
|
79
|
+
def self.fetch_from_issuer!(issuer_uri, entity_statement_endpoint: nil, fingerprint: nil, previous_statement: nil, timeout: 10)
|
|
80
|
+
url = OmniauthOpenidFederation::Utils.build_entity_statement_url(
|
|
81
|
+
issuer_uri,
|
|
82
|
+
entity_statement_endpoint: entity_statement_endpoint
|
|
83
|
+
)
|
|
84
|
+
fetch!(url, fingerprint: fingerprint, previous_statement: previous_statement, timeout: timeout)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Fetch entity statement from URL
|
|
88
|
+
#
|
|
89
|
+
# @param url [String] The URL to fetch the entity statement from
|
|
90
|
+
# @param fingerprint [String, nil] Expected SHA-256 fingerprint for validation
|
|
91
|
+
# @param previous_statement [String, EntityStatement, Hash, nil] Previous statement for validation
|
|
92
|
+
# @param timeout [Integer] HTTP request timeout in seconds (default: 10)
|
|
93
|
+
# @return [EntityStatement] The fetched and validated entity statement
|
|
94
|
+
# @raise [FetchError] If fetching fails
|
|
95
|
+
# @raise [ValidationError] If validation fails
|
|
96
|
+
def self.fetch!(url, fingerprint: nil, previous_statement: nil, timeout: 10)
|
|
97
|
+
# Use HttpClient for retry logic and configurable SSL verification
|
|
98
|
+
# Note: HttpClient uses HTTP gem, but entity statements might need Net::HTTP
|
|
99
|
+
# For now, we'll use a simple HTTP.get approach with HttpClient's retry logic
|
|
100
|
+
begin
|
|
101
|
+
# Convert URL to URI for HttpClient
|
|
102
|
+
response = HttpClient.get(url, timeout: timeout)
|
|
103
|
+
rescue OmniauthOpenidFederation::NetworkError => e
|
|
104
|
+
OmniauthOpenidFederation::Logger.error("[EntityStatement] Failed to fetch entity statement: #{e.message}")
|
|
105
|
+
raise FetchError, "Failed to fetch entity statement from #{url}: #{e.message}", e.backtrace
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
unless response.status.success?
|
|
109
|
+
error_msg = "Failed to fetch entity statement from #{url}: HTTP #{response.status}"
|
|
110
|
+
OmniauthOpenidFederation::Logger.error("[EntityStatement] #{error_msg}")
|
|
111
|
+
raise FetchError, error_msg
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# HTTP gem returns body as StringIO or similar, convert to string
|
|
115
|
+
entity_statement = response.body.to_s
|
|
116
|
+
|
|
117
|
+
instance = new(entity_statement, fingerprint: nil) # Don't set fingerprint in constructor
|
|
118
|
+
|
|
119
|
+
# Validate using full OpenID Federation validation (includes signature validation)
|
|
120
|
+
# This is required for OpenID Federation compliance
|
|
121
|
+
begin
|
|
122
|
+
EntityStatementParser.parse(entity_statement, validate_signature: true, validate_full: true)
|
|
123
|
+
OmniauthOpenidFederation::Logger.debug("[EntityStatement] Full validation successful")
|
|
124
|
+
rescue SignatureError, ValidationError => e
|
|
125
|
+
error_msg = "Entity statement validation failed: #{e.message}"
|
|
126
|
+
OmniauthOpenidFederation::Logger.error("[EntityStatement] #{error_msg}")
|
|
127
|
+
# Instrument entity statement validation failure
|
|
128
|
+
OmniauthOpenidFederation::Instrumentation.notify_entity_statement_validation_failed(
|
|
129
|
+
entity_statement_url: url,
|
|
130
|
+
validation_step: "full_validation",
|
|
131
|
+
error_message: e.message,
|
|
132
|
+
error_class: e.class.name
|
|
133
|
+
)
|
|
134
|
+
raise ValidationError, error_msg, e.backtrace
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Validate if fingerprint provided
|
|
138
|
+
if fingerprint
|
|
139
|
+
calculated_fingerprint = instance.calculate_fingerprint
|
|
140
|
+
unless instance.validate_fingerprint(fingerprint)
|
|
141
|
+
error_msg = "Entity statement fingerprint mismatch. Expected: #{fingerprint}, Got: #{calculated_fingerprint}"
|
|
142
|
+
OmniauthOpenidFederation::Logger.error("[EntityStatement] #{error_msg}")
|
|
143
|
+
# Instrument fingerprint mismatch
|
|
144
|
+
OmniauthOpenidFederation::Instrumentation.notify_fingerprint_mismatch(
|
|
145
|
+
entity_statement_url: url,
|
|
146
|
+
expected_fingerprint: fingerprint,
|
|
147
|
+
calculated_fingerprint: calculated_fingerprint
|
|
148
|
+
)
|
|
149
|
+
raise ValidationError, error_msg
|
|
150
|
+
end
|
|
151
|
+
OmniauthOpenidFederation::Logger.debug("[EntityStatement] Fingerprint validation successful: #{fingerprint}")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Validate against previous statement if provided
|
|
155
|
+
if previous_statement
|
|
156
|
+
unless instance.validate_against_previous(previous_statement)
|
|
157
|
+
error_msg = "Entity statement validation against previous statement failed"
|
|
158
|
+
OmniauthOpenidFederation::Logger.error("[EntityStatement] #{error_msg}")
|
|
159
|
+
raise ValidationError, error_msg
|
|
160
|
+
end
|
|
161
|
+
OmniauthOpenidFederation::Logger.debug("[EntityStatement] Previous statement validation successful")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
instance
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Calculate SHA-256 fingerprint of the entity statement
|
|
168
|
+
#
|
|
169
|
+
# @return [String] The lowercase hexadecimal fingerprint
|
|
170
|
+
def calculate_fingerprint
|
|
171
|
+
Digest::SHA256.hexdigest(entity_statement).downcase
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Validate fingerprint against expected value
|
|
175
|
+
#
|
|
176
|
+
# @param expected_fingerprint [String] The expected fingerprint
|
|
177
|
+
# @return [Boolean] true if fingerprints match
|
|
178
|
+
def validate_fingerprint(expected_fingerprint)
|
|
179
|
+
calculated = fingerprint.downcase
|
|
180
|
+
expected = expected_fingerprint.to_s.downcase
|
|
181
|
+
calculated == expected
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Validate against a previous entity statement
|
|
185
|
+
#
|
|
186
|
+
# @param previous_statement [String, EntityStatement, Hash] The previous statement to validate against
|
|
187
|
+
# @return [Boolean] true if validation passes
|
|
188
|
+
def validate_against_previous(previous_statement)
|
|
189
|
+
# Decode current statement
|
|
190
|
+
current_payload = decode_payload
|
|
191
|
+
|
|
192
|
+
# Handle different input types
|
|
193
|
+
previous_payload = if previous_statement.is_a?(String)
|
|
194
|
+
decode_jwt_payload(previous_statement)
|
|
195
|
+
elsif previous_statement.instance_of?(::OmniauthOpenidFederation::Federation::EntityStatement)
|
|
196
|
+
# If it's an EntityStatement instance, decode its payload
|
|
197
|
+
previous_statement.decode_payload
|
|
198
|
+
else
|
|
199
|
+
previous_statement
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Check if issuer matches
|
|
203
|
+
return false unless current_payload["iss"] == previous_payload["iss"]
|
|
204
|
+
|
|
205
|
+
# Check if this is a valid update (e.g., exp time is later)
|
|
206
|
+
current_exp = current_payload["exp"]
|
|
207
|
+
previous_exp = previous_payload["exp"]
|
|
208
|
+
|
|
209
|
+
return false if current_exp && previous_exp && current_exp < previous_exp
|
|
210
|
+
|
|
211
|
+
# Additional validation can be added here (e.g., check authority_hints)
|
|
212
|
+
true
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Parse entity statement and extract metadata
|
|
216
|
+
#
|
|
217
|
+
# @return [Hash] Hash containing issuer, subject, expiration, JWKS, and provider metadata
|
|
218
|
+
def parse
|
|
219
|
+
return @metadata if @metadata
|
|
220
|
+
|
|
221
|
+
payload = decode_payload
|
|
222
|
+
|
|
223
|
+
# Extract provider metadata
|
|
224
|
+
metadata_section = payload.fetch("metadata", {})
|
|
225
|
+
metadata_section.fetch("openid_provider", {})
|
|
226
|
+
|
|
227
|
+
# Extract entity JWKS - ensure it's a hash with keys array
|
|
228
|
+
entity_jwks = payload.fetch("jwks", {})
|
|
229
|
+
# Normalize to ensure it has :keys or "keys" key
|
|
230
|
+
if entity_jwks.nil? || !entity_jwks.is_a?(Hash)
|
|
231
|
+
entity_jwks = {keys: []}
|
|
232
|
+
elsif !entity_jwks.key?(:keys) && !entity_jwks.key?("keys")
|
|
233
|
+
entity_jwks = {keys: []}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Extract all entity types from metadata
|
|
237
|
+
metadata_section = payload.fetch("metadata", {})
|
|
238
|
+
provider_metadata = metadata_section.fetch("openid_provider", {})
|
|
239
|
+
rp_metadata = metadata_section.fetch("openid_relying_party", {})
|
|
240
|
+
|
|
241
|
+
@metadata = {
|
|
242
|
+
issuer: payload["iss"],
|
|
243
|
+
sub: payload["sub"],
|
|
244
|
+
exp: payload["exp"],
|
|
245
|
+
iat: payload["iat"],
|
|
246
|
+
jwks: entity_jwks,
|
|
247
|
+
metadata: {},
|
|
248
|
+
# Advanced claims (Entity Configuration specific)
|
|
249
|
+
authority_hints: payload["authority_hints"] || payload[:authority_hints],
|
|
250
|
+
trust_marks: payload["trust_marks"] || payload[:trust_marks],
|
|
251
|
+
trust_mark_issuers: payload["trust_mark_issuers"] || payload[:trust_mark_issuers],
|
|
252
|
+
trust_mark_owners: payload["trust_mark_owners"] || payload[:trust_mark_owners],
|
|
253
|
+
# Advanced claims (Subordinate Statement specific)
|
|
254
|
+
metadata_policy: payload["metadata_policy"] || payload[:metadata_policy],
|
|
255
|
+
metadata_policy_crit: payload["metadata_policy_crit"] || payload[:metadata_policy_crit],
|
|
256
|
+
constraints: payload["constraints"] || payload[:constraints],
|
|
257
|
+
source_endpoint: payload["source_endpoint"] || payload[:source_endpoint],
|
|
258
|
+
# Other claims
|
|
259
|
+
crit: payload["crit"] || payload[:crit],
|
|
260
|
+
# Determine statement type
|
|
261
|
+
is_entity_configuration: (payload["iss"] == payload["sub"]),
|
|
262
|
+
is_subordinate_statement: (payload["iss"] != payload["sub"])
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
# Extract OpenID Provider metadata if present
|
|
266
|
+
if provider_metadata.any?
|
|
267
|
+
@metadata[:metadata][:openid_provider] = {
|
|
268
|
+
issuer: provider_metadata["issuer"],
|
|
269
|
+
authorization_endpoint: provider_metadata["authorization_endpoint"],
|
|
270
|
+
token_endpoint: provider_metadata["token_endpoint"],
|
|
271
|
+
userinfo_endpoint: provider_metadata["userinfo_endpoint"],
|
|
272
|
+
jwks_uri: provider_metadata["jwks_uri"],
|
|
273
|
+
signed_jwks_uri: provider_metadata["signed_jwks_uri"],
|
|
274
|
+
end_session_endpoint: provider_metadata["end_session_endpoint"],
|
|
275
|
+
client_registration_types_supported: provider_metadata["client_registration_types_supported"],
|
|
276
|
+
federation_registration_endpoint: provider_metadata["federation_registration_endpoint"]
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Extract OpenID Relying Party metadata if present
|
|
281
|
+
if rp_metadata.any?
|
|
282
|
+
@metadata[:metadata][:openid_relying_party] = {
|
|
283
|
+
application_type: rp_metadata["application_type"],
|
|
284
|
+
redirect_uris: rp_metadata["redirect_uris"],
|
|
285
|
+
client_registration_types: rp_metadata["client_registration_types"],
|
|
286
|
+
signed_jwks_uri: rp_metadata["signed_jwks_uri"],
|
|
287
|
+
jwks_uri: rp_metadata["jwks_uri"],
|
|
288
|
+
organization_name: rp_metadata["organization_name"],
|
|
289
|
+
logo_uri: rp_metadata["logo_uri"],
|
|
290
|
+
grant_types: rp_metadata["grant_types"],
|
|
291
|
+
response_types: rp_metadata["response_types"],
|
|
292
|
+
scope: rp_metadata["scope"]
|
|
293
|
+
}
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
@metadata
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Save entity statement to file
|
|
300
|
+
#
|
|
301
|
+
# @param file_path [String] Path to save the entity statement
|
|
302
|
+
def save_to_file(file_path)
|
|
303
|
+
File.write(file_path, entity_statement)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Decode and return the JWT payload
|
|
307
|
+
#
|
|
308
|
+
# @return [Hash] The decoded JWT payload
|
|
309
|
+
def decode_payload
|
|
310
|
+
decode_jwt_payload(entity_statement)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
private
|
|
314
|
+
|
|
315
|
+
# Standard JWT has 3 parts: header.payload.signature
|
|
316
|
+
JWT_PARTS_COUNT = 3
|
|
317
|
+
|
|
318
|
+
def decode_jwt_payload(jwt_string)
|
|
319
|
+
jwt_parts = jwt_string.split(".")
|
|
320
|
+
raise ValidationError, "Invalid JWT format" if jwt_parts.length != JWT_PARTS_COUNT
|
|
321
|
+
|
|
322
|
+
# Decode payload (second part)
|
|
323
|
+
JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
|
|
324
|
+
rescue JSON::ParserError => e
|
|
325
|
+
raise ValidationError, "Failed to parse entity statement payload: #{e.message}"
|
|
326
|
+
rescue ArgumentError => e
|
|
327
|
+
raise ValidationError, "Failed to decode entity statement: #{e.message}"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
require "jwt"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "../logger"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
# Entity Statement Builder for OpenID Federation 1.0
|
|
9
|
+
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
10
|
+
#
|
|
11
|
+
# Builds self-signed entity statement JWTs that contain provider metadata and JWKS.
|
|
12
|
+
# Entity statements are used to publish provider configuration and enable signed JWKS support.
|
|
13
|
+
#
|
|
14
|
+
# @example Generate an entity statement
|
|
15
|
+
# builder = EntityStatementBuilder.new(
|
|
16
|
+
# issuer: "https://provider.example.com",
|
|
17
|
+
# subject: "https://provider.example.com",
|
|
18
|
+
# private_key: private_key,
|
|
19
|
+
# jwks: jwks_hash,
|
|
20
|
+
# metadata: {
|
|
21
|
+
# openid_provider: {
|
|
22
|
+
# issuer: "https://provider.example.com",
|
|
23
|
+
# authorization_endpoint: "https://provider.example.com/oauth2/authorize",
|
|
24
|
+
# token_endpoint: "https://provider.example.com/oauth2/token",
|
|
25
|
+
# userinfo_endpoint: "https://provider.example.com/oauth2/userinfo",
|
|
26
|
+
# jwks_uri: "https://provider.example.com/.well-known/jwks.json",
|
|
27
|
+
# signed_jwks_uri: "https://provider.example.com/.well-known/signed-jwks.json"
|
|
28
|
+
# }
|
|
29
|
+
# }
|
|
30
|
+
# )
|
|
31
|
+
# entity_statement_jwt = builder.build
|
|
32
|
+
module OmniauthOpenidFederation
|
|
33
|
+
module Federation
|
|
34
|
+
# Entity Statement Builder for OpenID Federation 1.0
|
|
35
|
+
#
|
|
36
|
+
# Builds self-signed entity statement JWTs for publishing provider configuration.
|
|
37
|
+
class EntityStatementBuilder
|
|
38
|
+
# @param issuer [String] Entity issuer (typically the provider URL)
|
|
39
|
+
# @param subject [String] Entity subject (typically same as issuer for self-issued statements)
|
|
40
|
+
# @param private_key [OpenSSL::PKey::RSA] Private key for signing the entity statement
|
|
41
|
+
# @param jwks [Hash] JWKS hash with "keys" array containing public keys
|
|
42
|
+
# @param metadata [Hash] Provider metadata hash with openid_provider section
|
|
43
|
+
# @param expiration_seconds [Integer] Expiration time in seconds from now (default: 86400 = 24 hours)
|
|
44
|
+
# @param kid [String, nil] Key ID to use for signing (defaults to first key's kid in JWKS)
|
|
45
|
+
# @param authority_hints [Array<String>, nil] Optional: Array of Entity Identifiers for Immediate Superiors (Entity Configuration only)
|
|
46
|
+
# @param trust_marks [Array<Hash>, nil] Optional: Array of Trust Mark objects (Entity Configuration only)
|
|
47
|
+
# @param trust_mark_issuers [Hash, nil] Optional: Trust Mark issuers configuration (Trust Anchor only)
|
|
48
|
+
# @param trust_mark_owners [Hash, nil] Optional: Trust Mark owners configuration (Trust Anchor only)
|
|
49
|
+
# @param metadata_policy [Hash, nil] Optional: Metadata policy (Subordinate Statement only)
|
|
50
|
+
# @param metadata_policy_crit [Array<String>, nil] Optional: Critical metadata policy operators (Subordinate Statement only)
|
|
51
|
+
# @param constraints [Hash, nil] Optional: Trust Chain constraints (Subordinate Statement only)
|
|
52
|
+
# @param source_endpoint [String, nil] Optional: Fetch endpoint URL (Subordinate Statement only)
|
|
53
|
+
# @param crit [Array<String>, nil] Optional: Critical claims that must be understood
|
|
54
|
+
def initialize(issuer:, subject:, private_key:, jwks:, metadata:, expiration_seconds: 86400, kid: nil,
|
|
55
|
+
authority_hints: nil, trust_marks: nil, trust_mark_issuers: nil, trust_mark_owners: nil,
|
|
56
|
+
metadata_policy: nil, metadata_policy_crit: nil, constraints: nil, source_endpoint: nil, crit: nil)
|
|
57
|
+
@issuer = issuer
|
|
58
|
+
@subject = subject
|
|
59
|
+
@private_key = private_key
|
|
60
|
+
@jwks = normalize_jwks(jwks)
|
|
61
|
+
@metadata = metadata
|
|
62
|
+
@expiration_seconds = expiration_seconds
|
|
63
|
+
@kid = kid || extract_kid_from_jwks(@jwks)
|
|
64
|
+
@authority_hints = authority_hints
|
|
65
|
+
@trust_marks = trust_marks
|
|
66
|
+
@trust_mark_issuers = trust_mark_issuers
|
|
67
|
+
@trust_mark_owners = trust_mark_owners
|
|
68
|
+
@metadata_policy = metadata_policy
|
|
69
|
+
@metadata_policy_crit = metadata_policy_crit
|
|
70
|
+
@constraints = constraints
|
|
71
|
+
@source_endpoint = source_endpoint
|
|
72
|
+
@crit = crit
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Build and sign the entity statement JWT
|
|
76
|
+
#
|
|
77
|
+
# @return [String] The signed entity statement JWT string
|
|
78
|
+
# @raise [ConfigurationError] If required parameters are missing
|
|
79
|
+
# @raise [SignatureError] If signing fails
|
|
80
|
+
def build
|
|
81
|
+
validate_parameters
|
|
82
|
+
|
|
83
|
+
payload = build_payload
|
|
84
|
+
|
|
85
|
+
# Build JWT header
|
|
86
|
+
# Per OpenID Federation 1.0 Section 3.1: typ MUST be "entity-statement+jwt"
|
|
87
|
+
header = {
|
|
88
|
+
alg: "RS256",
|
|
89
|
+
typ: "entity-statement+jwt",
|
|
90
|
+
kid: @kid
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
JWT.encode(payload, @private_key, "RS256", header)
|
|
95
|
+
rescue => e
|
|
96
|
+
error_msg = "Failed to sign entity statement: #{e.class} - #{e.message}"
|
|
97
|
+
OmniauthOpenidFederation::Logger.error("[EntityStatementBuilder] #{error_msg}")
|
|
98
|
+
raise SignatureError, error_msg, e.backtrace
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def validate_parameters
|
|
105
|
+
raise ConfigurationError, "Issuer is required" if @issuer.nil? || @issuer.empty?
|
|
106
|
+
raise ConfigurationError, "Subject is required" if @subject.nil? || @subject.empty?
|
|
107
|
+
raise ConfigurationError, "Private key is required" if @private_key.nil?
|
|
108
|
+
raise ConfigurationError, "JWKS is required" if @jwks.nil? || @jwks.empty?
|
|
109
|
+
raise ConfigurationError, "Metadata is required" if @metadata.nil? || @metadata.empty?
|
|
110
|
+
raise ConfigurationError, "JWKS must contain at least one key" if @jwks["keys"].nil? || @jwks["keys"].empty?
|
|
111
|
+
raise ConfigurationError, "Key ID (kid) is required" if @kid.nil? || @kid.empty?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_payload
|
|
115
|
+
now = Time.now.to_i
|
|
116
|
+
is_entity_configuration = (@issuer == @subject)
|
|
117
|
+
is_subordinate_statement = !is_entity_configuration
|
|
118
|
+
|
|
119
|
+
payload = {
|
|
120
|
+
iss: @issuer,
|
|
121
|
+
sub: @subject,
|
|
122
|
+
iat: now,
|
|
123
|
+
exp: now + @expiration_seconds,
|
|
124
|
+
jwks: @jwks,
|
|
125
|
+
metadata: @metadata
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Entity Configuration specific claims
|
|
129
|
+
if is_entity_configuration
|
|
130
|
+
payload[:authority_hints] = @authority_hints if @authority_hints
|
|
131
|
+
payload[:trust_marks] = @trust_marks if @trust_marks
|
|
132
|
+
payload[:trust_mark_issuers] = @trust_mark_issuers if @trust_mark_issuers
|
|
133
|
+
payload[:trust_mark_owners] = @trust_mark_owners if @trust_mark_owners
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Subordinate Statement specific claims
|
|
137
|
+
if is_subordinate_statement
|
|
138
|
+
payload[:metadata_policy] = @metadata_policy if @metadata_policy
|
|
139
|
+
payload[:metadata_policy_crit] = @metadata_policy_crit if @metadata_policy_crit
|
|
140
|
+
payload[:constraints] = @constraints if @constraints
|
|
141
|
+
payload[:source_endpoint] = @source_endpoint if @source_endpoint
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Common optional claims
|
|
145
|
+
payload[:crit] = @crit if @crit
|
|
146
|
+
|
|
147
|
+
payload
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def normalize_jwks(jwks)
|
|
151
|
+
# Ensure JWKS is a hash with "keys" array
|
|
152
|
+
if jwks.is_a?(Hash)
|
|
153
|
+
# If it has :keys or "keys", use as-is
|
|
154
|
+
if jwks.key?(:keys) || jwks.key?("keys")
|
|
155
|
+
keys = jwks[:keys] || jwks["keys"]
|
|
156
|
+
{"keys" => normalize_keys(keys)}
|
|
157
|
+
else
|
|
158
|
+
# If it's just a hash, wrap it
|
|
159
|
+
{"keys" => [jwks]}
|
|
160
|
+
end
|
|
161
|
+
elsif jwks.is_a?(Array)
|
|
162
|
+
{"keys" => normalize_keys(jwks)}
|
|
163
|
+
else
|
|
164
|
+
raise ConfigurationError, "JWKS must be a Hash or Array"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def normalize_keys(keys)
|
|
169
|
+
keys.map do |key|
|
|
170
|
+
if key.is_a?(Hash)
|
|
171
|
+
# Convert symbol keys to string keys
|
|
172
|
+
key.transform_keys(&:to_s)
|
|
173
|
+
else
|
|
174
|
+
key
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def extract_kid_from_jwks(jwks)
|
|
180
|
+
keys = jwks["keys"] || jwks[:keys] || []
|
|
181
|
+
return nil if keys.empty?
|
|
182
|
+
|
|
183
|
+
first_key = keys.first
|
|
184
|
+
first_key["kid"] || first_key[:kid]
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require_relative "entity_statement"
|
|
2
|
+
require_relative "../logger"
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../http_client"
|
|
5
|
+
require_relative "../utils"
|
|
6
|
+
|
|
7
|
+
# Entity Statement Fetcher abstraction for OpenID Federation
|
|
8
|
+
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
9
|
+
#
|
|
10
|
+
# Provides a pluggable interface for fetching entity statements from various sources.
|
|
11
|
+
# This abstraction improves testability and allows different fetching strategies.
|
|
12
|
+
module OmniauthOpenidFederation
|
|
13
|
+
module Federation
|
|
14
|
+
module EntityStatementFetcher
|
|
15
|
+
# Base class for entity statement fetchers
|
|
16
|
+
# Subclasses must implement #fetch_entity_statement
|
|
17
|
+
class Base
|
|
18
|
+
# Get the entity statement (cached)
|
|
19
|
+
#
|
|
20
|
+
# @return [EntityStatement] The entity statement instance
|
|
21
|
+
def entity_statement
|
|
22
|
+
@entity_statement ||= begin
|
|
23
|
+
content = fetch_entity_statement
|
|
24
|
+
EntityStatement.new(content)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Reload the entity statement (clear cache)
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
def reload!
|
|
32
|
+
@entity_statement = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Fetch the entity statement content
|
|
38
|
+
# Must be implemented by subclasses
|
|
39
|
+
#
|
|
40
|
+
# @return [String] The entity statement JWT string
|
|
41
|
+
# @raise [NotImplementedError] If not implemented by subclass
|
|
42
|
+
def fetch_entity_statement
|
|
43
|
+
raise NotImplementedError, "#{self.class} must implement #fetch_entity_statement"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Fetches entity statement from a federation URL
|
|
48
|
+
class UrlFetcher < Base
|
|
49
|
+
attr_reader :entity_statement_url, :fingerprint, :timeout
|
|
50
|
+
|
|
51
|
+
# Initialize URL fetcher
|
|
52
|
+
#
|
|
53
|
+
# @param entity_statement_url [String] The URL to fetch from
|
|
54
|
+
# @param fingerprint [String, nil] Expected SHA-256 fingerprint for verification
|
|
55
|
+
# @param timeout [Integer] HTTP timeout in seconds (default: 10)
|
|
56
|
+
def initialize(entity_statement_url, fingerprint: nil, timeout: 10)
|
|
57
|
+
@entity_statement_url = entity_statement_url
|
|
58
|
+
@fingerprint = fingerprint
|
|
59
|
+
@timeout = timeout
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def fetch_entity_statement
|
|
65
|
+
OmniauthOpenidFederation::Logger.debug("[EntityStatementFetcher::UrlFetcher] Fetching entity statement from #{Utils.sanitize_uri(@entity_statement_url)}")
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
response = HttpClient.get(@entity_statement_url, timeout: @timeout)
|
|
69
|
+
rescue OmniauthOpenidFederation::NetworkError => e
|
|
70
|
+
sanitized_uri = Utils.sanitize_uri(@entity_statement_url)
|
|
71
|
+
OmniauthOpenidFederation::Logger.error("[EntityStatementFetcher::UrlFetcher] Failed to fetch entity statement from #{sanitized_uri}: #{e.message}")
|
|
72
|
+
raise FetchError, "Failed to fetch entity statement from #{sanitized_uri}: #{e.message}", e.backtrace
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
unless response.status.success?
|
|
76
|
+
sanitized_uri = Utils.sanitize_uri(@entity_statement_url)
|
|
77
|
+
error_msg = "Failed to fetch entity statement from #{sanitized_uri}: HTTP #{response.status}"
|
|
78
|
+
OmniauthOpenidFederation::Logger.error("[EntityStatementFetcher::UrlFetcher] #{error_msg}")
|
|
79
|
+
raise FetchError, error_msg
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
entity_statement_content = response.body.to_s
|
|
83
|
+
|
|
84
|
+
# Validate fingerprint if provided
|
|
85
|
+
if @fingerprint
|
|
86
|
+
temp_statement = EntityStatement.new(entity_statement_content)
|
|
87
|
+
calculated_fingerprint = temp_statement.calculate_fingerprint
|
|
88
|
+
unless calculated_fingerprint == @fingerprint
|
|
89
|
+
error_msg = "Entity statement fingerprint mismatch. Expected: #{@fingerprint}, Got: #{calculated_fingerprint}"
|
|
90
|
+
OmniauthOpenidFederation::Logger.error("[EntityStatementFetcher::UrlFetcher] #{error_msg}")
|
|
91
|
+
# Instrument fingerprint mismatch
|
|
92
|
+
OmniauthOpenidFederation::Instrumentation.notify_fingerprint_mismatch(
|
|
93
|
+
entity_statement_url: @entity_statement_url,
|
|
94
|
+
expected_fingerprint: @fingerprint,
|
|
95
|
+
calculated_fingerprint: calculated_fingerprint
|
|
96
|
+
)
|
|
97
|
+
raise ValidationError, error_msg
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
entity_statement_content
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Fetches entity statement from a local file
|
|
106
|
+
class FileFetcher < Base
|
|
107
|
+
attr_reader :file_path
|
|
108
|
+
|
|
109
|
+
# Initialize file fetcher
|
|
110
|
+
#
|
|
111
|
+
# @param file_path [String] Path to the entity statement file
|
|
112
|
+
# @param allowed_dirs [Array<String>, nil] Allowed directories for path validation (default: Rails.root/config if Rails available)
|
|
113
|
+
def initialize(file_path, allowed_dirs: nil)
|
|
114
|
+
@file_path = file_path
|
|
115
|
+
@allowed_dirs = allowed_dirs || ((defined?(Rails) && Rails.root) ? [Rails.root.join("config").to_s] : nil)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def fetch_entity_statement
|
|
121
|
+
# Validate file path to prevent path traversal
|
|
122
|
+
validated_path = Utils.validate_file_path!(
|
|
123
|
+
@file_path,
|
|
124
|
+
allowed_dirs: @allowed_dirs
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
unless File.exist?(validated_path)
|
|
128
|
+
sanitized_path = Utils.sanitize_path(validated_path)
|
|
129
|
+
OmniauthOpenidFederation::Logger.warn("[EntityStatementFetcher::FileFetcher] Entity statement file not found: #{sanitized_path}")
|
|
130
|
+
raise FetchError, "Entity statement file not found: #{sanitized_path}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
OmniauthOpenidFederation::Logger.debug("[EntityStatementFetcher::FileFetcher] Loading entity statement from file: #{Utils.sanitize_path(validated_path)}")
|
|
134
|
+
File.read(validated_path).strip
|
|
135
|
+
rescue SecurityError => e
|
|
136
|
+
OmniauthOpenidFederation::Logger.error("[EntityStatementFetcher::FileFetcher] #{e.message}")
|
|
137
|
+
raise FetchError, e.message, e.backtrace
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|