Module: BetterAuth::SSO::OIDC::Discovery
- Defined in:
- lib/better_auth/sso/oidc/discovery.rb
Constant Summary collapse
- REQUIRED_DISCOVERY_FIELDS =
%i[issuer authorization_endpoint token_endpoint jwks_uri].freeze
- DISCOVERY_URL_FIELDS =
%i[ token_endpoint authorization_endpoint jwks_uri userinfo_endpoint revocation_endpoint end_session_endpoint introspection_endpoint ].freeze
Class Method Summary collapse
- .compute_discovery_url(issuer) ⇒ Object
- .discover_oidc_config(issuer:, fetch: nil, existing_config: nil, discovery_endpoint: nil, trusted_origin: nil, is_trusted_origin: nil, timeout: nil) ⇒ Object
- .ensure_runtime_discovery(config, issuer, trusted_origin, fetch: nil, timeout: nil) ⇒ Object
- .fetch_discovery_document(url, timeout: nil, fetch: nil) ⇒ Object
- .needs_runtime_discovery?(oidc_config) ⇒ Boolean
- .normalize_discovery_urls(document, issuer, trusted_origin = nil) ⇒ Object
- .normalize_endpoint_url(name, endpoint, issuer) ⇒ Object
- .normalize_url(name_or_value, value_or_issuer, issuer = nil, trusted_origin = nil) ⇒ Object
- .parse_discovery_body(data) ⇒ Object
- .parse_discovery_fetch_response(response) ⇒ Object
- .parse_http_url!(url, name, details: {}) ⇒ Object
- .raise_discovery_http_error(status, message) ⇒ Object
- .select_token_endpoint_auth_method(document_or_config = {}, existing_method = nil) ⇒ Object
- .validate_discovery_document(document, issuer) ⇒ Object
- .validate_discovery_url(url, trusted_origin = nil) ⇒ Object
Class Method Details
.compute_discovery_url(issuer) ⇒ Object
24 25 26 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 24 def compute_discovery_url(issuer) "#{issuer.to_s.sub(%r{/+\z}, "")}/.well-known/openid-configuration" end |
.discover_oidc_config(issuer:, fetch: nil, existing_config: nil, discovery_endpoint: nil, trusted_origin: nil, is_trusted_origin: nil, timeout: nil) ⇒ Object
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 93 def discover_oidc_config(issuer:, fetch: nil, existing_config: nil, discovery_endpoint: nil, trusted_origin: nil, is_trusted_origin: nil, timeout: nil) existing = BetterAuth::Plugins.normalize_hash(existing_config || {}) origin_check = trusted_origin || is_trusted_origin discovery_url = discovery_endpoint || existing[:discovery_endpoint] || compute_discovery_url(issuer) validate_discovery_url(discovery_url, origin_check) document = fetch_discovery_document(discovery_url, timeout: timeout, fetch: fetch) validate_discovery_document(document, issuer) normalized_document = normalize_discovery_urls(document, issuer, origin_check) { issuer: existing[:issuer] || normalized_document[:issuer], discovery_endpoint: existing[:discovery_endpoint] || discovery_url, client_id: existing[:client_id], client_secret: existing[:client_secret], authorization_endpoint: existing[:authorization_endpoint] || normalized_document[:authorization_endpoint], token_endpoint: existing[:token_endpoint] || normalized_document[:token_endpoint], jwks_endpoint: existing[:jwks_endpoint] || normalized_document[:jwks_uri], user_info_endpoint: existing[:user_info_endpoint] || normalized_document[:userinfo_endpoint], token_endpoint_authentication: select_token_endpoint_auth_method(normalized_document, existing[:token_endpoint_authentication]), scopes_supported: existing[:scopes_supported] || normalized_document[:scopes_supported], pkce: existing[:pkce], override_user_info: existing[:override_user_info], mapping: existing[:mapping] }.compact end |
.ensure_runtime_discovery(config, issuer, trusted_origin, fetch: nil, timeout: nil) ⇒ Object
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 144 def ensure_runtime_discovery(config, issuer, trusted_origin, fetch: nil, timeout: nil) normalized = BetterAuth::Plugins.normalize_hash(config || {}) return config unless needs_runtime_discovery?(normalized) discovered = discover_oidc_config( issuer: issuer, existing_config: normalized, trusted_origin: trusted_origin, fetch: fetch, timeout: timeout ) normalized.merge( authorization_endpoint: discovered[:authorization_endpoint], token_endpoint: discovered[:token_endpoint], token_endpoint_authentication: discovered[:token_endpoint_authentication], user_info_endpoint: discovered[:user_info_endpoint], jwks_endpoint: discovered[:jwks_endpoint] ).compact end |
.fetch_discovery_document(url, timeout: nil, fetch: nil) ⇒ Object
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 71 def fetch_discovery_document(url, timeout: nil, fetch: nil) response = if fetch fetch.call(url, timeout: timeout) else uri = URI(url) Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: timeout) do |http| http.get(uri.request_uri) end end parse_discovery_fetch_response(response) rescue DiscoveryError raise rescue Timeout::Error raise DiscoveryError.new("discovery_timeout", "OIDC discovery request timed out", details: {url: url}) rescue => exception if exception..match?(/aborted/i) raise DiscoveryError.new("discovery_timeout", "OIDC discovery request timed out", details: {url: url}) end raise DiscoveryError.new("discovery_unexpected_error", "OIDC discovery request failed", details: {url: url, error: exception.}) end |
.needs_runtime_discovery?(oidc_config) ⇒ Boolean
137 138 139 140 141 142 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 137 def needs_runtime_discovery?(oidc_config) config = BetterAuth::Plugins.normalize_hash(oidc_config || {}) config[:authorization_endpoint].to_s.empty? || config[:token_endpoint].to_s.empty? || config[:jwks_endpoint].to_s.empty? end |
.normalize_discovery_urls(document, issuer, trusted_origin = nil) ⇒ Object
61 62 63 64 65 66 67 68 69 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 61 def normalize_discovery_urls(document, issuer, trusted_origin = nil) doc = BetterAuth::Plugins.normalize_hash(document || {}).dup DISCOVERY_URL_FIELDS.each do |field| next if doc[field].to_s.empty? doc[field] = normalize_url(field.to_s, doc[field], issuer, trusted_origin) end doc end |
.normalize_endpoint_url(name, endpoint, issuer) ⇒ Object
196 197 198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 196 def normalize_endpoint_url(name, endpoint, issuer) raw = endpoint.to_s if raw.match?(%r{\Ahttps?://}i) uri = parse_http_url!(raw, name, details: {endpoint: name, url: raw}) return uri.to_s end issuer_uri = parse_http_url!(issuer, name, details: {endpoint: name, url: raw}) issuer_base = issuer_uri.to_s.sub(%r{/+\z}, "") endpoint_path = raw.sub(%r{\A/+}, "") normalized = "#{issuer_base}/#{endpoint_path}" parse_http_url!(normalized, name, details: {endpoint: name, url: normalized}).to_s end |
.normalize_url(name_or_value, value_or_issuer, issuer = nil, trusted_origin = nil) ⇒ Object
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 120 def normalize_url(name_or_value, value_or_issuer, issuer = nil, trusted_origin = nil) name = issuer.nil? ? "url" : name_or_value.to_s value = issuer.nil? ? name_or_value : value_or_issuer issuer_value = issuer.nil? ? value_or_issuer : issuer normalized = normalize_endpoint_url(name, value, issuer_value) if trusted_origin && !trusted_origin.call(normalized) raise DiscoveryError.new( "discovery_untrusted_origin", "The #{name} \"#{normalized}\" is not trusted by your trusted origins configuration.", details: {endpoint: name, url: normalized} ) end normalized end |
.parse_discovery_body(data) ⇒ Object
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 230 def parse_discovery_body(data) raise DiscoveryError.new("discovery_invalid_json", "OIDC discovery response was empty") if data.nil? return BetterAuth::Plugins.normalize_hash(data) if data.is_a?(Hash) parsed = JSON.parse(data.to_s) raise JSON::ParserError if !parsed.is_a?(Hash) BetterAuth::Plugins.normalize_hash(parsed) rescue JSON::ParserError raise DiscoveryError.new( "discovery_invalid_json", "OIDC discovery response was not valid JSON", details: {bodyPreview: data.to_s[0, 200]} ) end |
.parse_discovery_fetch_response(response) ⇒ Object
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 210 def parse_discovery_fetch_response(response) if response.respond_to?(:code) && response.respond_to?(:body) status = response.code.to_i body = response.body return parse_discovery_body(body) if status.between?(200, 299) raise_discovery_http_error(status, response..to_s) end normalized = response.is_a?(Hash) ? BetterAuth::Plugins.normalize_hash(response) : {data: response} error = normalized[:error] if error error_hash = BetterAuth::Plugins.normalize_hash(error) raise_discovery_http_error(error_hash[:status].to_i, error_hash[:message].to_s) end data = normalized.key?(:data) ? normalized[:data] : normalized parse_discovery_body(data) end |
.parse_http_url!(url, name, details: {}) ⇒ Object
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 176 def parse_http_url!(url, name, details: {}) uri = URI.parse(url.to_s) raise URI::InvalidURIError if uri.scheme.to_s.empty? || uri.host.to_s.empty? unless %w[http https].include?(uri.scheme) raise DiscoveryError.new( "discovery_invalid_url", "The url \"#{name}\" must use the http or https supported protocols", details: details.merge(protocol: "#{uri.scheme}:") ) end uri rescue URI::InvalidURIError raise DiscoveryError.new( "discovery_invalid_url", "The url \"#{name}\" must be valid", details: details ) end |
.raise_discovery_http_error(status, message) ⇒ Object
246 247 248 249 250 251 252 253 254 255 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 246 def raise_discovery_http_error(status, ) case status when 404 raise DiscoveryError.new("discovery_not_found", "OIDC discovery endpoint was not found", details: {status: status, message: }) when 408 raise DiscoveryError.new("discovery_timeout", "OIDC discovery request timed out", details: {status: status, message: }) else raise DiscoveryError.new("discovery_unexpected_error", "OIDC discovery request failed", details: {status: status, message: }) end end |
.select_token_endpoint_auth_method(document_or_config = {}, existing_method = nil) ⇒ Object
164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 164 def select_token_endpoint_auth_method(document_or_config = {}, existing_method = nil) return existing_method if existing_method config = BetterAuth::Plugins.normalize_hash(document_or_config || {}) return config[:token_endpoint_authentication] if config[:token_endpoint_authentication] methods = config[:token_endpoint_auth_methods_supported] || config[:methods] || [] return "client_secret_post" if Array(methods).include?("client_secret_post") && !Array(methods).include?("client_secret_basic") "client_secret_basic" end |
.validate_discovery_document(document, issuer) ⇒ Object
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 39 def validate_discovery_document(document, issuer) doc = BetterAuth::Plugins.normalize_hash(document || {}) missing = REQUIRED_DISCOVERY_FIELDS.select { |field| doc[field].to_s.empty? } unless missing.empty? raise DiscoveryError.new( "discovery_incomplete", "OIDC discovery document is missing required fields: #{missing.join(", ")}", details: {missingFields: missing.map(&:to_s)} ) end discovered = doc[:issuer].to_s.sub(%r{/+\z}, "") configured = issuer.to_s.sub(%r{/+\z}, "") return true if discovered == configured raise DiscoveryError.new( "issuer_mismatch", "OIDC discovery issuer does not match configured issuer", details: {discovered: doc[:issuer], configured: issuer} ) end |
.validate_discovery_url(url, trusted_origin = nil) ⇒ Object
28 29 30 31 32 33 34 35 36 37 |
# File 'lib/better_auth/sso/oidc/discovery.rb', line 28 def validate_discovery_url(url, trusted_origin = nil) uri = parse_http_url!(url, "discoveryEndpoint", details: {url: url}) return true unless trusted_origin && !trusted_origin.call(uri.to_s) raise DiscoveryError.new( "discovery_untrusted_origin", "The main discovery endpoint \"#{uri}\" is not trusted by your trusted origins configuration.", details: {url: uri.to_s} ) end |