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,187 @@
1
+ # Rack-compatible endpoint handler for federation endpoints
2
+ # Provides framework-agnostic HTTP endpoint handling
3
+ #
4
+ # @example Using with Rack
5
+ # require "rack"
6
+ # require "omniauth_openid_federation"
7
+ #
8
+ # app = Rack::Builder.new do
9
+ # map "/.well-known" do
10
+ # run OmniauthOpenidFederation::RackEndpoint.new
11
+ # end
12
+ # end
13
+ #
14
+ # Rack::Handler::WEBrick.run app, Port: 9292
15
+ #
16
+ # @example Using with Sinatra
17
+ # require "sinatra"
18
+ # require "omniauth_openid_federation"
19
+ #
20
+ # use OmniauthOpenidFederation::RackEndpoint
21
+ #
22
+ # get "/" do
23
+ # "Hello"
24
+ # end
25
+ require "rack"
26
+ require "json"
27
+ require "digest"
28
+ require_relative "cache_adapter"
29
+ require_relative "federation_endpoint"
30
+ require_relative "logger"
31
+
32
+ module OmniauthOpenidFederation
33
+ class RackEndpoint
34
+ # Rack call interface
35
+ #
36
+ # @param env [Hash] Rack environment
37
+ # @return [Array] [status, headers, body] Rack response
38
+ def call(env)
39
+ request = Rack::Request.new(env)
40
+ path = request.path_info
41
+
42
+ case path
43
+ when "/openid-federation"
44
+ handle_entity_statement
45
+ when "/openid-federation/fetch"
46
+ handle_fetch(request)
47
+ when "/jwks.json"
48
+ handle_jwks
49
+ when "/signed-jwks.json"
50
+ handle_signed_jwks
51
+ else
52
+ not_found
53
+ end
54
+ rescue OmniauthOpenidFederation::ConfigurationError => e
55
+ OmniauthOpenidFederation::Logger.error("[RackEndpoint] Configuration error: #{e.message}")
56
+ error_response(503, "Federation endpoint not configured")
57
+ rescue OmniauthOpenidFederation::SignatureError => e
58
+ OmniauthOpenidFederation::Logger.error("[RackEndpoint] Signature error: #{e.message}")
59
+ error_response(500, "Internal server error")
60
+ rescue => e
61
+ OmniauthOpenidFederation::Logger.error("[RackEndpoint] Error: #{e.class} - #{e.message}")
62
+ error_response(500, "Internal server error")
63
+ end
64
+
65
+ private
66
+
67
+ # Handle entity statement endpoint
68
+ #
69
+ # @return [Array] Rack response
70
+ def handle_entity_statement
71
+ entity_statement = OmniauthOpenidFederation::FederationEndpoint.generate_entity_statement
72
+
73
+ headers = {
74
+ "Content-Type" => "application/jwt",
75
+ "Cache-Control" => "public, max-age=3600"
76
+ }
77
+
78
+ [200, headers, [entity_statement]]
79
+ end
80
+
81
+ # Handle JWKS endpoint
82
+ #
83
+ # @return [Array] Rack response
84
+ def handle_jwks
85
+ config = OmniauthOpenidFederation::FederationEndpoint.configuration
86
+ jwks_to_serve = OmniauthOpenidFederation::FederationEndpoint.current_jwks
87
+
88
+ # Apply server-side caching if available
89
+ cache_key = "federation:jwks:#{Digest::SHA256.hexdigest(jwks_to_serve.to_json)}"
90
+ cache_ttl = config.jwks_cache_ttl || 3600
91
+
92
+ jwks_json = if CacheAdapter.available?
93
+ CacheAdapter.fetch(cache_key, expires_in: cache_ttl) do
94
+ jwks_to_serve.to_json
95
+ end
96
+ else
97
+ jwks_to_serve.to_json
98
+ end
99
+
100
+ headers = {
101
+ "Content-Type" => "application/json",
102
+ "Cache-Control" => "public, max-age=3600"
103
+ }
104
+
105
+ [200, headers, [JSON.parse(jwks_json).to_json]]
106
+ end
107
+
108
+ # Handle fetch endpoint (for Subordinate Statements)
109
+ #
110
+ # @param request [Rack::Request] The Rack request
111
+ # @return [Array] Rack response
112
+ def handle_fetch(request)
113
+ # Extract 'sub' query parameter (required per spec)
114
+ subject_entity_id = request.params["sub"]
115
+
116
+ unless subject_entity_id
117
+ return error_response(400, {error: "invalid_request", error_description: "Missing required parameter: sub"}.to_json)
118
+ end
119
+
120
+ # Validate that subject is not the issuer (invalid request per spec)
121
+ config = OmniauthOpenidFederation::FederationEndpoint.configuration
122
+ if subject_entity_id == config.issuer
123
+ return error_response(400, {error: "invalid_request", error_description: "Subject cannot be the issuer"}.to_json)
124
+ end
125
+
126
+ # Get Subordinate Statement
127
+ subordinate_statement = OmniauthOpenidFederation::FederationEndpoint.get_subordinate_statement(subject_entity_id)
128
+
129
+ unless subordinate_statement
130
+ return error_response(404, {error: "not_found", error_description: "Subordinate Statement not found for subject: #{subject_entity_id}"}.to_json)
131
+ end
132
+
133
+ headers = {
134
+ "Content-Type" => "application/entity-statement+jwt",
135
+ "Cache-Control" => "public, max-age=3600"
136
+ }
137
+
138
+ [200, headers, [subordinate_statement]]
139
+ end
140
+
141
+ # Handle signed JWKS endpoint
142
+ #
143
+ # @return [Array] Rack response
144
+ def handle_signed_jwks
145
+ config = OmniauthOpenidFederation::FederationEndpoint.configuration
146
+ signed_jwks_jwt = OmniauthOpenidFederation::FederationEndpoint.generate_signed_jwks
147
+
148
+ # Apply server-side caching if available
149
+ cache_key = "federation:signed_jwks:#{Digest::SHA256.hexdigest(signed_jwks_jwt)}"
150
+ cache_ttl = config.jwks_cache_ttl || 3600
151
+
152
+ cached_jwt = if CacheAdapter.available?
153
+ CacheAdapter.fetch(cache_key, expires_in: cache_ttl) do
154
+ signed_jwks_jwt
155
+ end
156
+ else
157
+ signed_jwks_jwt
158
+ end
159
+
160
+ headers = {
161
+ "Content-Type" => "application/jwt",
162
+ "Cache-Control" => "public, max-age=3600"
163
+ }
164
+
165
+ [200, headers, [cached_jwt]]
166
+ end
167
+
168
+ # Return 404 Not Found
169
+ #
170
+ # @return [Array] Rack response
171
+ def not_found
172
+ [404, {"Content-Type" => "text/plain"}, ["Not Found"]]
173
+ end
174
+
175
+ # Return error response
176
+ #
177
+ # @param status [Integer] HTTP status code
178
+ # @param message [String] Error message
179
+ # @return [Array] Rack response
180
+ def error_response(status, message)
181
+ content_type = (status == 503) ? "text/plain" : "application/json"
182
+ body = (status == 503) ? message : {error: message}.to_json
183
+
184
+ [status, {"Content-Type" => content_type}, [body]]
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,29 @@
1
+ # Railtie to load rake tasks and provide Rails integration
2
+ if defined?(Rails)
3
+ module OmniauthOpenidFederation
4
+ class Railtie < Rails::Railtie
5
+ # Add gem's controllers to autoload paths
6
+ # This ensures the controller can be found by Rails routing
7
+ initializer "omniauth_openid_federation.add_autoload_paths", before: :set_autoload_paths do |app|
8
+ controllers_path = File.join(File.dirname(__FILE__), "..", "..", "app", "controllers")
9
+ app.config.autoload_once_paths << controllers_path if File.exist?(controllers_path)
10
+ end
11
+
12
+ # Load controller when Rails is available (for development reloading)
13
+ config.to_prepare do
14
+ controller_path = File.join(File.dirname(__FILE__), "..", "..", "app", "controllers", "omniauth_openid_federation", "federation_controller.rb")
15
+ require controller_path if File.exist?(controller_path)
16
+ end
17
+
18
+ rake_tasks do
19
+ # Load rake tasks from lib/tasks
20
+ # Rails automatically loads lib/tasks/**/*.rake, but we ensure they're loaded here too
21
+ # File.dirname(__FILE__) = lib/omniauth_openid_federation
22
+ # .. = lib
23
+ # tasks = lib/tasks
24
+ task_files = Dir[File.join(File.dirname(__FILE__), "..", "tasks", "**", "*.rake")]
25
+ task_files.each { |task_file| load task_file } if task_files.any?
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,55 @@
1
+ require "digest"
2
+ require_relative "utils"
3
+ require_relative "logger"
4
+
5
+ # Rate limiting for JWKS fetching to prevent DoS attacks
6
+ module OmniauthOpenidFederation
7
+ module RateLimiter
8
+ # Default rate limiting configuration
9
+ DEFAULT_MAX_REQUESTS = 10
10
+ DEFAULT_WINDOW_SECONDS = 60
11
+
12
+ # Check if request should be throttled
13
+ #
14
+ # @param key [String] Unique identifier for the rate limit (e.g., jwks_uri)
15
+ # @param max_requests [Integer] Maximum requests allowed in window (default: 10)
16
+ # @param window [Integer] Time window in seconds (default: 60)
17
+ # @return [Boolean] true if request should be allowed, false if throttled
18
+ def self.allow?(key, max_requests: DEFAULT_MAX_REQUESTS, window: DEFAULT_WINDOW_SECONDS)
19
+ return true unless defined?(Rails) && Rails.cache
20
+
21
+ cache_key = "omniauth_openid_federation:rate_limit:#{Digest::SHA256.hexdigest(key)}"
22
+ current_time = Time.now.to_i
23
+ window_start = current_time - window
24
+
25
+ # Get existing request timestamps
26
+ timestamps = Rails.cache.read(cache_key) || []
27
+
28
+ # Filter out timestamps outside the current window
29
+ timestamps = timestamps.select { |ts| ts > window_start }
30
+
31
+ # Check if we've exceeded the limit
32
+ if timestamps.length >= max_requests
33
+ OmniauthOpenidFederation::Logger.warn("[RateLimiter] Rate limit exceeded for #{Utils.sanitize_uri(key)}: #{timestamps.length}/#{max_requests} requests in #{window}s")
34
+ return false
35
+ end
36
+
37
+ # Add current request timestamp
38
+ timestamps << current_time
39
+
40
+ # Store updated timestamps (expires after window)
41
+ Rails.cache.write(cache_key, timestamps, expires_in: window)
42
+
43
+ true
44
+ end
45
+
46
+ # Reset rate limit for a key (useful for testing or manual override)
47
+ #
48
+ # @param key [String] Unique identifier for the rate limit
49
+ def self.reset!(key)
50
+ return unless defined?(Rails) && Rails.cache
51
+ cache_key = "omniauth_openid_federation:rate_limit:#{Digest::SHA256.hexdigest(key)}"
52
+ Rails.cache.delete(cache_key)
53
+ end
54
+ end
55
+ end