Yggdrasil/services/oauth2Service.js
azures04 c5b6f6c107 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.
2026-01-11 21:03:12 +01:00

134 lines
4.9 KiB
JavaScript

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
}