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,1334 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Integration Test Flow for OpenID Federation
|
|
5
|
+
#
|
|
6
|
+
# This script tests the complete OpenID Federation flow:
|
|
7
|
+
# 1. Provider exposes entity statement with JWKS
|
|
8
|
+
# 2. Client exposes entity statement with JWKS
|
|
9
|
+
# 3. Client fetches provider statement with keys
|
|
10
|
+
# 4. Client sends login request (with signed request object)
|
|
11
|
+
# 5. Provider fetches client statement and keys
|
|
12
|
+
# 6. Exchange and authenticated login
|
|
13
|
+
#
|
|
14
|
+
# It also tests error scenarios:
|
|
15
|
+
# - Wrong statements
|
|
16
|
+
# - Wrong keys
|
|
17
|
+
# - Wrong request encryption, validation failure
|
|
18
|
+
# - Other edge cases
|
|
19
|
+
#
|
|
20
|
+
# Usage:
|
|
21
|
+
# ruby examples/integration_test_flow.rb
|
|
22
|
+
#
|
|
23
|
+
# Environment Variables:
|
|
24
|
+
# OP_URL - OP server URL (default: http://localhost:9292)
|
|
25
|
+
# RP_URL - RP server URL (default: http://localhost:9293)
|
|
26
|
+
# OP_PORT - OP server port (default: 9292)
|
|
27
|
+
# RP_PORT - RP server port (default: 9293)
|
|
28
|
+
# OP_ENTITY_ID - OP entity ID (default: https://op.example.com)
|
|
29
|
+
# RP_ENTITY_ID - RP entity ID (default: https://rp.example.com)
|
|
30
|
+
# TMP_DIR - Temporary directory for keys/configs (default: tmp/integration_test)
|
|
31
|
+
# AUTO_START_SERVERS - Auto-start servers (default: true)
|
|
32
|
+
# CLEANUP_ON_EXIT - Clean up tmp dirs on exit (default: true)
|
|
33
|
+
# KEY_TYPE - Key type: 'single' or 'separate' (default: separate)
|
|
34
|
+
|
|
35
|
+
require "bundler/setup"
|
|
36
|
+
require "net/http"
|
|
37
|
+
require "uri"
|
|
38
|
+
require "json"
|
|
39
|
+
require "base64"
|
|
40
|
+
require "openssl"
|
|
41
|
+
require "securerandom"
|
|
42
|
+
require "fileutils"
|
|
43
|
+
require "timeout"
|
|
44
|
+
require "open3"
|
|
45
|
+
require "jwe"
|
|
46
|
+
require "jwt"
|
|
47
|
+
|
|
48
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
49
|
+
require "omniauth_openid_federation"
|
|
50
|
+
|
|
51
|
+
class IntegrationTestFlow
|
|
52
|
+
OP_URL = ENV["OP_URL"] || "http://localhost:9292"
|
|
53
|
+
RP_URL = ENV["RP_URL"] || "http://localhost:9293"
|
|
54
|
+
OP_PORT = ENV["OP_PORT"]&.to_i || 9292
|
|
55
|
+
RP_PORT = ENV["RP_PORT"]&.to_i || 9293
|
|
56
|
+
# Use localhost URLs as entity IDs for complete isolation
|
|
57
|
+
OP_ENTITY_ID = ENV["OP_ENTITY_ID"] || "http://localhost:9292"
|
|
58
|
+
RP_ENTITY_ID = ENV["RP_ENTITY_ID"] || "http://localhost:9293"
|
|
59
|
+
TMP_DIR = ENV["TMP_DIR"] || File.join(Dir.pwd, "tmp", "integration_test")
|
|
60
|
+
AUTO_START_SERVERS = ENV["AUTO_START_SERVERS"] != "false"
|
|
61
|
+
CLEANUP_ON_EXIT = ENV["CLEANUP_ON_EXIT"] != "false"
|
|
62
|
+
KEY_TYPE = ENV["KEY_TYPE"] || "separate"
|
|
63
|
+
|
|
64
|
+
def initialize
|
|
65
|
+
@test_results = []
|
|
66
|
+
@tmp_dir = File.expand_path(TMP_DIR)
|
|
67
|
+
@op_pid = nil
|
|
68
|
+
@rp_pid = nil
|
|
69
|
+
@op_keys_dir = File.join(@tmp_dir, "op_keys")
|
|
70
|
+
@rp_keys_dir = File.join(@tmp_dir, "rp_keys")
|
|
71
|
+
@op_config_dir = File.join(@tmp_dir, "op_config")
|
|
72
|
+
@rp_config_dir = File.join(@tmp_dir, "rp_config")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def run
|
|
76
|
+
setup_directories
|
|
77
|
+
generate_keys
|
|
78
|
+
configure_servers
|
|
79
|
+
start_servers if AUTO_START_SERVERS
|
|
80
|
+
wait_for_servers
|
|
81
|
+
run_all_tests
|
|
82
|
+
ensure
|
|
83
|
+
cleanup if CLEANUP_ON_EXIT
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def run_all_tests
|
|
87
|
+
puts "=" * 80
|
|
88
|
+
puts "OpenID Federation Integration Tests"
|
|
89
|
+
puts "=" * 80
|
|
90
|
+
puts ""
|
|
91
|
+
puts "Configuration:"
|
|
92
|
+
puts " OP URL: #{OP_URL}"
|
|
93
|
+
puts " RP URL: #{RP_URL}"
|
|
94
|
+
puts " OP Entity ID: #{OP_ENTITY_ID} (localhost - no DNS needed)"
|
|
95
|
+
puts " RP Entity ID: #{RP_ENTITY_ID} (localhost - no DNS needed)"
|
|
96
|
+
puts " Tmp Dir: #{@tmp_dir}"
|
|
97
|
+
puts " Isolation: Complete localhost isolation, no external dependencies"
|
|
98
|
+
puts ""
|
|
99
|
+
|
|
100
|
+
test_suite("Happy Path Flow") do
|
|
101
|
+
test_provider_exposes_entity_statement
|
|
102
|
+
test_client_exposes_entity_statement
|
|
103
|
+
test_client_fetches_provider_statement
|
|
104
|
+
test_client_sends_login_request
|
|
105
|
+
test_provider_fetches_client_statement
|
|
106
|
+
test_exchange_and_authenticated_login
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
test_suite("Error Scenarios") do
|
|
110
|
+
test_wrong_entity_statement
|
|
111
|
+
test_wrong_jwks_keys
|
|
112
|
+
test_invalid_request_object
|
|
113
|
+
test_expired_entity_statement
|
|
114
|
+
test_missing_metadata
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
test_suite("Request Object Encryption") do
|
|
118
|
+
test_encrypted_request_object
|
|
119
|
+
test_invalid_encryption_key
|
|
120
|
+
test_malformed_encrypted_request
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
test_suite("ID Token Validation") do
|
|
124
|
+
test_id_token_validation_with_trust_chain
|
|
125
|
+
test_invalid_id_token_signature
|
|
126
|
+
test_expired_id_token
|
|
127
|
+
test_id_token_wrong_audience
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
test_suite("Entity Statement Validation") do
|
|
131
|
+
test_invalid_entity_statement_signature
|
|
132
|
+
test_wrong_algorithm_entity_statement
|
|
133
|
+
test_missing_required_claims_entity_statement
|
|
134
|
+
test_invalid_jwt_typ_entity_statement
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
test_suite("Signed JWKS Endpoint") do
|
|
138
|
+
test_signed_jwks_endpoint
|
|
139
|
+
test_invalid_signed_jwks_signature
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
test_suite("Request Object Validation Details") do
|
|
143
|
+
test_request_object_missing_required_claims
|
|
144
|
+
test_request_object_invalid_nonce
|
|
145
|
+
test_request_object_expiration
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
print_summary
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def setup_directories
|
|
154
|
+
puts "Setting up directories..."
|
|
155
|
+
[@tmp_dir, @op_keys_dir, @rp_keys_dir, @op_config_dir, @rp_config_dir].each do |dir|
|
|
156
|
+
FileUtils.mkdir_p(dir)
|
|
157
|
+
end
|
|
158
|
+
puts " Created: #{@tmp_dir}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def generate_keys
|
|
162
|
+
puts "Generating keys..."
|
|
163
|
+
puts " Key type: #{KEY_TYPE}"
|
|
164
|
+
|
|
165
|
+
# Generate OP keys
|
|
166
|
+
op_result = if KEY_TYPE == "separate"
|
|
167
|
+
OmniauthOpenidFederation::TasksHelper.prepare_client_keys(
|
|
168
|
+
key_type: "separate",
|
|
169
|
+
output_dir: @op_keys_dir
|
|
170
|
+
)
|
|
171
|
+
else
|
|
172
|
+
OmniauthOpenidFederation::TasksHelper.prepare_client_keys(
|
|
173
|
+
key_type: "single",
|
|
174
|
+
output_dir: @op_keys_dir
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Generate RP keys
|
|
179
|
+
rp_result = if KEY_TYPE == "separate"
|
|
180
|
+
OmniauthOpenidFederation::TasksHelper.prepare_client_keys(
|
|
181
|
+
key_type: "separate",
|
|
182
|
+
output_dir: @rp_keys_dir
|
|
183
|
+
)
|
|
184
|
+
else
|
|
185
|
+
OmniauthOpenidFederation::TasksHelper.prepare_client_keys(
|
|
186
|
+
key_type: "single",
|
|
187
|
+
output_dir: @rp_keys_dir
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Handle both single and separate key types
|
|
192
|
+
@op_signing_key_path = op_result[:signing_key_path] || op_result[:private_key_path]
|
|
193
|
+
@op_encryption_key_path = op_result[:encryption_key_path] || op_result[:private_key_path] || @op_signing_key_path
|
|
194
|
+
@rp_signing_key_path = rp_result[:signing_key_path] || rp_result[:private_key_path]
|
|
195
|
+
@rp_encryption_key_path = rp_result[:encryption_key_path] || rp_result[:private_key_path] || @rp_signing_key_path
|
|
196
|
+
|
|
197
|
+
puts " OP keys: #{@op_signing_key_path}"
|
|
198
|
+
puts " RP keys: #{@rp_signing_key_path}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def configure_servers
|
|
202
|
+
puts "Configuring servers..."
|
|
203
|
+
|
|
204
|
+
# Load keys
|
|
205
|
+
@op_signing_key = OpenSSL::PKey::RSA.new(File.read(@op_signing_key_path))
|
|
206
|
+
@op_encryption_key = OpenSSL::PKey::RSA.new(File.read(@op_encryption_key_path))
|
|
207
|
+
@rp_signing_key = OpenSSL::PKey::RSA.new(File.read(@rp_signing_key_path))
|
|
208
|
+
@rp_encryption_key = OpenSSL::PKey::RSA.new(File.read(@rp_encryption_key_path))
|
|
209
|
+
|
|
210
|
+
# Set environment variables for servers
|
|
211
|
+
# Use localhost URLs to ensure complete isolation
|
|
212
|
+
ENV["OP_ENTITY_ID"] = OP_ENTITY_ID
|
|
213
|
+
ENV["OP_SERVER_HOST"] = "localhost:#{OP_PORT}"
|
|
214
|
+
ENV["OP_SIGNING_KEY"] = @op_signing_key.to_pem
|
|
215
|
+
ENV["OP_ENCRYPTION_KEY"] = @op_encryption_key.to_pem
|
|
216
|
+
ENV["PORT"] = OP_PORT.to_s
|
|
217
|
+
# Override default metadata to use localhost URLs
|
|
218
|
+
op_metadata = {
|
|
219
|
+
"issuer" => OP_ENTITY_ID,
|
|
220
|
+
"authorization_endpoint" => "#{OP_URL}/auth",
|
|
221
|
+
"token_endpoint" => "#{OP_URL}/token",
|
|
222
|
+
"userinfo_endpoint" => "#{OP_URL}/userinfo",
|
|
223
|
+
"jwks_uri" => "#{OP_URL}/.well-known/jwks.json",
|
|
224
|
+
"signed_jwks_uri" => "#{OP_URL}/.well-known/signed-jwks.json",
|
|
225
|
+
"client_registration_types_supported" => ["automatic", "explicit"],
|
|
226
|
+
"response_types_supported" => ["code"],
|
|
227
|
+
"grant_types_supported" => ["authorization_code"],
|
|
228
|
+
"id_token_signing_alg_values_supported" => ["RS256"],
|
|
229
|
+
"id_token_encryption_alg_values_supported" => ["RSA-OAEP"],
|
|
230
|
+
"id_token_encryption_enc_values_supported" => ["A128CBC-HS256"],
|
|
231
|
+
"request_object_signing_alg_values_supported" => ["RS256"],
|
|
232
|
+
"request_object_encryption_alg_values_supported" => ["RSA-OAEP"],
|
|
233
|
+
"request_object_encryption_enc_values_supported" => ["A128CBC-HS256"],
|
|
234
|
+
"scopes_supported" => ["openid", "profile", "email"]
|
|
235
|
+
}
|
|
236
|
+
ENV["OP_METADATA"] = op_metadata.to_json
|
|
237
|
+
|
|
238
|
+
ENV["RP_ENTITY_ID"] = RP_ENTITY_ID
|
|
239
|
+
ENV["RP_SERVER_HOST"] = "localhost:#{RP_PORT}"
|
|
240
|
+
ENV["RP_SIGNING_KEY"] = @rp_signing_key.to_pem
|
|
241
|
+
ENV["RP_ENCRYPTION_KEY"] = @rp_encryption_key.to_pem
|
|
242
|
+
ENV["RP_PORT"] = RP_PORT.to_s
|
|
243
|
+
ENV["RP_REDIRECT_URIS"] = "#{RP_URL}/callback"
|
|
244
|
+
|
|
245
|
+
puts " Environment variables configured"
|
|
246
|
+
puts " OP Entity ID: #{OP_ENTITY_ID}"
|
|
247
|
+
puts " RP Entity ID: #{RP_ENTITY_ID}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def start_servers
|
|
251
|
+
puts "Starting servers..."
|
|
252
|
+
|
|
253
|
+
op_script = File.expand_path("../examples/mock_op_server.rb", __dir__)
|
|
254
|
+
rp_script = File.expand_path("../examples/mock_rp_server.rb", __dir__)
|
|
255
|
+
|
|
256
|
+
# Start OP server
|
|
257
|
+
puts " Starting OP server on port #{OP_PORT}..."
|
|
258
|
+
@op_pid = spawn_server(op_script, "OP", OP_PORT)
|
|
259
|
+
|
|
260
|
+
# Start RP server
|
|
261
|
+
puts " Starting RP server on port #{RP_PORT}..."
|
|
262
|
+
@rp_pid = spawn_server(rp_script, "RP", RP_PORT)
|
|
263
|
+
|
|
264
|
+
puts " Servers started (OP PID: #{@op_pid}, RP PID: #{@rp_pid})"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def spawn_server(script, name, port)
|
|
268
|
+
# Use spawn to run server in background with bundle exec
|
|
269
|
+
log_file = File.join(@tmp_dir, "#{name.downcase}_server.log")
|
|
270
|
+
err_file = File.join(@tmp_dir, "#{name.downcase}_server_error.log")
|
|
271
|
+
|
|
272
|
+
script_path = File.expand_path(script, __dir__)
|
|
273
|
+
project_root = File.expand_path("..", __dir__)
|
|
274
|
+
|
|
275
|
+
# Use bundle exec to ensure all dependencies (jwt, jwe, etc.) are available
|
|
276
|
+
pid = Process.spawn(
|
|
277
|
+
{
|
|
278
|
+
"RUBYOPT" => "-W0", # Suppress warnings
|
|
279
|
+
"BUNDLE_GEMFILE" => File.join(project_root, "Gemfile")
|
|
280
|
+
},
|
|
281
|
+
"bundle", "exec", "ruby", script_path,
|
|
282
|
+
out: [log_file, "w"],
|
|
283
|
+
err: [err_file, "w"],
|
|
284
|
+
chdir: project_root # Run from project root
|
|
285
|
+
)
|
|
286
|
+
Process.detach(pid)
|
|
287
|
+
|
|
288
|
+
# Give the process a moment to start
|
|
289
|
+
sleep 0.2
|
|
290
|
+
|
|
291
|
+
# Verify process is still running
|
|
292
|
+
begin
|
|
293
|
+
Process.kill(0, pid)
|
|
294
|
+
rescue Errno::ESRCH
|
|
295
|
+
# Process died immediately - check error log
|
|
296
|
+
sleep 0.1 # Give it a moment to write error log
|
|
297
|
+
if File.exist?(err_file) && File.size(err_file) > 0
|
|
298
|
+
error_content = File.read(err_file)
|
|
299
|
+
raise "Server #{name} failed to start. Error: #{error_content}"
|
|
300
|
+
else
|
|
301
|
+
raise "Server #{name} failed to start (process died immediately). Check #{err_file}"
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
pid
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def wait_for_servers
|
|
309
|
+
return unless AUTO_START_SERVERS
|
|
310
|
+
|
|
311
|
+
puts "Waiting for servers to be ready..."
|
|
312
|
+
# Give servers a moment to start binding to ports
|
|
313
|
+
sleep 0.5
|
|
314
|
+
|
|
315
|
+
max_attempts = 20 # Reduced from 30
|
|
316
|
+
check_interval = 0.2 # Check every 200ms instead of 500ms
|
|
317
|
+
|
|
318
|
+
[OP_URL, RP_URL].each do |url|
|
|
319
|
+
attempt = 0
|
|
320
|
+
ready = false
|
|
321
|
+
server_name = url.include?("9292") ? "OP" : "RP"
|
|
322
|
+
start_time = Time.now
|
|
323
|
+
|
|
324
|
+
while attempt < max_attempts && !ready
|
|
325
|
+
begin
|
|
326
|
+
uri = URI.parse("#{url}/")
|
|
327
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
328
|
+
http.open_timeout = 0.5 # Reduced timeout
|
|
329
|
+
http.read_timeout = 0.5
|
|
330
|
+
response = http.get(uri.path)
|
|
331
|
+
|
|
332
|
+
if response.code == "200"
|
|
333
|
+
elapsed = (Time.now - start_time).round(1)
|
|
334
|
+
puts " ✓ #{url} is ready (#{elapsed}s)"
|
|
335
|
+
ready = true
|
|
336
|
+
end
|
|
337
|
+
rescue
|
|
338
|
+
# Unexpected error - ignore for now
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
unless ready
|
|
342
|
+
attempt += 1
|
|
343
|
+
# Show progress every 5 attempts (1 second)
|
|
344
|
+
if attempt % 5 == 0
|
|
345
|
+
elapsed = (Time.now - start_time).round(1)
|
|
346
|
+
print "."
|
|
347
|
+
end
|
|
348
|
+
sleep check_interval
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
unless ready
|
|
353
|
+
elapsed = (Time.now - start_time).round(1)
|
|
354
|
+
puts "\n ✗ #{server_name} server at #{url} did not become ready in time (#{elapsed}s)"
|
|
355
|
+
err_log = File.join(@tmp_dir, "#{server_name.downcase}_server_error.log")
|
|
356
|
+
log_file = File.join(@tmp_dir, "#{server_name.downcase}_server.log")
|
|
357
|
+
|
|
358
|
+
if File.exist?(err_log) && File.size(err_log) > 0
|
|
359
|
+
puts " Error log:"
|
|
360
|
+
File.readlines(err_log).last(5).each { |line| puts " #{line.chomp}" }
|
|
361
|
+
end
|
|
362
|
+
if File.exist?(log_file) && File.size(log_file) > 0
|
|
363
|
+
puts " Last log entries:"
|
|
364
|
+
File.readlines(log_file).last(5).each { |line| puts " #{line.chomp}" }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
raise "#{server_name} server at #{url} did not become ready after #{elapsed} seconds"
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
puts ""
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def test_suite(name)
|
|
374
|
+
puts "Testing: #{name}"
|
|
375
|
+
puts "-" * 80
|
|
376
|
+
yield
|
|
377
|
+
puts ""
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def test(name)
|
|
381
|
+
print " ✓ #{name}... "
|
|
382
|
+
begin
|
|
383
|
+
result = yield
|
|
384
|
+
if result
|
|
385
|
+
puts "PASS"
|
|
386
|
+
@test_results << {name: name, status: :pass}
|
|
387
|
+
else
|
|
388
|
+
puts "FAIL"
|
|
389
|
+
@test_results << {name: name, status: :fail}
|
|
390
|
+
end
|
|
391
|
+
rescue => e
|
|
392
|
+
puts "ERROR: #{e.message}"
|
|
393
|
+
@test_results << {name: name, status: :error, error: e.message}
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def test_provider_exposes_entity_statement
|
|
398
|
+
test("Provider exposes entity statement with JWKS") do
|
|
399
|
+
uri = URI.parse("#{OP_URL}/.well-known/openid-federation")
|
|
400
|
+
response = Net::HTTP.get_response(uri)
|
|
401
|
+
|
|
402
|
+
return false unless response.code == "200"
|
|
403
|
+
return false unless response.content_type == "application/jwt"
|
|
404
|
+
|
|
405
|
+
jwt = response.body
|
|
406
|
+
parts = jwt.split(".")
|
|
407
|
+
return false unless parts.length == 3
|
|
408
|
+
|
|
409
|
+
payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
|
|
410
|
+
return false unless payload["iss"]
|
|
411
|
+
return false unless payload["jwks"]
|
|
412
|
+
return false unless payload["jwks"]["keys"]
|
|
413
|
+
|
|
414
|
+
true
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def test_client_exposes_entity_statement
|
|
419
|
+
test("Client exposes entity statement with JWKS") do
|
|
420
|
+
uri = URI.parse("#{RP_URL}/.well-known/openid-federation")
|
|
421
|
+
response = Net::HTTP.get_response(uri)
|
|
422
|
+
|
|
423
|
+
return false unless response.code == "200"
|
|
424
|
+
return false unless response.content_type == "application/jwt"
|
|
425
|
+
|
|
426
|
+
jwt = response.body
|
|
427
|
+
parts = jwt.split(".")
|
|
428
|
+
return false unless parts.length == 3
|
|
429
|
+
|
|
430
|
+
payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
|
|
431
|
+
return false unless payload["iss"]
|
|
432
|
+
return false unless payload["jwks"]
|
|
433
|
+
return false unless payload["jwks"]["keys"]
|
|
434
|
+
|
|
435
|
+
true
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def test_client_fetches_provider_statement
|
|
440
|
+
test("Client fetches provider statement with keys") do
|
|
441
|
+
# Use localhost URL directly (no DNS needed)
|
|
442
|
+
uri = URI.parse("#{OP_URL}/.well-known/openid-federation")
|
|
443
|
+
response = Net::HTTP.get_response(uri)
|
|
444
|
+
|
|
445
|
+
return false unless response.code == "200"
|
|
446
|
+
|
|
447
|
+
statement = OmniauthOpenidFederation::Federation::EntityStatement.new(response.body)
|
|
448
|
+
metadata = statement.parse
|
|
449
|
+
|
|
450
|
+
return false unless metadata[:issuer]
|
|
451
|
+
return false unless metadata[:jwks]
|
|
452
|
+
# Verify issuer uses localhost (no external DNS)
|
|
453
|
+
return false unless metadata[:issuer].include?("localhost")
|
|
454
|
+
|
|
455
|
+
true
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def test_client_sends_login_request
|
|
460
|
+
test("Client sends login request with signed request object") do
|
|
461
|
+
redirect_uri = "#{RP_URL}/callback"
|
|
462
|
+
client_id = RP_ENTITY_ID
|
|
463
|
+
provider_entity_id = OP_ENTITY_ID
|
|
464
|
+
|
|
465
|
+
# Verify we're using localhost URLs (no DNS needed)
|
|
466
|
+
return false unless client_id.include?("localhost")
|
|
467
|
+
return false unless provider_entity_id.include?("localhost")
|
|
468
|
+
|
|
469
|
+
jws = OmniauthOpenidFederation::Jws.new(
|
|
470
|
+
client_id: client_id,
|
|
471
|
+
redirect_uri: redirect_uri,
|
|
472
|
+
scope: "openid",
|
|
473
|
+
audience: provider_entity_id,
|
|
474
|
+
state: SecureRandom.hex(32),
|
|
475
|
+
nonce: SecureRandom.hex(32),
|
|
476
|
+
private_key: @rp_signing_key
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
request_object = jws.sign
|
|
480
|
+
|
|
481
|
+
parts = request_object.split(".")
|
|
482
|
+
return false unless parts.length == 3
|
|
483
|
+
|
|
484
|
+
header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
|
|
485
|
+
payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
|
|
486
|
+
|
|
487
|
+
return false unless header["alg"] == "RS256"
|
|
488
|
+
return false unless payload["client_id"] == client_id
|
|
489
|
+
return false unless payload["redirect_uri"] == redirect_uri
|
|
490
|
+
|
|
491
|
+
true
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def test_provider_fetches_client_statement
|
|
496
|
+
test("Provider fetches client statement and keys") do
|
|
497
|
+
uri = URI.parse("#{RP_URL}/.well-known/openid-federation")
|
|
498
|
+
response = Net::HTTP.get_response(uri)
|
|
499
|
+
|
|
500
|
+
return false unless response.code == "200"
|
|
501
|
+
|
|
502
|
+
statement = OmniauthOpenidFederation::Federation::EntityStatement.new(response.body)
|
|
503
|
+
metadata = statement.parse
|
|
504
|
+
|
|
505
|
+
return false unless metadata[:issuer]
|
|
506
|
+
return false unless metadata[:jwks]
|
|
507
|
+
|
|
508
|
+
true
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def test_exchange_and_authenticated_login
|
|
513
|
+
test("Exchange and authenticated login") do
|
|
514
|
+
uri = URI.parse("#{OP_URL}/token")
|
|
515
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
516
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
517
|
+
request.set_form_data({
|
|
518
|
+
"grant_type" => "authorization_code",
|
|
519
|
+
"code" => "test_code"
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
response = http.request(request)
|
|
523
|
+
return false unless response.code.to_i.between?(400, 500)
|
|
524
|
+
|
|
525
|
+
body = JSON.parse(response.body)
|
|
526
|
+
return false unless body["error"]
|
|
527
|
+
|
|
528
|
+
true
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def test_wrong_entity_statement
|
|
533
|
+
test("Wrong entity statement (invalid format)") do
|
|
534
|
+
uri = URI.parse("#{OP_URL}/.well-known/openid-federation?error_mode=invalid_statement")
|
|
535
|
+
response = Net::HTTP.get_response(uri)
|
|
536
|
+
|
|
537
|
+
return false unless response.code == "200"
|
|
538
|
+
|
|
539
|
+
jwt = response.body
|
|
540
|
+
parts = jwt.split(".")
|
|
541
|
+
return false unless parts.length != 3
|
|
542
|
+
|
|
543
|
+
true
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def test_wrong_jwks_keys
|
|
548
|
+
test("Wrong JWKS keys") do
|
|
549
|
+
uri = URI.parse("#{OP_URL}/.well-known/jwks.json?error_mode=wrong_keys")
|
|
550
|
+
response = Net::HTTP.get_response(uri)
|
|
551
|
+
|
|
552
|
+
return false unless response.code == "200"
|
|
553
|
+
|
|
554
|
+
jwks = JSON.parse(response.body)
|
|
555
|
+
return false unless jwks["keys"]
|
|
556
|
+
|
|
557
|
+
true
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def test_invalid_request_object
|
|
562
|
+
test("Invalid request object validation") do
|
|
563
|
+
uri = URI.parse("#{OP_URL}/auth?error_mode=invalid_request")
|
|
564
|
+
response = Net::HTTP.get_response(uri)
|
|
565
|
+
|
|
566
|
+
return false unless response.code.to_i >= 400
|
|
567
|
+
|
|
568
|
+
true
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def test_expired_entity_statement
|
|
573
|
+
test("Expired entity statement") do
|
|
574
|
+
uri = URI.parse("#{OP_URL}/.well-known/openid-federation?error_mode=expired_statement")
|
|
575
|
+
response = Net::HTTP.get_response(uri)
|
|
576
|
+
|
|
577
|
+
return false unless response.code == "200"
|
|
578
|
+
|
|
579
|
+
jwt = response.body
|
|
580
|
+
parts = jwt.split(".")
|
|
581
|
+
return false unless parts.length == 3
|
|
582
|
+
|
|
583
|
+
payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
|
|
584
|
+
exp = payload["exp"]
|
|
585
|
+
return false unless exp < Time.now.to_i
|
|
586
|
+
|
|
587
|
+
true
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def test_missing_metadata
|
|
592
|
+
test("Missing metadata in entity statement") do
|
|
593
|
+
uri = URI.parse("#{OP_URL}/.well-known/openid-federation?error_mode=missing_metadata")
|
|
594
|
+
response = Net::HTTP.get_response(uri)
|
|
595
|
+
|
|
596
|
+
return false unless response.code == "200"
|
|
597
|
+
|
|
598
|
+
jwt = response.body
|
|
599
|
+
parts = jwt.split(".")
|
|
600
|
+
return false unless parts.length == 3
|
|
601
|
+
|
|
602
|
+
payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
|
|
603
|
+
return false if payload["metadata"]
|
|
604
|
+
|
|
605
|
+
true
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def test_encrypted_request_object
|
|
610
|
+
test("Encrypted request object") do
|
|
611
|
+
# Fetch provider metadata to get encryption keys
|
|
612
|
+
provider_uri = URI.parse("#{OP_URL}/.well-known/openid-federation")
|
|
613
|
+
provider_response = Net::HTTP.get_response(provider_uri)
|
|
614
|
+
return false unless provider_response.code == "200"
|
|
615
|
+
|
|
616
|
+
provider_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(provider_response.body)
|
|
617
|
+
provider_metadata = provider_statement.parse
|
|
618
|
+
op_metadata = provider_metadata[:metadata][:openid_provider] || provider_metadata["metadata"]["openid_provider"]
|
|
619
|
+
|
|
620
|
+
# Get provider JWKS for encryption
|
|
621
|
+
jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
|
|
622
|
+
jwks_response = Net::HTTP.get_response(jwks_uri)
|
|
623
|
+
return false unless jwks_response.code == "200"
|
|
624
|
+
|
|
625
|
+
provider_jwks = JSON.parse(jwks_response.body)
|
|
626
|
+
encryption_key_data = provider_jwks["keys"]&.find { |k| (k["use"] || k[:use]) == "enc" } || provider_jwks["keys"]&.first
|
|
627
|
+
return false unless encryption_key_data
|
|
628
|
+
|
|
629
|
+
OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(encryption_key_data)
|
|
630
|
+
|
|
631
|
+
# Create signed request object
|
|
632
|
+
redirect_uri = "#{RP_URL}/callback"
|
|
633
|
+
jws = OmniauthOpenidFederation::Jws.new(
|
|
634
|
+
client_id: RP_ENTITY_ID,
|
|
635
|
+
redirect_uri: redirect_uri,
|
|
636
|
+
scope: "openid",
|
|
637
|
+
audience: OP_ENTITY_ID,
|
|
638
|
+
state: SecureRandom.hex(32),
|
|
639
|
+
nonce: SecureRandom.hex(32),
|
|
640
|
+
private_key: @rp_signing_key
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# Encrypt the request object
|
|
644
|
+
request_object = jws.sign(provider_metadata: op_metadata, always_encrypt: true)
|
|
645
|
+
|
|
646
|
+
# Verify it's encrypted (JWE has 5 parts)
|
|
647
|
+
parts = request_object.split(".")
|
|
648
|
+
return false unless parts.length == 5
|
|
649
|
+
|
|
650
|
+
# Try to send it to the provider (should succeed)
|
|
651
|
+
auth_uri = URI.parse("#{OP_URL}/auth")
|
|
652
|
+
auth_uri.query = URI.encode_www_form({"request" => request_object})
|
|
653
|
+
auth_response = Net::HTTP.get_response(auth_uri)
|
|
654
|
+
|
|
655
|
+
# Should redirect (302) or return error if validation fails, but not 500
|
|
656
|
+
return false if auth_response.code.to_i >= 500
|
|
657
|
+
|
|
658
|
+
true
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def test_invalid_encryption_key
|
|
663
|
+
test("Invalid encryption key") do
|
|
664
|
+
# Create an encrypted request object encrypted with a wrong key
|
|
665
|
+
# The provider will try to decrypt with its own key and fail
|
|
666
|
+
wrong_key = OpenSSL::PKey::RSA.new(2048)
|
|
667
|
+
|
|
668
|
+
# Create signed request object
|
|
669
|
+
redirect_uri = "#{RP_URL}/callback"
|
|
670
|
+
jws = OmniauthOpenidFederation::Jws.new(
|
|
671
|
+
client_id: RP_ENTITY_ID,
|
|
672
|
+
redirect_uri: redirect_uri,
|
|
673
|
+
scope: "openid",
|
|
674
|
+
audience: OP_ENTITY_ID,
|
|
675
|
+
state: SecureRandom.hex(32),
|
|
676
|
+
nonce: SecureRandom.hex(32),
|
|
677
|
+
private_key: @rp_signing_key
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
# Encrypt with wrong key (provider won't be able to decrypt with its key)
|
|
681
|
+
signed_jwt = jws.sign
|
|
682
|
+
encrypted_request = JWE.encrypt(signed_jwt, wrong_key)
|
|
683
|
+
|
|
684
|
+
# Verify it's encrypted (JWE has 5 parts)
|
|
685
|
+
parts = encrypted_request.split(".")
|
|
686
|
+
return false unless parts.length == 5
|
|
687
|
+
|
|
688
|
+
# Send to provider - should fail to decrypt
|
|
689
|
+
auth_uri = URI.parse("#{OP_URL}/auth")
|
|
690
|
+
auth_uri.query = URI.encode_www_form({"request" => encrypted_request})
|
|
691
|
+
auth_response = Net::HTTP.get_response(auth_uri)
|
|
692
|
+
|
|
693
|
+
# Should return error (400 or 401) due to decryption failure
|
|
694
|
+
return false unless auth_response.code.to_i.between?(400, 499)
|
|
695
|
+
|
|
696
|
+
body = begin
|
|
697
|
+
JSON.parse(auth_response.body)
|
|
698
|
+
rescue
|
|
699
|
+
{}
|
|
700
|
+
end
|
|
701
|
+
# Should have error about decryption failure
|
|
702
|
+
return false unless body["error"] || body["error_description"]
|
|
703
|
+
|
|
704
|
+
true
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def test_malformed_encrypted_request
|
|
709
|
+
test("Malformed encrypted request object") do
|
|
710
|
+
# Create a malformed encrypted request (invalid JWE format - not 5 parts)
|
|
711
|
+
malformed_request = "invalid.jwe.format.not.5.parts"
|
|
712
|
+
|
|
713
|
+
# Send to provider
|
|
714
|
+
auth_uri = URI.parse("#{OP_URL}/auth?error_mode=malformed_encryption")
|
|
715
|
+
auth_uri.query = URI.encode_www_form({"request" => malformed_request})
|
|
716
|
+
auth_response = Net::HTTP.get_response(auth_uri)
|
|
717
|
+
|
|
718
|
+
# Should return error (400 or 401)
|
|
719
|
+
return false unless auth_response.code.to_i.between?(400, 499)
|
|
720
|
+
|
|
721
|
+
body = begin
|
|
722
|
+
JSON.parse(auth_response.body)
|
|
723
|
+
rescue
|
|
724
|
+
{}
|
|
725
|
+
end
|
|
726
|
+
return false unless body["error"] || body["error_description"]
|
|
727
|
+
|
|
728
|
+
true
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
# ID Token Validation Tests
|
|
733
|
+
def test_id_token_validation_with_trust_chain
|
|
734
|
+
test("ID token validation with trust chain") do
|
|
735
|
+
# Get ID token from provider (via token exchange)
|
|
736
|
+
# First, we need to get an authorization code, but for testing we'll use the mock
|
|
737
|
+
# In a real scenario, we'd validate the ID token signature using provider's JWKS
|
|
738
|
+
|
|
739
|
+
# Fetch provider JWKS
|
|
740
|
+
jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
|
|
741
|
+
jwks_response = Net::HTTP.get_response(jwks_uri)
|
|
742
|
+
return false unless jwks_response.code == "200"
|
|
743
|
+
|
|
744
|
+
provider_jwks = JSON.parse(jwks_response.body)
|
|
745
|
+
return false unless provider_jwks["keys"]&.any?
|
|
746
|
+
|
|
747
|
+
# Verify we can decode a JWT using the JWKS (simulating ID token validation)
|
|
748
|
+
# Create a test ID token
|
|
749
|
+
now = Time.now.to_i
|
|
750
|
+
test_payload = {
|
|
751
|
+
iss: OP_ENTITY_ID,
|
|
752
|
+
sub: "user123",
|
|
753
|
+
aud: RP_ENTITY_ID,
|
|
754
|
+
exp: now + 3600,
|
|
755
|
+
iat: now,
|
|
756
|
+
nonce: SecureRandom.hex(32)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
# Use OP's private key directly (we have it from setup)
|
|
760
|
+
# Get kid from JWKS
|
|
761
|
+
signing_key_data = provider_jwks["keys"].find { |k| (k["use"] || k[:use]) == "sig" || !k["use"] }
|
|
762
|
+
return false unless signing_key_data
|
|
763
|
+
|
|
764
|
+
kid = signing_key_data["kid"] || signing_key_data[:kid]
|
|
765
|
+
|
|
766
|
+
# Sign the token using OP's private key
|
|
767
|
+
header = {alg: "RS256", typ: "JWT", kid: kid}
|
|
768
|
+
id_token = JWT.encode(test_payload, @op_signing_key, "RS256", header)
|
|
769
|
+
|
|
770
|
+
# Validate using JWKS
|
|
771
|
+
begin
|
|
772
|
+
decoded = OmniauthOpenidFederation::Jwks::Decode.jwt(id_token, "#{OP_URL}/.well-known/jwks.json")
|
|
773
|
+
payload = decoded.first
|
|
774
|
+
|
|
775
|
+
# Verify claims
|
|
776
|
+
return false unless payload["iss"] == OP_ENTITY_ID
|
|
777
|
+
return false unless payload["aud"] == RP_ENTITY_ID
|
|
778
|
+
return false unless payload["sub"]
|
|
779
|
+
return false unless payload["exp"] > Time.now.to_i
|
|
780
|
+
|
|
781
|
+
true
|
|
782
|
+
rescue
|
|
783
|
+
false
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def test_invalid_id_token_signature
|
|
789
|
+
test("Invalid ID token signature") do
|
|
790
|
+
# Create ID token signed with wrong key
|
|
791
|
+
wrong_key = OpenSSL::PKey::RSA.new(2048)
|
|
792
|
+
now = Time.now.to_i
|
|
793
|
+
test_payload = {
|
|
794
|
+
iss: OP_ENTITY_ID,
|
|
795
|
+
sub: "user123",
|
|
796
|
+
aud: RP_ENTITY_ID,
|
|
797
|
+
exp: now + 3600,
|
|
798
|
+
iat: now
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
id_token = JWT.encode(test_payload, wrong_key, "RS256")
|
|
802
|
+
|
|
803
|
+
# Try to validate - should fail
|
|
804
|
+
begin
|
|
805
|
+
OmniauthOpenidFederation::Jwks::Decode.jwt(id_token, "#{OP_URL}/.well-known/jwks.json")
|
|
806
|
+
false # Should have raised an error
|
|
807
|
+
rescue OmniauthOpenidFederation::SignatureError, JWT::VerificationError, JWT::DecodeError
|
|
808
|
+
true
|
|
809
|
+
rescue
|
|
810
|
+
# Other errors are also acceptable
|
|
811
|
+
true
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def test_expired_id_token
|
|
817
|
+
test("Expired ID token") do
|
|
818
|
+
# Fetch provider JWKS
|
|
819
|
+
jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
|
|
820
|
+
jwks_response = Net::HTTP.get_response(jwks_uri)
|
|
821
|
+
return false unless jwks_response.code == "200"
|
|
822
|
+
|
|
823
|
+
provider_jwks = JSON.parse(jwks_response.body)
|
|
824
|
+
signing_key_data = provider_jwks["keys"].find { |k| (k["use"] || k[:use]) == "sig" || !k["use"] }
|
|
825
|
+
return false unless signing_key_data
|
|
826
|
+
|
|
827
|
+
kid = signing_key_data["kid"] || signing_key_data[:kid]
|
|
828
|
+
|
|
829
|
+
# Create expired ID token using OP's private key
|
|
830
|
+
now = Time.now.to_i
|
|
831
|
+
expired_payload = {
|
|
832
|
+
iss: OP_ENTITY_ID,
|
|
833
|
+
sub: "user123",
|
|
834
|
+
aud: RP_ENTITY_ID,
|
|
835
|
+
exp: now - 3600, # Expired 1 hour ago
|
|
836
|
+
iat: now - 7200
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
header = {alg: "RS256", typ: "JWT", kid: kid}
|
|
840
|
+
id_token = JWT.encode(expired_payload, @op_signing_key, "RS256", header)
|
|
841
|
+
|
|
842
|
+
# Try to validate - should fail due to expiration
|
|
843
|
+
begin
|
|
844
|
+
decoded = OmniauthOpenidFederation::Jwks::Decode.jwt(id_token, "#{OP_URL}/.well-known/jwks.json")
|
|
845
|
+
payload = decoded.first
|
|
846
|
+
# Check if exp validation is working
|
|
847
|
+
payload["exp"] < Time.now.to_i
|
|
848
|
+
rescue JWT::ExpiredSignature, OmniauthOpenidFederation::ValidationError
|
|
849
|
+
true
|
|
850
|
+
rescue
|
|
851
|
+
false
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def test_id_token_wrong_audience
|
|
857
|
+
test("ID token with wrong audience") do
|
|
858
|
+
# Fetch provider JWKS
|
|
859
|
+
jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
|
|
860
|
+
jwks_response = Net::HTTP.get_response(jwks_uri)
|
|
861
|
+
return false unless jwks_response.code == "200"
|
|
862
|
+
|
|
863
|
+
provider_jwks = JSON.parse(jwks_response.body)
|
|
864
|
+
signing_key_data = provider_jwks["keys"].find { |k| (k["use"] || k[:use]) == "sig" || !k["use"] }
|
|
865
|
+
return false unless signing_key_data
|
|
866
|
+
|
|
867
|
+
kid = signing_key_data["kid"] || signing_key_data[:kid]
|
|
868
|
+
|
|
869
|
+
# Create ID token with wrong audience using OP's private key
|
|
870
|
+
now = Time.now.to_i
|
|
871
|
+
wrong_aud_payload = {
|
|
872
|
+
iss: OP_ENTITY_ID,
|
|
873
|
+
sub: "user123",
|
|
874
|
+
aud: "wrong-client-id", # Wrong audience
|
|
875
|
+
exp: now + 3600,
|
|
876
|
+
iat: now
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
header = {alg: "RS256", typ: "JWT", kid: kid}
|
|
880
|
+
id_token = JWT.encode(wrong_aud_payload, @op_signing_key, "RS256", header)
|
|
881
|
+
|
|
882
|
+
# Validate - signature should be valid but audience should be wrong
|
|
883
|
+
begin
|
|
884
|
+
decoded = OmniauthOpenidFederation::Jwks::Decode.jwt(id_token, "#{OP_URL}/.well-known/jwks.json")
|
|
885
|
+
payload = decoded.first
|
|
886
|
+
# Audience should not match
|
|
887
|
+
payload["aud"] != RP_ENTITY_ID
|
|
888
|
+
rescue
|
|
889
|
+
# If validation fails due to audience, that's also acceptable
|
|
890
|
+
true
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
# Entity Statement Validation Tests
|
|
896
|
+
def test_invalid_entity_statement_signature
|
|
897
|
+
test("Invalid signature on entity statement") do
|
|
898
|
+
# Get a valid entity statement to extract payload
|
|
899
|
+
uri = URI.parse("#{OP_URL}/.well-known/openid-federation")
|
|
900
|
+
response = Net::HTTP.get_response(uri)
|
|
901
|
+
return false unless response.code == "200"
|
|
902
|
+
|
|
903
|
+
valid_statement = response.body
|
|
904
|
+
parts = valid_statement.split(".")
|
|
905
|
+
return false unless parts.length == 3
|
|
906
|
+
|
|
907
|
+
# Extract header and payload
|
|
908
|
+
header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
|
|
909
|
+
payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
|
|
910
|
+
|
|
911
|
+
# Verify payload has JWKS with keys (needed for signature validation)
|
|
912
|
+
jwks = payload["jwks"] || payload[:jwks] || {}
|
|
913
|
+
keys = jwks["keys"] || jwks[:keys] || []
|
|
914
|
+
return false unless keys.any?
|
|
915
|
+
|
|
916
|
+
# Verify kid in header matches a key in JWKS
|
|
917
|
+
kid = header["kid"] || header[:kid]
|
|
918
|
+
matching_key = keys.find { |k| (k["kid"] || k[:kid]) == kid }
|
|
919
|
+
return false unless matching_key
|
|
920
|
+
|
|
921
|
+
# Create a statement with wrong signature (sign with different key)
|
|
922
|
+
# The JWKS in payload still has original keys, so signature validation will fail
|
|
923
|
+
wrong_key = OpenSSL::PKey::RSA.new(2048)
|
|
924
|
+
# Keep the same header and payload but sign with wrong key
|
|
925
|
+
invalid_statement = JWT.encode(payload, wrong_key, "RS256", header)
|
|
926
|
+
|
|
927
|
+
# Try to parse with signature validation - should fail
|
|
928
|
+
# The signature was created with wrong key, but JWKS in payload has original keys
|
|
929
|
+
# So when we validate using keys from JWKS, it will fail
|
|
930
|
+
begin
|
|
931
|
+
OmniauthOpenidFederation::Federation::EntityStatementParser.parse(
|
|
932
|
+
invalid_statement,
|
|
933
|
+
validate_signature: true,
|
|
934
|
+
validate_full: true
|
|
935
|
+
)
|
|
936
|
+
# If parsing succeeds, the signature validation didn't catch the error
|
|
937
|
+
false
|
|
938
|
+
rescue OmniauthOpenidFederation::SignatureError
|
|
939
|
+
true
|
|
940
|
+
rescue JWT::VerificationError
|
|
941
|
+
true
|
|
942
|
+
rescue OmniauthOpenidFederation::ValidationError => e
|
|
943
|
+
# ValidationError might be raised if signature validation fails during full validation
|
|
944
|
+
# Check if it's a signature-related error
|
|
945
|
+
error_msg = e.message.downcase
|
|
946
|
+
error_msg.include?("signature") || error_msg.include?("verification") || error_msg.include?("key")
|
|
947
|
+
rescue => e
|
|
948
|
+
# Check if it's a signature-related error in the message
|
|
949
|
+
error_msg = e.message.downcase
|
|
950
|
+
error_msg.include?("signature") || error_msg.include?("verification")
|
|
951
|
+
end
|
|
952
|
+
end
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def test_wrong_algorithm_entity_statement
|
|
956
|
+
test("Wrong algorithm in entity statement") do
|
|
957
|
+
# Create entity statement with wrong algorithm (HS256 instead of RS256)
|
|
958
|
+
# HS256 requires a string key, not RSA
|
|
959
|
+
hmac_key = SecureRandom.hex(32)
|
|
960
|
+
now = Time.now.to_i
|
|
961
|
+
payload = {
|
|
962
|
+
iss: OP_ENTITY_ID,
|
|
963
|
+
sub: OP_ENTITY_ID,
|
|
964
|
+
iat: now,
|
|
965
|
+
exp: now + 3600,
|
|
966
|
+
jwks: {keys: []},
|
|
967
|
+
metadata: {openid_provider: {}}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
# Sign with wrong algorithm (HS256 instead of RS256)
|
|
971
|
+
header = {alg: "HS256", typ: "entity-statement+jwt"}
|
|
972
|
+
invalid_statement = JWT.encode(payload, hmac_key, "HS256", header)
|
|
973
|
+
|
|
974
|
+
# Try to parse with full validation - should fail due to wrong algorithm
|
|
975
|
+
begin
|
|
976
|
+
OmniauthOpenidFederation::Federation::EntityStatementParser.parse(
|
|
977
|
+
invalid_statement,
|
|
978
|
+
validate_signature: false, # Can't validate HS256 signature with RS256 keys
|
|
979
|
+
validate_full: true
|
|
980
|
+
)
|
|
981
|
+
false # Should have raised an error
|
|
982
|
+
rescue OmniauthOpenidFederation::ValidationError, JWT::IncorrectAlgorithm, JWT::DecodeError
|
|
983
|
+
true
|
|
984
|
+
rescue
|
|
985
|
+
false
|
|
986
|
+
end
|
|
987
|
+
end
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
def test_missing_required_claims_entity_statement
|
|
991
|
+
test("Missing required claims in entity statement") do
|
|
992
|
+
# Test missing 'iss' claim
|
|
993
|
+
signing_key = @op_signing_key
|
|
994
|
+
now = Time.now.to_i
|
|
995
|
+
payload_missing_iss = {
|
|
996
|
+
# Missing iss
|
|
997
|
+
sub: OP_ENTITY_ID,
|
|
998
|
+
iat: now,
|
|
999
|
+
exp: now + 3600,
|
|
1000
|
+
jwks: {keys: []}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
header = {alg: "RS256", typ: "entity-statement+jwt"}
|
|
1004
|
+
invalid_statement = JWT.encode(payload_missing_iss, signing_key, "RS256", header)
|
|
1005
|
+
|
|
1006
|
+
# Try to parse with full validation - should fail due to missing iss claim
|
|
1007
|
+
begin
|
|
1008
|
+
OmniauthOpenidFederation::Federation::EntityStatementParser.parse(
|
|
1009
|
+
invalid_statement,
|
|
1010
|
+
validate_signature: true,
|
|
1011
|
+
validate_full: true
|
|
1012
|
+
)
|
|
1013
|
+
false # Should have raised an error
|
|
1014
|
+
rescue OmniauthOpenidFederation::ValidationError
|
|
1015
|
+
true
|
|
1016
|
+
rescue
|
|
1017
|
+
false
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
def test_invalid_jwt_typ_entity_statement
|
|
1023
|
+
test("Invalid JWT typ claim in entity statement") do
|
|
1024
|
+
signing_key = @op_signing_key
|
|
1025
|
+
now = Time.now.to_i
|
|
1026
|
+
payload = {
|
|
1027
|
+
iss: OP_ENTITY_ID,
|
|
1028
|
+
sub: OP_ENTITY_ID,
|
|
1029
|
+
iat: now,
|
|
1030
|
+
exp: now + 3600,
|
|
1031
|
+
jwks: {keys: []},
|
|
1032
|
+
metadata: {openid_provider: {}}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
# Wrong typ value
|
|
1036
|
+
header = {alg: "RS256", typ: "JWT"} # Should be "entity-statement+jwt"
|
|
1037
|
+
invalid_statement = JWT.encode(payload, signing_key, "RS256", header)
|
|
1038
|
+
|
|
1039
|
+
# Try to parse with full validation - should fail due to wrong typ
|
|
1040
|
+
begin
|
|
1041
|
+
OmniauthOpenidFederation::Federation::EntityStatementParser.parse(
|
|
1042
|
+
invalid_statement,
|
|
1043
|
+
validate_signature: true,
|
|
1044
|
+
validate_full: true
|
|
1045
|
+
)
|
|
1046
|
+
false # Should have raised an error
|
|
1047
|
+
rescue OmniauthOpenidFederation::ValidationError
|
|
1048
|
+
true
|
|
1049
|
+
rescue
|
|
1050
|
+
false
|
|
1051
|
+
end
|
|
1052
|
+
end
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# Signed JWKS Endpoint Tests
|
|
1056
|
+
def test_signed_jwks_endpoint
|
|
1057
|
+
test("Signed JWKS endpoint") do
|
|
1058
|
+
uri = URI.parse("#{OP_URL}/.well-known/signed-jwks.json")
|
|
1059
|
+
response = Net::HTTP.get_response(uri)
|
|
1060
|
+
|
|
1061
|
+
return false unless response.code == "200"
|
|
1062
|
+
return false unless response.content_type == "application/jwt"
|
|
1063
|
+
|
|
1064
|
+
signed_jwks = response.body
|
|
1065
|
+
parts = signed_jwks.split(".")
|
|
1066
|
+
return false unless parts.length == 3
|
|
1067
|
+
|
|
1068
|
+
# Try to decode and validate
|
|
1069
|
+
begin
|
|
1070
|
+
# Get OP's signing key for validation
|
|
1071
|
+
jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
|
|
1072
|
+
jwks_response = Net::HTTP.get_response(jwks_uri)
|
|
1073
|
+
return false unless jwks_response.code == "200"
|
|
1074
|
+
|
|
1075
|
+
provider_jwks = JSON.parse(jwks_response.body)
|
|
1076
|
+
signing_key_data = provider_jwks["keys"].find { |k| (k["use"] || k[:use]) == "sig" || !k["use"] }
|
|
1077
|
+
return false unless signing_key_data
|
|
1078
|
+
|
|
1079
|
+
public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
|
|
1080
|
+
# KeyExtractor returns a public key, use it directly
|
|
1081
|
+
decoded = JWT.decode(signed_jwks, public_key, true, {algorithm: "RS256"})
|
|
1082
|
+
|
|
1083
|
+
payload = decoded.first
|
|
1084
|
+
return false unless payload["keys"]
|
|
1085
|
+
return false unless payload["keys"].is_a?(Array)
|
|
1086
|
+
|
|
1087
|
+
true
|
|
1088
|
+
rescue
|
|
1089
|
+
false
|
|
1090
|
+
end
|
|
1091
|
+
end
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
def test_invalid_signed_jwks_signature
|
|
1095
|
+
test("Invalid signed JWKS signature") do
|
|
1096
|
+
# Get signed JWKS with invalid signature error mode
|
|
1097
|
+
uri = URI.parse("#{OP_URL}/.well-known/signed-jwks.json?error_mode=invalid_signature")
|
|
1098
|
+
response = Net::HTTP.get_response(uri)
|
|
1099
|
+
|
|
1100
|
+
return false unless response.code == "200"
|
|
1101
|
+
|
|
1102
|
+
signed_jwks = response.body
|
|
1103
|
+
parts = signed_jwks.split(".")
|
|
1104
|
+
return false unless parts.length == 3
|
|
1105
|
+
|
|
1106
|
+
# Try to validate - should fail
|
|
1107
|
+
begin
|
|
1108
|
+
jwks_uri = URI.parse("#{OP_URL}/.well-known/jwks.json")
|
|
1109
|
+
jwks_response = Net::HTTP.get_response(jwks_uri)
|
|
1110
|
+
return false unless jwks_response.code == "200"
|
|
1111
|
+
|
|
1112
|
+
provider_jwks = JSON.parse(jwks_response.body)
|
|
1113
|
+
signing_key_data = provider_jwks["keys"].find { |k| (k["use"] || k[:use]) == "sig" || !k["use"] }
|
|
1114
|
+
return false unless signing_key_data
|
|
1115
|
+
|
|
1116
|
+
public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
|
|
1117
|
+
# KeyExtractor returns a public key, use it directly
|
|
1118
|
+
JWT.decode(signed_jwks, public_key, true, {algorithm: "RS256"})
|
|
1119
|
+
false # Should have raised an error
|
|
1120
|
+
rescue JWT::VerificationError, OmniauthOpenidFederation::SignatureError
|
|
1121
|
+
true
|
|
1122
|
+
rescue
|
|
1123
|
+
false
|
|
1124
|
+
end
|
|
1125
|
+
end
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
# Request Object Validation Details Tests
|
|
1129
|
+
def test_request_object_missing_required_claims
|
|
1130
|
+
test("Request object with missing required claims") do
|
|
1131
|
+
# Create request object missing client_id
|
|
1132
|
+
signing_key = @rp_signing_key
|
|
1133
|
+
now = Time.now.to_i
|
|
1134
|
+
payload = {
|
|
1135
|
+
# Missing client_id
|
|
1136
|
+
redirect_uri: "#{RP_URL}/callback",
|
|
1137
|
+
response_type: "code",
|
|
1138
|
+
scope: "openid",
|
|
1139
|
+
state: SecureRandom.hex(32),
|
|
1140
|
+
nonce: SecureRandom.hex(32),
|
|
1141
|
+
iat: now,
|
|
1142
|
+
exp: now + 300
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
header = {alg: "RS256", typ: "JWT"}
|
|
1146
|
+
request_object = JWT.encode(payload, signing_key, "RS256", header)
|
|
1147
|
+
|
|
1148
|
+
# Send to provider - should fail validation
|
|
1149
|
+
auth_uri = URI.parse("#{OP_URL}/auth")
|
|
1150
|
+
auth_uri.query = URI.encode_www_form({"request" => request_object})
|
|
1151
|
+
auth_response = Net::HTTP.get_response(auth_uri)
|
|
1152
|
+
|
|
1153
|
+
# Should return error
|
|
1154
|
+
return false unless auth_response.code.to_i.between?(400, 499)
|
|
1155
|
+
|
|
1156
|
+
body = begin
|
|
1157
|
+
JSON.parse(auth_response.body)
|
|
1158
|
+
rescue
|
|
1159
|
+
{}
|
|
1160
|
+
end
|
|
1161
|
+
return false unless body["error"] || body["error_description"]
|
|
1162
|
+
|
|
1163
|
+
true
|
|
1164
|
+
end
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
def test_request_object_invalid_nonce
|
|
1168
|
+
test("Request object with invalid nonce") do
|
|
1169
|
+
# This test verifies that nonce validation works
|
|
1170
|
+
# In a real flow, the nonce in request object should match the nonce in ID token
|
|
1171
|
+
# For this test, we'll create a valid request object and verify it has a nonce
|
|
1172
|
+
redirect_uri = "#{RP_URL}/callback"
|
|
1173
|
+
jws = OmniauthOpenidFederation::Jws.new(
|
|
1174
|
+
client_id: RP_ENTITY_ID,
|
|
1175
|
+
redirect_uri: redirect_uri,
|
|
1176
|
+
scope: "openid",
|
|
1177
|
+
audience: OP_ENTITY_ID,
|
|
1178
|
+
state: SecureRandom.hex(32),
|
|
1179
|
+
nonce: SecureRandom.hex(32),
|
|
1180
|
+
private_key: @rp_signing_key
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
request_object = jws.sign
|
|
1184
|
+
|
|
1185
|
+
# Verify request object has nonce
|
|
1186
|
+
parts = request_object.split(".")
|
|
1187
|
+
return false unless parts.length == 3
|
|
1188
|
+
|
|
1189
|
+
payload = JSON.parse(Base64.urlsafe_decode64(parts[1]))
|
|
1190
|
+
return false unless payload["nonce"]
|
|
1191
|
+
return false if payload["nonce"].empty?
|
|
1192
|
+
|
|
1193
|
+
# The actual nonce validation happens during token exchange
|
|
1194
|
+
# This test verifies the nonce is present in the request object
|
|
1195
|
+
true
|
|
1196
|
+
end
|
|
1197
|
+
end
|
|
1198
|
+
|
|
1199
|
+
def test_request_object_expiration
|
|
1200
|
+
test("Request object expiration") do
|
|
1201
|
+
# Create expired request object
|
|
1202
|
+
signing_key = @rp_signing_key
|
|
1203
|
+
now = Time.now.to_i
|
|
1204
|
+
expired_payload = {
|
|
1205
|
+
client_id: RP_ENTITY_ID,
|
|
1206
|
+
redirect_uri: "#{RP_URL}/callback",
|
|
1207
|
+
response_type: "code",
|
|
1208
|
+
scope: "openid",
|
|
1209
|
+
state: SecureRandom.hex(32),
|
|
1210
|
+
nonce: SecureRandom.hex(32),
|
|
1211
|
+
iat: now - 600,
|
|
1212
|
+
exp: now - 300 # Expired 5 minutes ago
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
header = {alg: "RS256", typ: "JWT"}
|
|
1216
|
+
expired_request = JWT.encode(expired_payload, signing_key, "RS256", header)
|
|
1217
|
+
|
|
1218
|
+
# Send to provider - should fail due to expiration
|
|
1219
|
+
auth_uri = URI.parse("#{OP_URL}/auth")
|
|
1220
|
+
auth_uri.query = URI.encode_www_form({"request" => expired_request})
|
|
1221
|
+
auth_response = Net::HTTP.get_response(auth_uri)
|
|
1222
|
+
|
|
1223
|
+
# Should return error
|
|
1224
|
+
return false unless auth_response.code.to_i.between?(400, 499)
|
|
1225
|
+
|
|
1226
|
+
body = begin
|
|
1227
|
+
JSON.parse(auth_response.body)
|
|
1228
|
+
rescue
|
|
1229
|
+
{}
|
|
1230
|
+
end
|
|
1231
|
+
return false unless body["error"] || body["error_description"]
|
|
1232
|
+
|
|
1233
|
+
true
|
|
1234
|
+
end
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
def print_summary
|
|
1238
|
+
puts "=" * 80
|
|
1239
|
+
puts "Test Summary"
|
|
1240
|
+
puts "=" * 80
|
|
1241
|
+
|
|
1242
|
+
passed = @test_results.count { |r| r[:status] == :pass }
|
|
1243
|
+
failed = @test_results.count { |r| r[:status] == :fail }
|
|
1244
|
+
errors = @test_results.count { |r| r[:status] == :error }
|
|
1245
|
+
|
|
1246
|
+
puts "Total: #{@test_results.length}"
|
|
1247
|
+
puts "Passed: #{passed}"
|
|
1248
|
+
puts "Failed: #{failed}"
|
|
1249
|
+
puts "Errors: #{errors}"
|
|
1250
|
+
puts ""
|
|
1251
|
+
|
|
1252
|
+
if failed > 0 || errors > 0
|
|
1253
|
+
puts "Failed/Error Tests:"
|
|
1254
|
+
@test_results.each do |result|
|
|
1255
|
+
if result[:status] != :pass
|
|
1256
|
+
puts " - #{result[:name]}: #{result[:status]}"
|
|
1257
|
+
puts " #{result[:error]}" if result[:error]
|
|
1258
|
+
end
|
|
1259
|
+
end
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
puts ""
|
|
1263
|
+
puts (passed == @test_results.length) ? "✅ All tests passed!" : "❌ Some tests failed."
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
def cleanup
|
|
1267
|
+
puts ""
|
|
1268
|
+
puts "Cleaning up..."
|
|
1269
|
+
|
|
1270
|
+
# Kill servers
|
|
1271
|
+
if @op_pid
|
|
1272
|
+
begin
|
|
1273
|
+
begin
|
|
1274
|
+
Process.kill("TERM", @op_pid) if Process.kill(0, @op_pid)
|
|
1275
|
+
rescue
|
|
1276
|
+
nil
|
|
1277
|
+
end
|
|
1278
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
1279
|
+
# Process already dead or permission denied
|
|
1280
|
+
end
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
if @rp_pid
|
|
1284
|
+
begin
|
|
1285
|
+
begin
|
|
1286
|
+
Process.kill("TERM", @rp_pid) if Process.kill(0, @rp_pid)
|
|
1287
|
+
rescue
|
|
1288
|
+
nil
|
|
1289
|
+
end
|
|
1290
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
1291
|
+
# Process already dead or permission denied
|
|
1292
|
+
end
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
# Wait a bit for processes to terminate
|
|
1296
|
+
sleep 1
|
|
1297
|
+
|
|
1298
|
+
# Remove tmp directory if cleanup enabled
|
|
1299
|
+
if CLEANUP_ON_EXIT && File.directory?(@tmp_dir)
|
|
1300
|
+
FileUtils.rm_rf(@tmp_dir)
|
|
1301
|
+
puts " Removed: #{@tmp_dir}"
|
|
1302
|
+
else
|
|
1303
|
+
puts " Tmp directory preserved: #{@tmp_dir}"
|
|
1304
|
+
end
|
|
1305
|
+
end
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
# Run tests if executed directly
|
|
1309
|
+
if __FILE__ == $0
|
|
1310
|
+
puts "OpenID Federation Integration Test Flow"
|
|
1311
|
+
puts "=" * 80
|
|
1312
|
+
puts ""
|
|
1313
|
+
puts "Environment Variables:"
|
|
1314
|
+
puts " OP_URL - OP server URL (default: #{IntegrationTestFlow::OP_URL})"
|
|
1315
|
+
puts " RP_URL - RP server URL (default: #{IntegrationTestFlow::RP_URL})"
|
|
1316
|
+
puts " OP_PORT - OP server port (default: #{IntegrationTestFlow::OP_PORT})"
|
|
1317
|
+
puts " RP_PORT - RP server port (default: #{IntegrationTestFlow::RP_PORT})"
|
|
1318
|
+
puts " OP_ENTITY_ID - OP entity ID (default: #{IntegrationTestFlow::OP_ENTITY_ID} - localhost)"
|
|
1319
|
+
puts " RP_ENTITY_ID - RP entity ID (default: #{IntegrationTestFlow::RP_ENTITY_ID} - localhost)"
|
|
1320
|
+
puts " TMP_DIR - Temporary directory (default: tmp/integration_test)"
|
|
1321
|
+
puts " AUTO_START_SERVERS - Auto-start servers (default: true)"
|
|
1322
|
+
puts " CLEANUP_ON_EXIT - Clean up on exit (default: true)"
|
|
1323
|
+
puts " KEY_TYPE - Key type: 'single' or 'separate' (default: separate)"
|
|
1324
|
+
puts ""
|
|
1325
|
+
puts "Note: Default entity IDs use localhost URLs for complete isolation."
|
|
1326
|
+
puts " No DNS resolution or external dependencies required."
|
|
1327
|
+
puts ""
|
|
1328
|
+
puts "Example:"
|
|
1329
|
+
puts " KEY_TYPE=single ruby examples/integration_test_flow.rb"
|
|
1330
|
+
puts ""
|
|
1331
|
+
|
|
1332
|
+
test_flow = IntegrationTestFlow.new
|
|
1333
|
+
test_flow.run
|
|
1334
|
+
end
|