discourse/lib/discourse_connect_base.rb
Sam 020c77440e
FEATURE: allow for overlapping DiscourseConnect secrets per domain (#16915)
Previously we limited Discourse Connect provider to 1 secret per domain.

This made it pretty awkward to cycle secrets in environments where config
takes time to propagate

This change allows for the same domain to have multiple secrets

Also fixes internal implementation on DiscourseConnectProvider which was
not thread safe as it leaned on class variables to ferry data around

Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
Co-authored-by: David Taylor <david@taylorhq.com>
2022-05-31 15:24:04 +10:00

167 lines
3.5 KiB
Ruby

# frozen_string_literal: true
class DiscourseConnectBase
class ParseError < RuntimeError; end
ACCESSORS = %i{
add_groups
admin moderator
avatar_force_update
avatar_url
bio
card_background_url
confirmed_2fa
email
external_id
groups
locale
locale_force_update
location
logout
name
no_2fa_methods
nonce
profile_background_url
remove_groups
require_2fa
require_activation
return_sso_url
suppress_welcome_message
title
username
website
}
FIXNUMS = []
BOOLS = %i{
admin
avatar_force_update
confirmed_2fa
locale_force_update
logout
moderator
no_2fa_methods
require_2fa
require_activation
suppress_welcome_message
}
def self.nonce_expiry_time
@nonce_expiry_time ||= 10.minutes
end
def self.nonce_expiry_time=(v)
@nonce_expiry_time = v
end
def self.used_nonce_expiry_time
24.hours
end
attr_accessor(*ACCESSORS)
attr_writer :sso_secret, :sso_url
def self.sso_secret
raise RuntimeError, "sso_secret not implemented on class, be sure to set it on instance"
end
def self.sso_url
raise RuntimeError, "sso_url not implemented on class, be sure to set it on instance"
end
def self.parse(payload, sso_secret = nil, **init_kwargs)
sso = new(**init_kwargs)
sso.sso_secret = sso_secret if sso_secret
parsed = Rack::Utils.parse_query(payload)
decoded = Base64.decode64(parsed["sso"])
decoded_hash = Rack::Utils.parse_query(decoded)
if sso.sign(parsed["sso"]) != parsed["sig"]
diags = "\n\nsso: #{parsed["sso"]}\n\nsig: #{parsed["sig"]}\n\nexpected sig: #{sso.sign(parsed["sso"])}"
if parsed["sso"] =~ /[^a-zA-Z0-9=\r\n\/+]/m
raise ParseError, "The SSO field should be Base64 encoded, using only A-Z, a-z, 0-9, +, /, and = characters. Your input contains characters we don't understand as Base64, see http://en.wikipedia.org/wiki/Base64 #{diags}"
else
raise ParseError, "Bad signature for payload #{diags}"
end
end
ACCESSORS.each do |k|
val = decoded_hash[k.to_s]
val = val.to_i if FIXNUMS.include? k
if BOOLS.include? k
val = ["true", "false"].include?(val) ? val == "true" : nil
end
sso.public_send("#{k}=", val)
end
decoded_hash.each do |k, v|
if field = k[/^custom\.(.+)$/, 1]
sso.custom_fields[field] = v
end
end
sso
end
def diagnostics
DiscourseConnectBase::ACCESSORS.map { |a| "#{a}: #{public_send(a)}" }.join("\n")
end
def sso_secret
@sso_secret || self.class.sso_secret
end
def sso_url
@sso_url || self.class.sso_url
end
def custom_fields
@custom_fields ||= {}
end
def self.sign(payload, secret)
OpenSSL::HMAC.hexdigest("sha256", secret, payload)
end
def sign(payload, secret = nil)
secret = secret || sso_secret
self.class.sign(payload, secret)
end
def to_json
self.to_h.to_json
end
def to_url(base_url = nil)
base = "#{base_url || sso_url}"
"#{base}#{base.include?('?') ? '&' : '?'}#{payload}"
end
def payload(secret = nil)
payload = Base64.strict_encode64(unsigned_payload)
"sso=#{CGI::escape(payload)}&sig=#{sign(payload, secret)}"
end
def unsigned_payload
Rack::Utils.build_query(self.to_h)
end
def to_h
payload = {}
ACCESSORS.each do |k|
next if (val = public_send(k)) == nil
payload[k] = val
end
@custom_fields&.each do |k, v|
payload["custom.#{k}"] = v.to_s
end
payload
end
end