From 735a7233287f2f98e844edf2116051b2a12a3593 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 9 Aug 2026 18:22:26 +0200 Subject: [PATCH] feat: implement discord oauth flow --- nuxt.config.ts | 3 ++ server/api/account/discord-link.get.ts | 23 +++++++++ server/api/account/discord-unlink.post.ts | 15 ++++++ server/routes/account/connect/discord.get.ts | 53 ++++++++++++++++++++ server/utils/discord.ts | 14 +++++- server/utils/handleStripeWebhook.ts | 2 + server/utils/useGrpc.ts | 4 ++ shared/api-types.ts | 4 ++ 8 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 server/api/account/discord-link.get.ts create mode 100644 server/api/account/discord-unlink.post.ts create mode 100644 server/routes/account/connect/discord.get.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index aa51f27..ba980f4 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -45,12 +45,15 @@ export default defineNuxtConfig({ smtpFromEmail: '', smtpFromName: '', discordBotToken: '', + discordClientId: '', + discordClientSecret: '', discordGuildId: '', discordTesterRoleId: '', discordSupporterRoleId: '', discourseSsoSecret: '', public: { + baseUrl: 'https://pretendo.network', apiBase: 'https://api.pretendo.cc', hCaptchaSitekey: '', cookieSecure: false diff --git a/server/api/account/discord-link.get.ts b/server/api/account/discord-link.get.ts new file mode 100644 index 0000000..ea44a84 --- /dev/null +++ b/server/api/account/discord-link.get.ts @@ -0,0 +1,23 @@ +import { useDiscord } from "~~/server/utils/discord"; +import { ApiAccountDiscordLink } from "~~/shared/api-types"; + +export default defineEventHandler(async (event): Promise => { + enforceLoggedIn(event); + const discord = useDiscord(event); + if (!discord) throw createError({ + status: 400, + message: 'Discord integration not configured', + }) + + const redirectUrl = discord.makeCallbackUrl(); + const url = new URL("https://discord.com/oauth2/authorize"); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', 'code'); + url.searchParams.set('scope', 'identify'); + url.searchParams.set('redirect_uri', redirectUrl); + url.searchParams.set('prompt', 'scope'); + url.searchParams.set('integration_type', '1'); + return { + url: url.toString(), + } +}); diff --git a/server/api/account/discord-unlink.post.ts b/server/api/account/discord-unlink.post.ts new file mode 100644 index 0000000..d6783a6 --- /dev/null +++ b/server/api/account/discord-unlink.post.ts @@ -0,0 +1,15 @@ +export default defineEventHandler(async (event): Promise => { + const auth = enforceLoggedIn(event); + const discord = useDiscord(event); + if (!discord) throw createError({ + status: 400, + message: 'Discord integration not configured', + }) + + const grpc = useApiGrpcWithToken(event, auth.token); + await grpc.setDiscordConnectionData({ + id: '', + }); + + // TODO set roles based on stripe info +}); diff --git a/server/routes/account/connect/discord.get.ts b/server/routes/account/connect/discord.get.ts new file mode 100644 index 0000000..5003bfe --- /dev/null +++ b/server/routes/account/connect/discord.get.ts @@ -0,0 +1,53 @@ +type DiscordTokenResponse = { + "access_token": string, + "token_type": string, + "expires_in": number, + "refresh_token": string, + "scope": string +} + +type DiscordUserResponse = { + "user"?: { + id: string, + } +} + +// Discord oauth callback +export default defineEventHandler(async (event) => { + const discord = useDiscord(event); + const discordFetch = $fetch.create({ + baseURL: discord.baseUrl, + }); + const accessTokenCookie = getCookie(event, 'access_token'); + const query = getQuery(event); + + const authCode = query.code?.toString(); + if (!authCode || !accessTokenCookie) { + return sendRedirect(event, '/'); + } + + const tokens = await discordFetch('/oauth2/token', { + body: new URLSearchParams({ + 'grant_type': 'authorization_code', + 'code': authCode, + 'redirect_uri': discord.makeCallbackUrl(), + }) + }); + const authInfo = await discordFetch('/oauth2/@me', { + headers: { + 'Authorization': `Bearer ${tokens.access_token}` + } + }); + if (!authInfo.user) { + return sendRedirect(event, '/'); // No identify scope + } + + const grpc = useApiGrpcWithToken(event, accessTokenCookie ?? ''); + await grpc.setDiscordConnectionData({ + id: authInfo.user.id + }); + + // TODO set roles based on stripe info + + return sendRedirect(event, '/account'); +}); diff --git a/server/utils/discord.ts b/server/utils/discord.ts index 3605bd6..ec211dc 100644 --- a/server/utils/discord.ts +++ b/server/utils/discord.ts @@ -6,7 +6,11 @@ type DiscordIds = { guildId: string, supporterRoleId: string | null, testerRoleI type DiscordInstance = { rest: REST, + clientId: string, + clientSecret: string, + baseUrl: string, ids: DiscordIds, + makeCallbackUrl: () => string, } let discordInstance: DiscordInstance | null = null; @@ -14,14 +18,20 @@ let discordInstance: DiscordInstance | null = null; export function useDiscord(event: H3Event): DiscordInstance | null { if (!discordInstance) { const config = useRuntimeConfig(event); - if (config.discordBotToken && config.discordGuildId) { + if (config.discordBotToken && config.discordGuildId && config.discordClientId && config.discordClientSecret) { discordInstance = { rest: new REST({ version: '10' }).setToken(config.discordBotToken), + baseUrl: "https://discord.com/api/v10", + clientId: config.discordClientId, + clientSecret: config.discordClientSecret, ids: { guildId: config.discordGuildId, testerRoleId: config.discordTesterRoleId ? config.discordTesterRoleId : null, supporterRoleId: config.discordSupporterRoleId ? config.discordSupporterRoleId : null, - } + }, + makeCallbackUrl() { + return new URL('/account/connect/discord', config.public.baseUrl).toString() + }, } } } diff --git a/server/utils/handleStripeWebhook.ts b/server/utils/handleStripeWebhook.ts index 7291887..67859b1 100644 --- a/server/utils/handleStripeWebhook.ts +++ b/server/utils/handleStripeWebhook.ts @@ -34,6 +34,8 @@ async function sendToNotificationEmails(mailer: Transporter, notificationEmails: } } +// Handles incoming webhooks from stripe, does not validate. +// This does not use account server GRPC since the GRPC method can't change the access levels yet export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: Stripe.Event, notificationEmails: string[]) { const mailer = useMailer(event); const discord = useDiscord(event); diff --git a/server/utils/useGrpc.ts b/server/utils/useGrpc.ts index 0288c72..ab2223a 100644 --- a/server/utils/useGrpc.ts +++ b/server/utils/useGrpc.ts @@ -52,3 +52,7 @@ export function useAccountGrpc(event: H3Event): Client export function useLegacyApiGrpcWithToken(event: H3Event, token: string): Client { return getGrpcClient(event, APIDefinition, token); } + +export function useApiGrpcWithToken(event: H3Event, token: string): Client { + return getGrpcClient(event, APIDefinition, token); +} diff --git a/shared/api-types.ts b/shared/api-types.ts index b7d5130..ac1b167 100644 --- a/shared/api-types.ts +++ b/shared/api-types.ts @@ -33,6 +33,10 @@ export type ApiAuthLogin = { refreshToken: string; }; +export type ApiAccountDiscordLink = { + url: string +}; + export const LoginSchema = z.object({ username: z.string(), password: z.string()