DiscourseConnect Authentication for Remix

Related documentation:

DiscourseConnect is Discourse’s implementation of SSO. It allows a Discourse site to function as either the SSO client or the SSO provider for an external application. This app is going to use Remix as the SSO client and Discourse as the SSO provider.

Discourse’s documentation uses the term “DiscourseConnect,” but  sso is used in the the Discourse code base. I’ll refer to it as SSO for brevity.

I’ll be doing the work in my local development environment. Details for installing Discourse locally are here: https://meta.discourse.org/docs?ascending=false&category=56&tags=dev-install.

DiscourseConnect Provider overview

https://meta.discourse.org/t/use-discourse-as-an-identity-provider-sso-discourseconnect/32974.

A general description of the process is that the client application sends an  HMAC-SHA256 signed payload to Discourse. Discourse verifies the payload against the signature and sends a signed payload with the details needed to authenticate the user back to the client.

I’ll start with a fresh Remix app:

$ touch app/routes/login.tsx

Create a login resource route

The route only needs a loader function that returns a redirect. It’s going to make use of the request object, so I’ll add that now:

// app/routes/login.tsx

import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  console.log("in the login loader function");
  return redirect("/");
};

So far so good. Terminal output:

in the login loader function

Determine if the authentication flow is being initiated

The loader function is going to handle both initiating the authentication flow and processing the response from Discourse. The condition is that if sso and sig query parameters exist on the request object, the request originated from Discourse. If the sso and sig parameters do not exist on the response object, the loader needs to start the authentication process.

// app/routes/login.tsx

import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const sso = url.searchParams.get("sso");
  const sig = url.searchParams.get("sig");

  if (!sso && !sig) {
    // initiate the authentication flow
  }

  if (sso && sig) {
    // handle the response from Discourse
  }
  return redirect("/");
};

Generate a nonce

The payload that’s sent to Discourse needs to include a nonce. Discourse sends the nonce back to the client in its response. The nonce is used ensure that the response that is sent from Discourse is only used once – it will be destroyed by the client after the payload has been processed.

For now I’m using createCookieSessionStorage both for associating the nonce with a particular request, and for creating user sessions on Remix. In a production application, I might use createSessionStorage for the nonce and the user sessions.

Since the nonce session is needed both for initiating a new authentication flow and for processing the Discourse redirect, I’ll get the session near the top of the function.

Create the nonceStorage object:

$ mkdir app/services
$ touch app/services/session.server.ts
// app/services/session.server.ts

import { createCookieSessionStorage } from "@remix-run/node";

if (!process.env.NONCE_SECRET) {
  throw new Error("Required cookie secret not set");
}
const nonceSecret: string = process.env.NONCE_SECRET;

export const nonceStorage = createCookieSessionStorage({
  cookie: {
    name: "_nonce",
    sameSite: "lax",
    path: "/",
    httpOnly: true,
    secrets: [nonceSecret],
    secure: process.env.NODE_ENV === "production",
    maxAge: 60 * 10, // the Discourse nonce is valid for 10 minutes, match that for now
  },
});

Have the loader set the nonce on the session if the sso and sig parameters don’t exist on the request, and destroy the nonce session if the parameters are found. (I’m using randomBytes to generate the nonce):

// app/routes/login.tsx

import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";

import { randomBytes } from "node:crypto";

import { nonceStorage } from "~/services/session.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const sso = url.searchParams.get("sso");
  const sig = url.searchParams.get("sig");

  const nonceSession = await nonceStorage.getSession(
    request.headers.get("Cookie")
  );

  if (!sso && !sig) {
    const nonce = randomBytes(16).toString("hex");

    nonceSession.set("nonce", nonce);

    return redirect("/", {
      headers: {
        "Set-Cookie": await nonceStorage.commitSession(nonceSession),
      },
    });
  }

  if (sso && sig) {
    // handle the response from Discourse
  }
  return redirect("/", {
    headers: {
      "Set-Cookie": await nonceStorage.destroySession(nonceSession),
    },
  });
};

Visiting /login should now set the _nonce cookie. Visiting /login?sso=foo&sig=bar should destroy the _nonce session. Note that I’ve also got Discourse on localhost:

Assemble the payload and redirect to Discourse

The loader function needs the Discourse base URL (http://localhost:4200) and the SSO secret key. I’m storing both as environmental variable:

// app/routes/login.tsx

import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";

import { createHmac, randomBytes } from "node:crypto";

import { nonceStorage } from "~/services/session.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  if (!process.env.DISCOURSE_SSO_SECRET || !process.env.DISCOURSE_BASE_URL) {
    return redirect("/"); // todo: this should be configurable
  }

  const secret = process.env.DISCOURSE_SSO_SECRET;
  const url = new URL(request.url);
  const sso = url.searchParams.get("sso");
  const sig = url.searchParams.get("sig");

  const nonceSession = await nonceStorage.getSession(
    request.headers.get("Cookie")
  );

  if (!sso && !sig) {
    const nonce = randomBytes(16).toString("hex");
    const ssoPayload = `nonce=${nonce}&return_sso_url=http://localhost:5173/login`;
    const base64EncodedPayload = Buffer.from(ssoPayload).toString("base64");
    const urlEncodedPayload = encodeURIComponent(base64EncodedPayload);
    const signature = createHmac("sha256", secret)
      .update(base64EncodedPayload)
      .digest("hex");
    const discourseBaseUrl = process.env.DISCOURSE_BASE_URL;
    const discourseConnectUrl = `${discourseBaseUrl}/session/sso_provider?sso=${urlEncodedPayload}&sig=${signature}`;

    nonceSession.set("nonce", nonce);

    return redirect(discourseConnectUrl, {
      headers: {
        "Set-Cookie": await nonceStorage.commitSession(nonceSession),
      },
    });
  }

  if (sso && sig) {
    console.log(`response from Discourse, sso: ${sso}, sig: ${sig}`);
  }
  return redirect("/", {
    headers: {
      "Set-Cookie": await nonceStorage.destroySession(nonceSession),
    },
  });
};

Now that the login route is redirecting to Discourse, the Enable Discourse Connect Provider and Discourse Connect Provider Secrets settings need to be configured on Discourse:

It’s also worth enabling the Verbose Discourse Connect Logging setting. It adds extra messages to the error logs if anything goes wrong:

Visiting the /login route now outputs the following to the terminal:

response from Discourse, sso: YWRtaW49dHJ1ZSZhdmF0YXJfdXJsPWh0dHAlM0ElMkYlMkYxMjcuMC4wLjElM0E0MjAwJTJGdXBsb2FkcyUyRmRlZmF1bHQlMkZvcmlnaW5hbCUyRjFYJTJGMzE3MTA1YjQ2OTUyNjA0YWQ3NTQwNjliNGI0OGFmMWVmZGUxNDdmNS5qcGVnJmVtYWlsPXNpbW9uLmNvc3NhciU0MGV4YW1wbGUuY29tJmV4dGVybmFsX2lkPTcmZ3JvdXBzPWFkbWlucyUyQ3N0YWZmJTJDdHJ1c3RfbGV2ZWxfMSUyQ3RydXN0X2xldmVsXzAmbW9kZXJhdG9yPWZhbHNlJm5hbWU9c2Nvc3NhciZub25jZT01NWZmZWFkNWY4Zjc4N2RjYTAzMWE3Zjk2ZDc0M2UzYSZyZXR1cm5fc3NvX3VybD1odHRwJTNBJTJGJTJGbG9jYWxob3N0JTNBNTE3MyUyRmxvZ2luJnVzZXJuYW1lPXNjb3NzYXI=,
sig: c63333fa350c2a48406af8cfa9a794562dff939ca607a6f611d6ab5673277ba7

Before dealing with the response, I’ll look at how the payload that’s sent to Discourse is put together:

const ssoPayload = `nonce=${nonce}&return_sso_url=http://localhost:5173/login`;
const base64EncodedPayload = Buffer.from(ssoPayload).toString("base64");
const urlEncodedPayload = encodeURIComponent(base64EncodedPayload);

The ssoPayload is gets transformed into the value of the urlEncodedPayload that’s set as the value of the sso parameter in the discourseConnectUrl. It has two query parameters: nonce and return_sso_url.

The ssoPayload is Base64 encoded, then URL encoded.


Aside: Base64 encoding (and decoding)

For my own reference, Base64 encoding starts by converting each character of a string a binary value, based on its ASCII value. For example, given the string “foo”:

  • f has an ASCII value of 102, which is 01100110 in binary
  • o has an ASCII value of 111, which is 01101111 in binary

So “foo” in binary is 01100110 01101111 01101111

The binary string is then split into groups of 6 bits, because Base64 characters are represented by 6 bits of data:

011001 100110 111101 101111

Each group of 6 bits is then converted to a Base64 character using the Base64 index table: https://en.wikipedia.org/wiki/Base64.

This node session made it clear to me:

> let foo = Buffer.from("foo").toString("base64")
undefined
> console.log(foo)
Zm9v
undefined
> let bar = Buffer.from(foo, "base64").toString("utf-8")
undefined
> console.log(bar)
foo

Base64 decoding is just the opposite of the encoding process.


The next step is to calculate a HMAC-SHA256 digest of the Base64 encoded payload (not the URL encoded payload):

const signature = createHmac("sha256", secret)
  .update(base64EncodedPayload)
  .digest("hex");

The call to digest("hex") causes the digest to be output as a hexadecimal string instead of as a binary buffer. I’m assuming that converting binary data to a hexadecimal string is much like the process of converting binary data to Base64 characters: https://en.wikipedia.org/wiki/Hexadecimal.


Aside: confirming that HMAC digests works

The easiest way to demonstrate this is from a node terminal.

In a node session, generate the HMAC-SHA256 digest of the message "this is a test" with "foo" as the secret key:

$ node
Welcome to Node.js v20.10.0.
Type ".help" for more information.
> const {createHmac} = require('node:crypto')
undefined
> let signature = createHmac("sha256", "foo").update("this is a test").digest("hex")
undefined
> console.log(signature)
3bc227f25e303e37316b518b00dfdf37a36dd9d7653f79dce0a9985fd9882c2c
undefined

In a separate node session, assign the value of signature from the previous session to receivedDigest. Then calculate the digest of the same payload, again using "foo" as the key, and assign it to calculatedDigest.

receivedDigest and calculatedDigest are equal:

$ node
Welcome to Node.js v20.10.0.
Type ".help" for more information.
> const receivedDigest = "3bc227f25e303e37316b518b00dfdf37a36dd9d7653f79dce0a9985fd9882c2c"
undefined
> const {createHmac} = require('node:crypto')
undefined
> const calculatedDigest = createHmac("sha256", "foo").update("this is a test").digest("hex")
undefined
> console.log(calculatedDigest)
3bc227f25e303e37316b518b00dfdf37a36dd9d7653f79dce0a9985fd9882c2c
undefined
> console.log(receivedDigest === calculatedDigest)
true

This shows that both node sessions calculated the digest with the same secret key.


Set the nonce header and redirect the user to Discourse

  const discourseBaseUrl = process.env.DISCOURSE_BASE_URL;
  const discourseConnectUrl = `${discourseBaseUrl}/session/sso_provider?sso=${urlEncodedPayload}&sig=${signature}`;

  nonceSession.set("nonce", nonce);

  return redirect(discourseConnectUrl, {
    headers: {
      "Set-Cookie": await nonceStorage.commitSession(nonceSession),
    },
  });
}

if (sso && sig) {
  console.log(`response from Discourse, sso: ${sso}, sig: ${sig}`);
}

Handle the response from Discourse

The payload gets redirected to the Discourse /session/sso path. If everything checks out, Discourse will redirect the user back to the request’s return_sso_url.

If the user is already logged into Discourse, both the initial request from the SSO client, and the redirect back to the SSO client seem to be handled in the background. If the user is not logged into Discourse, they’ll be redirected to the Discourse login page, then redirected back to the client’s return_sso_url.

The Discourse redirect will have sso and sig query parameters. That causes the code in the code below to be executed:

// app/routes/login.tsx

//...
  if (sso && sig) {
    const computedSig = createHmac("sha256", secret).update(sso).digest();
    const receivedSigBytes = Buffer.from(sig, "hex");
    if (!computedSig.equals(receivedSigBytes)) {
      console.error(
        `Signature mismatch detected at ${new Date().toISOString()}`
      );
      throw new Response(
        "There was an error during the login process. Please try again. If the error persists, contact a site administrator",
        {
          status: 400,
          headers: {
            "Set-Cookie": await nonceStorage.destroySession(nonceSession),
          },
        }
      );
    }
    const decodedPayload = Buffer.from(sso, "base64").toString("utf-8");
    const params = new URLSearchParams(decodedPayload);
    const nonce = params.get("nonce");
    const sessionNonce = nonceSession.get("nonce");
    if (!(sessionNonce && sessionNonce === nonce)) {
      console.error(`Nonce mismatch detected at ${new Date().toISOString()}`);
      throw new Response(
        "There was an error during the login process. Please try again. If the error persists, contact a site administrator",
        {
          status: 400,
          headers: {
            "Set-Cookie": await nonceStorage.destroySession(nonceSession),
          },
        }
      );
    }

    const userSession = await discourseSessionStorage.getSession();
    /**
     * params that are always in the payload:
     * name: string
     * username: string
     * email: string
     * external_id: number (the Discourse user ID)
     * admin: boolean
     * moderator: boolean
     * groups: string (comma separated list)
     *
     * params that may be in the payload:
     * avatar_url: string
     * profile_background_url: string
     * card_background_url: string
     */
    const sessionParams = new Set([
      "external_id", // the user's Discourse ID
      "username",
      "avatar_url",
      "groups", // comma separated list of group names
    ]);

    for (const [key, value] of params) {
      if (sessionParams.has(key)) {
        userSession.set(key, value);
      }
    }
    const headers = new Headers();
    headers.append(
      "Set-Cookie",
      await nonceStorage.destroySession(nonceSession)
    );
    headers.append(
      "Set-Cookie",
      await discourseSessionStorage.commitSession(userSession)
    );
    return redirect("/", { headers });
  }

  throw new Response("Login Error", {
    status: 400,
    headers: { "Set-Cookie": await nonceStorage.destroySession(nonceSession) },
  });
};

Note that discourseSessionStorage.getSession()is being assigned to userSession. I’m using separate sessions to handle the nonce and the user session. Both sessions are configured here:

// app/services/session.server.ts

import { createCookieSessionStorage } from "@remix-run/node";

if (!process.env.NONCE_SECRET || !process.env.DISCOURSE_SESSION_SECRET) {
  throw new Error("Required cookie secret not set");
}
const nonceSecret: string = process.env.NONCE_SECRET;
const discourseSessionSecret: string = process.env.DISCOURSE_SESSION_SECRET;

export const nonceStorage = createCookieSessionStorage({
  cookie: {
    name: "_nonce",
    sameSite: "lax",
    path: "/",
    httpOnly: true,
    secrets: [nonceSecret],
    secure: process.env.NODE_ENV === "production",
    maxAge: 60 * 10, // 10 minutes for now, could probably be reduced
  },
});

export const discourseSessionStorage = createCookieSessionStorage({
  cookie: {
    name: "_session",
    sameSite: "lax",
    path: "/",
    httpOnly: true,
    secrets: [discourseSessionSecret],
    secure: process.env.NODE_ENV === "production",
    maxAge: 60 * 60 * 48, // set to two days for now
  },
});

Going through the authentication process step by step. The first step is essentially the same as the receivedDigest/calculatedDigest example that I posted above:

if (sso && sig) {
  const computedSig = createHmac("sha256", secret).update(sso).digest();
  const receivedSigBytes = Buffer.from(sig, "hex");
  if (computedSig.equals(receivedSigBytes)) {

The only difference is that the computedSig is returning a binary buffer (digest()instead of digest("hex")). The value of the sig parameter that’s received from Discourse is the hexadecimal representation of the payload digest. To compare the values, receivedSigBytes is assigned the value of the sig parameter converted back into bytes:

const receivedSigBytes = Buffer.from(sig, "hex");

If the values are not equal, a 400 response will be thrown, otherwise, the authentication process can continue:

const decodedPayload = Buffer.from(sso, "base64").toString("utf-8");
const params = new URLSearchParams(decodedPayload);
const nonce = params.get("nonce");
const sessionNonce = nonceSession.get("nonce");

The payload is decoded from its Base64 representation back to a "utf-8" string. It’s then converted into a URLSearchParams object to allow it’s parameters to be extracted.

The nonce from the parameters is compared to the nonceSession nonce. If the values are not equal, a 400 response is thrown, otherwise, the authentication process can begin:

  const userSession = await discourseSessionStorage.getSession();
    /**
     * params that are always in the payload:
     * name: string
     * username: string
     * email: string
     * external_id: number (the Discourse user ID)
     * admin: boolean
     * moderator: boolean
     * groups: string (comma separated list)
     *
     * params that may be in the payload:
     * avatar_url: string
     * profile_background_url: string
     * card_background_url: string
     */
    const sessionParams = new Set([
      "external_id", // the user's Discourse ID
      "username",
      "avatar_url",
      "groups", // comma separated list of group names
    ]);

    for (const [key, value] of params) {
      if (sessionParams.has(key)) {
        userSession.set(key, value);
      }
    }
    const headers = new Headers();
    headers.append(
      "Set-Cookie",
      await nonceStorage.destroySession(nonceSession)
    );
    headers.append(
      "Set-Cookie",
      await discourseSessionStorage.commitSession(userSession)
    );
    return redirect("/", { headers });
  }



After the parameters are set on the userSession, the headers are configured to destroy the nonceSession and commit the userSession. The session can then be accessed in any of the app’s loader or action functions.

Any responses that are thrown from the loader are caught here:


export function ErrorBoundary() {
  const error = useRouteError();
  if (isRouteErrorResponse(error) && error?.data) {
    const errorMessage = error?.data;

    return (
      <div>
        <h1>Login Error</h1>
        <p>{errorMessage}</p>
      </div>
    );
  } else {
    return (
      <div>
        <h1>Login Error</h1>
        <p>Something has gone wrong.</p>
      </div>
    );
  }
}

That’s good for now 🙂

The code is here: https://github.com/scossar/remix_discourse_auth.

Leave a comment