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.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.md +22 -0
  4. data/README.md +822 -0
  5. data/SECURITY.md +129 -0
  6. data/examples/README_INTEGRATION_TESTING.md +399 -0
  7. data/examples/README_MOCK_OP.md +243 -0
  8. data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
  9. data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
  10. data/examples/app/models/user.rb.example +39 -0
  11. data/examples/config/initializers/devise.rb.example +97 -0
  12. data/examples/config/initializers/federation_endpoint.rb.example +206 -0
  13. data/examples/config/mock_op.yml.example +83 -0
  14. data/examples/config/open_id_connect_config.rb.example +210 -0
  15. data/examples/config/routes.rb.example +12 -0
  16. data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
  17. data/examples/integration_test_flow.rb +1334 -0
  18. data/examples/jobs/README.md +194 -0
  19. data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
  20. data/examples/jobs/federation_files_generation_job.rb.example +87 -0
  21. data/examples/mock_op_server.rb +775 -0
  22. data/examples/mock_rp_server.rb +435 -0
  23. data/lib/omniauth_openid_federation/access_token.rb +504 -0
  24. data/lib/omniauth_openid_federation/cache.rb +39 -0
  25. data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
  26. data/lib/omniauth_openid_federation/configuration.rb +135 -0
  27. data/lib/omniauth_openid_federation/constants.rb +13 -0
  28. data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
  29. data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
  30. data/lib/omniauth_openid_federation/errors.rb +52 -0
  31. data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
  32. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
  33. data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
  34. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
  35. data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
  36. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
  37. data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
  38. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
  39. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
  40. data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
  41. data/lib/omniauth_openid_federation/http_client.rb +70 -0
  42. data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
  43. data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
  44. data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
  45. data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
  46. data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
  47. data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
  48. data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
  49. data/lib/omniauth_openid_federation/jws.rb +416 -0
  50. data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
  51. data/lib/omniauth_openid_federation/logger.rb +99 -0
  52. data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
  53. data/lib/omniauth_openid_federation/railtie.rb +29 -0
  54. data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
  55. data/lib/omniauth_openid_federation/strategy.rb +2029 -0
  56. data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
  57. data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
  58. data/lib/omniauth_openid_federation/utils.rb +166 -0
  59. data/lib/omniauth_openid_federation/validators.rb +126 -0
  60. data/lib/omniauth_openid_federation/version.rb +3 -0
  61. data/lib/omniauth_openid_federation.rb +98 -0
  62. data/lib/tasks/omniauth_openid_federation.rake +376 -0
  63. data/sig/federation.rbs +218 -0
  64. data/sig/jwks.rbs +63 -0
  65. data/sig/omniauth_openid_federation.rbs +254 -0
  66. data/sig/strategy.rbs +60 -0
  67. metadata +352 -0
@@ -0,0 +1,126 @@
1
+ # Input validation utilities for omniauth_openid_federation
2
+ module OmniauthOpenidFederation
3
+ module Validators
4
+ # Validate that a private key is present and valid
5
+ #
6
+ # @param private_key [OpenSSL::PKey::RSA, String, nil] The private key to validate
7
+ # @raise [ConfigurationError] If private key is missing or invalid
8
+ def self.validate_private_key!(private_key)
9
+ if private_key.nil?
10
+ raise ConfigurationError, "Private key is required for signed request objects"
11
+ end
12
+
13
+ # Try to parse if it's a string
14
+ if private_key.is_a?(String)
15
+ begin
16
+ OpenSSL::PKey::RSA.new(private_key)
17
+ rescue => e
18
+ raise ConfigurationError, "Invalid private key format: #{e.message}"
19
+ end
20
+ elsif !private_key.is_a?(OpenSSL::PKey::RSA)
21
+ raise ConfigurationError, "Private key must be an OpenSSL::PKey::RSA instance or PEM string"
22
+ end
23
+
24
+ true
25
+ end
26
+
27
+ # Validate that a URI is valid
28
+ #
29
+ # @param uri [String, URI, nil] The URI to validate
30
+ # @param required [Boolean] Whether the URI is required
31
+ # @raise [ConfigurationError] If URI is invalid or missing when required
32
+ def self.validate_uri!(uri, required: false)
33
+ if StringHelpers.blank?(uri)
34
+ if required
35
+ raise ConfigurationError, "URI is required"
36
+ end
37
+ return false
38
+ end
39
+
40
+ begin
41
+ parsed = URI.parse(uri.to_s)
42
+ unless parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS)
43
+ raise ConfigurationError, "URI must be HTTP or HTTPS: #{uri}"
44
+ end
45
+ true
46
+ rescue URI::InvalidURIError => e
47
+ raise ConfigurationError, "Invalid URI format: #{e.message}"
48
+ end
49
+ end
50
+
51
+ # Validate that a file path exists
52
+ #
53
+ # @param path [String, nil] The file path to validate
54
+ # @param required [Boolean] Whether the file is required
55
+ # @raise [ConfigurationError] If file is missing when required
56
+ def self.validate_file_path!(path, required: false)
57
+ if StringHelpers.blank?(path)
58
+ if required
59
+ raise ConfigurationError, "File path is required"
60
+ end
61
+ return false
62
+ end
63
+
64
+ unless File.exist?(path)
65
+ if required
66
+ raise ConfigurationError, "File not found: #{path}"
67
+ end
68
+ return false
69
+ end
70
+
71
+ true
72
+ end
73
+
74
+ # Validate client options hash
75
+ #
76
+ # @param client_options [Hash] The client options to validate
77
+ # @raise [ConfigurationError] If required options are missing
78
+ def self.validate_client_options!(client_options)
79
+ client_options ||= {}
80
+
81
+ # Normalize hash keys to symbols
82
+ normalized = normalize_hash(client_options)
83
+
84
+ # Validate required fields
85
+ if StringHelpers.blank?(normalized[:identifier])
86
+ raise ConfigurationError, "Client identifier is required"
87
+ end
88
+
89
+ if StringHelpers.blank?(normalized[:redirect_uri])
90
+ raise ConfigurationError, "Redirect URI is required"
91
+ end
92
+
93
+ # Validate redirect URI format
94
+ validate_uri!(normalized[:redirect_uri], required: true)
95
+
96
+ # Validate private key
97
+ validate_private_key!(normalized[:private_key])
98
+
99
+ # Validate endpoints if provided
100
+ %i[authorization_endpoint token_endpoint jwks_uri].each do |endpoint|
101
+ if normalized.key?(endpoint) && !StringHelpers.blank?(normalized[endpoint])
102
+ # Endpoints can be paths or full URLs
103
+ endpoint_value = normalized[endpoint]
104
+ unless endpoint_value.to_s.start_with?("/", "http://", "https://")
105
+ raise ConfigurationError, "Invalid endpoint format for #{endpoint}: #{endpoint_value}"
106
+ end
107
+ end
108
+ end
109
+
110
+ normalized
111
+ end
112
+
113
+ # Normalize hash keys to symbols
114
+ #
115
+ # @param hash [Hash] The hash to normalize
116
+ # @return [Hash] Hash with symbol keys
117
+ def self.normalize_hash(hash)
118
+ return {} if hash.nil?
119
+
120
+ hash.each_with_object({}) do |(k, v), result|
121
+ key = k.is_a?(String) ? k.to_sym : k
122
+ result[key] = v
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,3 @@
1
+ module OmniauthOpenidFederation
2
+ VERSION = "1.0.0".freeze
3
+ end
@@ -0,0 +1,98 @@
1
+ # OmniAuth OpenID Federation
2
+ #
3
+ # Custom OmniAuth strategy for OpenID Federation providers using openid_connect gem
4
+ # supporting signed request objects, ID token encryption, and OpenID Federation.
5
+ #
6
+ # @see https://openid.net/specs/openid-federation-1_0.html
7
+ module OmniauthOpenidFederation
8
+ # Configure the gem
9
+ #
10
+ # @yield [config] Yields the configuration object
11
+ # @example
12
+ # OmniauthOpenidFederation.configure do |config|
13
+ # config.verify_ssl = false # Only for development
14
+ # config.cache_ttl = 3600
15
+ # end
16
+ def self.configure
17
+ yield(Configuration.config) if block_given?
18
+ Configuration.config
19
+ end
20
+
21
+ # Get the global configuration
22
+ #
23
+ # @return [Configuration] The configuration instance
24
+ def self.config
25
+ Configuration.config
26
+ end
27
+ end
28
+
29
+ require_relative "omniauth_openid_federation/version"
30
+ require_relative "omniauth_openid_federation/string_helpers"
31
+ require_relative "omniauth_openid_federation/logger"
32
+ require_relative "omniauth_openid_federation/configuration"
33
+ require_relative "omniauth_openid_federation/errors"
34
+ require_relative "omniauth_openid_federation/instrumentation"
35
+ require_relative "omniauth_openid_federation/validators"
36
+ require_relative "omniauth_openid_federation/key_extractor"
37
+ require_relative "omniauth_openid_federation/constants"
38
+ require_relative "omniauth_openid_federation/cache"
39
+ require_relative "omniauth_openid_federation/cache_adapter"
40
+ require_relative "omniauth_openid_federation/utils"
41
+ require_relative "omniauth_openid_federation/jws"
42
+ require_relative "omniauth_openid_federation/jwks/normalizer"
43
+ require_relative "omniauth_openid_federation/jwks/fetch"
44
+ require_relative "omniauth_openid_federation/jwks/decode"
45
+ require_relative "omniauth_openid_federation/jwks/cache"
46
+ require_relative "omniauth_openid_federation/jwks/selector"
47
+ require_relative "omniauth_openid_federation/jwks/rotate"
48
+ require_relative "omniauth_openid_federation/federation/entity_statement"
49
+ require_relative "omniauth_openid_federation/federation/entity_statement_fetcher"
50
+ require_relative "omniauth_openid_federation/federation/entity_statement_parser"
51
+ require_relative "omniauth_openid_federation/federation/entity_statement_validator"
52
+ require_relative "omniauth_openid_federation/federation/entity_statement_helper"
53
+ require_relative "omniauth_openid_federation/federation/entity_statement_builder"
54
+ require_relative "omniauth_openid_federation/federation/trust_chain_resolver"
55
+ require_relative "omniauth_openid_federation/federation/metadata_policy_merger"
56
+ require_relative "omniauth_openid_federation/federation/signed_jwks"
57
+ require_relative "omniauth_openid_federation/federation_endpoint"
58
+ require_relative "omniauth_openid_federation/rack_endpoint"
59
+ require_relative "omniauth_openid_federation/entity_statement_reader"
60
+ require_relative "omniauth_openid_federation/tasks_helper"
61
+ require_relative "omniauth_openid_federation/endpoint_resolver"
62
+ require_relative "omniauth_openid_federation/strategy"
63
+ require_relative "omniauth_openid_federation/access_token"
64
+
65
+ module OmniauthOpenidFederation
66
+ # Rotate JWKS cache for a provider
67
+ # This is useful for background jobs to proactively refresh keys
68
+ #
69
+ # @param jwks_uri [String] The JWKS URI to refresh
70
+ # @param entity_statement_path [String, nil] Path to entity statement file (optional)
71
+ # @return [Hash] The refreshed JWKS hash
72
+ # @raise [FetchError] If fetching fails
73
+ # @raise [ValidationError] If validation fails
74
+ # @example
75
+ # # Rotate JWKS for a provider
76
+ # OmniauthOpenidFederation.rotate_jwks(
77
+ # "https://provider.example.com/.well-known/jwks.json",
78
+ # entity_statement_path: "config/provider-entity-statement.jwt"
79
+ # )
80
+ def self.rotate_jwks(jwks_uri, entity_statement_path: nil)
81
+ Jwks::Rotate.run(jwks_uri, entity_statement_path: entity_statement_path)
82
+ end
83
+ end
84
+
85
+ # Load Railtie for Rails integration (rake tasks, etc.)
86
+ if defined?(Rails)
87
+ require_relative "omniauth_openid_federation/railtie"
88
+ end
89
+
90
+ # Create an alias for Devise's autoload mechanism
91
+ # Devise tries to autoload OpenidFederation (camelCase) from :openid_federation
92
+ # but our class is OpenIDFederation (with capital ID)
93
+ module OmniAuth
94
+ module Strategies
95
+ # Alias for Devise autoload compatibility
96
+ OpenidFederation = OpenIDFederation unless defined?(OpenidFederation)
97
+ end
98
+ end
@@ -0,0 +1,376 @@
1
+ # Rake tasks for OmniAuth OpenID Federation
2
+ # Thin wrappers around TasksHelper for CLI interface
3
+
4
+ namespace :openid_federation do
5
+ desc "Fetch entity statement from OpenID Federation provider"
6
+ task :fetch_entity_statement, [:url, :fingerprint, :output_file] => :environment do |_t, args|
7
+ require "omniauth_openid_federation"
8
+
9
+ url = args[:url] || ENV["ENTITY_STATEMENT_URL"]
10
+ fingerprint = args[:fingerprint] || ENV["ENTITY_STATEMENT_FINGERPRINT"]
11
+ output_file = args[:output_file] || ENV["ENTITY_STATEMENT_OUTPUT"] || "config/provider-entity-statement.jwt"
12
+
13
+ unless url
14
+ puts "❌ Entity statement URL is required"
15
+ puts " Usage: rake openid_federation:fetch_entity_statement[URL,FINGERPRINT,OUTPUT_FILE]"
16
+ puts " Or set: ENTITY_STATEMENT_URL, ENTITY_STATEMENT_FINGERPRINT, ENTITY_STATEMENT_OUTPUT"
17
+ exit 1
18
+ end
19
+
20
+ puts "Fetching entity statement from #{url}..."
21
+ puts "Output file: #{OmniauthOpenidFederation::TasksHelper.resolve_path(output_file)}"
22
+
23
+ if fingerprint
24
+ puts "Expected fingerprint: #{fingerprint}"
25
+ end
26
+
27
+ begin
28
+ result = OmniauthOpenidFederation::TasksHelper.fetch_entity_statement(
29
+ url: url,
30
+ fingerprint: fingerprint,
31
+ output_file: output_file
32
+ )
33
+
34
+ puts "✅ Entity statement saved to: #{result[:output_path]}"
35
+ puts "✅ Fingerprint: #{result[:fingerprint]}"
36
+
37
+ metadata = result[:metadata]
38
+ puts "\n📋 Entity Statement Metadata:"
39
+ puts " Issuer: #{metadata[:issuer]}"
40
+ puts " Authorization Endpoint: #{metadata[:metadata][:openid_provider][:authorization_endpoint]}"
41
+ puts " Token Endpoint: #{metadata[:metadata][:openid_provider][:token_endpoint]}"
42
+ puts " UserInfo Endpoint: #{metadata[:metadata][:openid_provider][:userinfo_endpoint]}"
43
+ puts " JWKS URI: #{metadata[:metadata][:openid_provider][:jwks_uri]}"
44
+ if metadata[:metadata][:openid_provider][:signed_jwks_uri]
45
+ puts " Signed JWKS URI: #{metadata[:metadata][:openid_provider][:signed_jwks_uri]}"
46
+ end
47
+ rescue OmniauthOpenidFederation::Federation::EntityStatement::FetchError => e
48
+ puts "❌ Error fetching entity statement: #{e.message}"
49
+ exit 1
50
+ rescue OmniauthOpenidFederation::Federation::EntityStatement::ValidationError => e
51
+ puts "❌ Validation error: #{e.message}"
52
+ puts " âš ī¸ Entity statement fingerprint mismatch. Check provider documentation."
53
+ exit 1
54
+ rescue => e
55
+ puts "❌ Unexpected error: #{e.message}"
56
+ puts " #{e.class}: #{e.backtrace.first}"
57
+ exit 1
58
+ end
59
+ end
60
+
61
+ desc "Validate existing entity statement file"
62
+ task :validate_entity_statement, [:file_path, :fingerprint] => :environment do |_t, args|
63
+ require "omniauth_openid_federation"
64
+
65
+ file_path = args[:file_path] || ENV["ENTITY_STATEMENT_PATH"] || "config/provider-entity-statement.jwt"
66
+ expected_fingerprint = args[:fingerprint] || ENV["ENTITY_STATEMENT_FINGERPRINT"]
67
+
68
+ begin
69
+ result = OmniauthOpenidFederation::TasksHelper.validate_entity_statement(
70
+ file_path: file_path,
71
+ expected_fingerprint: expected_fingerprint
72
+ )
73
+
74
+ if expected_fingerprint
75
+ puts "✅ Fingerprint matches: #{result[:fingerprint]}"
76
+ else
77
+ puts "📋 Entity statement fingerprint: #{result[:fingerprint]}"
78
+ end
79
+
80
+ metadata = result[:metadata]
81
+ puts "\n📋 Entity Statement Metadata:"
82
+ puts " Issuer: #{metadata[:issuer]}"
83
+ puts " Authorization Endpoint: #{metadata[:metadata][:openid_provider][:authorization_endpoint]}"
84
+ puts " Token Endpoint: #{metadata[:metadata][:openid_provider][:token_endpoint]}"
85
+ puts " UserInfo Endpoint: #{metadata[:metadata][:openid_provider][:userinfo_endpoint]}"
86
+ puts " JWKS URI: #{metadata[:metadata][:openid_provider][:jwks_uri]}"
87
+ if metadata[:metadata][:openid_provider][:signed_jwks_uri]
88
+ puts " Signed JWKS URI: #{metadata[:metadata][:openid_provider][:signed_jwks_uri]}"
89
+ end
90
+ rescue OmniauthOpenidFederation::ConfigurationError => e
91
+ puts "❌ #{e.message}"
92
+ exit 1
93
+ rescue OmniauthOpenidFederation::ValidationError => e
94
+ puts "❌ Validation error: #{e.message}"
95
+ exit 1
96
+ rescue => e
97
+ puts "❌ Error: #{e.message}"
98
+ puts " #{e.class}: #{e.backtrace.first}"
99
+ exit 1
100
+ end
101
+ end
102
+
103
+ desc "Fetch JWKS from provider"
104
+ task :fetch_jwks, [:jwks_uri, :output_file] => :environment do |_t, args|
105
+ require "omniauth_openid_federation"
106
+
107
+ jwks_uri = args[:jwks_uri] || ENV["JWKS_URI"]
108
+ output_file = args[:output_file] || ENV["JWKS_OUTPUT"] || "config/provider-jwks.json"
109
+
110
+ unless jwks_uri
111
+ puts "❌ JWKS URI is required"
112
+ puts " Usage: rake openid_federation:fetch_jwks[JWKS_URI,OUTPUT_FILE]"
113
+ puts " Or set: JWKS_URI, JWKS_OUTPUT"
114
+ exit 1
115
+ end
116
+
117
+ puts "Fetching JWKS from #{jwks_uri}..."
118
+ puts "Output file: #{OmniauthOpenidFederation::TasksHelper.resolve_path(output_file)}"
119
+
120
+ begin
121
+ result = OmniauthOpenidFederation::TasksHelper.fetch_jwks(
122
+ jwks_uri: jwks_uri,
123
+ output_file: output_file
124
+ )
125
+
126
+ jwks = result[:jwks]
127
+ puts "✅ JWKS saved to: #{result[:output_path]}"
128
+ puts "✅ Keys found: #{jwks&.[]("keys")&.length || 0}"
129
+
130
+ jwks&.[]("keys")&.each_with_index do |key, index|
131
+ puts " Key #{index + 1}:"
132
+ puts " - kid: #{key["kid"]}"
133
+ puts " - kty: #{key["kty"]}"
134
+ puts " - use: #{key["use"] || "not specified"}"
135
+ end
136
+ rescue => e
137
+ puts "❌ Error fetching JWKS: #{e.message}"
138
+ puts " #{e.class}: #{e.backtrace.first}"
139
+ exit 1
140
+ end
141
+ end
142
+
143
+ desc "Parse entity statement and display endpoints"
144
+ task :parse_entity_statement, [:file_path] => :environment do |_t, args|
145
+ require "omniauth_openid_federation"
146
+ require "json"
147
+
148
+ file_path = args[:file_path] || ENV["ENTITY_STATEMENT_PATH"] || "config/provider-entity-statement.jwt"
149
+
150
+ begin
151
+ metadata = OmniauthOpenidFederation::TasksHelper.parse_entity_statement(file_path: file_path)
152
+
153
+ puts "📋 Entity Statement Metadata:"
154
+ puts JSON.pretty_generate(metadata)
155
+ rescue OmniauthOpenidFederation::ConfigurationError, OmniauthOpenidFederation::ValidationError => e
156
+ puts "❌ Error: #{e.message}"
157
+ exit 1
158
+ rescue => e
159
+ puts "❌ Error parsing entity statement: #{e.message}"
160
+ puts " #{e.class}: #{e.backtrace.first}"
161
+ exit 1
162
+ end
163
+ end
164
+
165
+ desc "Generate client keys and prepare public JWKS for provider registration"
166
+ task :prepare_client_keys, [:key_type, :output_dir] => :environment do |_t, args|
167
+ require "omniauth_openid_federation"
168
+ require "json"
169
+ require "fileutils"
170
+
171
+ key_type = (args[:key_type] || ENV["KEY_TYPE"] || "single").to_s.downcase
172
+ output_dir = args[:output_dir] || ENV["KEYS_OUTPUT_DIR"] || "config"
173
+
174
+ output_path = OmniauthOpenidFederation::TasksHelper.resolve_path(output_dir)
175
+
176
+ unless File.directory?(output_path)
177
+ FileUtils.mkdir_p(output_path)
178
+ puts "Created output directory: #{output_path}"
179
+ end
180
+
181
+ puts "Generating client keys..."
182
+ puts "Key type: #{key_type}"
183
+ puts "Output directory: #{output_path}"
184
+
185
+ begin
186
+ result = OmniauthOpenidFederation::TasksHelper.prepare_client_keys(
187
+ key_type: key_type,
188
+ output_dir: output_dir
189
+ )
190
+
191
+ puts "\n✅ Keys generated successfully:"
192
+ if key_type == "single"
193
+ puts " Private key: #{result[:private_key_path]}"
194
+ else
195
+ puts " Signing private key: #{result[:signing_key_path]}"
196
+ puts " Encryption private key: #{result[:encryption_key_path]}"
197
+ end
198
+
199
+ puts " Public JWKS: #{result[:public_jwks_path]}"
200
+ puts "\n📋 Send this JWKS to your provider for client registration:"
201
+ separator = "=" * 70
202
+ puts separator
203
+ puts JSON.pretty_generate(result[:jwks])
204
+ puts separator
205
+
206
+ puts "\nâš ī¸ SECURITY WARNING:"
207
+ puts " - Keep private keys secure! Never commit them to version control."
208
+ puts " - Add to .gitignore: config/*-private-key.pem"
209
+ puts " - Prefer storing secrets in secret vaults (1Password, HashiCorp Vault, etc.)"
210
+ puts " - Only send the public JWKS (client-jwks.json) to your provider"
211
+ rescue ArgumentError => e
212
+ puts "❌ #{e.message}"
213
+ puts " Valid options: 'single' (one key for both signing/encryption) or 'separate' (two keys)"
214
+ exit 1
215
+ rescue => e
216
+ puts "❌ Error generating keys: #{e.message}"
217
+ puts " #{e.class}: #{e.backtrace.first}"
218
+ exit 1
219
+ end
220
+ end
221
+
222
+ desc "Test local entity statement endpoint and all linked endpoints"
223
+ task :test_local_endpoint, [:base_url] => :environment do |_t, args|
224
+ require "omniauth_openid_federation"
225
+
226
+ base_url = args[:base_url] || ENV["BASE_URL"] || "http://localhost:3000"
227
+
228
+ puts "=" * 80
229
+ puts "Testing Local Entity Statement Endpoint"
230
+ puts "=" * 80
231
+ puts
232
+ puts "Base URL: #{base_url}"
233
+ puts "Entity Statement URL: #{base_url}/.well-known/openid-federation"
234
+ puts
235
+
236
+ begin
237
+ result = OmniauthOpenidFederation::TasksHelper.test_local_endpoint(base_url: base_url)
238
+
239
+ entity_statement = result[:entity_statement]
240
+ metadata = result[:metadata]
241
+ results = result[:results]
242
+ key_status = result[:key_status]
243
+ validation_warnings = result[:validation_warnings] || []
244
+
245
+ puts "đŸ“Ĩ Step 1: Fetching entity statement..."
246
+ if validation_warnings.any? { |w| w.include?("fetch") || w.include?("Fetch") }
247
+ puts "âš ī¸ Entity statement fetched with warnings"
248
+ else
249
+ puts "✅ Entity statement fetched successfully"
250
+ end
251
+ puts " Fingerprint: #{entity_statement.fingerprint}"
252
+ puts
253
+
254
+ puts "📋 Step 2: Parsing entity statement..."
255
+ if validation_warnings.any?
256
+ puts "âš ī¸ Entity statement parsed with validation warnings:"
257
+ validation_warnings.each do |warning|
258
+ puts " âš ī¸ #{warning}"
259
+ end
260
+ else
261
+ puts "✅ Entity statement parsed successfully"
262
+ end
263
+ puts " Issuer: #{metadata[:issuer]}"
264
+ puts " Subject: #{metadata[:sub]}"
265
+ puts " Expires: #{Time.at(metadata[:exp])}" if metadata[:exp]
266
+ puts " Issued At: #{Time.at(metadata[:iat])}" if metadata[:iat]
267
+ puts
268
+
269
+ # Key status information
270
+ if key_status
271
+ puts "🔑 Key Configuration:"
272
+ case key_status[:type]
273
+ when :single
274
+ puts " Status: Single key detected (#{key_status[:count]} key(s))"
275
+ when :separate
276
+ puts " Status: Separate keys detected (#{key_status[:count]} key(s))"
277
+ else
278
+ puts " Status: #{key_status[:type]} (#{key_status[:count]} key(s))"
279
+ end
280
+ puts " #{key_status[:recommendation]}"
281
+ puts
282
+ end
283
+
284
+ puts "🔗 Step 3: Testing endpoints from entity statement..."
285
+ puts " Found #{results.length} endpoint(s) to test"
286
+ puts
287
+
288
+ results.each do |name, result_data|
289
+ puts " Testing: #{name}"
290
+ case result_data[:status]
291
+ when :success
292
+ if result_data[:keys]
293
+ puts " ✅ JWKS fetched successfully (#{result_data[:keys]} key(s))"
294
+ else
295
+ puts " ✅ Endpoint accessible (HTTP #{result_data[:code]})"
296
+ end
297
+ when :warning
298
+ warning_msg = "HTTP #{result_data[:code]}"
299
+ if result_data[:code] == "404" && result_data[:body]
300
+ body_text = result_data[:body].strip
301
+ warning_msg += " - #{body_text}" if body_text.length > 0
302
+ end
303
+ puts " âš ī¸ Endpoint returned #{warning_msg}"
304
+ when :error
305
+ puts " ❌ Error: #{result_data[:message]}"
306
+ end
307
+ puts
308
+ end
309
+
310
+ # Summary
311
+ puts "=" * 80
312
+ puts "Test Summary"
313
+ puts "=" * 80
314
+ puts
315
+
316
+ success_count = results.values.count { |r| r[:status] == :success }
317
+ warning_count = results.values.count { |r| r[:status] == :warning }
318
+ error_count = results.values.count { |r| r[:status] == :error }
319
+
320
+ results.each do |name, result_data|
321
+ case result_data[:status]
322
+ when :success
323
+ puts "✅ #{name}: #{result_data[:keys] ? "#{result_data[:keys]} key(s)" : "OK"}"
324
+ when :warning
325
+ warning_msg = "HTTP #{result_data[:code]}"
326
+ if result_data[:code] == "404" && result_data[:body]
327
+ body_text = result_data[:body].strip
328
+ warning_msg += " - #{body_text}" if body_text.length > 0
329
+ end
330
+ puts "âš ī¸ #{name}: #{warning_msg}"
331
+ when :error
332
+ puts "❌ #{name}: #{result_data[:message]}"
333
+ end
334
+ end
335
+
336
+ puts
337
+ if results.empty?
338
+ puts "No endpoints found to test in entity statement."
339
+ if validation_warnings.any?
340
+ puts "Validation warnings: #{validation_warnings.length}"
341
+ end
342
+ puts
343
+ if validation_warnings.any?
344
+ puts "âš ī¸ Entity statement has validation warnings (see above)."
345
+ puts " Review the warnings and fix any issues before deploying to production."
346
+ else
347
+ puts "â„šī¸ Entity statement parsed successfully, but no testable endpoints found."
348
+ end
349
+ else
350
+ puts "Total: #{success_count} successful, #{warning_count} warning(s), #{error_count} error(s)"
351
+ if validation_warnings.any?
352
+ puts "Validation warnings: #{validation_warnings.length}"
353
+ end
354
+ puts
355
+
356
+ if error_count > 0
357
+ puts "âš ī¸ Some endpoints failed. Check the errors above."
358
+ exit 1
359
+ elsif validation_warnings.any?
360
+ puts "âš ī¸ Entity statement has validation warnings (see above), but endpoints were tested."
361
+ puts " Review the warnings and fix any issues before deploying to production."
362
+ else
363
+ puts "✅ All endpoints tested successfully!"
364
+ end
365
+ end
366
+ rescue OmniauthOpenidFederation::Federation::EntityStatement::FetchError => e
367
+ puts "❌ Error fetching entity statement: #{e.message}"
368
+ puts " Make sure the server is running and the endpoint is accessible"
369
+ exit 1
370
+ rescue => e
371
+ puts "❌ Unexpected error: #{e.message}"
372
+ puts " #{e.class}: #{e.backtrace.first(3).join("\n ")}"
373
+ exit 1
374
+ end
375
+ end
376
+ end