discourse/lib/discourse_connect_provider.rb
Matt Marjanović 619d43ea47
FEATURE: Add prompt=none functionality to SSO Provider protocol (#22393)
This commit adds support for an optional `prompt` parameter in the
payload of the /session/sso_provider endpoint.  If an SSO Consumer
adds a `prompt=none` parameter to the encoded/signed `sso` payload,
then Discourse will avoid trying to login a not-logged-in user:

 * If the user is already logged in, Discourse will immediately
   redirect back to the Consumer with the user's credentials in a
   signed payload, as usual.

 * If the user is not logged in, Discourse will immediately redirect
   back to the Consumer with a signed payload bearing the parameter
   `failed=true`.

This allows the SSO Consumer to simply test whether or not a user is
logged in, without forcing the user to try to log in.  This is useful
when the SSO Consumer allows both anonymous and authenticated access.
(E.g., users that are already logged-in to Discourse can be seamlessly
logged-in to the Consumer site, and anonymous users can remain
anonymous until they explicitly ask to log in.)

This feature is similar to the `prompt=none` functionality in an
OpenID Connect Authentication Request; see
https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
2023-09-28 12:53:28 +01:00

86 lines
2.7 KiB
Ruby

# frozen_string_literal: true
class DiscourseConnectProvider < DiscourseConnectBase
class BlankSecret < RuntimeError
end
class BlankReturnUrl < RuntimeError
end
class InvalidParameterValueError < RuntimeError
attr_reader :param
def initialize(param)
@param = param
super("Invalid value for parameter `#{param}`")
end
end
def self.parse(payload, sso_secret = nil, **init_kwargs)
# We extract the return_sso_url parameter early; we need the URL's host
# in order to lookup the correct SSO secret in our site settings.
parsed_payload = Rack::Utils.parse_query(payload)
return_sso_url = lookup_return_sso_url(parsed_payload)
raise ParseError if !return_sso_url
sso_secret ||= lookup_sso_secret(return_sso_url, parsed_payload)
if sso_secret.blank?
begin
host = URI.parse(return_sso_url).host
Rails.logger.warn(
"SSO failed; website #{host} is not in the `discourse_connect_provider_secrets` site settings",
)
rescue StandardError => e
# going for StandardError cause URI::Error may not be enough, eg it parses to something not
# responding to host
Discourse.warn_exception(
e,
message: "SSO failed; invalid or missing return_sso_url in SSO payload",
)
end
raise BlankSecret
end
sso = super(payload, sso_secret, **init_kwargs)
# Do general parameter validation now, after signature-verification has succeeded.
raise InvalidParameterValueError.new("prompt") if (sso.prompt != nil) && (sso.prompt != "none")
sso
end
def self.lookup_return_sso_url(parsed_payload)
decoded = Base64.decode64(parsed_payload["sso"])
decoded_hash = Rack::Utils.parse_query(decoded)
decoded_hash["return_sso_url"]
end
def self.lookup_sso_secret(return_sso_url, parsed_payload)
return nil unless return_sso_url && SiteSetting.enable_discourse_connect_provider
return_url_host = URI.parse(return_sso_url).host
provider_secrets =
SiteSetting
.discourse_connect_provider_secrets
.split("\n")
.map { |row| row.split("|", 2) }
.sort_by { |k, _| k }
.reverse
first_domain_match = nil
pair =
provider_secrets.find do |domain, configured_secret|
if WildcardDomainChecker.check_domain(domain, return_url_host)
first_domain_match ||= configured_secret
sign(parsed_payload["sso"], configured_secret) == parsed_payload["sig"]
end
end
# falls back to a secret which will fail to validate in DiscourseConnectBase
# this ensures error flow is correct
pair.present? ? pair[1] : first_domain_match
end
end