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,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
|