How to configure Single Sign-On for Discourse

Saad Ali
5 min readSep 30, 2023

--

Discourse vs Single Sign-On

Single Sign-On

Single Sign-On (SSO) in simple terms means you can log into multiple apps or websites using just one username and password. Imagine having a super special key that works for many different doors!

When you use SSO in an app or website, it means you don’t have to remember multiple usernames and passwords for each place. You log in once with your SSO details, and then you’re automatically logged into other connected apps without needing to enter your credentials again.

So, SSO is like having a master key for all your favorite places on the internet, making it quicker and easier to access them.

Single Sign-On

Discourse

Discourse is an open-source, modern internet forum and discussion platform designed for online communities. It’s like a virtual meeting place where people can engage in discussions, share ideas, ask questions, and collaborate on various topics.

Here’s a breakdown:

  1. Discussion Platform: Discourse provides a platform for organizing and hosting discussions. It’s structured to facilitate conversations in a clear and organized manner.
  2. Community Interaction: Users can create accounts, post messages, comment on discussions, and participate in various topics of interest. It encourages active engagement and participation.
  3. Categories and Topics: Discussions are organized into categories and topics, making it easy for users to find relevant content and follow specific subjects they are interested in.
  4. Modern Interface and Features: Discourse has a modern, intuitive interface with features like real-time updates, rich text formatting, notifications, tagging, and a user-friendly design.
  5. User Trust and Moderation: The platform allows for community moderation, flagging inappropriate content, and promoting a sense of trust and safety within the community.
  6. Open-Source: Being open-source means that the underlying code of Discourse is freely available for developers to view, modify, and contribute to, making it a customizable and adaptable platform.

Configuration

Background: I have a seperate application running in rails, now I implemented forums platform for discussion. Instead of creating a seperate authentication mechanism I decided to implement Single Sign-On. So here we go…

Step 1 [Enabling Discourse Connect]

As mentioned in the official docs as well, we first need to enable the following the flags in order to enable SSO

Enable SSO Flags

enable_discourse_connect : must be enabled, global switch
discourse_connect_url: the URL users will be sent to when attempting to log on
discourse_connect_secret: a secret string used to hash SSO payloads.

Step 2 [Validate your request]

After enabling the above flags, Discourse will redirect clients to discourse_connect_url with a signed payload:

https://somesite.com/sso?sso=PAYLOAD&sig=SIG

The payload is always base 64 243 encoded string comprising of a and a nonce (unique string for every request), sessionand return_sso_url . It means the decoded version of payload would be something like this: nonce=ABCD&return_sso_url=https%3A%2F%2Fdiscourse_site%2Fsession%2Fsso_login

Now, its time to validate this request, as said before we got encoded payload, so we need to first parse it, for that I am going to create function in my SingleSignOn class

class SingleSignOn
ACCESSORS = %i[
add_groups
admin
avatar_force_update
avatar_url
bio
card_background_url
confirmed_2fa
email
external_id
failed
groups
locale
locale_force_update
location
logout
moderator
name
no_2fa_methods
nonce
prompt
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
failed
locale_force_update
logout
moderator
no_2fa_methods
require_2fa
require_activation
suppress_welcome_message
]

def sign(payload)
OpenSSL::HMAC.hexdigest('sha256', sso_secret, payload)
end

def self.parse(payload, sso_secret = nil)
sso = new
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"] =~ %r{[^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
val = %w[true false].include?(val) ? val == "true" : nil if BOOLS.include? k
sso.public_send("#{k}=", val)
end

decoded_hash.each do |k, v|
if field = k[/\Acustom\.(.+)\z/, 1]
sso.custom_fields[field] = v
end
end

sso
end
end

Step 3 [Login/Logout User on Discourse]

In order to login/Logout user on Discourse platform, you need to use Discourse API Client. Lets create one

class DiscourseApiClient
def self.api_client
DISCOURSE_URL = Settings.discourse.discourse_url
DISCOURSE_USER_NAME = Settings.discourse.discourse_user_name
DISCOURSE_API_KEY = Settings.discourse.discourse_api_key
client = DiscourseApi::Client.new(DISCOURSE_URL)
client.api_username = DISCOURSE_USER_NAME
client.api_key = DISCOURSE_API_KEY
client
end

def self.sync_user(user, return_response_object = false, custom_device_token = nil)
SSO_SECRET = Settings.discourse.seo_secret
sso_sync_request_response = api_client.sync_sso(sso_secret: SSO_SECRET, name: user.full_name || '', username: user.username || '', email: user.email, external_id: user.id, custom_device_token: custom_device_token)

(return_response_object ? sso_sync_request_response : sso_sync_request_response.present?)
end

def self.login(user, session, cookies)
login_api_response = nil
sso_sync_request_response = sync_user(user, true)

if sso_sync_request_response.present?
user_id = sso_sync_request_response['id']
login_api_response = api_client.log_in(user_id) if user_id.present?
if login_api_response && login_api_response['success'] == 'OK'
session[:user_id] = user_id
cookies.permanent[login_api_response['cookie_name_to_be_set']] = { value: login_api_response['value'], httponly: login_api_response['httponly'] }
end
end
end

def self.logout(session)
api_client.log_out(session[:user_id]) if session[:user_id].present?
end
end

Step 4 [Authenticate User]

class SessionController
def new
SSO_SECRET = Settings.discourse.seo_secret
sso = SingleSignOn.parse(request.query_string, SSO_SECRET)
session[:sso_nonce] = sso.nonce if sso.present?
session[:sso_return_url] = sso.return_sso_url
end

def create
# User authentication code start
# ######
# end
begin
DiscourseApiHelper.login(user, session, cookies)
rescue => ex
Rails.logger.warn 'Discourse Server is Down: Login'
end

if sso_return_url.present?
sso = SingleSignOn.new
sso.sso_secret = SSO_SECRET
sso.nonce = sso_nonce
sso.email = user.email
sso.external_id = user.id
sso.username = user.username
sso_return_url = sso.to_url(sso_return_url)
session[:return_to_url] = sso_return_url
end

end

end

Real World Example

Lets say user attempts to login

  • Nonce is generated: cb68251eefb5211e58c00ff1395f0c0b
  • Raw payload is generated: nonce=cb68251eefb5211e58c00ff1395f0c0b
  • Payload is Base64 encoded: bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI=
  • Payload is URL encoded: bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D
  • HMAC-SHA256 is generated on the Base64 encoded Payload: 1ce1494f94484b6f6a092be9b15ccc1cdafb1f8460a3838fbb0e0883c4390471

Finally browser is redirected to: http://www.example.com/discourse/sso?sso=bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D&sig=1ce1494f94484b6f6a092be9b15ccc1cdafb1f8460a3838fbb0e0883c4390471

Hope you like this article feel free to reach out in case of any hurdle :)

--

--

Saad Ali

Software Engineer. Love to work in Python, Django and Rails