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,173 @@
|
|
|
1
|
+
# Cache adapter interface for framework-agnostic caching
|
|
2
|
+
# Provides a simple interface that can be implemented by any cache backend
|
|
3
|
+
#
|
|
4
|
+
# @example Using Rails cache (automatic)
|
|
5
|
+
# # Rails.cache is automatically detected and used if available
|
|
6
|
+
#
|
|
7
|
+
# @example Using a custom cache adapter
|
|
8
|
+
# class MyCacheAdapter
|
|
9
|
+
# def fetch(key, expires_in: nil)
|
|
10
|
+
# # Your cache implementation
|
|
11
|
+
# yield if block_given?
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# def read(key)
|
|
15
|
+
# # Your cache read implementation
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def write(key, value, expires_in: nil)
|
|
19
|
+
# # Your cache write implementation
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# def delete(key)
|
|
23
|
+
# # Your cache delete implementation
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# OmniauthOpenidFederation.configure do |config|
|
|
28
|
+
# config.cache_adapter = MyCacheAdapter.new
|
|
29
|
+
# end
|
|
30
|
+
module OmniauthOpenidFederation
|
|
31
|
+
class CacheAdapter
|
|
32
|
+
class << self
|
|
33
|
+
attr_writer :adapter
|
|
34
|
+
|
|
35
|
+
# Get the configured cache adapter
|
|
36
|
+
# Automatically detects Rails.cache if available
|
|
37
|
+
#
|
|
38
|
+
# @return [Object, nil] Cache adapter instance or nil if no cache available
|
|
39
|
+
def adapter
|
|
40
|
+
@adapter ||= detect_adapter
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reset the cache adapter (useful for testing)
|
|
44
|
+
# Forces re-detection of cache adapter on next access
|
|
45
|
+
#
|
|
46
|
+
# @return [void]
|
|
47
|
+
def reset!
|
|
48
|
+
@adapter = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if caching is available
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean] true if cache adapter is available
|
|
54
|
+
def available?
|
|
55
|
+
!adapter.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Fetch a value from cache, or compute and cache it
|
|
59
|
+
#
|
|
60
|
+
# @param key [String] Cache key
|
|
61
|
+
# @param expires_in [Integer, nil] Expiration time in seconds (nil = no expiration)
|
|
62
|
+
# @yield Block to compute value if not cached
|
|
63
|
+
# @return [Object] Cached or computed value
|
|
64
|
+
def fetch(key, expires_in: nil, &block)
|
|
65
|
+
return yield unless available? && block_given?
|
|
66
|
+
|
|
67
|
+
adapter.fetch(key, expires_in: expires_in, &block)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Read a value from cache
|
|
71
|
+
#
|
|
72
|
+
# @param key [String] Cache key
|
|
73
|
+
# @return [Object, nil] Cached value or nil
|
|
74
|
+
def read(key)
|
|
75
|
+
return nil unless available?
|
|
76
|
+
adapter.read(key)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Write a value to cache
|
|
80
|
+
#
|
|
81
|
+
# @param key [String] Cache key
|
|
82
|
+
# @param value [Object] Value to cache
|
|
83
|
+
# @param expires_in [Integer, nil] Expiration time in seconds (nil = no expiration)
|
|
84
|
+
# @return [void]
|
|
85
|
+
def write(key, value, expires_in: nil)
|
|
86
|
+
return unless available?
|
|
87
|
+
adapter.write(key, value, expires_in: expires_in)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Delete a value from cache
|
|
91
|
+
#
|
|
92
|
+
# @param key [String] Cache key
|
|
93
|
+
# @return [void]
|
|
94
|
+
def delete(key)
|
|
95
|
+
return unless available?
|
|
96
|
+
adapter.delete(key)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Clear all cache (if supported)
|
|
100
|
+
#
|
|
101
|
+
# @return [void]
|
|
102
|
+
def clear
|
|
103
|
+
return unless available?
|
|
104
|
+
adapter.clear if adapter.respond_to?(:clear)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Detect and return the appropriate cache adapter
|
|
110
|
+
#
|
|
111
|
+
# @return [Object, nil] Cache adapter instance or nil
|
|
112
|
+
def detect_adapter
|
|
113
|
+
# Use configured adapter from configuration if set
|
|
114
|
+
config = OmniauthOpenidFederation::Configuration.config
|
|
115
|
+
if config.cache_adapter
|
|
116
|
+
return config.cache_adapter
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Try Rails cache
|
|
120
|
+
if defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
|
|
121
|
+
return RailsCacheAdapter.new(Rails.cache)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Try ActiveSupport::Cache if available (without Rails)
|
|
125
|
+
if defined?(ActiveSupport::Cache::Store)
|
|
126
|
+
# Try to use a memory store as fallback
|
|
127
|
+
begin
|
|
128
|
+
require "active_support/cache/memory_store"
|
|
129
|
+
return RailsCacheAdapter.new(ActiveSupport::Cache::MemoryStore.new)
|
|
130
|
+
rescue LoadError
|
|
131
|
+
# ActiveSupport::Cache::MemoryStore not available
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Adapter for Rails/ActiveSupport cache stores
|
|
140
|
+
# Wraps Rails.cache or ActiveSupport::Cache::Store to provide consistent interface
|
|
141
|
+
class RailsCacheAdapter
|
|
142
|
+
def initialize(cache_store)
|
|
143
|
+
@cache_store = cache_store
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def fetch(key, expires_in: nil, &block)
|
|
147
|
+
options = {}
|
|
148
|
+
options[:expires_in] = expires_in if expires_in
|
|
149
|
+
# Handle nil key gracefully
|
|
150
|
+
return block.call if key.nil? && block_given?
|
|
151
|
+
@cache_store.fetch(key, options, &block)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def read(key)
|
|
155
|
+
@cache_store.read(key)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def write(key, value, expires_in: nil)
|
|
159
|
+
options = {}
|
|
160
|
+
options[:expires_in] = expires_in if expires_in
|
|
161
|
+
@cache_store.write(key, value, options)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def delete(key)
|
|
165
|
+
@cache_store.delete(key)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def clear
|
|
169
|
+
@cache_store.clear if @cache_store.respond_to?(:clear)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Configuration for omniauth_openid_federation
|
|
2
|
+
# Provides centralized configuration management
|
|
3
|
+
module OmniauthOpenidFederation
|
|
4
|
+
class Configuration
|
|
5
|
+
# SSL verification setting
|
|
6
|
+
# @return [Boolean] true to verify SSL certificates, false to skip verification
|
|
7
|
+
attr_accessor :verify_ssl
|
|
8
|
+
|
|
9
|
+
# Cache TTL for JWKS in seconds
|
|
10
|
+
# @return [Integer, nil] Cache TTL in seconds, or nil for manual rotation (never expires)
|
|
11
|
+
# - nil: Cache forever, manual rotation only (default)
|
|
12
|
+
# - positive integer: Cache expires after this many seconds
|
|
13
|
+
attr_accessor :cache_ttl
|
|
14
|
+
|
|
15
|
+
# Rotate JWKS cache on key-related errors
|
|
16
|
+
# @return [Boolean] true to automatically rotate cache on key-related errors, false to require manual rotation
|
|
17
|
+
# - false: Manual rotation only (default)
|
|
18
|
+
# - true: Automatically rotate cache when key-related errors occur (401, 403, 404, signature failures)
|
|
19
|
+
attr_accessor :rotate_on_errors
|
|
20
|
+
|
|
21
|
+
# HTTP request timeout in seconds
|
|
22
|
+
# @return [Integer] Timeout in seconds
|
|
23
|
+
attr_accessor :http_timeout
|
|
24
|
+
|
|
25
|
+
# Maximum number of retries for HTTP requests
|
|
26
|
+
# @return [Integer] Maximum retry count
|
|
27
|
+
attr_accessor :max_retries
|
|
28
|
+
|
|
29
|
+
# Retry delay in seconds (will be multiplied by retry attempt)
|
|
30
|
+
# @return [Integer] Base retry delay in seconds
|
|
31
|
+
attr_accessor :retry_delay
|
|
32
|
+
|
|
33
|
+
# HTTP options for HTTP::Options.new
|
|
34
|
+
# Can be a Hash or a Proc that returns a Hash
|
|
35
|
+
# @return [Hash, Proc, nil] HTTP options hash or proc that returns hash
|
|
36
|
+
# @example
|
|
37
|
+
# config.http_options = { ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE } }
|
|
38
|
+
# # Or with a proc for dynamic configuration:
|
|
39
|
+
# config.http_options = -> { { ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE } } }
|
|
40
|
+
attr_accessor :http_options
|
|
41
|
+
|
|
42
|
+
# Custom cache adapter (optional)
|
|
43
|
+
# If not set, automatically detects Rails.cache or ActiveSupport::Cache
|
|
44
|
+
# @return [Object, nil] Cache adapter instance or nil
|
|
45
|
+
# @example
|
|
46
|
+
# class MyCacheAdapter
|
|
47
|
+
# def fetch(key, expires_in: nil, &block)
|
|
48
|
+
# # Your implementation
|
|
49
|
+
# end
|
|
50
|
+
# end
|
|
51
|
+
# config.cache_adapter = MyCacheAdapter.new
|
|
52
|
+
attr_accessor :cache_adapter
|
|
53
|
+
|
|
54
|
+
# Root path for file operations (optional)
|
|
55
|
+
# Used for resolving relative file paths when Rails.root is not available
|
|
56
|
+
# @return [String, nil] Root path or nil
|
|
57
|
+
# @example
|
|
58
|
+
# config.root_path = "/path/to/app"
|
|
59
|
+
attr_accessor :root_path
|
|
60
|
+
|
|
61
|
+
# Clock skew tolerance in seconds for entity statement time validation
|
|
62
|
+
# Per OpenID Federation 1.0 Section 3.2.1, time validation MUST allow for clock skew
|
|
63
|
+
# @return [Integer] Clock skew tolerance in seconds (default: 60)
|
|
64
|
+
# @example
|
|
65
|
+
# config.clock_skew_tolerance = 120 # Allow 2 minutes of clock skew
|
|
66
|
+
attr_accessor :clock_skew_tolerance
|
|
67
|
+
|
|
68
|
+
# Custom instrumentation callback for security events
|
|
69
|
+
# Can be a Proc, object with #call or #notify method, or logger-like object
|
|
70
|
+
# @return [Proc, Object, nil] Instrumentation callback or nil to disable
|
|
71
|
+
# @example Configure with Sentry
|
|
72
|
+
# config.instrumentation = ->(event, data) do
|
|
73
|
+
# Sentry.capture_message("OpenID Federation: #{event}", level: :warning, extra: data)
|
|
74
|
+
# end
|
|
75
|
+
# @example Configure with Honeybadger
|
|
76
|
+
# config.instrumentation = ->(event, data) do
|
|
77
|
+
# Honeybadger.notify("OpenID Federation: #{event}", context: data)
|
|
78
|
+
# end
|
|
79
|
+
# @example Configure with custom logger
|
|
80
|
+
# config.instrumentation = ->(event, data) do
|
|
81
|
+
# Rails.logger.warn("[Security] #{event}: #{data.inspect}")
|
|
82
|
+
# end
|
|
83
|
+
# @example Disable instrumentation
|
|
84
|
+
# config.instrumentation = nil
|
|
85
|
+
attr_accessor :instrumentation
|
|
86
|
+
|
|
87
|
+
def initialize
|
|
88
|
+
@verify_ssl = true # Default to secure
|
|
89
|
+
@cache_ttl = nil # Default: manual rotation (never expires)
|
|
90
|
+
@rotate_on_errors = false # Default: manual rotation only
|
|
91
|
+
@http_timeout = 10
|
|
92
|
+
@max_retries = 3
|
|
93
|
+
@retry_delay = 1
|
|
94
|
+
@http_options = nil
|
|
95
|
+
@cache_adapter = nil
|
|
96
|
+
@root_path = nil
|
|
97
|
+
@clock_skew_tolerance = 60 # Default: 60 seconds clock skew tolerance
|
|
98
|
+
@instrumentation = nil # Default: no instrumentation
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Configure the gem
|
|
102
|
+
#
|
|
103
|
+
# @yield [config] Yields the configuration object
|
|
104
|
+
# @example
|
|
105
|
+
# OmniauthOpenidFederation.configure do |config|
|
|
106
|
+
# config.verify_ssl = false # Only for development
|
|
107
|
+
# config.cache_ttl = 3600 # Cache expires after 1 hour
|
|
108
|
+
# config.rotate_on_errors = true # Rotate on key-related errors
|
|
109
|
+
# end
|
|
110
|
+
def self.configure
|
|
111
|
+
yield(config) if block_given?
|
|
112
|
+
config
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get the global configuration instance (thread-safe)
|
|
116
|
+
#
|
|
117
|
+
# @return [Configuration] The configuration instance
|
|
118
|
+
def self.config
|
|
119
|
+
@config_mutex ||= Mutex.new
|
|
120
|
+
@config_mutex.synchronize do
|
|
121
|
+
@config ||= new
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Reset configuration (useful for testing)
|
|
126
|
+
#
|
|
127
|
+
# @return [void]
|
|
128
|
+
def self.reset!
|
|
129
|
+
@config_mutex ||= Mutex.new
|
|
130
|
+
@config_mutex.synchronize do
|
|
131
|
+
@config = nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Constants for omniauth_openid_federation
|
|
2
|
+
module OmniauthOpenidFederation
|
|
3
|
+
module Constants
|
|
4
|
+
# HTTP status codes that indicate key-related errors (possible key rotation)
|
|
5
|
+
KEY_ROTATION_HTTP_CODES = [401, 403, 404].freeze
|
|
6
|
+
|
|
7
|
+
# Request object expiration time in seconds (10 minutes)
|
|
8
|
+
REQUEST_OBJECT_EXPIRATION_SECONDS = 600
|
|
9
|
+
|
|
10
|
+
# Maximum retry delay in seconds (prevents unbounded retry delays)
|
|
11
|
+
MAX_RETRY_DELAY_SECONDS = 60
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "string_helpers"
|
|
4
|
+
require_relative "logger"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "utils"
|
|
7
|
+
require_relative "federation/entity_statement"
|
|
8
|
+
|
|
9
|
+
# Endpoint resolver for OpenID Federation
|
|
10
|
+
# Resolves OAuth 2.0 endpoints from entity statement metadata or configuration
|
|
11
|
+
module OmniauthOpenidFederation
|
|
12
|
+
# Endpoint resolver for OpenID Federation
|
|
13
|
+
#
|
|
14
|
+
# Resolves OAuth 2.0 endpoints from entity statement metadata or configuration.
|
|
15
|
+
#
|
|
16
|
+
# @example Resolve endpoints from entity statement
|
|
17
|
+
# endpoints = EndpointResolver.resolve(
|
|
18
|
+
# entity_statement_path: "config/provider-entity-statement.jwt",
|
|
19
|
+
# config: {}
|
|
20
|
+
# )
|
|
21
|
+
class EndpointResolver
|
|
22
|
+
class << self
|
|
23
|
+
# Resolve endpoints from entity statement or configuration
|
|
24
|
+
#
|
|
25
|
+
# Priority: config -> entity statement -> nil
|
|
26
|
+
#
|
|
27
|
+
# @param entity_statement_path [String, nil] Path to entity statement file
|
|
28
|
+
# @param config [Hash] Configuration hash with endpoint keys
|
|
29
|
+
# @return [Hash] Hash with :authorization_endpoint, :token_endpoint, :userinfo_endpoint, :jwks_uri, :entity_statement_endpoint, :audience
|
|
30
|
+
def resolve(entity_statement_path: nil, config: {})
|
|
31
|
+
# Try to get endpoints from entity statement if available
|
|
32
|
+
entity_metadata = load_entity_statement_metadata(entity_statement_path)
|
|
33
|
+
|
|
34
|
+
# Extract endpoints with priority: config -> entity statement -> nil
|
|
35
|
+
# For entity statement, prefer full URLs if available, otherwise extract path
|
|
36
|
+
# Entity statement metadata may contain full URLs (preferred) or paths
|
|
37
|
+
entity_provider_metadata = entity_metadata&.dig(:metadata, :openid_provider) || {}
|
|
38
|
+
|
|
39
|
+
# Get endpoint from entity statement (may be full URL or path)
|
|
40
|
+
entity_auth_endpoint = entity_provider_metadata["authorization_endpoint"] || entity_provider_metadata[:authorization_endpoint]
|
|
41
|
+
entity_token_endpoint = entity_provider_metadata["token_endpoint"] || entity_provider_metadata[:token_endpoint]
|
|
42
|
+
entity_userinfo_endpoint = entity_provider_metadata["userinfo_endpoint"] || entity_provider_metadata[:userinfo_endpoint]
|
|
43
|
+
entity_jwks_uri = entity_provider_metadata["jwks_uri"] || entity_provider_metadata[:jwks_uri]
|
|
44
|
+
|
|
45
|
+
# Use config if provided, otherwise use entity statement value (full URL or path)
|
|
46
|
+
authorization_endpoint = config[:authorization_endpoint] || entity_auth_endpoint
|
|
47
|
+
token_endpoint = config[:token_endpoint] || entity_token_endpoint
|
|
48
|
+
userinfo_endpoint = config[:userinfo_endpoint] || entity_userinfo_endpoint
|
|
49
|
+
jwks_uri = config[:jwks_uri] || entity_jwks_uri
|
|
50
|
+
|
|
51
|
+
# Entity statement endpoint (defaults to /.well-known/openid-federation if not specified)
|
|
52
|
+
entity_statement_endpoint = config[:entity_statement_endpoint] ||
|
|
53
|
+
extract_path_from_url(entity_metadata&.dig(:metadata, :openid_federation, "federation_entity_endpoint")) ||
|
|
54
|
+
extract_path_from_url(entity_metadata&.dig(:metadata, :openid_federation, :federation_entity_endpoint)) ||
|
|
55
|
+
"/.well-known/openid-federation"
|
|
56
|
+
|
|
57
|
+
# Determine audience
|
|
58
|
+
# For OpenID Federation, audience should be the provider issuer (not token endpoint)
|
|
59
|
+
audience = config[:audience]
|
|
60
|
+
unless audience
|
|
61
|
+
# Try provider issuer from entity statement first (preferred for OpenID Federation)
|
|
62
|
+
provider_issuer = entity_metadata&.dig(:metadata, :openid_provider, "issuer") ||
|
|
63
|
+
entity_metadata&.dig(:metadata, :openid_provider, :issuer)
|
|
64
|
+
if StringHelpers.present?(provider_issuer)
|
|
65
|
+
audience = provider_issuer
|
|
66
|
+
else
|
|
67
|
+
# Fallback to token endpoint URL if provider issuer not available
|
|
68
|
+
token_endpoint_url = entity_metadata&.dig(:metadata, :openid_provider, "token_endpoint") ||
|
|
69
|
+
entity_metadata&.dig(:metadata, :openid_provider, :token_endpoint)
|
|
70
|
+
audience = token_endpoint_url if StringHelpers.present?(token_endpoint_url)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
authorization_endpoint: authorization_endpoint,
|
|
76
|
+
token_endpoint: token_endpoint,
|
|
77
|
+
userinfo_endpoint: userinfo_endpoint,
|
|
78
|
+
jwks_uri: jwks_uri,
|
|
79
|
+
entity_statement_endpoint: entity_statement_endpoint,
|
|
80
|
+
audience: audience
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Build full entity statement URL from issuer and endpoint path
|
|
85
|
+
#
|
|
86
|
+
# @param issuer_uri [String, URI] Issuer URI (e.g., "https://provider.example.com")
|
|
87
|
+
# @param entity_statement_endpoint [String, nil] Entity statement endpoint path (e.g., "/.well-known/openid-federation")
|
|
88
|
+
# @return [String] Full entity statement URL
|
|
89
|
+
def build_entity_statement_url(issuer_uri, entity_statement_endpoint: nil)
|
|
90
|
+
Utils.build_entity_statement_url(issuer_uri, entity_statement_endpoint: entity_statement_endpoint)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build full endpoint URL from issuer and endpoint path
|
|
94
|
+
#
|
|
95
|
+
# @param issuer_uri [String, URI] Issuer URI (e.g., "https://provider.example.com")
|
|
96
|
+
# @param endpoint_path [String] Endpoint path (e.g., "/oauth2/authorize")
|
|
97
|
+
# @return [String] Full endpoint URL
|
|
98
|
+
def build_endpoint_url(issuer_uri, endpoint_path)
|
|
99
|
+
Utils.build_endpoint_url(issuer_uri, endpoint_path)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Validate that required endpoints are present
|
|
103
|
+
# @param endpoints [Hash] Endpoints hash from resolve
|
|
104
|
+
# @param issuer_uri [URI] Issuer URI for building audience if needed
|
|
105
|
+
# @return [Hash] Validated endpoints with audience built if needed
|
|
106
|
+
# @raise [ConfigurationError] If required endpoints are missing
|
|
107
|
+
def validate_and_build_audience(endpoints, issuer_uri: nil)
|
|
108
|
+
if StringHelpers.blank?(endpoints[:authorization_endpoint])
|
|
109
|
+
raise ConfigurationError, "Authorization endpoint not configured. Provide authorization_endpoint in config or entity statement"
|
|
110
|
+
end
|
|
111
|
+
if StringHelpers.blank?(endpoints[:token_endpoint])
|
|
112
|
+
raise ConfigurationError, "Token endpoint not configured. Provide token_endpoint in config or entity statement"
|
|
113
|
+
end
|
|
114
|
+
if StringHelpers.blank?(endpoints[:jwks_uri])
|
|
115
|
+
raise ConfigurationError, "JWKS URI not configured. Provide jwks_uri in config or entity statement"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Build audience from issuer + token_endpoint if not provided
|
|
119
|
+
# Note: For OpenID Federation, audience should ideally be provider issuer,
|
|
120
|
+
# but if not available, we build from issuer + token_endpoint path
|
|
121
|
+
unless StringHelpers.present?(endpoints[:audience])
|
|
122
|
+
if issuer_uri
|
|
123
|
+
# For some providers, audience may be issuer + path prefix
|
|
124
|
+
# But we'll use token_endpoint as fallback since we don't have provider issuer here
|
|
125
|
+
audience_uri = issuer_uri.dup
|
|
126
|
+
audience_uri.path = endpoints[:token_endpoint]
|
|
127
|
+
endpoints[:audience] = audience_uri.to_s
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
endpoints
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def load_entity_statement_metadata(entity_statement_path)
|
|
137
|
+
return nil unless entity_statement_path && File.exist?(entity_statement_path)
|
|
138
|
+
|
|
139
|
+
begin
|
|
140
|
+
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(File.read(entity_statement_path))
|
|
141
|
+
entity_statement.parse
|
|
142
|
+
rescue => e
|
|
143
|
+
OmniauthOpenidFederation::Logger.warn("[EndpointResolver] Failed to parse entity statement: #{e.message}")
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def extract_path_from_url(url)
|
|
149
|
+
return nil if StringHelpers.blank?(url)
|
|
150
|
+
return url.to_s if url.to_s.start_with?("http://", "https://") # Return full URL as-is
|
|
151
|
+
|
|
152
|
+
begin
|
|
153
|
+
uri = URI.parse(url.to_s)
|
|
154
|
+
# If it's a full URL, return the path; if it's already a path, return as-is
|
|
155
|
+
if uri.host
|
|
156
|
+
StringHelpers.present?(uri.path) ? uri.path : nil
|
|
157
|
+
else
|
|
158
|
+
# No host means it's already a path
|
|
159
|
+
url.to_s.start_with?("/") ? url.to_s : "/#{url}"
|
|
160
|
+
end
|
|
161
|
+
rescue URI::InvalidURIError
|
|
162
|
+
# If it's already a path (starts with /), return as-is
|
|
163
|
+
url.to_s.start_with?("/") ? url.to_s : nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require "jwt"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "base64"
|
|
4
|
+
require_relative "key_extractor"
|
|
5
|
+
require_relative "utils"
|
|
6
|
+
require_relative "configuration"
|
|
7
|
+
require_relative "logger"
|
|
8
|
+
|
|
9
|
+
# Entity Statement Reader for OpenID Federation 1.0
|
|
10
|
+
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
11
|
+
# @see https://openid.net/specs/openid-federation-1_0.html#section-3 Section 3: Entity Statement
|
|
12
|
+
#
|
|
13
|
+
# Entity statements are self-signed JWTs that contain provider metadata and JWKS.
|
|
14
|
+
# This class provides utilities for:
|
|
15
|
+
# - Extracting JWKS from entity statements for validating signed JWKS
|
|
16
|
+
# - Parsing provider metadata from entity statements
|
|
17
|
+
# - Validating entity statement fingerprints (SHA-256 hash)
|
|
18
|
+
#
|
|
19
|
+
# Entity statements are typically fetched from /.well-known/openid-federation endpoint
|
|
20
|
+
# and stored locally for use in validating signed JWKS and extracting provider configuration.
|
|
21
|
+
module OmniauthOpenidFederation
|
|
22
|
+
class EntityStatementReader
|
|
23
|
+
# Standard JWT has 3 parts: header.payload.signature
|
|
24
|
+
JWT_PARTS_COUNT = 3
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Fetch JWKS keys from entity statement
|
|
28
|
+
#
|
|
29
|
+
# @param entity_statement_path [String, nil] Path to entity statement file
|
|
30
|
+
# @return [Array<Hash>] Array of JWK hash objects
|
|
31
|
+
def fetch_keys(entity_statement_path: nil)
|
|
32
|
+
entity_statement = load_entity_statement(entity_statement_path)
|
|
33
|
+
return [] if entity_statement.nil? || entity_statement.empty?
|
|
34
|
+
|
|
35
|
+
# Decode self-signed entity statement
|
|
36
|
+
# Entity statements are self-signed, so we validate using their own JWKS
|
|
37
|
+
# First, decode without validation to get the JWKS
|
|
38
|
+
jwt_parts = entity_statement.split(".")
|
|
39
|
+
return [] if jwt_parts.length != JWT_PARTS_COUNT
|
|
40
|
+
|
|
41
|
+
# Decode payload (second part)
|
|
42
|
+
payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
|
|
43
|
+
|
|
44
|
+
# Extract JWKS from entity statement claims
|
|
45
|
+
payload.fetch("jwks", {}).fetch("keys", [])
|
|
46
|
+
|
|
47
|
+
# Return JWK hashes directly (no need to convert to objects)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Parse provider metadata from entity statement
|
|
51
|
+
#
|
|
52
|
+
# @param entity_statement_path [String, nil] Path to entity statement file
|
|
53
|
+
# @return [Hash, nil] Hash with provider metadata or nil if not found
|
|
54
|
+
def parse_metadata(entity_statement_path: nil)
|
|
55
|
+
entity_statement = load_entity_statement(entity_statement_path)
|
|
56
|
+
return nil if entity_statement.nil? || entity_statement.empty?
|
|
57
|
+
|
|
58
|
+
# Decode JWT payload
|
|
59
|
+
jwt_parts = entity_statement.split(".")
|
|
60
|
+
return nil if jwt_parts.length != JWT_PARTS_COUNT
|
|
61
|
+
|
|
62
|
+
claims = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
|
|
63
|
+
|
|
64
|
+
# Extract provider metadata
|
|
65
|
+
metadata = claims.fetch("metadata", {})
|
|
66
|
+
provider_metadata = metadata.fetch("openid_provider", {})
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
issuer: provider_metadata["issuer"],
|
|
70
|
+
authorization_endpoint: provider_metadata["authorization_endpoint"],
|
|
71
|
+
token_endpoint: provider_metadata["token_endpoint"],
|
|
72
|
+
userinfo_endpoint: provider_metadata["userinfo_endpoint"],
|
|
73
|
+
jwks_uri: provider_metadata["jwks_uri"],
|
|
74
|
+
signed_jwks_uri: provider_metadata["signed_jwks_uri"],
|
|
75
|
+
entity_issuer: claims["iss"],
|
|
76
|
+
entity_jwks: claims.fetch("jwks", {})
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Validate entity statement fingerprint
|
|
81
|
+
#
|
|
82
|
+
# @param entity_statement_content [String] The entity statement content
|
|
83
|
+
# @param expected_fingerprint [String] The expected SHA-256 fingerprint
|
|
84
|
+
# @return [Boolean] true if fingerprints match
|
|
85
|
+
def validate_fingerprint(entity_statement_content, expected_fingerprint)
|
|
86
|
+
calculated = Digest::SHA256.hexdigest(entity_statement_content).downcase
|
|
87
|
+
expected = expected_fingerprint.downcase
|
|
88
|
+
calculated == expected
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def load_entity_statement(entity_statement_path)
|
|
94
|
+
return nil if entity_statement_path.nil? || entity_statement_path.to_s.empty?
|
|
95
|
+
|
|
96
|
+
# Determine allowed directories for file path validation
|
|
97
|
+
config = OmniauthOpenidFederation::Configuration.config
|
|
98
|
+
allowed_dirs = if defined?(Rails) && Rails.root
|
|
99
|
+
[Rails.root.join("config").to_s]
|
|
100
|
+
elsif config.root_path
|
|
101
|
+
[File.join(config.root_path, "config")]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
begin
|
|
105
|
+
# Validate file path to prevent path traversal attacks
|
|
106
|
+
validated_path = Utils.validate_file_path!(
|
|
107
|
+
entity_statement_path,
|
|
108
|
+
allowed_dirs: allowed_dirs
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return nil unless File.exist?(validated_path)
|
|
112
|
+
|
|
113
|
+
File.read(validated_path)
|
|
114
|
+
rescue SecurityError => e
|
|
115
|
+
# Log security error but return nil to maintain backward compatibility
|
|
116
|
+
Logger.warn("[EntityStatementReader] Security error: #{e.message}")
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Exception hierarchy for omniauth_openid_federation
|
|
2
|
+
# Provides structured error handling with specific exception types
|
|
3
|
+
module OmniauthOpenidFederation
|
|
4
|
+
# Base error class for all omniauth_openid_federation errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Configuration errors (missing required options, invalid values, etc.)
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Security-related errors (signature failures, decryption errors, etc.)
|
|
11
|
+
class SecurityError < Error; end
|
|
12
|
+
|
|
13
|
+
# Network errors (HTTP failures, timeouts, etc.)
|
|
14
|
+
class NetworkError < Error; end
|
|
15
|
+
|
|
16
|
+
# Validation errors (invalid tokens, malformed data, etc.)
|
|
17
|
+
class ValidationError < Error; end
|
|
18
|
+
|
|
19
|
+
# Decryption errors (ID token decryption failures)
|
|
20
|
+
class DecryptionError < SecurityError; end
|
|
21
|
+
|
|
22
|
+
# Encryption errors (request object encryption failures)
|
|
23
|
+
class EncryptionError < SecurityError; end
|
|
24
|
+
|
|
25
|
+
# Signature errors (JWT signature verification failures)
|
|
26
|
+
class SignatureError < SecurityError; end
|
|
27
|
+
|
|
28
|
+
# Fetch errors (failed to fetch entity statements, JWKS, etc.)
|
|
29
|
+
class FetchError < NetworkError; end
|
|
30
|
+
|
|
31
|
+
# Key-related errors (indicates possible key rotation)
|
|
32
|
+
# Used for cache rotation logic when key-related HTTP errors occur
|
|
33
|
+
class KeyRelatedError < FetchError
|
|
34
|
+
def key_related_error?
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Key-related validation errors (signature failures, decode errors)
|
|
40
|
+
class KeyRelatedValidationError < ValidationError
|
|
41
|
+
def key_related_error?
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Compatibility aliases for federation classes
|
|
47
|
+
module Federation
|
|
48
|
+
# Alias for backward compatibility with tests
|
|
49
|
+
FetchError = OmniauthOpenidFederation::FetchError
|
|
50
|
+
ValidationError = OmniauthOpenidFederation::ValidationError
|
|
51
|
+
end
|
|
52
|
+
end
|