Related documentation:
- https://meta.discourse.org/t/use-discourse-as-an-identity-provider-sso-discourseconnect/32974
- https://remix.run/docs/en/main/utils/sessions
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 is01100110
in binaryo
has an ASCII value of 111, which is01101111
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.