Add Discord OAuth2 account linking and login support

Introduces Discord OAuth2 integration for account association and login, including new routes for linking, unlinking, and authenticating via Discord. Adds supporting services, repositories, and schema validation for the OAuth2 flow. Refactors database schema and queries for consistency, and updates dependencies to include required OAuth2 libraries.
This commit is contained in:
2026-01-11 21:03:12 +01:00
parent 5b81f57adb
commit c5b6f6c107
19 changed files with 656 additions and 36 deletions

View File

@@ -139,13 +139,13 @@ async function logPlayerAction(playerUuid, actionCode) {
module.exports = {
loginAdmin,
uploadCape,
deleteCape,
registerAdmin,
hasPermission,
getAdminProfile,
grantPermission,
logPlayerAction,
revokePermission,
checkAdminAccess,
deleteCape,
logPlayerAction,
changeAdminPassword,
changeAdminPassword
}

View File

@@ -61,6 +61,7 @@ async function authenticate({ identifier, password, clientToken, requireUser })
uuid: userResult.user.uuid,
username: userResult.user.username,
clientToken: $clientToken,
type: "Minecraft"
}, keys.authenticationKeys.private, {
subject: userResult.user.uuid,
issuer: "LentiaYggdrasil",
@@ -105,6 +106,70 @@ async function authenticate({ identifier, password, clientToken, requireUser })
}
}
async function authenticateWithoutPassword({ identifier, requireUser }) {
let userResult
try {
userResult = await authRepository.getUser(identifier, true)
} catch (error) {
if (error.code === 404) {
throw new DefaultError(403, "Invalid credentials. Invalid username or password.", "ForbiddenOperationException")
}
throw error
}
delete userResult.user.password
const $clientToken = crypto.randomUUID()
const accessToken = jwt.sign({
uuid: userResult.user.uuid,
username: userResult.user.username,
clientToken: $clientToken,
type: "Minecraft_OAuth2"
}, keys.authenticationKeys.private, {
subject: userResult.user.uuid,
issuer: "LentiaYggdrasil",
expiresIn: "1d",
algorithm: "RS256"
})
const clientSessionProcess = await authRepository.insertClientSession(accessToken, $clientToken, userResult.user.uuid)
const userObject = {
clientToken: clientSessionProcess.clientToken,
accessToken: clientSessionProcess.accessToken,
availableProfiles: [{
name: userResult.user.username,
id: userResult.user.uuid,
}],
selectedProfile: {
name: userResult.user.username,
id: userResult.user.uuid,
}
}
if (requireUser) {
try {
const propertiesRequest = await authRepository.getPlayerProperties(userResult.user.uuid)
userObject.user = {
username: userResult.user.username,
properties: propertiesRequest.properties
}
} catch (error) {
if (error.code !== 404) throw error
userObject.user = {
username: userResult.user.username,
properties: []
}
}
}
return {
code: 200,
response: userObject
}
}
async function refreshToken({ previousAccessToken, clientToken, requireUser }) {
let sessionCheck
try {
@@ -125,6 +190,7 @@ async function refreshToken({ previousAccessToken, clientToken, requireUser }) {
uuid: userResult.user.uuid,
username: userResult.user.username,
clientToken: $clientToken,
type: "Minecraft"
}, keys.authenticationKeys.private, {
subject: userResult.user.uuid,
issuer: "LentiaYggdrasil",
@@ -281,9 +347,10 @@ module.exports = {
signout,
validate,
invalidate,
registerUser,
authenticate,
refreshToken,
registerUser,
verifyAccessToken,
checkUsernameAvailability
checkUsernameAvailability,
authenticateWithoutPassword,
}

134
services/oauth2Service.js Normal file
View File

@@ -0,0 +1,134 @@
const { DiscordOAuth2 } = require("@mgalacyber/discord-oauth2")
const oauth2Repository = require("../repositories/oauth2Repository")
const userService = require("./userService")
const authService = require("./authService")
const { StateTypes, Scopes, PromptTypes, ResponseCodeTypes } = require("@mgalacyber/discord-oauth2")
const { DefaultError } = require("../errors/errors")
const oauth2_association = new DiscordOAuth2({
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
redirectUri: process.env.DISCORD_ASSOCIATION_REDIRECT_URL
})
const oauth2_login = new DiscordOAuth2({
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
redirectUri: process.env.DISCORD_LOGIN_REDIRECT_URL
})
async function generateAssociationDiscordURL(playerUuid) {
const redirectObject = await oauth2_association.GenerateOAuth2Url({
state: StateTypes.UserAuth,
scope: [
Scopes.Identify
],
prompt: PromptTypes.Consent,
responseCode: ResponseCodeTypes.Code,
})
await oauth2Repository.createLinkAttempt(redirectObject.state, playerUuid)
return redirectObject
}
async function handleAssociationCallback(provider, code, state) {
const playerUuid = await oauth2Repository.popLinkAttempt(state)
if (!playerUuid) {
throw new DefaultError(400, "Invalid or expired session state.", "InvalidStateError")
}
let isProviderAlreadyLinked = false
try {
await userService.getPlayerProperty(playerUuid, `${provider}Id`)
isProviderAlreadyLinked = true
} catch (error) {
if (error.code !== 404) throw error
}
if (isProviderAlreadyLinked) {
throw new DefaultError(409, `Account from ${provider} already linked to that player`, "AlreadyLinkedException")
}
try {
const tokenResponse = await oauth2_association.GetAccessToken(code)
const userProfile = await oauth2_association.UserDataSchema.GetUserProfile(tokenResponse.accessToken)
if (!userProfile || !userProfile.id) {
throw new DefaultError(500, `Failed to retrieve ${provider} profile.`)
}
await userService.addPlayerProperty(playerUuid, `${provider}Id`, userProfile.id)
return {
code: 200,
message: "Account linked successfully",
provider: {
id: userProfile.id,
username: userProfile.username
}
}
} catch (error) {
if (error instanceof DefaultError) throw error
throw new DefaultError(500, `${provider} authentication failed: + ${error.message}`)
}
}
async function unlinkAccount(provider, playerUuid) {
try {
const property = await userService.getPlayerProperty(playerUuid, `${provider}Id`).catch(() => null)
if (!property) {
throw new DefaultError(404, `No ${provider} account linked to this player.`, "NotLinkedError")
}
const success = await oauth2Repository.unlinkProviderAccount(provider, playerUuid)
if (!success) {
throw new DefaultError(500, "Failed to unlink the account. Please try again.")
}
return {
code: 200,
message: `${provider} account successfully unlinked.`
}
} catch (error) {
if (error instanceof DefaultError) throw error;
throw new DefaultError(500, "An error occurred during unlinking: " + error.message)
}
}
async function generateLoginDiscordURL() {
const redirectObject = await oauth2_login.GenerateOAuth2Url({
state: StateTypes.UserAuth,
scope: [
Scopes.Identify
],
prompt: PromptTypes.Consent,
responseCode: ResponseCodeTypes.Code,
})
return redirectObject
}
async function handleLoginCallback(provider, code, requestUser) {
try {
const tokenResponse = await oauth2_login.GetAccessToken(code)
const userProfile = await oauth2_login.UserDataSchema.GetUserProfile(tokenResponse.accessToken)
if (!userProfile || !userProfile.id) {
throw new DefaultError(500, `Failed to retrieve ${provider} profile.`)
}
const propertyObject = await userService.getPlayerPropertyByValue(`${provider}Id`, userProfile.id)
return await authService.authenticateWithoutPassword({ identifier: propertyObject.property.uuid, requireUser: requestUser || true })
} catch (error) {
if (error.code == 404) {
throw new DefaultError(404, `No ${provider} account linked to any player.`, "NotLinkedError")
}
if (error instanceof DefaultError) throw error
throw new DefaultError(500, `${provider} authentication failed: + ${error.message}`)
}
}
module.exports = {
unlinkAccount,
handleLoginCallback,
generateLoginDiscordURL,
handleAssociationCallback,
generateAssociationDiscordURL
}

View File

@@ -29,6 +29,10 @@ async function getPlayerProperty(uuid, key) {
return await userRepository.getPlayerProperty(key, uuid)
}
async function getPlayerPropertyByValue(key, value) {
return await userRepository.getPlayerPropertyByValue(key, value)
}
async function addPlayerProperty(uuid, key, value) {
return await userRepository.addPropertyToPlayer(key, value, uuid)
}
@@ -561,6 +565,7 @@ module.exports = {
getPlayerCertificate,
savePlayerCertificate,
clearAllPlayerActions,
getPlayerPropertyByValue,
getPlayerNameChangeStatus,
getPlayerUsernamesHistory,
deleteExpiredCertificates,