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

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.message.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.message})
end

.needs_runtime_discovery?(oidc_config) ⇒ Boolean

Returns:

  • (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.message.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, message)
  case status
  when 404
    raise DiscoveryError.new("discovery_not_found", "OIDC discovery endpoint was not found", details: {status: status, message: message})
  when 408
    raise DiscoveryError.new("discovery_timeout", "OIDC discovery request timed out", details: {status: status, message: message})
  else
    raise DiscoveryError.new("discovery_unexpected_error", "OIDC discovery request failed", details: {status: status, message: 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

Raises:



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

Raises:



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