omniauth_openid_federation 1.3.0 → 2.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 +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +120 -14
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +3 -3
- data/config/polyrun_coverage.yml +15 -0
- data/config/polyrun_spec_quality.yml +29 -0
- data/config/routes.rb +20 -10
- data/examples/README_INTEGRATION_TESTING.md +3 -0
- data/examples/integration_test_flow.rb +10 -8
- data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
- data/examples/jobs/federation_files_generation_job.rb.example +3 -2
- data/examples/mock_op_server.rb +4 -5
- data/examples/mock_rp_server.rb +3 -3
- data/examples/standalone_multiple_endpoints_example.rb +494 -0
- data/lib/omniauth_openid_federation/access_token.rb +91 -450
- data/lib/omniauth_openid_federation/cache.rb +16 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
- data/lib/omniauth_openid_federation/constants.rb +3 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +8 -91
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +54 -21
- data/lib/omniauth_openid_federation/federation_endpoint.rb +40 -172
- data/lib/omniauth_openid_federation/http_client.rb +145 -32
- data/lib/omniauth_openid_federation/http_errors.rb +14 -0
- data/lib/omniauth_openid_federation/id_token.rb +19 -0
- data/lib/omniauth_openid_federation/jwe.rb +26 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
- data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
- data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
- data/lib/omniauth_openid_federation/jws.rb +3 -11
- data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
- data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
- data/lib/omniauth_openid_federation/rack.rb +2 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +21 -9
- data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
- data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
- data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
- data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
- data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
- data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
- data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
- data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
- data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
- data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
- data/lib/omniauth_openid_federation/strategy.rb +27 -1910
- data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
- data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
- data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
- data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
- data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +28 -790
- data/lib/omniauth_openid_federation/time_helpers.rb +67 -0
- data/lib/omniauth_openid_federation/user_info.rb +15 -0
- data/lib/omniauth_openid_federation/utils.rb +14 -9
- data/lib/omniauth_openid_federation/validators.rb +55 -44
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +10 -3
- data/lib/tasks/omniauth_openid_federation.rake +5 -5
- data/sig/omniauth_openid_federation.rbs +8 -1
- data/sig/strategy.rbs +6 -6
- metadata +252 -43
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "../http_client"
|
|
3
|
+
require_relative "../string_helpers"
|
|
4
|
+
require_relative "../jwe"
|
|
5
|
+
|
|
6
|
+
module OmniauthOpenidFederation
|
|
7
|
+
module Tasks
|
|
8
|
+
module AuthenticationFlowTester
|
|
9
|
+
def self.run(
|
|
10
|
+
login_page_url:,
|
|
11
|
+
base_url:,
|
|
12
|
+
provider_acr: nil
|
|
13
|
+
)
|
|
14
|
+
require "uri"
|
|
15
|
+
require "cgi"
|
|
16
|
+
require "json"
|
|
17
|
+
require "base64"
|
|
18
|
+
|
|
19
|
+
results = {
|
|
20
|
+
steps_completed: [],
|
|
21
|
+
errors: [],
|
|
22
|
+
warnings: [],
|
|
23
|
+
csrf_token: nil,
|
|
24
|
+
cookies: [],
|
|
25
|
+
authorization_url: nil,
|
|
26
|
+
instructions: []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
results[:steps_completed] << "fetch_csrf_token"
|
|
30
|
+
|
|
31
|
+
html_body = nil
|
|
32
|
+
cookie_header = nil
|
|
33
|
+
csrf_token = nil
|
|
34
|
+
cookies = []
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
login_response = HttpClient.get(
|
|
38
|
+
login_page_url,
|
|
39
|
+
connect_timeout: 10,
|
|
40
|
+
read_timeout: 10,
|
|
41
|
+
max_retries: 0
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
unless login_response.status.success?
|
|
45
|
+
raise "Failed to fetch login page: #{login_response.status.code} #{login_response.status.reason}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Extract cookies
|
|
49
|
+
set_cookie_headers = login_response.headers["Set-Cookie"]
|
|
50
|
+
if set_cookie_headers
|
|
51
|
+
cookie_list = set_cookie_headers.is_a?(Array) ? set_cookie_headers : [set_cookie_headers]
|
|
52
|
+
cookie_list.each do |set_cookie|
|
|
53
|
+
cookie_str = set_cookie.to_s
|
|
54
|
+
# Security: Limit cookie header size to prevent DoS attacks (max 4KB per cookie)
|
|
55
|
+
next if cookie_str.length > 4096
|
|
56
|
+
# Security: Use non-greedy matching with length limits to prevent ReDoS
|
|
57
|
+
cookie_match = cookie_str.match(/^([^=]{1,256})=([^;]{1,4096})/)
|
|
58
|
+
cookies << "#{cookie_match[1]}=#{cookie_match[2]}" if cookie_match
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
cookie_header = cookies.join("; ")
|
|
63
|
+
|
|
64
|
+
# Extract CSRF token from HTML
|
|
65
|
+
html_body = login_response.body.to_s
|
|
66
|
+
|
|
67
|
+
# Security: Limit HTML body size to prevent DoS attacks (max 1MB)
|
|
68
|
+
if html_body.bytesize > 1_048_576
|
|
69
|
+
raise "HTML response too large (#{html_body.bytesize} bytes), possible DoS attack"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Try meta tag first
|
|
73
|
+
# Security: Use non-greedy matching and limit capture group to prevent ReDoS
|
|
74
|
+
csrf_meta_match = html_body.match(/<meta\s+name=["']csrf-token["']\s+content=["']([^"']{1,256})["']/i)
|
|
75
|
+
csrf_token = csrf_meta_match[1] if csrf_meta_match
|
|
76
|
+
|
|
77
|
+
# Try form input if not found
|
|
78
|
+
# Security: Use non-greedy matching and limit capture group to prevent ReDoS
|
|
79
|
+
unless csrf_token
|
|
80
|
+
csrf_input_match = html_body.match(/<input[^>]*name=["']authenticity_token["'][^>]*value=["']([^"']{1,256})["']/i)
|
|
81
|
+
csrf_token = csrf_input_match[1] if csrf_input_match
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
unless csrf_token
|
|
85
|
+
raise "Failed to extract CSRF token from login page"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
results[:csrf_token] = csrf_token
|
|
89
|
+
results[:cookies] = cookies
|
|
90
|
+
results[:steps_completed] << "extract_csrf_and_cookies"
|
|
91
|
+
rescue => e
|
|
92
|
+
results[:errors] << "Step 1 (CSRF token): #{e.message}"
|
|
93
|
+
raise
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Step 2: Find authorization form/button in HTML
|
|
97
|
+
results[:steps_completed] << "find_authorization_form"
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
# Try to find form with action containing "openid_federation"
|
|
101
|
+
# Security: Use non-greedy matching and limit capture group to prevent ReDoS
|
|
102
|
+
form_match = html_body.match(/<form[^>]*action=["']([^"']{0,2048}openid[_-]?federation[^"']{0,2048})["'][^>]*>/i)
|
|
103
|
+
auth_endpoint = nil
|
|
104
|
+
|
|
105
|
+
if form_match
|
|
106
|
+
form_action = form_match[1]
|
|
107
|
+
# Note: Rake tasks are developer tools, no security validation needed
|
|
108
|
+
begin
|
|
109
|
+
auth_endpoint = if form_action.start_with?("http://", "https://")
|
|
110
|
+
URI.parse(form_action).to_s
|
|
111
|
+
else
|
|
112
|
+
URI.join(base_url, form_action).to_s
|
|
113
|
+
end
|
|
114
|
+
rescue URI::InvalidURIError => e
|
|
115
|
+
raise "Invalid form action URI: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
else
|
|
118
|
+
# Try to find button/link with href containing "openid_federation"
|
|
119
|
+
# Security: Use non-greedy matching and limit capture group to prevent ReDoS
|
|
120
|
+
button_match = html_body.match(/<a[^>]*href=["']([^"']{0,2048}openid[_-]?federation[^"']{0,2048})["'][^>]*>/i)
|
|
121
|
+
if button_match
|
|
122
|
+
button_href = button_match[1]
|
|
123
|
+
# Note: Rake tasks are developer tools, no security validation needed
|
|
124
|
+
begin
|
|
125
|
+
auth_endpoint = if button_href.start_with?("http://", "https://")
|
|
126
|
+
URI.parse(button_href).to_s
|
|
127
|
+
else
|
|
128
|
+
URI.join(base_url, button_href).to_s
|
|
129
|
+
end
|
|
130
|
+
rescue URI::InvalidURIError => e
|
|
131
|
+
raise "Invalid button href URI: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
# Fallback: try common paths
|
|
135
|
+
common_paths = [
|
|
136
|
+
"/users/auth/openid_federation",
|
|
137
|
+
"/auth/openid_federation",
|
|
138
|
+
"/openid_federation"
|
|
139
|
+
]
|
|
140
|
+
auth_endpoint = nil
|
|
141
|
+
common_paths.each do |path|
|
|
142
|
+
test_url = URI.join(base_url, path).to_s
|
|
143
|
+
begin
|
|
144
|
+
test_response = HttpClient.get(
|
|
145
|
+
test_url,
|
|
146
|
+
connect_timeout: 5,
|
|
147
|
+
read_timeout: 5,
|
|
148
|
+
max_retries: 0
|
|
149
|
+
)
|
|
150
|
+
if test_response.status.code >= 300 && test_response.status.code < 400
|
|
151
|
+
auth_endpoint = test_url
|
|
152
|
+
break
|
|
153
|
+
end
|
|
154
|
+
rescue
|
|
155
|
+
# Continue to next path
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
auth_endpoint ||= URI.join(base_url, "/users/auth/openid_federation").to_s
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
results[:auth_endpoint] = auth_endpoint
|
|
163
|
+
results[:steps_completed] << "resolve_auth_endpoint"
|
|
164
|
+
rescue => e
|
|
165
|
+
results[:errors] << "Step 2 (Find authorization form): #{e.message}"
|
|
166
|
+
raise
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Step 3: Request authorization URL
|
|
170
|
+
results[:steps_completed] << "request_authorization"
|
|
171
|
+
|
|
172
|
+
begin
|
|
173
|
+
headers = {
|
|
174
|
+
"X-CSRF-Token" => csrf_token,
|
|
175
|
+
"X-Requested-With" => "XMLHttpRequest",
|
|
176
|
+
"Referer" => login_page_url
|
|
177
|
+
}
|
|
178
|
+
headers["Cookie"] = cookie_header unless cookie_header.empty?
|
|
179
|
+
|
|
180
|
+
form_data = {}
|
|
181
|
+
# Include acr_values if provided (must be configured in request_object_params to be included in JWT)
|
|
182
|
+
form_data[:acr_values] = provider_acr if StringHelpers.present?(provider_acr)
|
|
183
|
+
|
|
184
|
+
auth_response = HttpClient.post(
|
|
185
|
+
auth_endpoint,
|
|
186
|
+
connect_timeout: 10,
|
|
187
|
+
read_timeout: 10,
|
|
188
|
+
max_retries: 0,
|
|
189
|
+
headers: headers,
|
|
190
|
+
form: form_data
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
authorization_url = nil
|
|
194
|
+
|
|
195
|
+
if auth_response.status.code >= 300 && auth_response.status.code < 400
|
|
196
|
+
location = auth_response.headers["Location"]
|
|
197
|
+
if location
|
|
198
|
+
# Security: Validate location header
|
|
199
|
+
if location.length > 2048
|
|
200
|
+
raise "Location header exceeds maximum length"
|
|
201
|
+
end
|
|
202
|
+
authorization_url = if location.start_with?("http://", "https://")
|
|
203
|
+
# Note: Rake tasks are developer tools, no security validation needed
|
|
204
|
+
location
|
|
205
|
+
else
|
|
206
|
+
URI.join(base_url, location).to_s
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
elsif auth_response.status.code == 200
|
|
210
|
+
authorization_url = auth_response.headers["Location"] || auth_response.body.to_s
|
|
211
|
+
authorization_url = nil unless authorization_url&.start_with?("http")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
unless authorization_url
|
|
215
|
+
raise "Failed to get authorization URL: #{auth_response.status.code} #{auth_response.status.reason}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
results[:authorization_url] = authorization_url
|
|
219
|
+
results[:steps_completed] << "authorization_url_received"
|
|
220
|
+
rescue => e
|
|
221
|
+
results[:errors] << "Step 3 (Authorization request): #{e.message}"
|
|
222
|
+
raise
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Return results with instructions
|
|
226
|
+
results[:instructions] = [
|
|
227
|
+
"1. Copy the authorization URL and open it in your browser",
|
|
228
|
+
"2. Complete the authentication with your provider",
|
|
229
|
+
"3. After authentication, you'll be redirected to a callback URL",
|
|
230
|
+
"4. Copy the ENTIRE callback URL (including all parameters) and provide it when prompted"
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
results
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "../string_helpers"
|
|
3
|
+
require_relative "../jwe"
|
|
4
|
+
require_relative "../strategy"
|
|
5
|
+
|
|
6
|
+
module OmniauthOpenidFederation
|
|
7
|
+
module Tasks
|
|
8
|
+
module CallbackProcessor
|
|
9
|
+
def self.process(
|
|
10
|
+
callback_url:,
|
|
11
|
+
base_url:,
|
|
12
|
+
client_id:, redirect_uri:, private_key:, entity_statement_url: nil,
|
|
13
|
+
entity_statement_path: nil,
|
|
14
|
+
provider_acr: nil,
|
|
15
|
+
client_entity_statement_url: nil,
|
|
16
|
+
client_entity_statement_path: nil
|
|
17
|
+
)
|
|
18
|
+
require "uri"
|
|
19
|
+
require "json"
|
|
20
|
+
require "base64"
|
|
21
|
+
require_relative "../strategy"
|
|
22
|
+
|
|
23
|
+
results = {
|
|
24
|
+
steps_completed: [],
|
|
25
|
+
errors: [],
|
|
26
|
+
warnings: [],
|
|
27
|
+
compliance_checks: {},
|
|
28
|
+
token_info: {},
|
|
29
|
+
id_token_claims: {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Parse callback URL
|
|
33
|
+
begin
|
|
34
|
+
# Note: Rake tasks are developer tools, no security validation needed
|
|
35
|
+
begin
|
|
36
|
+
uri = URI.parse(callback_url)
|
|
37
|
+
rescue URI::InvalidURIError => e
|
|
38
|
+
raise "Invalid callback URL: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
pairs = URI.decode_www_form(uri.query || "")
|
|
41
|
+
params = pairs.group_by(&:first).transform_values { |vs| vs.map(&:last) }
|
|
42
|
+
|
|
43
|
+
auth_code = params["code"]&.first
|
|
44
|
+
state = params["state"]&.first
|
|
45
|
+
error = params["error"]&.first
|
|
46
|
+
error_description = params["error_description"]&.first
|
|
47
|
+
|
|
48
|
+
if error
|
|
49
|
+
raise "Authorization error: #{error}#{" - #{error_description}" if error_description}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
unless auth_code
|
|
53
|
+
raise "No authorization code found in callback URL"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
results[:authorization_code] = auth_code
|
|
57
|
+
results[:state] = state
|
|
58
|
+
results[:steps_completed] << "parse_callback"
|
|
59
|
+
rescue => e
|
|
60
|
+
results[:errors] << "Callback parsing: #{e.message}"
|
|
61
|
+
raise
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Build strategy options from provided parameters
|
|
65
|
+
begin
|
|
66
|
+
# Resolve entity statement URL if only path provided
|
|
67
|
+
resolved_entity_statement_url = entity_statement_url
|
|
68
|
+
if resolved_entity_statement_url.nil? && entity_statement_path
|
|
69
|
+
# If only path provided, try to resolve from base_url
|
|
70
|
+
resolved_entity_statement_url = "#{base_url}/.well-known/openid-federation"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resolve client entity statement URL if only path provided
|
|
74
|
+
resolved_client_entity_statement_url = client_entity_statement_url
|
|
75
|
+
if resolved_client_entity_statement_url.nil? && client_entity_statement_path
|
|
76
|
+
resolved_client_entity_statement_url = "#{base_url}/.well-known/openid-federation"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Build strategy options
|
|
80
|
+
strategy_options = {
|
|
81
|
+
discovery: true,
|
|
82
|
+
scope: [:openid],
|
|
83
|
+
response_type: "code",
|
|
84
|
+
client_auth_method: :jwt_bearer,
|
|
85
|
+
client_signing_alg: :RS256,
|
|
86
|
+
always_encrypt_request_object: true,
|
|
87
|
+
entity_statement_url: resolved_entity_statement_url,
|
|
88
|
+
entity_statement_path: entity_statement_path,
|
|
89
|
+
client_entity_statement_url: resolved_client_entity_statement_url,
|
|
90
|
+
client_entity_statement_path: client_entity_statement_path,
|
|
91
|
+
client_options: {
|
|
92
|
+
identifier: client_id,
|
|
93
|
+
redirect_uri: redirect_uri,
|
|
94
|
+
private_key: private_key
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Store client_auth_method before filtering nil values
|
|
99
|
+
client_auth_method = strategy_options[:client_auth_method] || :jwt_bearer
|
|
100
|
+
|
|
101
|
+
# Remove nil values
|
|
102
|
+
strategy_options = strategy_options.reject { |_k, v| v.nil? }
|
|
103
|
+
strategy_options[:client_options] = strategy_options[:client_options].reject { |_k, v| v.nil? }
|
|
104
|
+
|
|
105
|
+
strategy = OmniAuth::Strategies::OpenIDFederation.new(nil, strategy_options)
|
|
106
|
+
oidc_client = strategy.client
|
|
107
|
+
|
|
108
|
+
unless oidc_client
|
|
109
|
+
raise "Failed to initialize OpenID Connect client"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
unless oidc_client.private_key
|
|
113
|
+
raise "Private key not set on OpenID Connect client (required for private_key_jwt)"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
results[:steps_completed] << "initialize_strategy"
|
|
117
|
+
rescue => e
|
|
118
|
+
results[:errors] << "Strategy initialization: #{e.message}"
|
|
119
|
+
raise
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Exchange authorization code for tokens
|
|
123
|
+
begin
|
|
124
|
+
oidc_client.authorization_code = auth_code
|
|
125
|
+
oidc_client.redirect_uri = redirect_uri
|
|
126
|
+
access_token = oidc_client.access_token!(client_auth_method)
|
|
127
|
+
|
|
128
|
+
id_token_raw = access_token.id_token
|
|
129
|
+
access_token_value = access_token.access_token
|
|
130
|
+
refresh_token = access_token.refresh_token
|
|
131
|
+
|
|
132
|
+
results[:token_info] = {
|
|
133
|
+
access_token: access_token_value ? "#{access_token_value[0..30]}..." : nil,
|
|
134
|
+
refresh_token: refresh_token ? "Present" : "Not provided",
|
|
135
|
+
id_token_encrypted: id_token_raw ? "#{id_token_raw[0..50]}..." : nil
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
results[:steps_completed] << "token_exchange"
|
|
139
|
+
rescue => e
|
|
140
|
+
results[:errors] << "Token exchange: #{e.message}"
|
|
141
|
+
raise
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Decrypt and validate ID token
|
|
145
|
+
begin
|
|
146
|
+
id_token = strategy.send(:decode_id_token, id_token_raw)
|
|
147
|
+
|
|
148
|
+
results[:id_token_claims] = {
|
|
149
|
+
iss: id_token.iss,
|
|
150
|
+
sub: id_token.sub,
|
|
151
|
+
aud: id_token.aud,
|
|
152
|
+
exp: id_token.exp,
|
|
153
|
+
iat: id_token.iat,
|
|
154
|
+
nonce: id_token.nonce,
|
|
155
|
+
acr: id_token.acr,
|
|
156
|
+
auth_time: id_token.auth_time,
|
|
157
|
+
amr: id_token.amr
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# Validate required claims
|
|
161
|
+
required_claims = {
|
|
162
|
+
iss: id_token.iss,
|
|
163
|
+
sub: id_token.sub,
|
|
164
|
+
aud: id_token.aud,
|
|
165
|
+
exp: id_token.exp,
|
|
166
|
+
iat: id_token.iat
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
missing_claims = required_claims.select { |_k, v| v.nil? }
|
|
170
|
+
if missing_claims.empty?
|
|
171
|
+
results[:id_token_valid] = true
|
|
172
|
+
else
|
|
173
|
+
results[:errors] << "Missing required ID token claims: #{missing_claims.keys.join(", ")}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
results[:steps_completed] << "id_token_validation"
|
|
177
|
+
rescue => e
|
|
178
|
+
results[:errors] << "ID token validation: #{e.message}"
|
|
179
|
+
raise
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Validate OpenID Federation compliance
|
|
183
|
+
results[:compliance_checks] = {
|
|
184
|
+
"Signed Request Objects" => {
|
|
185
|
+
status: "✅ MANDATORY",
|
|
186
|
+
description: "All requests use signed request objects (RFC 9101)",
|
|
187
|
+
verified: true
|
|
188
|
+
},
|
|
189
|
+
"ID Token Encryption" => {
|
|
190
|
+
status: "✅ MANDATORY",
|
|
191
|
+
description: "ID tokens are encrypted (RSA-OAEP + A128CBC-HS256)",
|
|
192
|
+
verified: OmniauthOpenidFederation::Jwe.encrypted?(id_token_raw)
|
|
193
|
+
},
|
|
194
|
+
"Client Assertion (private_key_jwt)" => {
|
|
195
|
+
status: "✅ MANDATORY",
|
|
196
|
+
description: "Token endpoint uses private_key_jwt authentication",
|
|
197
|
+
verified: true
|
|
198
|
+
},
|
|
199
|
+
"Entity Statement JWKS" => {
|
|
200
|
+
status: "✅ MANDATORY",
|
|
201
|
+
description: "JWKS extracted from entity statement",
|
|
202
|
+
verified: StringHelpers.present?(entity_statement_path) || StringHelpers.present?(entity_statement_url)
|
|
203
|
+
},
|
|
204
|
+
"Signed JWKS Support" => {
|
|
205
|
+
status: "✅ MANDATORY",
|
|
206
|
+
description: "Supports OpenID Federation signed JWKS for key rotation",
|
|
207
|
+
verified: true
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# Check for client entity statement (optional but recommended)
|
|
212
|
+
if StringHelpers.present?(client_entity_statement_path) || StringHelpers.present?(client_entity_statement_url)
|
|
213
|
+
results[:compliance_checks]["Client Entity Statement"] = {
|
|
214
|
+
status: "✅ RECOMMENDED",
|
|
215
|
+
description: "Client entity statement for federation-based key management",
|
|
216
|
+
verified: true
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Check registration type (automatic if client entity statement is provided)
|
|
221
|
+
if StringHelpers.present?(client_entity_statement_path) || StringHelpers.present?(client_entity_statement_url)
|
|
222
|
+
results[:compliance_checks]["Automatic Registration"] = {
|
|
223
|
+
status: "✅ ENABLED",
|
|
224
|
+
description: "Automatic client registration using entity statement",
|
|
225
|
+
verified: true
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
results[:all_compliance_verified] = results[:compliance_checks].all? { |_k, v| v[:verified] }
|
|
230
|
+
|
|
231
|
+
results
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require_relative "../utils"
|
|
5
|
+
require_relative "path_resolver"
|
|
6
|
+
|
|
7
|
+
module OmniauthOpenidFederation
|
|
8
|
+
module Tasks
|
|
9
|
+
module ClientKeysPreparer
|
|
10
|
+
def self.prepare(key_type:, output_dir:)
|
|
11
|
+
unless %w[single separate].include?(key_type)
|
|
12
|
+
raise ArgumentError, "Invalid key_type: #{key_type}. Valid options: 'single' or 'separate'"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
output_path = PathResolver.resolve(output_dir)
|
|
16
|
+
|
|
17
|
+
# Create output directory if it doesn't exist
|
|
18
|
+
FileUtils.mkdir_p(output_path) unless File.directory?(output_path)
|
|
19
|
+
|
|
20
|
+
result = if key_type == "single"
|
|
21
|
+
generate_single_key(output_path)
|
|
22
|
+
else
|
|
23
|
+
generate_separate_keys(output_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
success: true,
|
|
28
|
+
output_path: output_path,
|
|
29
|
+
**result
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.generate_single_key(output_path)
|
|
34
|
+
private_key = OpenSSL::PKey::RSA.new(2048)
|
|
35
|
+
jwk_hash = Utils.rsa_key_to_jwk(private_key, use: "sig")
|
|
36
|
+
|
|
37
|
+
# Remove private key components and 'use' field for backward compatibility
|
|
38
|
+
public_jwk = jwk_hash.reject { |k, _v| %w[d p q dp dq qi use].include?(k.to_s) }
|
|
39
|
+
jwks = {keys: [public_jwk]}
|
|
40
|
+
|
|
41
|
+
# Save private key
|
|
42
|
+
private_key_path = File.join(output_path, "client-private-key.pem")
|
|
43
|
+
File.write(private_key_path, private_key.to_pem)
|
|
44
|
+
File.chmod(0o600, private_key_path)
|
|
45
|
+
|
|
46
|
+
# Save public JWKS
|
|
47
|
+
public_jwks_path = File.join(output_path, "client-jwks.json")
|
|
48
|
+
File.write(public_jwks_path, JSON.pretty_generate(jwks))
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
private_key_path: private_key_path,
|
|
52
|
+
public_jwks_path: public_jwks_path,
|
|
53
|
+
jwks: jwks
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.generate_separate_keys(output_path)
|
|
58
|
+
signing_private_key = OpenSSL::PKey::RSA.new(2048)
|
|
59
|
+
encryption_private_key = OpenSSL::PKey::RSA.new(2048)
|
|
60
|
+
|
|
61
|
+
signing_jwk_hash = Utils.rsa_key_to_jwk(signing_private_key, use: "sig")
|
|
62
|
+
encryption_jwk_hash = Utils.rsa_key_to_jwk(encryption_private_key, use: "enc")
|
|
63
|
+
|
|
64
|
+
# Remove private key components and add 'use' field
|
|
65
|
+
signing_public_jwk = signing_jwk_hash.reject { |k, _v| %w[d p q dp dq qi].include?(k.to_s) }.merge("use" => "sig")
|
|
66
|
+
encryption_public_jwk = encryption_jwk_hash.reject { |k, _v| %w[d p q dp dq qi].include?(k.to_s) }.merge("use" => "enc")
|
|
67
|
+
|
|
68
|
+
jwks = {keys: [signing_public_jwk, encryption_public_jwk]}
|
|
69
|
+
|
|
70
|
+
# Save private keys
|
|
71
|
+
signing_key_path = File.join(output_path, "client-signing-private-key.pem")
|
|
72
|
+
encryption_key_path = File.join(output_path, "client-encryption-private-key.pem")
|
|
73
|
+
|
|
74
|
+
File.write(signing_key_path, signing_private_key.to_pem)
|
|
75
|
+
File.write(encryption_key_path, encryption_private_key.to_pem)
|
|
76
|
+
File.chmod(0o600, signing_key_path)
|
|
77
|
+
File.chmod(0o600, encryption_key_path)
|
|
78
|
+
|
|
79
|
+
# Save public JWKS
|
|
80
|
+
public_jwks_path = File.join(output_path, "client-jwks.json")
|
|
81
|
+
File.write(public_jwks_path, JSON.pretty_generate(jwks))
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
signing_key_path: signing_key_path,
|
|
85
|
+
encryption_key_path: encryption_key_path,
|
|
86
|
+
public_jwks_path: public_jwks_path,
|
|
87
|
+
jwks: jwks
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class << self
|
|
92
|
+
private :generate_single_key, :generate_separate_keys
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|