Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Discourse SSO updates #363

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion example.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
30 changes: 2 additions & 28 deletions src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -454,20 +454,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 ([email protected]) 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,
Expand Down Expand Up @@ -538,20 +525,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 ([email protected]) 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,
Expand Down
4 changes: 4 additions & 0 deletions src/schema/pnid.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/stripe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}
}

Expand Down
103 changes: 102 additions & 1 deletion src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,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));
Expand All @@ -257,10 +263,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 ([email protected]) 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,
Expand All @@ -281,5 +379,8 @@ module.exports = {
assignDiscordMemberTesterRole,
removeDiscordMemberSupporterRole,
removeDiscordMemberTesterRole,
signDiscoursePayload
createDiscoursePayload,
signDiscoursePayload,
discourseUserExists,
syncDiscourseSso
};
Loading