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,435 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Mock Relying Party (RP) Server for OpenID Federation Testing
|
|
5
|
+
#
|
|
6
|
+
# This is a standalone Webrick server that acts as a mock RP 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
|
+
# - Callback endpoint for authorization responses
|
|
11
|
+
# - Full OpenID Federation flow simulation
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# ruby examples/mock_rp_server.rb
|
|
15
|
+
#
|
|
16
|
+
# Access:
|
|
17
|
+
# http://localhost:9293/.well-known/openid-federation
|
|
18
|
+
# http://localhost:9293/login?provider=<op_entity_id>
|
|
19
|
+
# http://localhost:9293/callback - Authorization callback
|
|
20
|
+
|
|
21
|
+
require "bundler/setup"
|
|
22
|
+
require "webrick"
|
|
23
|
+
require "yaml"
|
|
24
|
+
require "json"
|
|
25
|
+
require "jwt"
|
|
26
|
+
require "openssl"
|
|
27
|
+
require "base64"
|
|
28
|
+
require "uri"
|
|
29
|
+
require "cgi"
|
|
30
|
+
require "securerandom"
|
|
31
|
+
require "net/http"
|
|
32
|
+
|
|
33
|
+
# Add the gem to the load path
|
|
34
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
35
|
+
require "omniauth_openid_federation"
|
|
36
|
+
|
|
37
|
+
class MockRPServer
|
|
38
|
+
# Load configuration
|
|
39
|
+
def self.load_config
|
|
40
|
+
config_path = File.expand_path("../config/mock_rp.yml", __dir__)
|
|
41
|
+
if File.exist?(config_path)
|
|
42
|
+
YAML.load_file(config_path)
|
|
43
|
+
else
|
|
44
|
+
{
|
|
45
|
+
"entity_id" => ENV["RP_ENTITY_ID"] || "http://localhost:9293",
|
|
46
|
+
"server_host" => ENV["RP_SERVER_HOST"] || "localhost:9293",
|
|
47
|
+
"signing_key" => ENV["RP_SIGNING_KEY"],
|
|
48
|
+
"encryption_key" => ENV["RP_ENCRYPTION_KEY"],
|
|
49
|
+
"trust_anchors" => parse_trust_anchors(ENV["RP_TRUST_ANCHORS"]),
|
|
50
|
+
"authority_hints" => parse_array(ENV["RP_AUTHORITY_HINTS"]),
|
|
51
|
+
"redirect_uris" => parse_array(ENV["RP_REDIRECT_URIS"]) || ["http://localhost:9293/callback"]
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.parse_trust_anchors(str)
|
|
57
|
+
return [] unless str
|
|
58
|
+
JSON.parse(str)
|
|
59
|
+
rescue JSON::ParserError
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.parse_array(str)
|
|
64
|
+
return [] unless str
|
|
65
|
+
str.split(",").map(&:strip)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.load_signing_key(key_data)
|
|
69
|
+
if key_data.nil? || key_data.empty?
|
|
70
|
+
OpenSSL::PKey::RSA.new(2048)
|
|
71
|
+
elsif key_data.is_a?(String)
|
|
72
|
+
if key_data.include?("BEGIN")
|
|
73
|
+
OpenSSL::PKey::RSA.new(key_data)
|
|
74
|
+
else
|
|
75
|
+
OpenSSL::PKey::RSA.new(Base64.decode64(key_data))
|
|
76
|
+
end
|
|
77
|
+
else
|
|
78
|
+
raise "Invalid signing key format"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.load_encryption_key(key_data)
|
|
83
|
+
return nil if key_data.nil? || key_data.empty?
|
|
84
|
+
load_signing_key(key_data)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Initialize configuration
|
|
88
|
+
CONFIG = load_config
|
|
89
|
+
ENTITY_ID = CONFIG["entity_id"] || "http://localhost:9293"
|
|
90
|
+
SERVER_HOST = CONFIG["server_host"] || "localhost:9293"
|
|
91
|
+
SIGNING_KEY = load_signing_key(CONFIG["signing_key"])
|
|
92
|
+
ENCRYPTION_KEY = load_encryption_key(CONFIG["encryption_key"]) || SIGNING_KEY
|
|
93
|
+
TRUST_ANCHORS = CONFIG["trust_anchors"] || []
|
|
94
|
+
AUTHORITY_HINTS = CONFIG["authority_hints"] || []
|
|
95
|
+
REDIRECT_URIS = CONFIG["redirect_uris"] || ["http://localhost:9293/callback"]
|
|
96
|
+
|
|
97
|
+
# Store for authorization state
|
|
98
|
+
AUTHORIZATION_STATE = {}
|
|
99
|
+
|
|
100
|
+
# Configure FederationEndpoint (deferred until server starts)
|
|
101
|
+
def self.configure_federation_endpoint
|
|
102
|
+
base_url = base_url_static
|
|
103
|
+
# Use localhost URLs if entity_id is localhost (for isolation)
|
|
104
|
+
redirect_uris = if ENTITY_ID.include?("localhost")
|
|
105
|
+
REDIRECT_URIS.map { |uri| uri.include?("localhost") ? uri : "#{base_url}/callback" }
|
|
106
|
+
else
|
|
107
|
+
REDIRECT_URIS.map { |uri| uri.gsub("https://rp.example.com", base_url) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
OmniauthOpenidFederation::FederationEndpoint.auto_configure(
|
|
111
|
+
issuer: ENTITY_ID,
|
|
112
|
+
private_key: SIGNING_KEY,
|
|
113
|
+
metadata: {
|
|
114
|
+
openid_relying_party: {
|
|
115
|
+
"redirect_uris" => redirect_uris,
|
|
116
|
+
"client_name" => "Mock RP Server",
|
|
117
|
+
"application_type" => "web"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Set authority_hints if provided (must be done after auto_configure)
|
|
123
|
+
if AUTHORITY_HINTS.any?
|
|
124
|
+
OmniauthOpenidFederation::FederationEndpoint.configure do |config|
|
|
125
|
+
config.authority_hints = AUTHORITY_HINTS
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.base_url_static
|
|
131
|
+
"http://#{SERVER_HOST}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Webrick servlet to handle requests
|
|
135
|
+
class Servlet < WEBrick::HTTPServlet::AbstractServlet
|
|
136
|
+
def service(req, res)
|
|
137
|
+
path = req.path
|
|
138
|
+
method = req.request_method
|
|
139
|
+
params = parse_query(req.query_string)
|
|
140
|
+
|
|
141
|
+
case [method, path]
|
|
142
|
+
when ["GET", "/"]
|
|
143
|
+
handle_health_check(req, res)
|
|
144
|
+
when ["GET", "/.well-known/openid-federation"]
|
|
145
|
+
handle_entity_configuration(req, res)
|
|
146
|
+
when ["GET", "/.well-known/openid-federation/fetch"]
|
|
147
|
+
handle_fetch(req, res, params)
|
|
148
|
+
when ["GET", "/.well-known/jwks.json"]
|
|
149
|
+
handle_jwks(req, res)
|
|
150
|
+
when ["GET", "/login"]
|
|
151
|
+
handle_login(req, res, params)
|
|
152
|
+
when ["GET", "/callback"]
|
|
153
|
+
handle_callback(req, res, params)
|
|
154
|
+
else
|
|
155
|
+
res.status = 404
|
|
156
|
+
res.content_type = "application/json"
|
|
157
|
+
res.body = {error: "not_found", error_description: "Endpoint not found"}.to_json
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def parse_query(query_string)
|
|
164
|
+
return {} unless query_string
|
|
165
|
+
CGI.parse(query_string).transform_values { |v| v.first }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def base_url(req)
|
|
169
|
+
scheme = req.request_uri.scheme || "http"
|
|
170
|
+
host = req.host
|
|
171
|
+
port = req.port
|
|
172
|
+
port_str = ((port == 80 && scheme == "http") || (port == 443 && scheme == "https")) ? "" : ":#{port}"
|
|
173
|
+
"#{scheme}://#{host}#{port_str}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def json_response(res, data, status: 200)
|
|
177
|
+
res.status = status
|
|
178
|
+
res.content_type = "application/json"
|
|
179
|
+
res.body = data.to_json
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def handle_health_check(req, res)
|
|
183
|
+
json_response(res, {
|
|
184
|
+
status: "ok",
|
|
185
|
+
entity_id: MockRPServer::ENTITY_ID,
|
|
186
|
+
endpoints: {
|
|
187
|
+
entity_configuration: "#{base_url(req)}/.well-known/openid-federation",
|
|
188
|
+
login: "#{base_url(req)}/login?provider=<op_entity_id>",
|
|
189
|
+
callback: "#{base_url(req)}/callback"
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def handle_entity_configuration(req, res)
|
|
195
|
+
entity_statement = OmniauthOpenidFederation::FederationEndpoint.generate_entity_statement
|
|
196
|
+
res.status = 200
|
|
197
|
+
res.content_type = "application/jwt"
|
|
198
|
+
res["Cache-Control"] = "public, max-age=3600"
|
|
199
|
+
res.body = entity_statement
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def handle_fetch(req, res, params)
|
|
203
|
+
subject_entity_id = params["sub"]
|
|
204
|
+
unless subject_entity_id
|
|
205
|
+
return json_response(res, {error: "invalid_request", error_description: "Missing required parameter: sub"}, status: 400)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
subordinate_statement = OmniauthOpenidFederation::FederationEndpoint.get_subordinate_statement(subject_entity_id)
|
|
209
|
+
unless subordinate_statement
|
|
210
|
+
return json_response(res, {error: "not_found", error_description: "Subordinate Statement not found"}, status: 404)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
res.status = 200
|
|
214
|
+
res.content_type = "application/entity-statement+jwt"
|
|
215
|
+
res["Cache-Control"] = "public, max-age=3600"
|
|
216
|
+
res.body = subordinate_statement
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def handle_jwks(req, res)
|
|
220
|
+
jwks = OmniauthOpenidFederation::FederationEndpoint.current_jwks
|
|
221
|
+
res.status = 200
|
|
222
|
+
res.content_type = "application/json"
|
|
223
|
+
res["Cache-Control"] = "public, max-age=3600"
|
|
224
|
+
res.body = jwks.to_json
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def handle_login(req, res, params)
|
|
228
|
+
# Default to localhost OP if not specified (for isolation)
|
|
229
|
+
provider_entity_id = params["provider"] || ENV["OP_ENTITY_ID"] || "http://localhost:9292"
|
|
230
|
+
redirect_uri = "#{base_url(req)}/callback"
|
|
231
|
+
|
|
232
|
+
# Step 1: Fetch provider's entity statement
|
|
233
|
+
provider_url = if provider_entity_id.include?("localhost")
|
|
234
|
+
port = provider_entity_id.match(/:(\d+)/)&.captures&.first || "9292"
|
|
235
|
+
"http://localhost:#{port}/.well-known/openid-federation"
|
|
236
|
+
else
|
|
237
|
+
"#{provider_entity_id}/.well-known/openid-federation"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
begin
|
|
241
|
+
provider_statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
|
|
242
|
+
provider_url
|
|
243
|
+
)
|
|
244
|
+
provider_metadata = provider_statement.parse
|
|
245
|
+
rescue => e
|
|
246
|
+
return json_response(res, {error: "provider_error", error_description: "Failed to fetch provider entity statement: #{e.message}"}, status: 500)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Step 2: Extract authorization endpoint
|
|
250
|
+
op_metadata = provider_metadata[:metadata][:openid_provider] || provider_metadata["metadata"]["openid_provider"]
|
|
251
|
+
authorization_endpoint = op_metadata[:authorization_endpoint] || op_metadata["authorization_endpoint"]
|
|
252
|
+
|
|
253
|
+
unless authorization_endpoint
|
|
254
|
+
return json_response(res, {error: "provider_error", error_description: "Provider metadata missing authorization_endpoint"}, status: 500)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Step 3: Build signed request object
|
|
258
|
+
state = SecureRandom.hex(32)
|
|
259
|
+
nonce = SecureRandom.hex(32)
|
|
260
|
+
|
|
261
|
+
begin
|
|
262
|
+
jws = OmniauthOpenidFederation::Jws.new(
|
|
263
|
+
client_id: MockRPServer::ENTITY_ID,
|
|
264
|
+
redirect_uri: redirect_uri,
|
|
265
|
+
scope: "openid profile email",
|
|
266
|
+
audience: provider_entity_id,
|
|
267
|
+
state: state,
|
|
268
|
+
nonce: nonce,
|
|
269
|
+
private_key: MockRPServer::SIGNING_KEY
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Check if provider requires encryption
|
|
273
|
+
request_object_encryption_alg = op_metadata[:request_object_encryption_alg] || op_metadata["request_object_encryption_alg"]
|
|
274
|
+
if request_object_encryption_alg
|
|
275
|
+
provider_jwks_uri = op_metadata[:jwks_uri] || op_metadata["jwks_uri"]
|
|
276
|
+
if provider_jwks_uri
|
|
277
|
+
provider_jwks = OmniauthOpenidFederation::Jwks::Fetch.run(provider_jwks_uri)
|
|
278
|
+
encryption_key_data = provider_jwks["keys"]&.find { |k| (k["use"] || k[:use]) == "enc" } || provider_jwks["keys"]&.first
|
|
279
|
+
if encryption_key_data
|
|
280
|
+
OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(encryption_key_data)
|
|
281
|
+
request_object = jws.sign(provider_metadata: op_metadata, always_encrypt: true)
|
|
282
|
+
else
|
|
283
|
+
request_object = jws.sign(provider_metadata: op_metadata)
|
|
284
|
+
end
|
|
285
|
+
else
|
|
286
|
+
request_object = jws.sign(provider_metadata: op_metadata)
|
|
287
|
+
end
|
|
288
|
+
else
|
|
289
|
+
request_object = jws.sign(provider_metadata: op_metadata)
|
|
290
|
+
end
|
|
291
|
+
rescue => e
|
|
292
|
+
return json_response(res, {error: "request_error", error_description: "Failed to generate request object: #{e.message}"}, status: 500)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Step 4: Store state
|
|
296
|
+
MockRPServer::AUTHORIZATION_STATE[state] = {
|
|
297
|
+
provider_entity_id: provider_entity_id,
|
|
298
|
+
redirect_uri: redirect_uri,
|
|
299
|
+
nonce: nonce,
|
|
300
|
+
created_at: Time.now
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Step 5: Redirect to provider
|
|
304
|
+
auth_url = URI.parse(authorization_endpoint)
|
|
305
|
+
auth_url.query = URI.encode_www_form({
|
|
306
|
+
"request" => request_object
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
res.status = 302
|
|
310
|
+
res["Location"] = auth_url.to_s
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def handle_callback(req, res, params)
|
|
314
|
+
code = params["code"]
|
|
315
|
+
state = params["state"]
|
|
316
|
+
error = params["error"]
|
|
317
|
+
error_description = params["error_description"]
|
|
318
|
+
|
|
319
|
+
if error
|
|
320
|
+
return json_response(res, {error: error, error_description: error_description}, status: 400)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
unless code && state
|
|
324
|
+
return json_response(res, {error: "invalid_request", error_description: "Missing code or state"}, status: 400)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
state_data = MockRPServer::AUTHORIZATION_STATE.delete(state)
|
|
328
|
+
unless state_data
|
|
329
|
+
return json_response(res, {error: "invalid_state", error_description: "Invalid or expired state"}, status: 400)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
provider_entity_id = state_data[:provider_entity_id]
|
|
333
|
+
|
|
334
|
+
# Step 6: Exchange authorization code for tokens
|
|
335
|
+
begin
|
|
336
|
+
provider_url = if provider_entity_id.include?("localhost")
|
|
337
|
+
port = provider_entity_id.match(/:(\d+)/)&.captures&.first || "9292"
|
|
338
|
+
"http://localhost:#{port}/.well-known/openid-federation"
|
|
339
|
+
else
|
|
340
|
+
"#{provider_entity_id}/.well-known/openid-federation"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
provider_statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
|
|
344
|
+
provider_url
|
|
345
|
+
)
|
|
346
|
+
provider_metadata = provider_statement.parse
|
|
347
|
+
op_metadata = provider_metadata[:metadata][:openid_provider] || provider_metadata["metadata"]["openid_provider"]
|
|
348
|
+
token_endpoint = op_metadata[:token_endpoint] || op_metadata["token_endpoint"]
|
|
349
|
+
|
|
350
|
+
# Exchange code for tokens
|
|
351
|
+
uri = URI.parse(token_endpoint)
|
|
352
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
353
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
354
|
+
request.set_form_data({
|
|
355
|
+
"grant_type" => "authorization_code",
|
|
356
|
+
"code" => code,
|
|
357
|
+
"redirect_uri" => state_data[:redirect_uri]
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
response = http.request(request)
|
|
361
|
+
token_response = JSON.parse(response.body)
|
|
362
|
+
|
|
363
|
+
if response.code != "200"
|
|
364
|
+
return json_response(res, token_response, status: response.code.to_i)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
id_token = token_response["id_token"]
|
|
368
|
+
|
|
369
|
+
# Step 7: Validate ID token
|
|
370
|
+
provider_jwks_uri = op_metadata[:jwks_uri] || op_metadata["jwks_uri"]
|
|
371
|
+
OmniauthOpenidFederation::Jwks::Fetch.run(provider_jwks_uri)
|
|
372
|
+
|
|
373
|
+
decoded = OmniauthOpenidFederation::Jwks::Decode.jwt(id_token, provider_jwks_uri)
|
|
374
|
+
id_token_payload = decoded.first
|
|
375
|
+
|
|
376
|
+
# Validate claims
|
|
377
|
+
unless id_token_payload["iss"] == provider_entity_id
|
|
378
|
+
return json_response(res, {error: "invalid_token", error_description: "Invalid issuer"}, status: 400)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
unless id_token_payload["aud"] == MockRPServer::ENTITY_ID
|
|
382
|
+
return json_response(res, {error: "invalid_token", error_description: "Invalid audience"}, status: 400)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
unless id_token_payload["nonce"] == state_data[:nonce]
|
|
386
|
+
return json_response(res, {error: "invalid_token", error_description: "Invalid nonce"}, status: 400)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
json_response(res, {
|
|
390
|
+
status: "success",
|
|
391
|
+
user: {
|
|
392
|
+
sub: id_token_payload["sub"],
|
|
393
|
+
iss: id_token_payload["iss"]
|
|
394
|
+
},
|
|
395
|
+
id_token: id_token_payload
|
|
396
|
+
})
|
|
397
|
+
rescue => e
|
|
398
|
+
json_response(res, {error: "token_error", error_description: "Failed to exchange code or validate token: #{e.message}"}, status: 500)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def self.run!
|
|
404
|
+
port = ENV["RP_PORT"]&.to_i || 9293
|
|
405
|
+
bind = ENV["RP_BIND"] || "localhost"
|
|
406
|
+
|
|
407
|
+
server = WEBrick::HTTPServer.new(Port: port, BindAddress: bind)
|
|
408
|
+
server.mount("/", Servlet)
|
|
409
|
+
|
|
410
|
+
trap("INT") { server.shutdown }
|
|
411
|
+
trap("TERM") { server.shutdown }
|
|
412
|
+
|
|
413
|
+
server.start
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Run the server
|
|
418
|
+
if __FILE__ == $0
|
|
419
|
+
# Configure federation endpoint before starting
|
|
420
|
+
MockRPServer.configure_federation_endpoint
|
|
421
|
+
|
|
422
|
+
puts "Starting Mock RP Server..."
|
|
423
|
+
puts "Entity ID: #{MockRPServer::ENTITY_ID}"
|
|
424
|
+
puts "Server: http://#{MockRPServer::SERVER_HOST}"
|
|
425
|
+
puts ""
|
|
426
|
+
puts "Endpoints:"
|
|
427
|
+
puts " GET /.well-known/openid-federation - Entity Configuration"
|
|
428
|
+
puts " GET /.well-known/openid-federation/fetch?sub=<entity_id> - Fetch Subordinate Statement"
|
|
429
|
+
puts " GET /.well-known/jwks.json - JWKS"
|
|
430
|
+
puts " GET /login?provider=<op_entity_id> - Initiate login flow"
|
|
431
|
+
puts " GET /callback - Authorization callback"
|
|
432
|
+
puts ""
|
|
433
|
+
|
|
434
|
+
MockRPServer.run!
|
|
435
|
+
end
|