omniauth_openid_federation 1.3.2 → 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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/README.md +119 -13
  4. data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
  5. data/config/polyrun_coverage.yml +15 -0
  6. data/config/polyrun_spec_quality.yml +29 -0
  7. data/examples/README_INTEGRATION_TESTING.md +3 -0
  8. data/examples/integration_test_flow.rb +10 -8
  9. data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
  10. data/examples/jobs/federation_files_generation_job.rb.example +3 -2
  11. data/examples/mock_op_server.rb +4 -5
  12. data/examples/mock_rp_server.rb +3 -3
  13. data/examples/standalone_multiple_endpoints_example.rb +494 -0
  14. data/lib/omniauth_openid_federation/access_token.rb +91 -450
  15. data/lib/omniauth_openid_federation/cache.rb +16 -0
  16. data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
  17. data/lib/omniauth_openid_federation/constants.rb +3 -0
  18. data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
  19. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +2 -4
  20. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
  21. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +51 -6
  22. data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
  23. data/lib/omniauth_openid_federation/http_client.rb +145 -32
  24. data/lib/omniauth_openid_federation/http_errors.rb +14 -0
  25. data/lib/omniauth_openid_federation/id_token.rb +19 -0
  26. data/lib/omniauth_openid_federation/jwe.rb +26 -0
  27. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
  28. data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
  29. data/lib/omniauth_openid_federation/jws.rb +2 -11
  30. data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
  31. data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
  32. data/lib/omniauth_openid_federation/rack.rb +2 -0
  33. data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
  34. data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
  35. data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
  36. data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
  37. data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
  38. data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
  39. data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
  40. data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
  41. data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
  42. data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
  43. data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
  44. data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
  45. data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
  46. data/lib/omniauth_openid_federation/strategy.rb +27 -1910
  47. data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
  48. data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
  49. data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
  50. data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
  51. data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
  52. data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
  53. data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
  54. data/lib/omniauth_openid_federation/user_info.rb +15 -0
  55. data/lib/omniauth_openid_federation/utils.rb +10 -2
  56. data/lib/omniauth_openid_federation/validators.rb +43 -8
  57. data/lib/omniauth_openid_federation/version.rb +1 -1
  58. data/lib/omniauth_openid_federation.rb +9 -3
  59. data/lib/tasks/omniauth_openid_federation.rake +1 -2
  60. data/sig/omniauth_openid_federation.rbs +2 -1
  61. data/sig/strategy.rbs +6 -6
  62. metadata +181 -71
@@ -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