Browse Source

feat: added basic OIDC login/registering, fixes small GitHub issues

Kristian Vos 4 months ago
parent
commit
aa77282e59

+ 5 - 0
.wiki/Configuration.md

@@ -96,6 +96,11 @@ For more information on configuration files please refer to the
 | `apis.github.client` | GitHub OAuth Application client, obtained from [here](https://github.com/settings/developers). |
 | `apis.github.client` | GitHub OAuth Application client, obtained from [here](https://github.com/settings/developers). |
 | `apis.github.secret` | GitHub OAuth Application secret, obtained with client. |
 | `apis.github.secret` | GitHub OAuth Application secret, obtained with client. |
 | `apis.github.redirect_uri` | The backend url with `/auth/github/authorize/callback` appended, for example `http://localhost/backend/auth/github/authorize/callback`. This is configured based on the `url` config option by default. |
 | `apis.github.redirect_uri` | The backend url with `/auth/github/authorize/callback` appended, for example `http://localhost/backend/auth/github/authorize/callback`. This is configured based on the `url` config option by default. |
+| `apis.oidc.enabled` | Whether to enable OIDC authentication. |
+| `apis.oidc.client_id` | OIDC client id. |
+| `apis.oidc.client_secret` | OIDC client secret. |
+| `apis.oidc.openid_configuration_url` | The URL that points to the openid_configuration resource of the OIDC provider. |
+| `apis.oidc.redirect_uri` | The backend url with `/auth/oidc/authorize/callback` appended, for example `http://localhost/backend/auth/github/authorize/callback`. This is configured based on the `url` config option by default, so this is optional. |
 | `apis.discogs.enabled` | Whether to enable Discogs API usage. |
 | `apis.discogs.enabled` | Whether to enable Discogs API usage. |
 | `apis.discogs.client` | Discogs Application client, obtained from [here](https://www.discogs.com/settings/developers). |
 | `apis.discogs.client` | Discogs Application client, obtained from [here](https://www.discogs.com/settings/developers). |
 | `apis.discogs.secret` | Discogs Application secret, obtained with client. |
 | `apis.discogs.secret` | Discogs Application secret, obtained with client. |

+ 7 - 0
backend/config/default.json

@@ -62,6 +62,13 @@
 			"enabled": false,
 			"enabled": false,
 			"client": "",
 			"client": "",
 			"secret": ""
 			"secret": ""
+		},
+		"oidc": {
+			"enabled": false,
+			"client_id": "",
+			"secret_secret": "",
+			"openid_configuration_url": "",
+			"redirect_uri": ""
 		}
 		}
 	},
 	},
 	"cors": {
 	"cors": {

+ 4 - 1
backend/index.js

@@ -194,7 +194,10 @@ class ModuleManager {
 	 */
 	 */
 	onFail(module) {
 	onFail(module) {
 		if (this.modulesNotInitialized.indexOf(module) !== -1) {
 		if (this.modulesNotInitialized.indexOf(module) !== -1) {
-			this.log("ERROR", "A module failed to initialize!");
+			this.log(
+				"ERROR",
+				`Module "${module.name}" failed to initialize at stage ${module.getStage()}! Check error above.`
+			);
 		}
 		}
 	}
 	}
 
 

+ 1 - 0
backend/logic/actions/users.js

@@ -1448,6 +1448,7 @@ export default {
 
 
 				if (user.services.password && user.services.password.password) sanitisedUser.password = true;
 				if (user.services.password && user.services.password.password) sanitisedUser.password = true;
 				if (user.services.github && user.services.github.id) sanitisedUser.github = true;
 				if (user.services.github && user.services.github.id) sanitisedUser.github = true;
+				if (user.services.oidc && user.services.oidc.sub) sanitisedUser.oidc = true;
 
 
 				this.log("SUCCESS", "FIND_BY_SESSION", `User found. "${user.username}".`);
 				this.log("SUCCESS", "FIND_BY_SESSION", `User found. "${user.username}".`);
 				return cb({
 				return cb({

+ 196 - 119
backend/logic/app.js

@@ -4,6 +4,8 @@ import cookieParser from "cookie-parser";
 import bodyParser from "body-parser";
 import bodyParser from "body-parser";
 import express from "express";
 import express from "express";
 import http from "http";
 import http from "http";
+import axios from "axios";
+
 import CoreClass from "../core";
 import CoreClass from "../core";
 
 
 let AppModule;
 let AppModule;
@@ -21,158 +23,233 @@ class _AppModule extends CoreClass {
 	 * Initialises the app module
 	 * Initialises the app module
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	initialize() {
-		return new Promise(resolve => {
-			UsersModule = this.moduleManager.modules.users;
+	async initialize() {
+		UsersModule = this.moduleManager.modules.users;
 
 
-			const app = (this.app = express());
-			const SIDname = config.get("cookie");
-			this.server = http.createServer(app).listen(config.get("port"));
+		const app = (this.app = express());
+		const SIDname = config.get("cookie");
+		this.server = http.createServer(app).listen(config.get("port"));
 
 
-			app.use(cookieParser());
+		app.use(cookieParser());
 
 
-			app.use(bodyParser.json());
-			app.use(bodyParser.urlencoded({ extended: true }));
+		app.use(bodyParser.json());
+		app.use(bodyParser.urlencoded({ extended: true }));
 
 
-			const appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
+		const appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
 
 
-			const corsOptions = JSON.parse(JSON.stringify(config.get("cors")));
-			corsOptions.origin.push(appUrl);
-			corsOptions.credentials = true;
+		const corsOptions = JSON.parse(JSON.stringify(config.get("cors")));
+		corsOptions.origin.push(appUrl);
+		corsOptions.credentials = true;
 
 
-			app.use(cors(corsOptions));
-			app.options("*", cors(corsOptions));
+		app.use(cors(corsOptions));
+		app.options("*", cors(corsOptions));
 
 
-			/**
-			 * @param {object} res - response object from Express
-			 * @param {string} err - custom error message
-			 */
-			function redirectOnErr(res, err) {
-				res.redirect(`${appUrl}?err=${encodeURIComponent(err)}`);
-			}
+		/**
+		 * @param {object} res - response object from Express
+		 * @param {string} err - custom error message
+		 */
+		function redirectOnErr(res, err) {
+			res.redirect(`${appUrl}?err=${encodeURIComponent(err)}`);
+		}
 
 
-			if (config.get("apis.github.enabled")) {
-				const redirectUri =
-					config.get("apis.github.redirect_uri").length > 0
-						? config.get("apis.github.redirect_uri")
-						: `${appUrl}/backend/auth/github/authorize/callback`;
+		if (config.get("apis.github.enabled")) {
+			const redirectUri =
+				config.get("apis.github.redirect_uri").length > 0
+					? config.get("apis.github.redirect_uri")
+					: `${appUrl}/backend/auth/github/authorize/callback`;
 
 
-				app.get("/auth/github/authorize", async (req, res) => {
-					if (this.getStatus() !== "READY") {
-						this.log(
-							"INFO",
-							"APP_REJECTED_GITHUB_AUTHORIZE",
-							`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.");
-					}
-
-					const params = [
-						`client_id=${config.get("apis.github.client")}`,
-						`redirect_uri=${redirectUri}`,
-						`scope=user:email`
-					].join("&");
-					return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-				});
+			app.get("/auth/github/authorize", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`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.");
+				}
 
 
-				app.get("/auth/github/link", async (req, res) => {
-					if (this.getStatus() !== "READY") {
-						this.log(
-							"INFO",
-							"APP_REJECTED_GITHUB_AUTHORIZE",
-							`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.");
-					}
-
-					const params = [
-						`client_id=${config.get("apis.github.client")}`,
-						`redirect_uri=${redirectUri}`,
-						`scope=user:email`,
-						`state=${req.cookies[SIDname]}` // TODO don't do this
-					].join("&");
-					return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-				});
+				const params = [
+					`client_id=${config.get("apis.github.client")}`,
+					`redirect_uri=${redirectUri}`,
+					`scope=user:email`
+				].join("&");
+				return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+			});
 
 
-				app.get("/auth/github/authorize/callback", async (req, res) => {
-					if (this.getStatus() !== "READY") {
-						this.log(
-							"INFO",
-							"APP_REJECTED_GITHUB_AUTHORIZE",
-							`A user tried to use github authorize, but the APP module is currently not ready.`
-						);
+			app.get("/auth/github/link", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`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.");
+				}
+
+				const params = [
+					`client_id=${config.get("apis.github.client")}`,
+					`redirect_uri=${redirectUri}`,
+					`scope=user:email`,
+					`state=${req.cookies[SIDname]}` // TODO don't do this
+				].join("&");
+				return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+			});
+
+			app.get("/auth/github/authorize/callback", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`A user tried to use github authorize, but the APP module is currently not ready.`
+					);
+
+					redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+					return;
+				}
+
+				const { code, state, error, error_description: errorDescription } = req.query;
+
+				// 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);
+
+							res.cookie(SIDname, sessionId, {
+								expires: date,
+								secure: config.get("url.secure"),
+								path: "/",
+								domain: config.get("url.host")
+							});
 
 
-						redirectOnErr(res, "Something went wrong on our end. Please try again later.");
-						return;
-					}
-
-					const { code, state, error, error_description: errorDescription } = req.query;
-
-					// 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);
-
-								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(redirectUrl);
-						})
-						.catch(err => {
 							this.log(
 							this.log(
-								"ERROR",
+								"INFO",
 								"AUTH_GITHUB_AUTHORIZE_CALLBACK",
 								"AUTH_GITHUB_AUTHORIZE_CALLBACK",
-								`Failed to authorize with GitHub. "${err.message}"`
+								`User "${userId}" successfully authorized with GitHub.`
 							);
 							);
+						}
 
 
-							return 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);
+					});
+			});
+		}
+
+		if (config.get("apis.oidc.enabled")) {
+			const redirectUri =
+				config.get("apis.oidc.redirect_uri").length > 0
+					? config.get("apis.oidc.redirect_uri")
+					: `${appUrl}/backend/auth/oidc/authorize/callback`;
+
+			// TODO don't fetch the openid configuration twice (app module and user module)
+			const openidConfigurationResponse = await axios.get(config.get("apis.oidc.openid_configuration_url"));
+
+			const { authorization_endpoint: authorizationEndpoint } = openidConfigurationResponse.data;
+
+			app.get("/auth/oidc/authorize", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_OIDC_AUTHORIZE",
+						`A user tried to use OIDC authorize, but the APP module is currently not ready.`
+					);
+					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+				}
 
 
-			app.get("/auth/verify_email", (req, res) => {
+				const params = [
+					`client_id=${config.get("apis.oidc.client_id")}`,
+					`redirect_uri=${redirectUri}`,
+					`scope=basic openid`, // TODO check if openid is necessary for us
+					`response_type=code`
+				].join("&");
+				return res.redirect(`${authorizationEndpoint}?${params}`);
+			});
+
+			app.get("/auth/oidc/authorize/callback", async (req, res) => {
 				if (this.getStatus() !== "READY") {
 				if (this.getStatus() !== "READY") {
 					this.log(
 					this.log(
 						"INFO",
 						"INFO",
-						"APP_REJECTED_VERIFY_EMAIL",
-						`A user tried to use verify email, but the APP module is currently not ready.`
+						"APP_REJECTED_OIDC_AUTHORIZE",
+						`A user tried to use OIDC authorize, but the APP module is currently not ready.`
 					);
 					);
+
 					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;
 					return;
 				}
 				}
 
 
-				const { code } = req.query;
+				const { code, state, error, error_description: errorDescription } = req.query;
 
 
-				UsersModule.runJob("VERIFY_EMAIL", { code })
-					.then(() => {
-						this.log("INFO", "VERIFY_EMAIL", `Successfully verified email.`);
+				// OIDC_AUTHORIZE_CALLBACK job handles login/register
+				UsersModule.runJob("OIDC_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);
+
+							res.cookie(SIDname, sessionId, {
+								expires: date,
+								secure: config.get("url.secure"),
+								path: "/",
+								domain: config.get("url.host")
+							});
+
+							this.log(
+								"INFO",
+								"AUTH_OIDC_AUTHORIZE_CALLBACK",
+								`User "${userId}" successfully authorized with OIDC.`
+							);
+						}
 
 
-						res.redirect(`${appUrl}?toast=Thank you for verifying your email`);
+						res.redirect(redirectUrl);
 					})
 					})
 					.catch(err => {
 					.catch(err => {
-						this.log("ERROR", "VERIFY_EMAIL", `Verifying email failed. "${err.message}"`);
+						this.log(
+							"ERROR",
+							"AUTH_OIDC_AUTHORIZE_CALLBACK",
+							`Failed to authorize with OIDC. "${err.message}"`
+						);
 
 
-						res.json({
-							status: "error",
-							message: err.message
-						});
+						return redirectOnErr(res, err.message);
 					});
 					});
 			});
 			});
+		}
+
+		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.`
+				);
+				redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+				return;
+			}
+
+			const { code } = req.query;
 
 
-			resolve();
+			UsersModule.runJob("VERIFY_EMAIL", { code })
+				.then(() => {
+					this.log("INFO", "VERIFY_EMAIL", `Successfully verified 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
+					});
+				});
 		});
 		});
 	}
 	}
 
 

+ 4 - 0
backend/logic/db/schemas/user.js

@@ -28,6 +28,10 @@ export default {
 		github: {
 		github: {
 			id: Number,
 			id: Number,
 			access_token: String
 			access_token: String
+		},
+		oidc: {
+			sub: String,
+			access_token: String
 		}
 		}
 	},
 	},
 	statistics: {
 	statistics: {

+ 218 - 6
backend/logic/users.js

@@ -55,10 +55,14 @@ class _UsersModule extends CoreClass {
 		});
 		});
 
 
 		this.appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
 		this.appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
-		this.redirectUri =
+		this.githubRedirectUri =
 			config.get("apis.github.redirect_uri").length > 0
 			config.get("apis.github.redirect_uri").length > 0
 				? config.get("apis.github.redirect_uri")
 				? config.get("apis.github.redirect_uri")
 				: `${this.appUrl}/backend/auth/github/authorize/callback`;
 				: `${this.appUrl}/backend/auth/github/authorize/callback`;
+		this.oidcRedirectUri =
+			config.get("apis.oidc.redirect_uri").length > 0
+				? config.get("apis.oidc.redirect_uri")
+				: `${this.appUrl}/backend/auth/oidc/authorize/callback`;
 
 
 		this.oauth2 = new OAuth2(
 		this.oauth2 = new OAuth2(
 			config.get("apis.github.client"),
 			config.get("apis.github.client"),
@@ -77,6 +81,42 @@ class _UsersModule extends CoreClass {
 					else resolve({ accessToken, refreshToken, results });
 					else resolve({ accessToken, refreshToken, results });
 				});
 				});
 			});
 			});
+
+		if (config.get("apis.oidc.enabled")) {
+			const openidConfigurationResponse = await axios.get(config.get("apis.oidc.openid_configuration_url"));
+
+			const { token_endpoint: tokenEndpoint, userinfo_endpoint: userinfoEndpoint } =
+				openidConfigurationResponse.data;
+
+			// TODO somehow make this endpoint immutable, if possible in some way
+			this.oidcUserinfoEndpoint = userinfoEndpoint;
+
+			//
+			const clientId = config.get("apis.oidc.client_id");
+			const clientSecret = config.get("apis.oidc.client_secret");
+
+			this.getOIDCOAuthAccessToken = async code => {
+				const tokenResponse = await axios.post(
+					tokenEndpoint,
+					{
+						grant_type: "authorization_code",
+						code,
+						client_id: clientId,
+						client_secret: clientSecret,
+						redirect_uri: this.oidcRedirectUri
+					},
+					{
+						headers: {
+							"Content-Type": "application/x-www-form-urlencoded"
+						}
+					}
+				);
+
+				const { access_token: accessToken } = tokenResponse.data;
+
+				return { accessToken };
+			};
+		}
 	}
 	}
 
 
 	/**
 	/**
@@ -209,7 +249,7 @@ class _UsersModule extends CoreClass {
 
 
 		// Tries to get access token. We don't use the refresh token currently
 		// Tries to get access token. We don't use the refresh token currently
 		const { accessToken, /* refreshToken, */ results } = await UsersModule.getOAuthAccessToken(code, {
 		const { accessToken, /* refreshToken, */ results } = await UsersModule.getOAuthAccessToken(code, {
-			redirect_uri: UsersModule.redirectUri
+			redirect_uri: UsersModule.githubRedirectUri
 		});
 		});
 		if (!accessToken) throw new Error(results.error_description);
 		if (!accessToken) throw new Error(results.error_description);
 
 
@@ -243,11 +283,11 @@ class _UsersModule extends CoreClass {
 			userId = user._id;
 			userId = user._id;
 		} else {
 		} else {
 			// Try to register the user. Will throw an error if it's unable to do so or any error occurs
 			// Try to register the user. Will throw an error if it's unable to do so or any error occurs
-			userId = await UsersModule.runJob(
+			({ userId } = await UsersModule.runJob(
 				"GITHUB_AUTHORIZE_CALLBACK_REGISTER",
 				"GITHUB_AUTHORIZE_CALLBACK_REGISTER",
 				{ githubUserData, accessToken },
 				{ githubUserData, accessToken },
 				this
 				this
-			);
+			));
 		}
 		}
 
 
 		// Create session for the userId gotten above, as the user existed or was successfully registered
 		// Create session for the userId gotten above, as the user existed or was successfully registered
@@ -338,7 +378,7 @@ class _UsersModule extends CoreClass {
 			location: githubUserData.data.location,
 			location: githubUserData.data.location,
 			bio: githubUserData.data.bio,
 			bio: githubUserData.data.bio,
 			email: {
 			email: {
-				primaryEmailAddress,
+				address: primaryEmailAddress,
 				verificationToken
 				verificationToken
 			},
 			},
 			services: {
 			services: {
@@ -436,6 +476,178 @@ class _UsersModule extends CoreClass {
 		};
 		};
 	}
 	}
 
 
+	/**
+	 * Handles callback route being accessed, which has data from OIDC during the oauth process
+	 * Will be used to either log the user in or register the user
+	 * @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.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 OIDC_AUTHORIZE_CALLBACK(payload) {
+		const { code, error, errorDescription } = payload;
+		if (error) throw new Error(errorDescription);
+
+		// Tries to get access token. We don't use the refresh token currently
+		const { accessToken } = await UsersModule.getOIDCOAuthAccessToken(code);
+
+		// Gets user data
+		const userInfoResponse = await axios.post(
+			UsersModule.oidcUserinfoEndpoint,
+			{},
+			{
+				headers: {
+					Authorization: `Bearer ${accessToken}`
+				}
+			}
+		);
+		if (!userInfoResponse.data.preferred_username) throw new Error("Something went wrong, no preferred_username.");
+		// TODO verify sub from userinfo and token response, see 5.3.2 https://openid.net/specs/openid-connect-core-1_0.html
+
+		// TODO we don't use linking for OIDC currently, so remove this or utilize it in some other way if needed
+		// If we specified a state in the first step when we redirected the user to OIDC, it was to link a
+		// OIDC account to an existing Musare account, so continue with a job specifically for linking the account
+		// if (state)
+		// 	return UsersModule.runJob(
+		// 		"OIDC_AUTHORIZE_CALLBACK_LINK",
+		// 		{ state, sub: userInfoResponse.data.sub, accessToken },
+		// 		this
+		// 	);
+
+		const user = await UsersModule.userModel.findOne({ "services.oidc.sub": userInfoResponse.data.sub });
+		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 anyways
+			user.services.oidc.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(
+				"OIDC_AUTHORIZE_CALLBACK_REGISTER",
+				{ userInfoResponse: userInfoResponse.data, 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.toString())
+			},
+			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.userInfoResponse - data we got from the OIDC user info API endpoint
+	 * @param {string} payload.accessToken - access token for the GitHub user
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async OIDC_AUTHORIZE_CALLBACK_REGISTER(payload) {
+		const { userInfoResponse, accessToken } = payload;
+		let user;
+
+		// Check if username already exists
+		user = await UsersModule.userModel.findOne({
+			username: new RegExp(`^${userInfoResponse.preferred_username}$`, "i")
+		});
+		if (user) throw new Error(`An account with that username already exists.`); // TODO eventually we'll want users to be able to pick their own username maybe
+
+		const emailAddress = userInfoResponse.email;
+		if (!emailAddress) throw new Error("No email address found.");
+
+		user = await UsersModule.userModel.findOne({ "email.address": emailAddress });
+		if (user && Object.keys(JSON.parse(user.services.github)).length === 0)
+			throw new Error(`An account with that email address already exists, but is not linked to OIDC.`);
+		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: emailAddress
+			},
+			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: userInfoResponse.preferred_username,
+			name: userInfoResponse.name,
+			location: "",
+			bio: "",
+			email: {
+				address: emailAddress,
+				verificationToken
+			},
+			services: {
+				oidc: {
+					sub: userInfoResponse.sub,
+					access_token: accessToken
+				}
+			},
+			avatar: {
+				type: "gravatar",
+				url: gravatarUrl
+			},
+			likedSongsPlaylist,
+			dislikedSongsPlaylist
+		};
+
+		await UsersModule.userModel.create(user);
+
+		await UsersModule.verifyEmailSchema(emailAddress, userInfoResponse.preferred_username, verificationToken);
+		await ActivitiesModule.runJob(
+			"ADD_ACTIVITY",
+			{
+				userId,
+				type: "user__joined",
+				payload: { message: "Welcome to Musare!" }
+			},
+			this
+		);
+
+		return {
+			userId
+		};
+	}
+
 	/**
 	/**
 	 * Attempts to register a user
 	 * Attempts to register a user
 	 * @param {object} payload - object that contains the payload
 	 * @param {object} payload - object that contains the payload
@@ -609,7 +821,7 @@ class _UsersModule extends CoreClass {
 		if (!user) throw new Error("User not found.");
 		if (!user) throw new Error("User not found.");
 		if (user.username === username) throw new Error("New username can't be the same as the old username.");
 		if (user.username === username) throw new Error("New username can't be the same as the old username.");
 
 
-		const existingUser = UsersModule.userModel.findOne({ username: new RegExp(`^${username}$`, "i") });
+		const existingUser = await UsersModule.userModel.findOne({ username: new RegExp(`^${username}$`, "i") });
 		if (existingUser) throw new Error("That username is already in use.");
 		if (existingUser) throw new Error("That username is already in use.");
 
 
 		await UsersModule.userModel.updateOne({ _id: userId }, { $set: { username } }, { runValidators: true });
 		await UsersModule.userModel.updateOne({ _id: userId }, { $set: { username } }, { runValidators: true });

+ 1 - 0
backend/logic/ws.js

@@ -576,6 +576,7 @@ class _WSModule extends CoreClass {
 						key: config.get("apis.recaptcha.key")
 						key: config.get("apis.recaptcha.key")
 					},
 					},
 					githubAuthentication: config.get("apis.github.enabled"),
 					githubAuthentication: config.get("apis.github.enabled"),
+					oidcAuthentication: config.get("apis.oidc.enabled"),
 					messages: config.get("messages"),
 					messages: config.get("messages"),
 					christmas: config.get("christmas"),
 					christmas: config.get("christmas"),
 					footerLinks: config.get("footerLinks"),
 					footerLinks: config.get("footerLinks"),

+ 7 - 0
frontend/src/App.vue

@@ -254,6 +254,13 @@ onMounted(async () => {
 				router.push(localStorage.getItem("github_redirect"));
 				router.push(localStorage.getItem("github_redirect"));
 				localStorage.removeItem("github_redirect");
 				localStorage.removeItem("github_redirect");
 			}
 			}
+			if (
+				configStore.oidcAuthentication &&
+				localStorage.getItem("oidc_redirect")
+			) {
+				router.push(localStorage.getItem("oidc_redirect"));
+				localStorage.removeItem("oidc_redirect");
+			}
 		});
 		});
 	}, true);
 	}, true);
 
 

+ 19 - 1
frontend/src/components/modals/Login.vue

@@ -19,7 +19,8 @@ const password = ref({
 const passwordElement = ref();
 const passwordElement = ref();
 
 
 const configStore = useConfigStore();
 const configStore = useConfigStore();
-const { githubAuthentication, registrationDisabled } = storeToRefs(configStore);
+const { githubAuthentication, oidcAuthentication, registrationDisabled } =
+	storeToRefs(configStore);
 const { login } = useUserAuthStore();
 const { login } = useUserAuthStore();
 
 
 const { openModal, closeCurrentModal } = useModalsStore();
 const { openModal, closeCurrentModal } = useModalsStore();
@@ -66,6 +67,9 @@ const changeToRegisterModal = () => {
 const githubRedirect = () => {
 const githubRedirect = () => {
 	localStorage.setItem("github_redirect", route.path);
 	localStorage.setItem("github_redirect", route.path);
 };
 };
+const oidcRedirect = () => {
+	localStorage.setItem("oidc_redirect", route.path);
+};
 </script>
 </script>
 
 
 <template>
 <template>
@@ -164,6 +168,20 @@ const githubRedirect = () => {
 						</div>
 						</div>
 						&nbsp;&nbsp;Login with GitHub
 						&nbsp;&nbsp;Login with GitHub
 					</a>
 					</a>
+					<a
+						v-if="oidcAuthentication"
+						class="button is-oidc"
+						:href="configStore.urls.api + '/auth/oidc/authorize'"
+						@click="oidcRedirect()"
+					>
+						<div class="icon">
+							<img
+								class="invert"
+								src="/assets/social/github.svg"
+							/>
+						</div>
+						&nbsp;&nbsp;Login with OIDC
+					</a>
 				</div>
 				</div>
 
 
 				<p
 				<p

+ 24 - 2
frontend/src/components/modals/Register.vue

@@ -41,8 +41,12 @@ const passwordElement = ref();
 const { register } = useUserAuthStore();
 const { register } = useUserAuthStore();
 
 
 const configStore = useConfigStore();
 const configStore = useConfigStore();
-const { registrationDisabled, recaptcha, githubAuthentication } =
-	storeToRefs(configStore);
+const {
+	registrationDisabled,
+	recaptcha,
+	githubAuthentication,
+	oidcAuthentication
+} = storeToRefs(configStore);
 const { openModal, closeCurrentModal } = useModalsStore();
 const { openModal, closeCurrentModal } = useModalsStore();
 
 
 const submitModal = () => {
 const submitModal = () => {
@@ -80,6 +84,10 @@ const githubRedirect = () => {
 	localStorage.setItem("github_redirect", route.path);
 	localStorage.setItem("github_redirect", route.path);
 };
 };
 
 
+const oidcRedirect = () => {
+	localStorage.setItem("oidc_redirect", route.path);
+};
+
 watch(
 watch(
 	() => username.value.value,
 	() => username.value.value,
 	value => {
 	value => {
@@ -288,6 +296,20 @@ onMounted(async () => {
 						</div>
 						</div>
 						&nbsp;&nbsp;Register with GitHub
 						&nbsp;&nbsp;Register with GitHub
 					</a>
 					</a>
+					<a
+						v-if="oidcAuthentication"
+						class="button is-oidc"
+						:href="configStore.urls.api + '/auth/oidc/authorize'"
+						@click="oidcRedirect()"
+					>
+						<div class="icon">
+							<img
+								class="invert"
+								src="/assets/social/github.svg"
+							/>
+						</div>
+						&nbsp;&nbsp;Register with OIDC
+					</a>
 				</div>
 				</div>
 
 
 				<p class="content-box-optional-helper">
 				<p class="content-box-optional-helper">

+ 35 - 6
frontend/src/components/modals/RemoveAccount.vue

@@ -19,13 +19,14 @@ const props = defineProps({
 });
 });
 
 
 const configStore = useConfigStore();
 const configStore = useConfigStore();
-const { cookie, githubAuthentication, messages } = storeToRefs(configStore);
+const { cookie, githubAuthentication, oidcAuthentication, messages } =
+	storeToRefs(configStore);
 const settingsStore = useSettingsStore();
 const settingsStore = useSettingsStore();
 const route = useRoute();
 const route = useRoute();
 
 
 const { socket } = useWebsocketsStore();
 const { socket } = useWebsocketsStore();
 
 
-const { isPasswordLinked, isGithubLinked } = settingsStore;
+const { isPasswordLinked, isGithubLinked, isOIDCLinked } = settingsStore;
 
 
 const { closeCurrentModal } = useModalsStore();
 const { closeCurrentModal } = useModalsStore();
 
 
@@ -80,6 +81,11 @@ const confirmGithubLink = () =>
 		} else new Toast(res.message);
 		} else new Toast(res.message);
 	});
 	});
 
 
+const confirmOIDCLink = () => {
+	// TODO
+	step.value = "remove-account";
+};
+
 const relinkGithub = () => {
 const relinkGithub = () => {
 	localStorage.setItem(
 	localStorage.setItem(
 		"github_redirect",
 		"github_redirect",
@@ -155,10 +161,7 @@ onMounted(async () => {
 			<div
 			<div
 				class="content-box"
 				class="content-box"
 				id="password-linked"
 				id="password-linked"
-				v-if="
-					step === 'confirm-identity' &&
-					(isPasswordLinked || !githubAuthentication)
-				"
+				v-if="step === 'confirm-identity' && isPasswordLinked"
 			>
 			>
 				<h2 class="content-box-title">Enter your password</h2>
 				<h2 class="content-box-title">Enter your password</h2>
 				<p class="content-box-description">
 				<p class="content-box-description">
@@ -242,6 +245,32 @@ onMounted(async () => {
 				</div>
 				</div>
 			</div>
 			</div>
 
 
+			<div
+				class="content-box"
+				v-else-if="
+					oidcAuthentication &&
+					isOIDCLinked &&
+					step === 'confirm-identity'
+				"
+			>
+				<h2 class="content-box-title">Verify your OIDC</h2>
+				<p class="content-box-description">
+					Check your account is still linked to remove your account.
+				</p>
+
+				<div class="content-box-inputs">
+					<a class="button is-oidc" @click="confirmOIDCLink()">
+						<div class="icon">
+							<img
+								class="invert"
+								src="/assets/social/github.svg"
+							/>
+						</div>
+						&nbsp; Check whether OIDC is linked
+					</a>
+				</div>
+			</div>
+
 			<div
 			<div
 				class="content-box"
 				class="content-box"
 				v-if="githubAuthentication && step === 'relink-github'"
 				v-if="githubAuthentication && step === 'relink-github'"

+ 22 - 0
frontend/src/pages/Admin/Users/index.vue

@@ -71,6 +71,14 @@ const columns = ref<TableColumn[]>([
 		minWidth: 115,
 		minWidth: 115,
 		defaultWidth: 115
 		defaultWidth: 115
 	},
 	},
+	{
+		name: "oidcSub",
+		displayName: "OIDC sub",
+		properties: ["services.oidc.sub"],
+		sortProperty: "services.oidc.sub",
+		minWidth: 115,
+		defaultWidth: 115
+	},
 	{
 	{
 		name: "hasPassword",
 		name: "hasPassword",
 		displayName: "Has Password",
 		displayName: "Has Password",
@@ -139,6 +147,13 @@ const filters = ref<TableFilter[]>([
 		filterTypes: ["contains", "exact", "regex"],
 		filterTypes: ["contains", "exact", "regex"],
 		defaultFilterType: "contains"
 		defaultFilterType: "contains"
 	},
 	},
+	{
+		name: "oidcSub",
+		displayName: "OIDC sub",
+		property: "services.oidc.sub",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
 	{
 	{
 		name: "hasPassword",
 		name: "hasPassword",
 		displayName: "Has Password",
 		displayName: "Has Password",
@@ -286,6 +301,13 @@ onMounted(() => {
 					>{{ slotProps.item.services.github.id }}</span
 					>{{ slotProps.item.services.github.id }}</span
 				>
 				>
 			</template>
 			</template>
+			<template #column-oidcSub="slotProps">
+				<span
+					v-if="slotProps.item.services.oidc"
+					:title="slotProps.item.services.oidc.sub"
+					>{{ slotProps.item.services.oidc.sub }}</span
+				>
+			</template>
 			<template #column-hasPassword="slotProps">
 			<template #column-hasPassword="slotProps">
 				<span :title="slotProps.item.hasPassword">{{
 				<span :title="slotProps.item.hasPassword">{{
 					slotProps.item.hasPassword
 					slotProps.item.hasPassword

+ 2 - 0
frontend/src/stores/config.ts

@@ -9,6 +9,7 @@ export const useConfigStore = defineStore("config", {
 			key: string;
 			key: string;
 		};
 		};
 		githubAuthentication: boolean;
 		githubAuthentication: boolean;
+		oidcAuthentication: boolean;
 		messages: Record<string, string>;
 		messages: Record<string, string>;
 		christmas: boolean;
 		christmas: boolean;
 		footerLinks: Record<string, string | boolean>;
 		footerLinks: Record<string, string | boolean>;
@@ -33,6 +34,7 @@ export const useConfigStore = defineStore("config", {
 			key: ""
 			key: ""
 		},
 		},
 		githubAuthentication: false,
 		githubAuthentication: false,
+		oidcAuthentication: false,
 		messages: {
 		messages: {
 			accountRemoval:
 			accountRemoval:
 				"Your account will be deactivated instantly and your data will shortly be deleted by an admin."
 				"Your account will be deactivated instantly and your data will shortly be deleted by an admin."

+ 1 - 0
frontend/src/stores/settings.ts

@@ -31,6 +31,7 @@ export const useSettingsStore = defineStore("settings", {
 	},
 	},
 	getters: {
 	getters: {
 		isGithubLinked: state => state.originalUser.github,
 		isGithubLinked: state => state.originalUser.github,
+		isOIDCLinked: state => state.originalUser.oidc,
 		isPasswordLinked: state => state.originalUser.password
 		isPasswordLinked: state => state.originalUser.password
 	}
 	}
 });
 });

+ 5 - 0
frontend/src/types/user.ts

@@ -28,9 +28,14 @@ export interface User {
 			id: number;
 			id: number;
 			access_token: string;
 			access_token: string;
 		};
 		};
+		oidc?: {
+			sub: string;
+			access_token: string;
+		};
 	};
 	};
 	password?: boolean;
 	password?: boolean;
 	github?: boolean;
 	github?: boolean;
+	oidc?: boolean;
 	statistics: {
 	statistics: {
 		songsRequested: number;
 		songsRequested: number;
 	};
 	};