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