This commit is contained in:
Matthew Lopez 2026-03-17 10:49:50 -04:00 committed by GitHub
commit 41b16e8f1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 145 additions and 30 deletions

View File

@ -46,5 +46,32 @@
"api_token": "api_token",
"board_name": "board_name"
},
"api_base": "https://api.domain.com"
"api_base": "https://api.domain.com",
"discourse": {
"sso": {
"secret": "Discourse SSO secret"
},
"api": {
"base_url": "https://discourse.example.com",
"username": "system",
"key": "Discourse API key"
},
"groups": {
"access_level": {
"1": "testers",
"2": "juxt-moderators",
"3": "developers"
},
"stripe_tier": {
"1": "supporters-mario",
"2": "supporters-super",
"3": "supporters-mega"
},
"discord_role": {
"1234567890123456789": "developers",
"9876543210987654321": "discord-moderators",
"1234567890987654321": "network-moderators"
}
}
}
}

View File

@ -467,20 +467,7 @@ router.get('/sso/discourse', async (request, response, next) => {
try {
const accountData = await util.getUserAccountData(request, response);
// * Discourse REQUIRES unique emails, however we do not due to NN also
// * not requiring unique email addresses. Email addresses, for now,
// * are faked using the users PID. This will essentially disable email
// * for the forum, but it's a bullet we have to bite for right now.
// TODO - We can run our own SMTP server which maps fake emails (pid@pretendo.whatever) to users real emails
const payload = Buffer.from(new URLSearchParams({
nonce: decodedPayload.get('nonce'),
external_id: accountData.pid,
email: `${accountData.pid}@invalid.com`, // * Hack to get unique emails
username: accountData.username,
name: accountData.username,
avatar_url: accountData.mii.image_url,
avatar_force_update: true
}).toString()).toString('base64');
const payload = await util.createDiscoursePayload(decodedPayload.get('nonce'), accountData);
const query = new URLSearchParams({
sso: payload,
@ -551,20 +538,7 @@ router.post('/sso/discourse', async (request, response, next) => {
const accountData = await util.getUserAccountData(request, response);
// * Discourse REQUIRES unique emails, however we do not due to NN also
// * not requiring unique email addresses. Email addresses, for now,
// * are faked using the users PID. This will essentially disable email
// * for the forum, but it's a bullet we have to bite for right now.
// TODO - We can run our own SMTP server which maps fake emails (pid@pretendo.whatever) to users real emails
const payload = Buffer.from(new URLSearchParams({
nonce: decodedPayload.get('nonce'),
external_id: accountData.pid,
email: `${accountData.pid}@invalid.com`, // * Hack to get unique emails
username: accountData.username,
name: accountData.username,
avatar_url: accountData.mii.image_url,
avatar_force_update: true
}).toString()).toString('base64');
const payload = await util.createDiscoursePayload(decodedPayload.get('nonce'), accountData);
const query = new URLSearchParams({
sso: payload,

View File

@ -10,6 +10,10 @@ const PNIDSchema = new Schema({
server_access_level: String,
access_level: Number,
username: String,
mii: {
name: String,
image_url: String
},
connections: {
discord: {
id: String

View File

@ -286,6 +286,15 @@ async function handleStripeEvent(event) {
}
}
}
try {
if (await util.discourseUserExists(pid)) {
const updatedPNID = await database.PNID.findOne({ pid });
await util.syncDiscourseSso(updatedPNID);
}
} catch (error) {
logger.error(`Error syncing user Discourse SSO | ${pid} | - ${error.message}`);
}
}
}

View File

@ -230,6 +230,12 @@ function nintendoPasswordHash(password, pid) {
return hashed;
}
async function discordMemberHasRole(memberId, roleId) {
const response = await discordRest.get(DiscordRoutes.guildMember(config.discord.guild_id, memberId));
return response.roles.includes(roleId);
}
async function assignDiscordMemberSupporterRole(memberId, roleId) {
if (memberId && memberId.trim() !== '') {
await discordRest.put(DiscordRoutes.guildMemberRole(config.discord.guild_id, memberId, config.discord.roles.supporter));
@ -256,10 +262,102 @@ async function removeDiscordMemberTesterRole(memberId) {
}
}
async function createDiscoursePayload(nonce, accountData) {
const groups = config.discourse.groups;
const managedGroups = Object.values(groups).flatMap(category => Object.values(category));
const addGroups = [];
// * If more than one of the provided groups in add_groups are configured to
// * be automatically set as the primary group, Discourse unfortunately
// * appears to set the user's primary group arbitrarily and
// * non-deterministically. However, it also ignores groups that the user
// * was already in before this sign-in, so the primary group won't change
// * if none of the user's group memberships change.
if (accountData.connections.discord?.id) {
for (const role in groups.discord_role) {
if (await discordMemberHasRole(accountData.connections.discord.id, role)) {
addGroups.push(groups.discord_role[role]);
}
}
}
if (accountData.connections.stripe?.tier_level) {
for (const tier in groups.stripe_tier) {
if (accountData.connections.stripe.tier_level.toString() === tier) {
addGroups.push(groups.stripe_tier[tier]);
}
}
}
for (const level in groups.access_level) {
if (accountData.access_level.toString() === level) {
addGroups.push(groups.access_level[level]);
}
}
const removeGroups = managedGroups.filter(group => !addGroups.includes(group));
// * Discourse SSO Payload
// * https://meta.discourse.org/t/official-single-sign-on-for-discourse-sso/13045
// * Discourse REQUIRES unique emails, however we do not due to NN also
// * not requiring unique email addresses. Email addresses, for now,
// * are faked using the users PID. This will essentially disable email
// * for the forum, but it's a bullet we have to bite for right now.
// TODO - We can run our own SMTP server which maps fake emails (pid@pretendo.whatever) to users real emails
return Buffer.from(new URLSearchParams({
nonce: nonce,
external_id: accountData.pid,
email: `${accountData.pid}@invalid.com`, // * Hack to get unique emails
username: accountData.username,
name: accountData.mii.name,
avatar_url: accountData.mii.image_url,
avatar_force_update: true,
add_groups: addGroups.join(','),
remove_groups: removeGroups.join(',')
}).toString()).toString('base64');
}
function signDiscoursePayload(payload) {
return crypto.createHmac('sha256', config.discourse.sso.secret).update(payload).digest('hex');
}
async function discourseUserExists(pid) {
const response = await got.get(`${config.discourse.api.base_url}/users/by-external/${pid}.json`, {
throwHttpErrors: false,
responseType: 'json'
});
if (response.statusCode === 200) {
return true;
} else if (response.statusCode === 404) {
return false;
} else {
throw new Error(`Discourse API error while checking if user ${pid} exists: ${response.statusCode} - ${JSON.stringify(response.body)}`);
}
}
async function syncDiscourseSso(pnid) {
// * Documentation: https://meta.discourse.org/t/sync-discourseconnect-user-data-with-the-sync-sso-route/84398
const headers = {
'Content-Type': 'multipart/form-data',
'Api-Username': config.discourse.api.username,
'Api-Key': config.discourse.api.key
};
const payload = await createDiscoursePayload('', pnid);
const post_data = {
'sso': payload,
'sig': signDiscoursePayload(payload)
};
return got.post(`${config.discourse.api.base_url}/admin/users/sync_sso`, {
headers: headers,
form: post_data,
responseType: 'json'
});
}
module.exports = {
fullUrl,
getLocale,
@ -280,5 +378,8 @@ module.exports = {
assignDiscordMemberTesterRole,
removeDiscordMemberSupporterRole,
removeDiscordMemberTesterRole,
signDiscoursePayload
createDiscoursePayload,
signDiscoursePayload,
discourseUserExists,
syncDiscourseSso
};