Browse Source

refactor: move user verify email and github callback logic to users module, small fixes

Kristian Vos 2 months ago
parent
commit
f916b93942
2 changed files with 357 additions and 395 deletions
  1. 45 386
      backend/logic/app.js
  2. 312 9
      backend/logic/users.js

+ 45 - 386
backend/logic/app.js

@@ -1,23 +1,13 @@
 import config from "config";
-import axios from "axios";
-import async from "async";
 import cors from "cors";
 import cookieParser from "cookie-parser";
 import bodyParser from "body-parser";
 import express from "express";
-import oauth from "oauth";
 import http from "http";
 import CoreClass from "../core";
 
-const { OAuth2 } = oauth;
-
 let AppModule;
-let MailModule;
-let CacheModule;
-let DBModule;
-let ActivitiesModule;
-let PlaylistsModule;
-let UtilsModule;
+let UsersModule;
 
 class _AppModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
@@ -33,12 +23,7 @@ class _AppModule extends CoreClass {
 	 */
 	initialize() {
 		return new Promise(resolve => {
-			MailModule = this.moduleManager.modules.mail;
-			CacheModule = this.moduleManager.modules.cache;
-			DBModule = this.moduleManager.modules.db;
-			ActivitiesModule = this.moduleManager.modules.activities;
-			PlaylistsModule = this.moduleManager.modules.playlists;
-			UtilsModule = this.moduleManager.modules.utils;
+			UsersModule = this.moduleManager.modules.users;
 
 			const app = (this.app = express());
 			const SIDname = config.get("cookie");
@@ -49,13 +34,6 @@ class _AppModule extends CoreClass {
 			app.use(bodyParser.json());
 			app.use(bodyParser.urlencoded({ extended: true }));
 
-			let userModel;
-			DBModule.runJob("GET_MODEL", { modelName: "user" })
-				.then(model => {
-					userModel = model;
-				})
-				.catch(console.error);
-
 			const appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
 
 			const corsOptions = JSON.parse(JSON.stringify(config.get("cors")));
@@ -74,15 +52,6 @@ class _AppModule extends CoreClass {
 			}
 
 			if (config.get("apis.github.enabled")) {
-				const oauth2 = new OAuth2(
-					config.get("apis.github.client"),
-					config.get("apis.github.secret"),
-					"https://github.com/",
-					"login/oauth/authorize",
-					"login/oauth/access_token",
-					null
-				);
-
 				const redirectUri =
 					config.get("apis.github.redirect_uri").length > 0
 						? config.get("apis.github.redirect_uri")
@@ -120,7 +89,7 @@ class _AppModule extends CoreClass {
 						`client_id=${config.get("apis.github.client")}`,
 						`redirect_uri=${redirectUri}`,
 						`scope=user:email`,
-						`state=${req.cookies[SIDname]}`
+						`state=${req.cookies[SIDname]}` // TODO don't do this
 					].join("&");
 					return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
 				});
@@ -133,384 +102,74 @@ class _AppModule extends CoreClass {
 							`A user tried to use github authorize, but the APP module is currently not ready.`
 						);
 
-						return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+						redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+						return;
 					}
 
-					const { code } = req.query;
-					let accessToken;
-					let body;
-					let address;
-
-					const { state } = req.query;
-
-					const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 });
-
-					return async.waterfall(
-						[
-							next => {
-								if (req.query.error) return next(req.query.error_description);
-								return next();
-							},
-
-							next => {
-								oauth2.getOAuthAccessToken(code, { redirect_uri: redirectUri }, next);
-							},
-
-							(_accessToken, refreshToken, results, next) => {
-								if (results.error) return next(results.error_description);
-
-								accessToken = _accessToken;
-
-								const options = {
-									headers: {
-										"User-Agent": "request",
-										Authorization: `token ${accessToken}`
-									}
-								};
-
-								return axios
-									.get("https://api.github.com/user", options)
-									.then(github => next(null, github))
-									.catch(err => next(err));
-							},
-
-							(github, next) => {
-								if (github.status !== 200) return next(github.data.message);
-
-								if (state) {
-									return async.waterfall(
-										[
-											next => {
-												CacheModule.runJob("HGET", {
-													table: "sessions",
-													key: state
-												})
-													.then(session => next(null, session))
-													.catch(next);
-											},
-
-											(session, next) => {
-												if (!session) return next("Invalid session.");
-												return userModel.findOne({ _id: session.userId }, next);
-											},
-
-											(user, next) => {
-												if (!user) return next("User not found.");
-												if (user.services.github && user.services.github.id)
-													return next("Account already has GitHub linked.");
-
-												return userModel.updateOne(
-													{ _id: user._id },
-													{
-														$set: {
-															"services.github": {
-																id: github.data.id,
-																access_token: accessToken
-															}
-														}
-													},
-													{ runValidators: true },
-													err => {
-														if (err) return next(err);
-														return next(null, user, github.data);
-													}
-												);
-											},
-
-											user => {
-												CacheModule.runJob("PUB", {
-													channel: "user.linkGithub",
-													value: user._id
-												});
-
-												CacheModule.runJob("PUB", {
-													channel: "user.updated",
-													value: { userId: user._id }
-												});
-
-												res.redirect(`${appUrl}/settings?tab=security`);
-											}
-										],
-										next
-									);
-								}
-
-								if (!github.data.id) return next("Something went wrong, no id.");
-
-								return userModel.findOne({ "services.github.id": github.data.id }, (err, user) => {
-									next(err, user, github.data);
-								});
-							},
-
-							(user, _body, next) => {
-								body = _body;
-
-								if (user) {
-									user.services.github.access_token = accessToken;
-									return user.save(() => next(true, user._id));
-								}
+					const { code, state, error, error_description: errorDescription } = req.query;
 
-								return userModel.findOne(
-									{
-										username: new RegExp(`^${body.login}$`, "i")
-									},
-									(err, user) => next(err, user)
-								);
-							},
-
-							(user, next) => {
-								if (user) return next(`An account with that username already exists.`);
-
-								return axios
-									.get("https://api.github.com/user/emails", {
-										headers: {
-											"User-Agent": "request",
-											Authorization: `token ${accessToken}`
-										}
-									})
-									.then(res => next(null, res.data))
-									.catch(err => next(err));
-							},
-
-							(body, next) => {
-								if (!Array.isArray(body)) return next(body.message);
-
-								body.forEach(email => {
-									if (email.primary) address = email.email.toLowerCase();
-								});
+					// GITHUB_AUTHORIZE_CALLBACK job handles login/register/linking
+					UsersModule.runJob("GITHUB_AUTHORIZE_CALLBACK", { code, state, error, errorDescription })
+						.then(({ redirectUrl, sessionId, userId }) => {
+							if (sessionId) {
+								const date = new Date();
+								date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
 
-								return userModel.findOne({ "email.address": address }, next);
-							},
-
-							(user, next) => {
-								UtilsModule.runJob("GENERATE_RANDOM_STRING", {
-									length: 12
-								}).then(_id => next(null, user, _id));
-							},
-
-							(user, _id, next) => {
-								if (user) {
-									if (Object.keys(JSON.parse(user.services.github)).length === 0)
-										return next(
-											`An account with that email address exists, but is not linked to GitHub.`
-										);
-									return next(`An account with that email address already exists.`);
-								}
-
-								return next(null, {
-									_id,
-									username: body.login,
-									name: body.name,
-									location: body.location,
-									bio: body.bio,
-									email: {
-										address,
-										verificationToken
-									},
-									services: {
-										github: {
-											id: body.id,
-											access_token: accessToken
-										}
-									}
-								});
-							},
-
-							// generate the url for gravatar avatar
-							(user, next) => {
-								UtilsModule.runJob("CREATE_GRAVATAR", {
-									email: user.email.address
-								}).then(url => {
-									user.avatar = { type: "gravatar", url };
-									next(null, user);
-								});
-							},
-
-							// save the new user to the database
-							(user, next) => {
-								userModel.create(user, next);
-							},
-
-							(user, next) => {
-								MailModule.runJob("GET_SCHEMA", {
-									schemaName: "verifyEmail"
-								}).then(verifyEmailSchema => {
-									verifyEmailSchema(address, body.login, user.email.verificationToken, err => {
-										next(err, user._id);
-									});
-								});
-							},
-
-							// create a liked songs playlist for the new user
-							(userId, next) => {
-								PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
-									userId,
-									displayName: "Liked Songs",
-									type: "user-liked"
-								})
-									.then(likedSongsPlaylist => {
-										next(null, likedSongsPlaylist, userId);
-									})
-									.catch(err => next(err));
-							},
-
-							// create a disliked songs playlist for the new user
-							(likedSongsPlaylist, userId, next) => {
-								PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
-									userId,
-									displayName: "Disliked Songs",
-									type: "user-disliked"
-								})
-									.then(dislikedSongsPlaylist => {
-										next(
-											null,
-											{
-												likedSongsPlaylist,
-												dislikedSongsPlaylist
-											},
-											userId
-										);
-									})
-									.catch(err => next(err));
-							},
-
-							// associate liked + disliked songs playlist to the user object
-							({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => {
-								userModel.updateOne(
-									{ _id: userId },
-									{
-										$set: {
-											likedSongsPlaylist,
-											dislikedSongsPlaylist
-										}
-									},
-									{ runValidators: true },
-									err => {
-										if (err) return next(err);
-										return next(null, userId);
-									}
-								);
-							},
-
-							// add the activity of account creation
-							(userId, next) => {
-								ActivitiesModule.runJob("ADD_ACTIVITY", {
-									userId,
-									type: "user__joined",
-									payload: { message: "Welcome to Musare!" }
-								});
-
-								next(null, userId);
-							}
-						],
-						async (err, userId) => {
-							if (err && err !== true) {
-								err = await UtilsModule.runJob("GET_ERROR", {
-									error: err
+								res.cookie(SIDname, sessionId, {
+									expires: date,
+									secure: config.get("url.secure"),
+									path: "/",
+									domain: config.get("url.host")
 								});
 
 								this.log(
-									"ERROR",
+									"INFO",
 									"AUTH_GITHUB_AUTHORIZE_CALLBACK",
-									`Failed to authorize with GitHub. "${err}"`
+									`User "${userId}" successfully authorized with GitHub.`
 								);
-
-								return redirectOnErr(res, err);
 							}
 
-							const sessionId = await UtilsModule.runJob("GUID", {});
-							const sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
-								schemaName: "session"
-							});
-
-							return CacheModule.runJob("HSET", {
-								table: "sessions",
-								key: sessionId,
-								value: sessionSchema(sessionId, userId)
-							})
-								.then(() => {
-									const date = new Date();
-									date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
-
-									res.cookie(SIDname, sessionId, {
-										expires: date,
-										secure: config.get("url.secure"),
-										path: "/",
-										domain: config.get("url.host")
-									});
-
-									this.log(
-										"INFO",
-										"AUTH_GITHUB_AUTHORIZE_CALLBACK",
-										`User "${userId}" successfully authorized with GitHub.`
-									);
-
-									res.redirect(appUrl);
-								})
-								.catch(err => redirectOnErr(res, err.message));
-						}
-					);
+							res.redirect(redirectUrl);
+						})
+						.catch(err => {
+							this.log(
+								"ERROR",
+								"AUTH_GITHUB_AUTHORIZE_CALLBACK",
+								`Failed to authorize with GitHub. "${err.message}"`
+							);
+
+							return redirectOnErr(res, err.message);
+						});
 				});
 			}
 
-			app.get("/auth/verify_email", async (req, res) => {
+			app.get("/auth/verify_email", (req, res) => {
 				if (this.getStatus() !== "READY") {
 					this.log(
 						"INFO",
 						"APP_REJECTED_VERIFY_EMAIL",
 						`A user tried to use verify email, but the APP module is currently not ready.`
 					);
-					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+					redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+					return;
 				}
 
 				const { code } = req.query;
 
-				return async.waterfall(
-					[
-						next => {
-							if (!code) return next("Invalid code.");
-							return next();
-						},
-
-						next => {
-							userModel.findOne({ "email.verificationToken": code }, next);
-						},
-
-						(user, next) => {
-							if (!user) return next("User not found.");
-							if (user.email.verified) return next("This email is already verified.");
-
-							return userModel.updateOne(
-								{ "email.verificationToken": code },
-								{
-									$set: { "email.verified": true },
-									$unset: { "email.verificationToken": "" }
-								},
-								{ runValidators: true },
-								next
-							);
-						}
-					],
-					err => {
-						if (err) {
-							let error = "An error occurred.";
-
-							if (typeof err === "string") error = err;
-							else if (err.message) error = err.message;
-
-							this.log("ERROR", "VERIFY_EMAIL", `Verifying email failed. "${error}"`);
-
-							return res.json({
-								status: "error",
-								message: error
-							});
-						}
-
+				UsersModule.runJob("VERIFY_EMAIL", { code })
+					.then(() => {
 						this.log("INFO", "VERIFY_EMAIL", `Successfully verified email.`);
 
-						return res.redirect(`${appUrl}?toast=Thank you for verifying your email`);
-					}
-				);
+						res.redirect(`${appUrl}?toast=Thank you for verifying your email`);
+					})
+					.catch(err => {
+						this.log("ERROR", "VERIFY_EMAIL", `Verifying email failed. "${err.message}"`);
+
+						res.json({
+							status: "error",
+							message: err.message
+						});
+					});
 			});
 
 			resolve();

+ 312 - 9
backend/logic/users.js

@@ -1,6 +1,10 @@
 import config from "config";
+import oauth from "oauth";
+import axios from "axios";
 import CoreClass from "../core";
 
+const { OAuth2 } = oauth;
+
 let UsersModule;
 let MailModule;
 let CacheModule;
@@ -8,6 +12,8 @@ let DBModule;
 let PlaylistsModule;
 let WSModule;
 let MediaModule;
+let UtilsModule;
+let ActivitiesModule;
 
 class _UsersModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
@@ -27,6 +33,9 @@ class _UsersModule extends CoreClass {
 		WSModule = this.moduleManager.modules.ws;
 		CacheModule = this.moduleManager.modules.cache;
 		MediaModule = this.moduleManager.modules.media;
+		UtilsModule = this.moduleManager.modules.utils;
+		ActivitiesModule = this.moduleManager.modules.activities;
+		PlaylistsModule = this.moduleManager.modules.playlists;
 
 		this.userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
 		this.dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" });
@@ -34,7 +43,36 @@ class _UsersModule extends CoreClass {
 		this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
 		this.activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" });
 
-		this.dataRequestEmail = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "dataRequest" });
+		this.dataRequestEmailSchema = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "dataRequest" });
+		this.verifyEmailSchema = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "verifyEmail" });
+
+		this.sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
+			schemaName: "session"
+		});
+
+		this.appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
+		this.redirectUri =
+			config.get("apis.github.redirect_uri").length > 0
+				? config.get("apis.github.redirect_uri")
+				: `${this.appUrl}/backend/auth/github/authorize/callback`;
+
+		this.oauth2 = new OAuth2(
+			config.get("apis.github.client"),
+			config.get("apis.github.secret"),
+			"https://github.com/",
+			"login/oauth/authorize",
+			"login/oauth/access_token",
+			null
+		);
+
+		// getOAuthAccessToken uses callbacks by default, so make a helper function to turn it into a promise instead
+		this.getOAuthAccessToken = (...args) =>
+			new Promise((resolve, reject) => {
+				this.oauth2.getOAuthAccessToken(...args, (err, accessToken, refreshToken, results) => {
+					if (err) reject(err);
+					else resolve({ accessToken, refreshToken, results });
+				});
+			});
 	}
 
 	/**
@@ -60,7 +98,7 @@ class _UsersModule extends CoreClass {
 		if (config.get("sendDataRequestEmails")) {
 			const adminUsers = await UsersModule.userModel.find({ role: "admin" });
 			const to = adminUsers.map(adminUser => adminUser.email.address);
-			await UsersModule.dataRequestEmail(to, userId, "remove");
+			await UsersModule.dataRequestEmailSchema(to, userId, "remove");
 		}
 
 		// Delete activities
@@ -87,8 +125,8 @@ class _UsersModule extends CoreClass {
 		const likedPlaylist = await UsersModule.playlistModel.findOne({ createdBy: userId, type: "user-liked" });
 		const dislikedPlaylist = await UsersModule.playlistModel.findOne({ createdBy: userId, type: "user-disliked" });
 		const songsToAdjustRatings = [
-			...likedPlaylist.songs.map(({ mediaSource }) => mediaSource),
-			...dislikedPlaylist.songs.map(({ mediaSource }) => mediaSource)
+			...(likedPlaylist?.songs?.map(({ mediaSource }) => mediaSource) ?? []),
+			...(dislikedPlaylist?.songs?.map(({ mediaSource }) => mediaSource) ?? [])
 		];
 
 		// Delete playlists created by user
@@ -127,11 +165,276 @@ class _UsersModule extends CoreClass {
 		);
 	}
 
-	// EXAMPLE_JOB() {
-	// 	return new Promise((resolve, reject) => {
-	// 		if (true) resolve({});
-	// 		else reject(new Error("Nothing changed."));
-	// 	});
+	/**
+	 * Tries to verify email from email verification token/code
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.code - email verification token/code
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async VERIFY_EMAIL(payload) {
+		const { code } = payload;
+		if (!code) throw new Error("Invalid code.");
+
+		const user = await UsersModule.userModel.findOne({ "email.verificationToken": code });
+		if (!user) throw new Error("User not found.");
+		if (user.email.verified) throw new Error("This email is already verified.");
+
+		await UsersModule.userModel.updateOne(
+			{ "email.verificationToken": code },
+			{
+				$set: { "email.verified": true },
+				$unset: { "email.verificationToken": "" }
+			},
+			{ runValidators: true }
+		);
+	}
+
+	/**
+	 * Handles callback route being accessed, which has data from GitHub during the oauth process
+	 * Will be used to either log the user in, register the user, or link the GitHub account to an existing account
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.code - code we need to use to get the access token
+	 * @param {string} payload.state - custom state we may have passed to GitHub during the first step
+	 * @param {string} payload.error - error code if an error occured
+	 * @param {string} payload.errorDescription - error description if an error occured
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GITHUB_AUTHORIZE_CALLBACK(payload) {
+		const { code, state, error, errorDescription } = payload;
+		if (error) throw new Error(errorDescription);
+
+		// Tries to get access token. We don't use the refresh token currently
+		const { accessToken, /* refreshToken, */ results } = await UsersModule.getOAuthAccessToken(code, {
+			redirect_uri: UsersModule.redirectUri
+		});
+		if (!accessToken) throw new Error(results.error_description);
+
+		const options = {
+			headers: {
+				"User-Agent": "request",
+				Authorization: `token ${accessToken}`
+			}
+		};
+		// Gets user data
+		const githubUserData = await axios.get("https://api.github.com/user", options);
+		if (githubUserData.status !== 200) throw new Error(githubUserData.data.message);
+		if (!githubUserData.data.id) throw new Error("Something went wrong, no id.");
+
+		// If we specified a state in the first step when we redirected the user to GitHub, it was to link a
+		// GitHub account to an existing Musare account, so continue with a job specifically for linking the account
+		if (state)
+			return UsersModule.runJob(
+				"GITHUB_AUTHORIZE_CALLBACK_LINK",
+				{ state, githubId: githubUserData.data.id, accessToken },
+				this
+			);
+
+		const user = await UsersModule.userModel.findOne({ "services.github.id": githubUserData.data.id });
+		let userId;
+		if (user) {
+			// Refresh access token, though it's pretty useless as it'll probably expire and then be useless,
+			// and we don't use it afterwards at all
+			user.services.github.access_token = accessToken;
+			await user.save();
+			userId = user._id;
+		} else {
+			// Try to register the user. Will throw an error if it's unable to do so or any error occurs
+			userId = await UsersModule.runJob(
+				"GITHUB_AUTHORIZE_CALLBACK_REGISTER",
+				{ githubUserData, accessToken },
+				this
+			);
+		}
+
+		// Create session for the userId gotten above, as the user existed or was successfully registered
+		const sessionId = await UtilsModule.runJob("GUID", {}, this);
+		await CacheModule.runJob(
+			"HSET",
+			{
+				table: "sessions",
+				key: sessionId,
+				value: UsersModule.sessionSchema(sessionId, userId)
+			},
+			this
+		);
+
+		return { sessionId, userId, redirectUrl: UsersModule.appUrl };
+	}
+
+	/**
+	 * Handles registering the user in the GitHub login/register/link callback/process
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.githubUserData - data we got from the /user API endpoint from GitHub
+	 * @param {string} payload.accessToken - access token for the GitHub user
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GITHUB_AUTHORIZE_CALLBACK_REGISTER(payload) {
+		const { githubUserData, accessToken } = payload;
+		let user;
+
+		// Check if username already exists
+		user = await UsersModule.userModel.findOne({ username: new RegExp(`^${githubUserData.data.login}$`, "i") });
+		if (user) throw new Error(`An account with that username already exists.`);
+
+		// Get emails used for GitHub account
+		const githubEmailsData = await axios.get("https://api.github.com/user/emails", {
+			headers: {
+				"User-Agent": "request",
+				Authorization: `token ${accessToken}`
+			}
+		});
+		if (!Array.isArray(githubEmailsData.data)) throw new Error(githubEmailsData.message);
+
+		const primaryEmailAddress = githubEmailsData.data.find(emailAddress => emailAddress.primary)?.email;
+		if (!primaryEmailAddress) throw new Error("No primary email address found.");
+
+		user = await UsersModule.userModel.findOne({ "email.address": primaryEmailAddress });
+		if (user && Object.keys(JSON.parse(user.services.github)).length === 0)
+			throw new Error(`An account with that email address exists, but is not linked to GitHub.`);
+		if (user) throw new Error(`An account with that email address already exists.`);
+
+		const userId = await UtilsModule.runJob(
+			"GENERATE_RANDOM_STRING",
+			{
+				length: 12
+			},
+			this
+		);
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+		const gravatarUrl = await UtilsModule.runJob(
+			"CREATE_GRAVATAR",
+			{
+				email: primaryEmailAddress
+			},
+			this
+		);
+		const likedSongsPlaylist = await PlaylistsModule.runJob(
+			"CREATE_USER_PLAYLIST",
+			{
+				userId,
+				displayName: "Liked Songs",
+				type: "user-liked"
+			},
+			this
+		);
+		const dislikedSongsPlaylist = await PlaylistsModule.runJob(
+			"CREATE_USER_PLAYLIST",
+			{
+				userId,
+				displayName: "Disliked Songs",
+				type: "user-disliked"
+			},
+			this
+		);
+
+		user = {
+			_id: userId,
+			username: githubUserData.data.login,
+			name: githubUserData.data.name,
+			location: githubUserData.data.location,
+			bio: githubUserData.data.bio,
+			email: {
+				primaryEmailAddress,
+				verificationToken
+			},
+			services: {
+				github: {
+					id: githubUserData.data.id,
+					access_token: accessToken
+				}
+			},
+			avatar: {
+				type: "gravatar",
+				url: gravatarUrl
+			},
+			likedSongsPlaylist,
+			dislikedSongsPlaylist
+		};
+
+		await UsersModule.userModel.create(user);
+
+		await UsersModule.verifyEmailSchema(primaryEmailAddress, githubUserData.data.login, verificationToken);
+		await ActivitiesModule.runJob(
+			"ADD_ACTIVITY",
+			{
+				userId,
+				type: "user__joined",
+				payload: { message: "Welcome to Musare!" }
+			},
+			this
+		);
+
+		return {
+			userId
+		};
+	}
+
+	/**
+	 * Job to attempt to link a GitHub user to a Musare account
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.state - state we passed to GitHub and got back from GitHub
+	 * @param {string} payload.githubId - GitHub user id
+	 * @param {string} payload.accessToken - GitHub user access token
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GITHUB_AUTHORIZE_CALLBACK_LINK(payload) {
+		const { state, githubId, accessToken } = payload;
+
+		// State is currently the session id (SID), so check if that session (still) exists
+		const session = await CacheModule.runJob(
+			"HGET",
+			{
+				table: "sessions",
+				key: state
+			},
+			this
+		);
+		if (!session) throw new Error("Invalid session.");
+
+		const user = await UsersModule.userModel.findOne({ _id: session.userId });
+		if (!user) throw new Error("User not found.");
+		if (user.services.github && user.services.github.id) throw new Error("Account already has GitHub linked.");
+
+		const { _id: userId } = user;
+
+		await UsersModule.userModel.updateOne(
+			{ _id: userId },
+			{
+				$set: {
+					"services.github": {
+						id: githubId,
+						access_token: accessToken
+					}
+				}
+			},
+			{ runValidators: true }
+		);
+
+		await CacheModule.runJob(
+			"PUB",
+			{
+				channel: "user.linkGithub",
+				value: userId
+			},
+			this
+		);
+		await CacheModule.runJob(
+			"PUB",
+			{
+				channel: "user.updated",
+				value: { userId }
+			},
+			this
+		);
+
+		return {
+			redirectUrl: `${UsersModule.appUrl}/settings?tab=security`
+		};
+	}
+
+	// async EXAMPLE_JOB() {
+	// 	if (true) return;
+	// 	else throw new Error("Nothing changed.");
 	// }
 }