From 8db1db2ec696597981c367002cb9e2fd3ea6da97 Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Sun, 16 Aug 2026 00:00:50 -0400 Subject: [PATCH] Setup account creation through discord OAuth2 --- apps/api/src/server.ts | 90 ++++++++++++++++++++++++++++++- apps/api/src/services/accounts.ts | 12 ++++- apps/api/src/util.ts | 3 ++ 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index d7c318a..ee94dda 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -3,7 +3,7 @@ import { PlayerSchema, } from "@blade-and-brawn/domain" import { cors } from "@elysiajs/cors"; -import { Elysia, NotFoundError, status, t } from "elysia"; +import { Elysia, NotFoundError, redirect, status, t } from "elysia"; import { PrintfulError, WebflowError, @@ -21,6 +21,7 @@ import { StandardsParamsSchema } from "@blade-and-brawn/calculator"; import { StandardsService } from "./services/standards"; import { EventsService, EventStatusSchema } from "./services/events"; import { AccountsService } from "./services/accounts"; +import { Value } from "@sinclair/typebox/value"; // CONSTANTS // ----------------------- @@ -76,7 +77,7 @@ export const app = new Elysia() ], }), ) - .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) + .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()), discordOAuthState: t.Optional(t.String()) }) }) .use(authPlugin) .error({ @@ -140,6 +141,91 @@ export const app = new Elysia() }); }, { body: t.Object({ password: t.String() }) }) + .group("/auth/discord", (app) => app + .get("/login", async ({ cookie: { discordOAuthState } }) => { + const state = crypto.randomUUID(); + + discordOAuthState.set({ + value: state, + path: "/", + maxAge: 60 * 10, // 10 min + sameSite: "lax", + httpOnly: true, + secure: env.NODE_ENV === "production" + }); + + const url = new URL("https://discord.com/api/oauth2/authorize"); + url.searchParams.set("client_id", env.BOT_CLIENT_ID); + url.searchParams.set("redirect_uri", env.BOT_REDIRECT_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "identify email"); + url.searchParams.set("state", state); + + return redirect(url.toString(), 302); + }) + .get("/callback", async ({ query, cookie: { discordOAuthState, auth }, jwt }) => { + if (query.error) throw status(400, { error: query.error_description ? `${query.error}: ${query.error_description}` : query.error }); + + if (!query.state) throw status(400, "No Discord OAuth2 state query parameter provided"); + if (query.state !== discordOAuthState.value) throw status(400, "Invalid Discord OAuth state"); + + const tokenRes = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: env.BOT_CLIENT_ID, + client_secret: env.BOT_CLIENT_SECRET, + grant_type: "authorization_code", + code: query.code ?? "", + redirect_uri: env.BOT_REDIRECT_URL + }).toString() + }); + if (!tokenRes.ok) throw status(502, { error: await tokenRes.json() }); + + const tokenResPayload = await tokenRes.json(); + Value.Assert(t.Object({ access_token: t.String() }), tokenResPayload); + + const identityRes = await fetch("https://discord.com/api/users/@me", { + headers: { "Authorization": `Bearer ${tokenResPayload.access_token}` } + }); + if (!identityRes.ok) throw status(502, { error: await identityRes.json() }); + + const identityResPayload = await identityRes.json(); + Value.Assert(t.Object({ id: t.String(), email: t.Optional(t.String()), username: t.String() }), identityResPayload); + + // Create the account + const existingAccount = await s.Accounts.get(`@${identityResPayload.id}`); + if (!existingAccount) { + await s.Accounts.create( + identityResPayload.id, + identityResPayload.email, + identityResPayload.username + ); + } + + // auth.set({ + // value: await jwt.sign({ sessionId: randomUUIDv7(), exp: JWT_EXP }), + // path: "/", + // maxAge: 60 * 60 * 24 * 7, + // sameSite: "lax", + // httpOnly: true, + // secure: env.NODE_ENV === "production", + // domain: env.NODE_ENV === "production" ? + // ".bladeandbrawn.com" : + // undefined + // }); + }, { + query: t.Object({ + code: t.Optional(t.String()), + state: t.Optional(t.String()), + error: t.Optional(t.String()), + error_description: t.Optional(t.String()), + }) + }) + ) + // CALCULATOR .group("/calculator", (app) => app // Non-authenticated diff --git a/apps/api/src/services/accounts.ts b/apps/api/src/services/accounts.ts index b07747c..c04c4e9 100644 --- a/apps/api/src/services/accounts.ts +++ b/apps/api/src/services/accounts.ts @@ -13,9 +13,19 @@ export class AccountsService { : ["accounts.id", "=", id]; } + async create(discordId: string, email?: string, name?: string) { + await db.insertInto("accounts") + .values({ + discord_id: discordId, + name: name ?? "", + email: email ?? null + }) + .execute(); + } + async get(id: string) { return await (db).selectFrom("accounts") - .selectAll() + .select(["discord_id", "name", "email", "gender"]) .where(...AccountsService.idComparison(id)) .executeTakeFirst(); } diff --git a/apps/api/src/util.ts b/apps/api/src/util.ts index 4e50f7e..f3d97fc 100644 --- a/apps/api/src/util.ts +++ b/apps/api/src/util.ts @@ -26,6 +26,9 @@ export const env = { MAX_WORKER_COUNT: requireEnv("MAX_WORKER_COUNT"), NODE_ENV: optionEnv("NODE_ENV", "development"), LOG_LEVEL: optionEnv("LOG_LEVEL", "info"), + BOT_CLIENT_ID: requireEnv("BOT_CLIENT_ID"), + BOT_CLIENT_SECRET: requireEnv("BOT_CLIENT_SECRET"), + BOT_REDIRECT_URL: requireEnv("BOT_REDIRECT_URL"), }; export const WORKER_COUNT = Math.min(os.availableParallelism(), +env.MAX_WORKER_COUNT);