Anvil App + Discourse Community with SSO

I just set up a self hosted Discourse forum that integrates with my Anvil community management app.

Discourse

Lots of info out there on how to set up a self hosted Discourse forum. I went with the Digital Ocean 1-click app. Guide here: https://www.youtube.com/watch?v=UU-o2Zsq3Ag

Discourse SSO

Configuring this was remarkably easy. I just needed to create a secret (generated in Anvil using the secrets service), and point it to my SSO endpoint in my Anvil app.

Anvil API Endpoint

This was a bit tricky for me to figure out, but after a lot of tinkering I finally got it. Code below.

@anvil.server.http_endpoint('/login-sso', cross_site_session=True)
def login_sso(sso, sig):

    secret_key = anvil.secrets.get_secret('discourse_secret')

    # Verify the signature
    expected_sig = hmac.new(secret_key.encode(), msg=sso.encode(), digestmod=hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected_sig, sig):
        raise anvil.server.HttpError(403, "Signature mismatch")

    # Decode the payload
    payload = base64.b64decode(urllib.parse.unquote(sso)).decode()
    params = dict(urllib.parse.parse_qsl(payload))
    nonce = params['nonce']

    user = None
    if not user:
        user = anvil.users.get_user(allow_remembered=True)

    if not user or user['auth_forumchat'] != True:
        return "User not logged in or does not have access to forum."

    discourse_url = app_tables.forum.get(tenant=user['tenant'])['discourse_url']
    
    # Prepare the return payload with user info
    user_info = {
        'nonce': nonce,
        'email': user['email'],
        'external_id': user.get_id(),
        'username': user['first_name'] + '_' + user['last_name'],
        'name': user['first_name'] + ' ' + user['last_name']
    }
    print(user_info)
    # unsigned payload generated
    return_payload = '&'.join([f"{key}={value}" for key, value in user_info.items()])

    # Base64-encode and URL-encode the return payload
    b64_return_payload = base64.b64encode(return_payload.encode()).decode()
    print('b64 return payload')
    url_encoded_payload = urllib.parse.quote_plus(b64_return_payload)
    print('url encoded payload')

    # Sign the return payload
    return_sig = hmac.new(secret_key.encode(), msg=b64_return_payload.encode(), digestmod=hashlib.sha256).hexdigest()
    print('return sig')

    # Redirect back to Discourse
    discourse_redirect_url = f"https://{discourse_url}/session/sso_login?sso={url_encoded_payload}&sig={return_sig}"
    return anvil.server.HttpResponse(302, headers={"Location": discourse_redirect_url})

Some bells and whistles

I added a nav button to the top of my Anvil app to open the forum in the same tab:
image

With this event handler:

    def link_forum_nav_click(self, **event_args):
        """This method is called when the link is clicked"""
        anvil.js.window.location.href = Global.forumlink

And in Discourse, I added a top nav button to go back to the Anvil app by editing the Header HTML:

Conclusion

This saved me a LOT of time as I was considering re-creating forum functionality in my community app. I’m glad I didn’t have to do that, and I’m grateful that Discourse is open source and so well supported with plugins and such.

Open Source

By the way, my Anvil app is open source:

5 Likes