feat: implement discord oauth flow

This commit is contained in:
mrjvs
2026-08-09 18:22:26 +02:00
parent dcf00f5ec9
commit 735a723328
8 changed files with 116 additions and 2 deletions

View File

@@ -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

View File

@@ -0,0 +1,23 @@
import { useDiscord } from "~~/server/utils/discord";
import { ApiAccountDiscordLink } from "~~/shared/api-types";
export default defineEventHandler(async (event): Promise<ApiAccountDiscordLink> => {
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(),
}
});

View File

@@ -0,0 +1,15 @@
export default defineEventHandler(async (event): Promise<void> => {
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
});

View File

@@ -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<DiscordTokenResponse>('/oauth2/token', {
body: new URLSearchParams({
'grant_type': 'authorization_code',
'code': authCode,
'redirect_uri': discord.makeCallbackUrl(),
})
});
const authInfo = await discordFetch<DiscordUserResponse>('/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');
});

View File

@@ -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()
},
}
}
}

View File

@@ -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);

View File

@@ -52,3 +52,7 @@ export function useAccountGrpc(event: H3Event): Client<AccountServiceDefinition>
export function useLegacyApiGrpcWithToken(event: H3Event, token: string): Client<APIDefinition> {
return getGrpcClient(event, APIDefinition, token);
}
export function useApiGrpcWithToken(event: H3Event, token: string): Client<APIDefinition> {
return getGrpcClient(event, APIDefinition, token);
}

View File

@@ -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()