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,775 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Enhanced Mock OpenID Provider (OP) Server for OpenID Federation Testing
|
|
5
|
+
#
|
|
6
|
+
# This is a comprehensive standalone Webrick server that acts as a mock OP server
|
|
7
|
+
# for testing OpenID Federation flows. It supports:
|
|
8
|
+
# - Entity Configuration endpoint (/.well-known/openid-federation)
|
|
9
|
+
# - Fetch Endpoint (/.well-known/openid-federation/fetch)
|
|
10
|
+
# - Authorization Endpoint (/auth) with trust chain resolution and request object validation
|
|
11
|
+
# - Token Endpoint (/token) with ID Token signing
|
|
12
|
+
# - Request object validation (signed and optionally encrypted)
|
|
13
|
+
# - Error injection modes for testing failure scenarios
|
|
14
|
+
#
|
|
15
|
+
# Configuration:
|
|
16
|
+
# - Load from YAML file: config/mock_op.yml
|
|
17
|
+
# - Or set environment variables
|
|
18
|
+
#
|
|
19
|
+
# Usage:
|
|
20
|
+
# ruby examples/mock_op_server.rb
|
|
21
|
+
#
|
|
22
|
+
# Access:
|
|
23
|
+
# http://localhost:9292/.well-known/openid-federation
|
|
24
|
+
# http://localhost:9292/auth?request=<signed_jwt>
|
|
25
|
+
# http://localhost:9292/token (POST with code)
|
|
26
|
+
#
|
|
27
|
+
# Error Injection Modes (via query param ?error_mode=<mode>):
|
|
28
|
+
# - invalid_statement: Return invalid entity statement
|
|
29
|
+
# - wrong_keys: Return wrong JWKS keys
|
|
30
|
+
# - invalid_request: Reject request object
|
|
31
|
+
# - invalid_signature: Return invalid signature
|
|
32
|
+
# - expired_statement: Return expired entity statement
|
|
33
|
+
# - missing_metadata: Return statement without metadata
|
|
34
|
+
|
|
35
|
+
require "bundler/setup"
|
|
36
|
+
require "webrick"
|
|
37
|
+
require "yaml"
|
|
38
|
+
require "json"
|
|
39
|
+
require "jwt"
|
|
40
|
+
require "jwe"
|
|
41
|
+
require "openssl"
|
|
42
|
+
require "base64"
|
|
43
|
+
require "uri"
|
|
44
|
+
require "cgi"
|
|
45
|
+
require "securerandom"
|
|
46
|
+
|
|
47
|
+
# Add the gem to the load path
|
|
48
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
49
|
+
require "omniauth_openid_federation"
|
|
50
|
+
|
|
51
|
+
class MockOPServer
|
|
52
|
+
# Load configuration from YAML or environment
|
|
53
|
+
def self.load_config
|
|
54
|
+
config_path = File.expand_path("../config/mock_op.yml", __dir__)
|
|
55
|
+
if File.exist?(config_path)
|
|
56
|
+
YAML.load_file(config_path)
|
|
57
|
+
else
|
|
58
|
+
# Fall back to environment variables
|
|
59
|
+
{
|
|
60
|
+
"entity_id" => ENV["OP_ENTITY_ID"] || "http://localhost:9292",
|
|
61
|
+
"server_host" => ENV["OP_SERVER_HOST"] || "localhost:9292",
|
|
62
|
+
"signing_key" => ENV["OP_SIGNING_KEY"],
|
|
63
|
+
"encryption_key" => ENV["OP_ENCRYPTION_KEY"],
|
|
64
|
+
"trust_anchors" => parse_trust_anchors(ENV["OP_TRUST_ANCHORS"]),
|
|
65
|
+
"authority_hints" => parse_array(ENV["OP_AUTHORITY_HINTS"]),
|
|
66
|
+
"op_metadata" => parse_json(ENV["OP_METADATA"]) || default_op_metadata,
|
|
67
|
+
"require_request_encryption" => ENV["OP_REQUIRE_ENCRYPTION"] == "true",
|
|
68
|
+
"validate_request_objects" => ENV["OP_VALIDATE_REQUESTS"] != "false"
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.parse_trust_anchors(str)
|
|
74
|
+
return [] unless str
|
|
75
|
+
JSON.parse(str)
|
|
76
|
+
rescue JSON::ParserError
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.parse_array(str)
|
|
81
|
+
return [] unless str
|
|
82
|
+
str.split(",").map(&:strip)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.parse_json(str)
|
|
86
|
+
return nil unless str
|
|
87
|
+
JSON.parse(str)
|
|
88
|
+
rescue JSON::ParserError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.default_op_metadata
|
|
93
|
+
{
|
|
94
|
+
"issuer" => "http://localhost:9292",
|
|
95
|
+
"authorization_endpoint" => "http://localhost:9292/auth",
|
|
96
|
+
"token_endpoint" => "http://localhost:9292/token",
|
|
97
|
+
"userinfo_endpoint" => "http://localhost:9292/userinfo",
|
|
98
|
+
"jwks_uri" => "http://localhost:9292/.well-known/jwks.json",
|
|
99
|
+
"signed_jwks_uri" => "http://localhost:9292/.well-known/signed-jwks.json",
|
|
100
|
+
"client_registration_types_supported" => ["automatic", "explicit"],
|
|
101
|
+
"response_types_supported" => ["code"],
|
|
102
|
+
"grant_types_supported" => ["authorization_code"],
|
|
103
|
+
"id_token_signing_alg_values_supported" => ["RS256"],
|
|
104
|
+
"id_token_encryption_alg_values_supported" => ["RSA-OAEP"],
|
|
105
|
+
"id_token_encryption_enc_values_supported" => ["A128CBC-HS256"],
|
|
106
|
+
"request_object_signing_alg_values_supported" => ["RS256"],
|
|
107
|
+
"request_object_encryption_alg_values_supported" => ["RSA-OAEP"],
|
|
108
|
+
"request_object_encryption_enc_values_supported" => ["A128CBC-HS256"],
|
|
109
|
+
"scopes_supported" => ["openid", "profile", "email"]
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.load_signing_key(key_data)
|
|
114
|
+
if key_data.nil? || key_data.empty?
|
|
115
|
+
# Generate a new key for testing
|
|
116
|
+
OpenSSL::PKey::RSA.new(2048)
|
|
117
|
+
elsif key_data.is_a?(String)
|
|
118
|
+
if key_data.include?("BEGIN")
|
|
119
|
+
OpenSSL::PKey::RSA.new(key_data)
|
|
120
|
+
else
|
|
121
|
+
OpenSSL::PKey::RSA.new(Base64.decode64(key_data))
|
|
122
|
+
end
|
|
123
|
+
else
|
|
124
|
+
raise "Invalid signing key format"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.load_encryption_key(key_data)
|
|
129
|
+
return nil if key_data.nil? || key_data.empty?
|
|
130
|
+
load_signing_key(key_data)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.normalize_trust_anchors(trust_anchors)
|
|
134
|
+
trust_anchors.map do |ta|
|
|
135
|
+
{
|
|
136
|
+
entity_id: ta["entity_id"] || ta[:entity_id],
|
|
137
|
+
jwks: ta["jwks"] || ta[:jwks]
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Initialize configuration
|
|
143
|
+
CONFIG = load_config
|
|
144
|
+
ENTITY_ID = CONFIG["entity_id"] || "http://localhost:9292"
|
|
145
|
+
SERVER_HOST = CONFIG["server_host"] || "localhost:9292"
|
|
146
|
+
SIGNING_KEY = load_signing_key(CONFIG["signing_key"])
|
|
147
|
+
ENCRYPTION_KEY = load_encryption_key(CONFIG["encryption_key"]) || SIGNING_KEY
|
|
148
|
+
TRUST_ANCHORS = CONFIG["trust_anchors"] || []
|
|
149
|
+
AUTHORITY_HINTS = CONFIG["authority_hints"] || []
|
|
150
|
+
OP_METADATA = CONFIG["op_metadata"] || default_op_metadata
|
|
151
|
+
REQUIRE_REQUEST_ENCRYPTION = CONFIG["require_request_encryption"] || false
|
|
152
|
+
VALIDATE_REQUEST_OBJECTS = CONFIG["validate_request_objects"] != false
|
|
153
|
+
|
|
154
|
+
# Store for authorization codes (in production, use a database)
|
|
155
|
+
AUTHORIZATION_CODES = {}
|
|
156
|
+
|
|
157
|
+
# Store for registered RPs (for testing)
|
|
158
|
+
REGISTERED_RPS = {}
|
|
159
|
+
|
|
160
|
+
# Configure FederationEndpoint (deferred until server starts)
|
|
161
|
+
def self.configure_federation_endpoint
|
|
162
|
+
base_url = base_url_static
|
|
163
|
+
# Use localhost URLs if entity_id is localhost (for isolation)
|
|
164
|
+
if ENTITY_ID.include?("localhost")
|
|
165
|
+
# Override metadata to use localhost URLs
|
|
166
|
+
metadata = OP_METADATA.dup
|
|
167
|
+
metadata["issuer"] = ENTITY_ID
|
|
168
|
+
metadata["authorization_endpoint"] = "#{base_url}/auth"
|
|
169
|
+
metadata["token_endpoint"] = "#{base_url}/token"
|
|
170
|
+
metadata["userinfo_endpoint"] = "#{base_url}/userinfo"
|
|
171
|
+
metadata["jwks_uri"] = "#{base_url}/.well-known/jwks.json"
|
|
172
|
+
metadata["signed_jwks_uri"] = "#{base_url}/.well-known/signed-jwks.json"
|
|
173
|
+
else
|
|
174
|
+
metadata = OP_METADATA.merge(
|
|
175
|
+
"issuer" => ENTITY_ID,
|
|
176
|
+
"authorization_endpoint" => "#{base_url}/auth",
|
|
177
|
+
"token_endpoint" => "#{base_url}/token",
|
|
178
|
+
"userinfo_endpoint" => "#{base_url}/userinfo",
|
|
179
|
+
"jwks_uri" => "#{base_url}/.well-known/jwks.json",
|
|
180
|
+
"signed_jwks_uri" => "#{base_url}/.well-known/signed-jwks.json"
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
OmniauthOpenidFederation::FederationEndpoint.auto_configure(
|
|
185
|
+
issuer: ENTITY_ID,
|
|
186
|
+
private_key: SIGNING_KEY,
|
|
187
|
+
metadata: {
|
|
188
|
+
openid_provider: metadata
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Set authority_hints if provided (must be done after auto_configure)
|
|
193
|
+
if AUTHORITY_HINTS.any?
|
|
194
|
+
OmniauthOpenidFederation::FederationEndpoint.configure do |config|
|
|
195
|
+
config.authority_hints = AUTHORITY_HINTS
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Configure subordinate statements if provided
|
|
201
|
+
def self.configure_subordinate_statements
|
|
202
|
+
if CONFIG["subordinate_statements"]
|
|
203
|
+
OmniauthOpenidFederation::FederationEndpoint.configure do |config|
|
|
204
|
+
config.subordinate_statements = CONFIG["subordinate_statements"]
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.base_url_static
|
|
210
|
+
"http://#{SERVER_HOST}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Webrick servlet to handle requests
|
|
214
|
+
class Servlet < WEBrick::HTTPServlet::AbstractServlet
|
|
215
|
+
def service(req, res)
|
|
216
|
+
path = req.path
|
|
217
|
+
method = req.request_method
|
|
218
|
+
params = parse_query(req.query_string)
|
|
219
|
+
error_mode = params["error_mode"] || req.header["x-error-mode"]&.first
|
|
220
|
+
|
|
221
|
+
# Parse body for POST requests
|
|
222
|
+
body_params = {}
|
|
223
|
+
if method == "POST" && req.body && !req.body.empty?
|
|
224
|
+
body_params = if req.content_type&.include?("application/json")
|
|
225
|
+
begin
|
|
226
|
+
JSON.parse(req.body)
|
|
227
|
+
rescue
|
|
228
|
+
{}
|
|
229
|
+
end
|
|
230
|
+
else
|
|
231
|
+
parse_form_data(req.body)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Merge query params and body params
|
|
236
|
+
all_params = params.merge(body_params)
|
|
237
|
+
|
|
238
|
+
case [method, path]
|
|
239
|
+
when ["GET", "/"]
|
|
240
|
+
handle_health_check(req, res)
|
|
241
|
+
when ["GET", "/.well-known/openid-federation"]
|
|
242
|
+
handle_entity_configuration(req, res, error_mode)
|
|
243
|
+
when ["GET", "/.well-known/openid-federation/fetch"]
|
|
244
|
+
handle_fetch(req, res, params, error_mode)
|
|
245
|
+
when ["GET", "/.well-known/jwks.json"]
|
|
246
|
+
handle_jwks(req, res, error_mode)
|
|
247
|
+
when ["GET", "/.well-known/signed-jwks.json"]
|
|
248
|
+
handle_signed_jwks(req, res, error_mode)
|
|
249
|
+
when ["GET", "/auth"]
|
|
250
|
+
handle_authorization(req, res, all_params, error_mode)
|
|
251
|
+
when ["POST", "/token"]
|
|
252
|
+
handle_token(req, res, all_params)
|
|
253
|
+
when ["GET", "/userinfo"]
|
|
254
|
+
handle_userinfo(req, res)
|
|
255
|
+
else
|
|
256
|
+
res.status = 404
|
|
257
|
+
res.content_type = "application/json"
|
|
258
|
+
res.body = {error: "not_found", error_description: "Endpoint not found"}.to_json
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
def parse_query(query_string)
|
|
265
|
+
return {} unless query_string
|
|
266
|
+
CGI.parse(query_string).transform_values { |v| v.first }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def parse_form_data(body)
|
|
270
|
+
return {} unless body
|
|
271
|
+
CGI.parse(body).transform_values { |v| v.first }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def base_url(req)
|
|
275
|
+
scheme = req.request_uri.scheme || "http"
|
|
276
|
+
host = req.host
|
|
277
|
+
port = req.port
|
|
278
|
+
port_str = ((port == 80 && scheme == "http") || (port == 443 && scheme == "https")) ? "" : ":#{port}"
|
|
279
|
+
"#{scheme}://#{host}#{port_str}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def json_response(res, data, status: 200)
|
|
283
|
+
res.status = status
|
|
284
|
+
res.content_type = "application/json"
|
|
285
|
+
res.body = data.to_json
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def error_response(res, error, description, status: 400)
|
|
289
|
+
json_response(res, {error: error, error_description: description}, status: status)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def handle_health_check(req, res)
|
|
293
|
+
json_response(res, {
|
|
294
|
+
status: "ok",
|
|
295
|
+
entity_id: MockOPServer::ENTITY_ID,
|
|
296
|
+
endpoints: {
|
|
297
|
+
entity_configuration: "#{base_url(req)}/.well-known/openid-federation",
|
|
298
|
+
fetch: "#{base_url(req)}/.well-known/openid-federation/fetch",
|
|
299
|
+
authorization: "#{base_url(req)}/auth",
|
|
300
|
+
token: "#{base_url(req)}/token",
|
|
301
|
+
userinfo: "#{base_url(req)}/userinfo"
|
|
302
|
+
},
|
|
303
|
+
error_modes: [
|
|
304
|
+
"invalid_statement",
|
|
305
|
+
"wrong_keys",
|
|
306
|
+
"invalid_request",
|
|
307
|
+
"invalid_signature",
|
|
308
|
+
"expired_statement",
|
|
309
|
+
"missing_metadata"
|
|
310
|
+
]
|
|
311
|
+
})
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def handle_entity_configuration(req, res, error_mode)
|
|
315
|
+
if error_mode == "invalid_statement"
|
|
316
|
+
res.status = 200
|
|
317
|
+
res.content_type = "application/jwt"
|
|
318
|
+
res.body = "invalid.entity.statement"
|
|
319
|
+
return
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
if error_mode == "expired_statement"
|
|
323
|
+
res.status = 200
|
|
324
|
+
res.content_type = "application/jwt"
|
|
325
|
+
res.body = generate_expired_entity_statement
|
|
326
|
+
return
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
if error_mode == "wrong_keys"
|
|
330
|
+
res.status = 200
|
|
331
|
+
res.content_type = "application/jwt"
|
|
332
|
+
res.body = generate_entity_statement_with_wrong_keys
|
|
333
|
+
return
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
if error_mode == "missing_metadata"
|
|
337
|
+
res.status = 200
|
|
338
|
+
res.content_type = "application/jwt"
|
|
339
|
+
res.body = generate_entity_statement_without_metadata
|
|
340
|
+
return
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
entity_statement = OmniauthOpenidFederation::FederationEndpoint.generate_entity_statement
|
|
344
|
+
res.status = 200
|
|
345
|
+
res.content_type = "application/jwt"
|
|
346
|
+
res["Cache-Control"] = "public, max-age=3600"
|
|
347
|
+
res.body = entity_statement
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def handle_fetch(req, res, params, error_mode)
|
|
351
|
+
subject_entity_id = params["sub"]
|
|
352
|
+
|
|
353
|
+
unless subject_entity_id
|
|
354
|
+
return error_response(res, "invalid_request", "Missing required parameter: sub")
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
if subject_entity_id == MockOPServer::ENTITY_ID
|
|
358
|
+
return error_response(res, "invalid_request", "Subject cannot be the issuer")
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
if error_mode == "invalid_statement"
|
|
362
|
+
res.status = 200
|
|
363
|
+
res.content_type = "application/entity-statement+jwt"
|
|
364
|
+
res.body = "invalid.subordinate.statement"
|
|
365
|
+
return
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
subordinate_statement = OmniauthOpenidFederation::FederationEndpoint.get_subordinate_statement(subject_entity_id)
|
|
369
|
+
|
|
370
|
+
unless subordinate_statement
|
|
371
|
+
return error_response(res, "not_found", "Subordinate Statement not found for subject: #{subject_entity_id}", status: 404)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
res.status = 200
|
|
375
|
+
res.content_type = "application/entity-statement+jwt"
|
|
376
|
+
res["Cache-Control"] = "public, max-age=3600"
|
|
377
|
+
res.body = subordinate_statement
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def handle_jwks(req, res, error_mode)
|
|
381
|
+
if error_mode == "wrong_keys"
|
|
382
|
+
wrong_key = OpenSSL::PKey::RSA.new(2048)
|
|
383
|
+
jwk = JWT::JWK.new(wrong_key.public_key)
|
|
384
|
+
jwks = {keys: [jwk.export]}
|
|
385
|
+
return json_response(res, jwks)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
jwks = OmniauthOpenidFederation::FederationEndpoint.current_jwks
|
|
389
|
+
res.status = 200
|
|
390
|
+
res.content_type = "application/json"
|
|
391
|
+
res["Cache-Control"] = "public, max-age=3600"
|
|
392
|
+
res.body = jwks.to_json
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def handle_signed_jwks(req, res, error_mode)
|
|
396
|
+
if error_mode == "invalid_signature"
|
|
397
|
+
res.status = 200
|
|
398
|
+
res.content_type = "application/jwt"
|
|
399
|
+
res.body = generate_invalid_signed_jwks
|
|
400
|
+
return
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
signed_jwks = OmniauthOpenidFederation::FederationEndpoint.generate_signed_jwks
|
|
404
|
+
res.status = 200
|
|
405
|
+
res.content_type = "application/jwt"
|
|
406
|
+
res["Cache-Control"] = "public, max-age=3600"
|
|
407
|
+
res.body = signed_jwks
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def handle_authorization(req, res, params, error_mode)
|
|
411
|
+
request_object = params["request"]
|
|
412
|
+
client_id = params["client_id"]
|
|
413
|
+
redirect_uri = params["redirect_uri"]
|
|
414
|
+
state = params["state"]
|
|
415
|
+
nonce = params["nonce"]
|
|
416
|
+
|
|
417
|
+
# Validate request object if present
|
|
418
|
+
if request_object
|
|
419
|
+
if error_mode == "invalid_request"
|
|
420
|
+
return error_response(res, "invalid_request_object", "Request object validation failed")
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
begin
|
|
424
|
+
validated_request = validate_request_object(request_object, error_mode)
|
|
425
|
+
client_id = validated_request[:client_id] || validated_request["client_id"] || client_id
|
|
426
|
+
redirect_uri = validated_request[:redirect_uri] || validated_request["redirect_uri"] || redirect_uri
|
|
427
|
+
state = validated_request[:state] || validated_request["state"] || state
|
|
428
|
+
nonce = validated_request[:nonce] || validated_request["nonce"] || nonce
|
|
429
|
+
rescue OmniauthOpenidFederation::DecryptionError => e
|
|
430
|
+
return error_response(res, "invalid_request_object", "Request object decryption failed: #{e.message}")
|
|
431
|
+
rescue => e
|
|
432
|
+
return error_response(res, "invalid_request_object", "Request object validation failed: #{e.message}")
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
unless client_id && redirect_uri
|
|
437
|
+
return error_response(res, "invalid_request", "Missing required parameters: client_id, redirect_uri")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Resolve RP's trust chain if client_id is an Entity ID
|
|
441
|
+
rp_effective_metadata = nil
|
|
442
|
+
if is_entity_id?(client_id) && MockOPServer::TRUST_ANCHORS.any?
|
|
443
|
+
begin
|
|
444
|
+
resolver = OmniauthOpenidFederation::Federation::TrustChainResolver.new(
|
|
445
|
+
leaf_entity_id: client_id,
|
|
446
|
+
trust_anchors: MockOPServer.normalize_trust_anchors(MockOPServer::TRUST_ANCHORS)
|
|
447
|
+
)
|
|
448
|
+
trust_chain = resolver.resolve!
|
|
449
|
+
|
|
450
|
+
# Extract RP metadata from trust chain
|
|
451
|
+
leaf_statement = trust_chain.first
|
|
452
|
+
leaf_parsed = leaf_statement.is_a?(Hash) ? leaf_statement : leaf_statement.parse
|
|
453
|
+
leaf_metadata = extract_metadata_from_parsed(leaf_parsed)
|
|
454
|
+
|
|
455
|
+
# Merge metadata policies
|
|
456
|
+
merger = OmniauthOpenidFederation::Federation::MetadataPolicyMerger.new(trust_chain: trust_chain)
|
|
457
|
+
rp_effective_metadata = merger.merge_and_apply(leaf_metadata)
|
|
458
|
+
rescue => e
|
|
459
|
+
return error_response(res, "invalid_client", "Failed to resolve client trust chain: #{e.message}")
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Extract redirect_uri from effective metadata if available
|
|
464
|
+
if rp_effective_metadata
|
|
465
|
+
rp_metadata = rp_effective_metadata[:openid_relying_party] || rp_effective_metadata["openid_relying_party"]
|
|
466
|
+
if rp_metadata
|
|
467
|
+
allowed_redirect_uris = rp_metadata[:redirect_uris] || rp_metadata["redirect_uris"] || []
|
|
468
|
+
unless allowed_redirect_uris.include?(redirect_uri)
|
|
469
|
+
return error_response(res, "invalid_request", "redirect_uri not in client's allowed redirect_uris")
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Generate authorization code
|
|
475
|
+
code = SecureRandom.hex(32)
|
|
476
|
+
MockOPServer::AUTHORIZATION_CODES[code] = {
|
|
477
|
+
client_id: client_id,
|
|
478
|
+
redirect_uri: redirect_uri,
|
|
479
|
+
state: state,
|
|
480
|
+
nonce: nonce,
|
|
481
|
+
created_at: Time.now
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
# Redirect back to RP with authorization code
|
|
485
|
+
redirect_uri_with_code = URI.parse(redirect_uri)
|
|
486
|
+
query_params = redirect_uri_with_code.query ? CGI.parse(redirect_uri_with_code.query) : {}
|
|
487
|
+
query_params["code"] = [code]
|
|
488
|
+
query_params["state"] = [state] if state
|
|
489
|
+
query_params["iss"] = [MockOPServer::ENTITY_ID]
|
|
490
|
+
|
|
491
|
+
redirect_uri_with_code.query = URI.encode_www_form(query_params.flatten.map { |k, v| [k, v] }.flatten)
|
|
492
|
+
res.status = 302
|
|
493
|
+
res["Location"] = redirect_uri_with_code.to_s
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def handle_token(req, res, params)
|
|
497
|
+
grant_type = params["grant_type"]
|
|
498
|
+
code = params["code"]
|
|
499
|
+
redirect_uri = params["redirect_uri"]
|
|
500
|
+
params["client_id"]
|
|
501
|
+
|
|
502
|
+
unless grant_type == "authorization_code"
|
|
503
|
+
return error_response(res, "unsupported_grant_type", "Only authorization_code grant type is supported")
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
unless code
|
|
507
|
+
return error_response(res, "invalid_request", "Missing authorization code")
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
code_data = MockOPServer::AUTHORIZATION_CODES.delete(code)
|
|
511
|
+
unless code_data
|
|
512
|
+
return error_response(res, "invalid_grant", "Invalid or expired authorization code", status: 401)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Validate redirect_uri matches
|
|
516
|
+
if redirect_uri && redirect_uri != code_data[:redirect_uri]
|
|
517
|
+
return error_response(res, "invalid_grant", "redirect_uri mismatch", status: 401)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Generate ID Token
|
|
521
|
+
id_token = generate_id_token(
|
|
522
|
+
client_id: code_data[:client_id],
|
|
523
|
+
nonce: code_data[:nonce]
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Generate Access Token (mock)
|
|
527
|
+
access_token = SecureRandom.hex(32)
|
|
528
|
+
|
|
529
|
+
json_response(res, {
|
|
530
|
+
access_token: access_token,
|
|
531
|
+
token_type: "Bearer",
|
|
532
|
+
expires_in: 3600,
|
|
533
|
+
id_token: id_token
|
|
534
|
+
})
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def handle_userinfo(req, res)
|
|
538
|
+
json_response(res, {
|
|
539
|
+
sub: "user123",
|
|
540
|
+
name: "Test User",
|
|
541
|
+
email: "test@example.com"
|
|
542
|
+
})
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def is_entity_id?(str)
|
|
546
|
+
str.is_a?(String) && str.start_with?("http://", "https://")
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def extract_metadata_from_parsed(parsed)
|
|
550
|
+
metadata = parsed[:metadata] || parsed["metadata"] || {}
|
|
551
|
+
result = {}
|
|
552
|
+
metadata.each do |entity_type, entity_metadata|
|
|
553
|
+
result[entity_type.to_sym] = entity_metadata
|
|
554
|
+
end
|
|
555
|
+
result
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def validate_request_object(request_jwt, error_mode = nil)
|
|
559
|
+
# Check if it's encrypted (JWE - 5 parts)
|
|
560
|
+
parts_count = request_jwt.split(".").length
|
|
561
|
+
if parts_count == 5
|
|
562
|
+
# Error injection: malformed encrypted request (triggered by error_mode)
|
|
563
|
+
if error_mode == "malformed_encryption"
|
|
564
|
+
raise OmniauthOpenidFederation::DecryptionError, "Malformed encrypted request object"
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Try to decrypt - will fail if wrong key was used
|
|
568
|
+
begin
|
|
569
|
+
request_jwt = JWE.decrypt(request_jwt, MockOPServer::ENCRYPTION_KEY)
|
|
570
|
+
rescue => e
|
|
571
|
+
raise OmniauthOpenidFederation::DecryptionError, "Failed to decrypt request object: #{e.message}"
|
|
572
|
+
end
|
|
573
|
+
elsif parts_count != 3 && error_mode == "malformed_encryption"
|
|
574
|
+
# Not a valid JWT or JWE format
|
|
575
|
+
raise OmniauthOpenidFederation::DecryptionError, "Malformed encrypted request object"
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Decode and validate the request object
|
|
579
|
+
parts = request_jwt.split(".")
|
|
580
|
+
raise OmniauthOpenidFederation::ValidationError, "Invalid JWT format" if parts.length != 3
|
|
581
|
+
|
|
582
|
+
header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
|
|
583
|
+
payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
|
|
584
|
+
|
|
585
|
+
# Validate algorithm
|
|
586
|
+
alg = header["alg"] || header[:alg]
|
|
587
|
+
unless alg == "RS256"
|
|
588
|
+
raise OmniauthOpenidFederation::ValidationError, "Unsupported algorithm: #{alg}"
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Extract client_id to get their JWKS
|
|
592
|
+
client_id = payload["client_id"] || payload[:client_id]
|
|
593
|
+
unless client_id
|
|
594
|
+
raise OmniauthOpenidFederation::ValidationError, "Missing client_id in request object"
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Fetch client's entity statement to get their JWKS
|
|
598
|
+
if is_entity_id?(client_id)
|
|
599
|
+
begin
|
|
600
|
+
# If client_id is localhost, use direct localhost URL (for isolation)
|
|
601
|
+
client_url = if client_id.include?("localhost")
|
|
602
|
+
port = client_id.match(/:(\d+)/)&.captures&.first || "9293"
|
|
603
|
+
"http://localhost:#{port}/.well-known/openid-federation"
|
|
604
|
+
else
|
|
605
|
+
"#{client_id}/.well-known/openid-federation"
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
client_statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
|
|
609
|
+
client_url
|
|
610
|
+
)
|
|
611
|
+
client_parsed = client_statement.parse
|
|
612
|
+
client_jwks = client_parsed[:jwks] || client_parsed["jwks"] || {}
|
|
613
|
+
client_keys = client_jwks[:keys] || client_jwks["keys"] || []
|
|
614
|
+
|
|
615
|
+
# Find the key used for signing
|
|
616
|
+
kid = header["kid"] || header[:kid]
|
|
617
|
+
signing_key_data = client_keys.find { |k| (k["kid"] || k[:kid]) == kid }
|
|
618
|
+
|
|
619
|
+
unless signing_key_data
|
|
620
|
+
raise OmniauthOpenidFederation::ValidationError, "Signing key not found in client JWKS"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Convert JWK to OpenSSL key
|
|
624
|
+
public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
|
|
625
|
+
|
|
626
|
+
# Verify signature
|
|
627
|
+
JWT.decode(request_jwt, public_key, true, {algorithm: "RS256"})
|
|
628
|
+
rescue => e
|
|
629
|
+
raise OmniauthOpenidFederation::ValidationError, "Request object validation failed: #{e.message}"
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
payload
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def generate_id_token(client_id:, nonce: nil)
|
|
637
|
+
now = Time.now.to_i
|
|
638
|
+
jwks = OmniauthOpenidFederation::FederationEndpoint.current_jwks
|
|
639
|
+
signing_key = OmniauthOpenidFederation::FederationEndpoint.configuration.private_key
|
|
640
|
+
kid = jwks["keys"]&.first&.dig("kid") || jwks[:keys]&.first&.dig(:kid)
|
|
641
|
+
|
|
642
|
+
payload = {
|
|
643
|
+
iss: MockOPServer::ENTITY_ID,
|
|
644
|
+
sub: "user123",
|
|
645
|
+
aud: client_id,
|
|
646
|
+
exp: now + 3600,
|
|
647
|
+
iat: now,
|
|
648
|
+
nonce: nonce
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
header = {
|
|
652
|
+
alg: "RS256",
|
|
653
|
+
typ: "JWT",
|
|
654
|
+
kid: kid
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
JWT.encode(payload, signing_key, "RS256", header)
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def generate_expired_entity_statement
|
|
661
|
+
jwk = JWT::JWK.new(MockOPServer::SIGNING_KEY.public_key)
|
|
662
|
+
jwk_export = jwk.export
|
|
663
|
+
jwk_export[:kid] = jwk_export[:kid] || SecureRandom.hex(16)
|
|
664
|
+
|
|
665
|
+
payload = {
|
|
666
|
+
iss: MockOPServer::ENTITY_ID,
|
|
667
|
+
sub: MockOPServer::ENTITY_ID,
|
|
668
|
+
iat: Time.now.to_i - 7200,
|
|
669
|
+
exp: Time.now.to_i - 3600,
|
|
670
|
+
jwks: {keys: [jwk_export]},
|
|
671
|
+
metadata: {
|
|
672
|
+
openid_provider: MockOPServer::OP_METADATA
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
header = {alg: "RS256", typ: "entity-statement+jwt", kid: jwk_export[:kid]}
|
|
677
|
+
JWT.encode(payload, MockOPServer::SIGNING_KEY, "RS256", header)
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def generate_entity_statement_with_wrong_keys
|
|
681
|
+
wrong_key = OpenSSL::PKey::RSA.new(2048)
|
|
682
|
+
jwk = JWT::JWK.new(wrong_key.public_key)
|
|
683
|
+
jwk_export = jwk.export
|
|
684
|
+
jwk_export[:kid] = jwk_export[:kid] || SecureRandom.hex(16)
|
|
685
|
+
|
|
686
|
+
payload = {
|
|
687
|
+
iss: MockOPServer::ENTITY_ID,
|
|
688
|
+
sub: MockOPServer::ENTITY_ID,
|
|
689
|
+
iat: Time.now.to_i,
|
|
690
|
+
exp: Time.now.to_i + 3600,
|
|
691
|
+
jwks: {keys: [jwk_export]},
|
|
692
|
+
metadata: {
|
|
693
|
+
openid_provider: MockOPServer::OP_METADATA
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
header = {alg: "RS256", typ: "entity-statement+jwt", kid: jwk_export[:kid]}
|
|
698
|
+
JWT.encode(payload, MockOPServer::SIGNING_KEY, "RS256", header)
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def generate_entity_statement_without_metadata
|
|
702
|
+
jwk = JWT::JWK.new(MockOPServer::SIGNING_KEY.public_key)
|
|
703
|
+
jwk_export = jwk.export
|
|
704
|
+
jwk_export[:kid] = jwk_export[:kid] || SecureRandom.hex(16)
|
|
705
|
+
|
|
706
|
+
payload = {
|
|
707
|
+
iss: MockOPServer::ENTITY_ID,
|
|
708
|
+
sub: MockOPServer::ENTITY_ID,
|
|
709
|
+
iat: Time.now.to_i,
|
|
710
|
+
exp: Time.now.to_i + 3600,
|
|
711
|
+
jwks: {keys: [jwk_export]}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
header = {alg: "RS256", typ: "entity-statement+jwt", kid: jwk_export[:kid]}
|
|
715
|
+
JWT.encode(payload, MockOPServer::SIGNING_KEY, "RS256", header)
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def generate_invalid_signed_jwks
|
|
719
|
+
wrong_key = OpenSSL::PKey::RSA.new(2048)
|
|
720
|
+
jwk = JWT::JWK.new(wrong_key.public_key)
|
|
721
|
+
jwk_export = jwk.export
|
|
722
|
+
|
|
723
|
+
payload = {
|
|
724
|
+
keys: [jwk_export]
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
header = {alg: "RS256", typ: "JWT", kid: jwk_export[:kid]}
|
|
728
|
+
JWT.encode(payload, wrong_key, "RS256", header)
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def self.run!
|
|
733
|
+
port = ENV["PORT"]&.to_i || 9292
|
|
734
|
+
bind = ENV["BIND"] || "localhost"
|
|
735
|
+
|
|
736
|
+
server = WEBrick::HTTPServer.new(Port: port, BindAddress: bind)
|
|
737
|
+
server.mount("/", Servlet)
|
|
738
|
+
|
|
739
|
+
trap("INT") { server.shutdown }
|
|
740
|
+
trap("TERM") { server.shutdown }
|
|
741
|
+
|
|
742
|
+
server.start
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
# Run the server
|
|
747
|
+
if __FILE__ == $0
|
|
748
|
+
# Configure federation endpoint before starting
|
|
749
|
+
MockOPServer.configure_federation_endpoint
|
|
750
|
+
MockOPServer.configure_subordinate_statements
|
|
751
|
+
|
|
752
|
+
puts "Starting Enhanced Mock OP Server..."
|
|
753
|
+
puts "Entity ID: #{MockOPServer::ENTITY_ID}"
|
|
754
|
+
puts "Server: http://#{MockOPServer::SERVER_HOST}"
|
|
755
|
+
puts ""
|
|
756
|
+
puts "Endpoints:"
|
|
757
|
+
puts " GET /.well-known/openid-federation - Entity Configuration"
|
|
758
|
+
puts " GET /.well-known/openid-federation/fetch?sub=<entity_id> - Fetch Subordinate Statement"
|
|
759
|
+
puts " GET /.well-known/jwks.json - JWKS"
|
|
760
|
+
puts " GET /.well-known/signed-jwks.json - Signed JWKS"
|
|
761
|
+
puts " GET /auth?request=<signed_jwt> - Authorization (with request object validation)"
|
|
762
|
+
puts " POST /token - Token Exchange"
|
|
763
|
+
puts " GET /userinfo - UserInfo (mock)"
|
|
764
|
+
puts ""
|
|
765
|
+
puts "Error Injection Modes (add ?error_mode=<mode> to any endpoint):"
|
|
766
|
+
puts " - invalid_statement: Return invalid entity statement"
|
|
767
|
+
puts " - wrong_keys: Return wrong JWKS keys"
|
|
768
|
+
puts " - invalid_request: Reject request object"
|
|
769
|
+
puts " - invalid_signature: Return invalid signature"
|
|
770
|
+
puts " - expired_statement: Return expired entity statement"
|
|
771
|
+
puts " - missing_metadata: Return statement without metadata"
|
|
772
|
+
puts ""
|
|
773
|
+
|
|
774
|
+
MockOPServer.run!
|
|
775
|
+
end
|