Browse Source

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

Kristian Vos 2 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.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.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.client` | Discogs Application client, obtained from [here](https://www.discogs.com/settings/developers). |
 | `apis.discogs.secret` | Discogs Application secret, obtained with client. |

+ 7 - 0
backend/config/default.json

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

+ 4 - 1
backend/index.js

@@ -194,7 +194,10 @@ class ModuleManager {
 	 */
 	onFail(module) {
 		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.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}".`);
 				return cb({

+ 196 - 119
backend/logic/app.js

@@ -4,6 +4,8 @@ import cookieParser from "cookie-parser";
 import bodyParser from "body-parser";
 import express from "express";
 import http from "http";
+import axios from "axios";
+
 import CoreClass from "../core";
 
 let AppModule;
@@ -21,158 +23,233 @@ class _AppModule extends CoreClass {
 	 * Initialises the app module
 	 * @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(
-								"ERROR",
+								"INFO",
 								"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") {
 					this.log(
 						"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.");
 					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 => {
-						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: {
 			id: Number,
 			access_token: String
+		},
+		oidc: {
+			sub: String,
+			access_token: String
 		}
 	},
 	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.redirectUri =
+		this.githubRedirectUri =
 			config.get("apis.github.redirect_uri").length > 0
 				? config.get("apis.github.redirect_uri")
 				: `${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(
 			config.get("apis.github.client"),
@@ -77,6 +81,42 @@ class _UsersModule extends CoreClass {
 					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
 		const { accessToken, /* refreshToken, */ results } = await UsersModule.getOAuthAccessToken(code, {
-			redirect_uri: UsersModule.redirectUri
+			redirect_uri: UsersModule.githubRedirectUri
 		});
 		if (!accessToken) throw new Error(results.error_description);
 
@@ -243,11 +283,11 @@ class _UsersModule extends CoreClass {
 			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(
+			({ 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
@@ -338,7 +378,7 @@ class _UsersModule extends CoreClass {
 			location: githubUserData.data.location,
 			bio: githubUserData.data.bio,
 			email: {
-				primaryEmailAddress,
+				address: primaryEmailAddress,
 				verificationToken
 			},
 			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
 	 * @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.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.");
 
 		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")
 					},
 					githubAuthentication: config.get("apis.github.enabled"),
+					oidcAuthentication: config.get("apis.oidc.enabled"),
 					messages: config.get("messages"),
 					christmas: config.get("christmas"),
 					footerLinks: config.get("footerLinks"),

+ 7 - 0
frontend/src/App.vue

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

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

@@ -19,7 +19,8 @@ const password = ref({
 const passwordElement = ref();
 
 const configStore = useConfigStore();
-const { githubAuthentication, registrationDisabled } = storeToRefs(configStore);
+const { githubAuthentication, oidcAuthentication, registrationDisabled } =
+	storeToRefs(configStore);
 const { login } = useUserAuthStore();
 
 const { openModal, closeCurrentModal } = useModalsStore();
@@ -66,6 +67,9 @@ const changeToRegisterModal = () => {
 const githubRedirect = () => {
 	localStorage.setItem("github_redirect", route.path);
 };
+const oidcRedirect = () => {
+	localStorage.setItem("oidc_redirect", route.path);
+};
 </script>
 
 <template>
@@ -164,6 +168,20 @@ const githubRedirect = () => {
 						</div>
 						&nbsp;&nbsp;Login with GitHub
 					</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>
 
 				<p

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

@@ -41,8 +41,12 @@ const passwordElement = ref();
 const { register } = useUserAuthStore();
 
 const configStore = useConfigStore();
-const { registrationDisabled, recaptcha, githubAuthentication } =
-	storeToRefs(configStore);
+const {
+	registrationDisabled,
+	recaptcha,
+	githubAuthentication,
+	oidcAuthentication
+} = storeToRefs(configStore);
 const { openModal, closeCurrentModal } = useModalsStore();
 
 const submitModal = () => {
@@ -80,6 +84,10 @@ const githubRedirect = () => {
 	localStorage.setItem("github_redirect", route.path);
 };
 
+const oidcRedirect = () => {
+	localStorage.setItem("oidc_redirect", route.path);
+};
+
 watch(
 	() => username.value.value,
 	value => {
@@ -288,6 +296,20 @@ onMounted(async () => {
 						</div>
 						&nbsp;&nbsp;Register with GitHub
 					</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>
 
 				<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 { cookie, githubAuthentication, messages } = storeToRefs(configStore);
+const { cookie, githubAuthentication, oidcAuthentication, messages } =
+	storeToRefs(configStore);
 const settingsStore = useSettingsStore();
 const route = useRoute();
 
 const { socket } = useWebsocketsStore();
 
-const { isPasswordLinked, isGithubLinked } = settingsStore;
+const { isPasswordLinked, isGithubLinked, isOIDCLinked } = settingsStore;
 
 const { closeCurrentModal } = useModalsStore();
 
@@ -80,6 +81,11 @@ const confirmGithubLink = () =>
 		} else new Toast(res.message);
 	});
 
+const confirmOIDCLink = () => {
+	// TODO
+	step.value = "remove-account";
+};
+
 const relinkGithub = () => {
 	localStorage.setItem(
 		"github_redirect",
@@ -155,10 +161,7 @@ onMounted(async () => {
 			<div
 				class="content-box"
 				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>
 				<p class="content-box-description">
@@ -242,6 +245,32 @@ onMounted(async () => {
 				</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
 				class="content-box"
 				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,
 		defaultWidth: 115
 	},
+	{
+		name: "oidcSub",
+		displayName: "OIDC sub",
+		properties: ["services.oidc.sub"],
+		sortProperty: "services.oidc.sub",
+		minWidth: 115,
+		defaultWidth: 115
+	},
 	{
 		name: "hasPassword",
 		displayName: "Has Password",
@@ -139,6 +147,13 @@ const filters = ref<TableFilter[]>([
 		filterTypes: ["contains", "exact", "regex"],
 		defaultFilterType: "contains"
 	},
+	{
+		name: "oidcSub",
+		displayName: "OIDC sub",
+		property: "services.oidc.sub",
+		filterTypes: ["contains", "exact", "regex"],
+		defaultFilterType: "contains"
+	},
 	{
 		name: "hasPassword",
 		displayName: "Has Password",
@@ -286,6 +301,13 @@ onMounted(() => {
 					>{{ slotProps.item.services.github.id }}</span
 				>
 			</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">
 				<span :title="slotProps.item.hasPassword">{{
 					slotProps.item.hasPassword

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

@@ -9,6 +9,7 @@ export const useConfigStore = defineStore("config", {
 			key: string;
 		};
 		githubAuthentication: boolean;
+		oidcAuthentication: boolean;
 		messages: Record<string, string>;
 		christmas: boolean;
 		footerLinks: Record<string, string | boolean>;
@@ -33,6 +34,7 @@ export const useConfigStore = defineStore("config", {
 			key: ""
 		},
 		githubAuthentication: false,
+		oidcAuthentication: false,
 		messages: {
 			accountRemoval:
 				"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: {
 		isGithubLinked: state => state.originalUser.github,
+		isOIDCLinked: state => state.originalUser.oidc,
 		isPasswordLinked: state => state.originalUser.password
 	}
 });

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

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