discourse/plugins/discourse-openid-connect/lib/openid_connect_authenticator.rb
David Taylor 2167034172
FEATURE: Enable group sync for OpenID Connect plugin (#39082)
When configured, this will check the configured 'claim' for an array of
group names. These group names will be ingested into core's "Associated
Groups" system.

To connect an OIDC group to a Discourse group, first ensure that at
least one member of the group has logged-in. Then go to the Discourse
group "Manage" tab, then "Membership" -> "Automatic", and select the
`oidc:*` group from the list.

<img width="1099" height="670" alt="SCR-20260402-pyfg"
src="https://github.com/user-attachments/assets/1bb36c9f-5077-47a1-bb4c-e755b87e6622"
/>
2026-04-16 18:04:17 +01:00

175 lines
6.2 KiB
Ruby

# frozen_string_literal: true
require "base64"
require "openssl"
class OpenIDConnectAuthenticator < Auth::ManagedAuthenticator
def name
"oidc"
end
def can_revoke?
SiteSetting.openid_connect_allow_association_change
end
def can_connect_existing_user?
SiteSetting.openid_connect_allow_association_change
end
def enabled?
SiteSetting.openid_connect_enabled
end
def primary_email_verified?(auth)
supplied_verified_boolean = auth["extra"]["raw_info"]["email_verified"]
# If the payload includes the email_verified boolean, use it. Otherwise assume true
if supplied_verified_boolean.nil?
true
else
# Many providers violate the spec, and send this as a string rather than a boolean
supplied_verified_boolean == true ||
(supplied_verified_boolean.is_a?(String) && supplied_verified_boolean.downcase == "true")
end
end
def provides_groups?
SiteSetting.openid_connect_groups_claim.present?
end
def after_authenticate(auth_token, existing_account: nil)
result = super
if provides_groups?
claim = SiteSetting.openid_connect_groups_claim
groups = auth_token.extra&.dig(:raw_info, claim)
if groups.is_a?(Array)
result.associated_groups = groups.map { |group_name| { id: group_name, name: group_name } }
elsif groups.present?
oidc_log("groups claim '#{claim}' is not an array: #{groups.class}", error: true)
else
oidc_log("groups claim '#{claim}' not found in auth token")
end
end
result
end
def always_update_user_email?
SiteSetting.openid_connect_overrides_email
end
def match_by_email
SiteSetting.openid_connect_match_by_email
end
def discovery_document
document_url = SiteSetting.openid_connect_discovery_document.presence
if !document_url
oidc_log("No discovery document URL specified", error: true)
return
end
from_cache = true
result =
Discourse
.cache
.fetch("openid-connect-discovery-#{document_url}", expires_in: 10.minutes) do
from_cache = false
oidc_log("Fetching discovery document from #{document_url}")
connection =
Faraday.new(request: { timeout: request_timeout_seconds }) do |c|
c.use Faraday::Response::RaiseError
c.adapter FinalDestination::FaradayAdapter
end
JSON.parse(connection.get(document_url).body)
rescue Faraday::Error, JSON::ParserError => e
oidc_log("Fetching discovery document raised error #{e.class} #{e.message}", error: true)
nil
end
oidc_log("Discovery document loaded from cache") if from_cache
oidc_log("Discovery document is\n\n#{result.to_yaml}")
result
end
def oidc_log(message, error: false)
if error
Rails.logger.error("OIDC Log: #{message}")
elsif SiteSetting.openid_connect_verbose_logging
Rails.logger.warn("OIDC Log: #{message}")
end
end
def register_middleware(omniauth)
omniauth.provider :openid_connect,
name: :oidc,
error_handler:
lambda { |error, message|
handlers = SiteSetting.openid_connect_error_redirects.split("\n")
handlers.each do |row|
parts = row.split("|")
return parts[1] if message.include? parts[0]
end
nil
},
verbose_logger: lambda { |message| oidc_log(message) },
setup:
lambda { |env|
opts = env["omniauth.strategy"].options
token_params = {}
token_params[
:scope
] = SiteSetting.openid_connect_token_scope if SiteSetting.openid_connect_token_scope.present?
opts.deep_merge!(
client_id: SiteSetting.openid_connect_client_id,
client_secret: SiteSetting.openid_connect_client_secret,
discovery_document: discovery_document,
scope: SiteSetting.openid_connect_authorize_scope,
token_params: token_params,
passthrough_authorize_options:
SiteSetting.openid_connect_authorize_parameters.split("|"),
claims: SiteSetting.openid_connect_claims,
pkce: SiteSetting.openid_connect_use_pkce,
pkce_options: {
code_verifier: -> { generate_code_verifier },
code_challenge: ->(code_verifier) do
generate_code_challenge(code_verifier)
end,
code_challenge_method: "S256",
},
)
opts[:client_options][:connection_opts] = {
request: {
timeout: request_timeout_seconds,
},
}
opts[:client_options][:connection_build] = lambda do |builder|
if SiteSetting.openid_connect_verbose_logging
builder.response :logger,
Rails.logger,
{ bodies: true, formatter: OIDCFaradayFormatter }
end
builder.request :url_encoded # form-encode POST params
builder.adapter FinalDestination::FaradayAdapter # make requests with FinalDestination::HTTP
end
}
end
def generate_code_verifier
Base64.urlsafe_encode64(OpenSSL::Random.random_bytes(32)).tr("=", "")
end
def generate_code_challenge(code_verifier)
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).tr("+/", "-_").tr("=", "")
end
def request_timeout_seconds
GlobalSetting.openid_connect_request_timeout_seconds
end
end