From 17af2f3d1f4e9876260aca0ebf0d90d2e6da7b2d Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Sun, 16 Aug 2026 01:51:12 -0400 Subject: [PATCH] Implement full discord OAuth api flow --- apps/api/src/scripts/register-webhooks.ts | 2 +- apps/api/src/server.ts | 93 +++++++++++++++-------- apps/api/src/services/accounts.ts | 7 +- apps/api/src/util.ts | 1 + apps/portal/src/routes/login/+page.svelte | 8 +- 5 files changed, 70 insertions(+), 41 deletions(-) diff --git a/apps/api/src/scripts/register-webhooks.ts b/apps/api/src/scripts/register-webhooks.ts index 675bb75..93edbad 100644 --- a/apps/api/src/scripts/register-webhooks.ts +++ b/apps/api/src/scripts/register-webhooks.ts @@ -2,7 +2,7 @@ import { Printful, PrintfulClient, PrintfulError, Webflow, WebflowClient, Webflo import { env } from "../util"; const DOMAIN = env.NODE_ENV === "development" ? - "dev.api.bladeandbrawn.com" : + "api-dev.bladeandbrawn.com" : "api.bladeandbrawn.com"; const PRINTFUL_WEBHOOK_URL = env.NODE_ENV === "development" ? diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index ee94dda..f517461 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -27,6 +27,7 @@ import { Value } from "@sinclair/typebox/value"; // ----------------------- const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec const JWT_EXP = "1d"; +const JWT_EXP_SECONDS = 60 * 60 * 24; // keep in sync with JWT_EXP; used for cookie maxAge // SERVICES // ----------------------- @@ -52,12 +53,25 @@ const authPlugin = new Elysia({ name: "auth" }) .use(jwt({ name: "jwt", secret: env.AUTH_SECRET })) .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) .macro({ - auth: { + authAdmin: { async resolve({ jwt, cookie: { auth } }) { const token = auth.value && await jwt.verify(auth.value); - if (!token || !token.sessionId) throw status(401, "Unauthorized"); + if (!token || token.role !== "admin" || !token.sessionId) throw status(401, "Unauthorized"); return { sessionId: token.sessionId.toString() }; } + }, + authUser: { + async resolve({ jwt, cookie: { auth } }) { + const token = auth.value && await jwt.verify(auth.value); + if (!token || !token.sessionId || (token.role !== "user" && token.role !== "admin")) throw status(401, "Unauthorized"); + if (token.role === "user" && !token.accountId) throw status(401, "Unauthorized"); + + return { + sessionId: token.sessionId.toString(), + isAdmin: token.role === "admin", + accountId: token.role === "user" ? token.accountId!.toString() : null, + }; + } } }); @@ -72,12 +86,18 @@ export const app = new Elysia() /^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.com$/i, // testing /^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.webflow\.io$/i, + /^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn-test\.webflow\.io$/i, // development "http://localhost:5173", ], }), ) - .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()), discordOAuthState: t.Optional(t.String()) }) }) + .guard({ + cookie: t.Cookie({ + auth: t.Optional(t.String()), + authDiscord: t.Optional(t.String()) + }) + }) .use(authPlugin) .error({ @@ -124,14 +144,14 @@ export const app = new Elysia() .get("/health", () => ({ status: "ok" })) // AUTHENTICATION - .post("/auth/login", async ({ jwt, body, cookie: { auth } }) => { + .post("/auth/admin/login", async ({ jwt, body, cookie: { auth } }) => { const match = crypto.timingSafeEqual(sha256Sum(body.password), Buffer.from(env.ADMIN_PASSWORD, "hex")); if (!match) throw status(401, "Invalid credentials"); auth.set({ - value: await jwt.sign({ sessionId: randomUUIDv7(), exp: JWT_EXP }), + value: await jwt.sign({ role: "admin", sessionId: randomUUIDv7(), exp: JWT_EXP }), path: "/", - maxAge: 60 * 60 * 24 * 7, + maxAge: JWT_EXP_SECONDS, sameSite: "lax", httpOnly: true, secure: env.NODE_ENV === "production", @@ -142,10 +162,10 @@ export const app = new Elysia() }, { body: t.Object({ password: t.String() }) }) .group("/auth/discord", (app) => app - .get("/login", async ({ cookie: { discordOAuthState } }) => { + .get("/login", async ({ cookie: { authDiscord } }) => { const state = crypto.randomUUID(); - discordOAuthState.set({ + authDiscord.set({ value: state, path: "/", maxAge: 60 * 10, // 10 min @@ -163,11 +183,11 @@ export const app = new Elysia() return redirect(url.toString(), 302); }) - .get("/callback", async ({ query, cookie: { discordOAuthState, auth }, jwt }) => { + .get("/callback", async ({ query, cookie: { authDiscord, 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"); + if (query.state !== authDiscord.value) throw status(400, "Invalid Discord OAuth state"); const tokenRes = await fetch("https://discord.com/api/oauth2/token", { method: "POST", @@ -182,7 +202,10 @@ export const app = new Elysia() redirect_uri: env.BOT_REDIRECT_URL }).toString() }); - if (!tokenRes.ok) throw status(502, { error: await tokenRes.json() }); + if (!tokenRes.ok) { + const errorBody = await tokenRes.json().catch(() => null); + throw status(502, { error: errorBody ?? "Discord token exchange failed" }); + } const tokenResPayload = await tokenRes.json(); Value.Assert(t.Object({ access_token: t.String() }), tokenResPayload); @@ -190,32 +213,38 @@ export const app = new Elysia() 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() }); + if (!identityRes.ok) { + const errorBody = await identityRes.json().catch(() => null); + throw status(502, { error: errorBody ?? "Failed to fetch Discord identity" }); + } 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( + let accountId = (await s.Accounts.get(`@${identityResPayload.id}`))?.id; + if (!accountId) { + accountId = (await s.Accounts.create( identityResPayload.id, identityResPayload.email, identityResPayload.username - ); + )).id; } - // 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 - // }); + // Authenticate + auth.set({ + value: await jwt.sign({ role: "user", sessionId: randomUUIDv7(), accountId, exp: JWT_EXP }), + path: "/", + maxAge: JWT_EXP_SECONDS, + sameSite: env.NODE_ENV === "production" ? "lax" : "none", + httpOnly: true, + secure: true, + domain: env.NODE_ENV === "production" ? + ".bladeandbrawn.com" : + undefined + }); + + return redirect(env.BOT_LOGIN_REDIRECT_URL, 302); }, { query: t.Object({ code: t.Optional(t.String()), @@ -238,7 +267,7 @@ export const app = new Elysia() }), }) // Authenticated - .guard({ auth: true }) + .guard({ authAdmin: true }) .get("/standards/config", async () => { return await s.Calculator.Standards.Config.get(); }) @@ -248,7 +277,7 @@ export const app = new Elysia() body: t.Object({ standardsConfigId: t.String() }) }) ) - .group("/standards", { auth: true }, (app) => app + .group("/standards", { authAdmin: true }, (app) => app .post("/configs", async ({ body: { name, datasetId, params } }) => { return await s.Standards.Configs.create(name, datasetId, params); }, { @@ -288,7 +317,7 @@ export const app = new Elysia() ) // COMMERCE - .group("/commerce", { auth: true }, (app) => app + .group("/commerce", { authAdmin: true }, (app) => app .group("/products", (app) => app .get("/", async ({ query }) => { const [pProducts, wProducts] = await Promise.all([ @@ -392,7 +421,7 @@ export const app = new Elysia() ) // EVENTS - .group("/events", { auth: true }, (app) => app + .group("/events", { authAdmin: true }, (app) => app .get("/", async ({ query }) => { const events = await s.Events.list({ filter: { status: query.status, type: query.type, group: query.group }, @@ -436,7 +465,7 @@ export const app = new Elysia() params: t.Object({ id: t.String() }) }) // Authenticated - .guard({ auth: true }) + .guard({ authAdmin: true }) .get("/:id", async ({ params: { id } }) => { const account = await s.Accounts.get(id); if (!account) throw new NotFoundError("Account not found"); diff --git a/apps/api/src/services/accounts.ts b/apps/api/src/services/accounts.ts index c04c4e9..fb6c30b 100644 --- a/apps/api/src/services/accounts.ts +++ b/apps/api/src/services/accounts.ts @@ -14,18 +14,19 @@ export class AccountsService { } async create(discordId: string, email?: string, name?: string) { - await db.insertInto("accounts") + return await db.insertInto("accounts") .values({ discord_id: discordId, name: name ?? "", email: email ?? null }) - .execute(); + .returning("id") + .executeTakeFirstOrThrow(); } async get(id: string) { return await (db).selectFrom("accounts") - .select(["discord_id", "name", "email", "gender"]) + .select(["id", "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 f3d97fc..bd100e8 100644 --- a/apps/api/src/util.ts +++ b/apps/api/src/util.ts @@ -29,6 +29,7 @@ export const env = { BOT_CLIENT_ID: requireEnv("BOT_CLIENT_ID"), BOT_CLIENT_SECRET: requireEnv("BOT_CLIENT_SECRET"), BOT_REDIRECT_URL: requireEnv("BOT_REDIRECT_URL"), + BOT_LOGIN_REDIRECT_URL: requireEnv("BOT_LOGIN_REDIRECT_URL"), }; export const WORKER_COUNT = Math.min(os.availableParallelism(), +env.MAX_WORKER_COUNT); diff --git a/apps/portal/src/routes/login/+page.svelte b/apps/portal/src/routes/login/+page.svelte index 8a0af1c..9892edf 100644 --- a/apps/portal/src/routes/login/+page.svelte +++ b/apps/portal/src/routes/login/+page.svelte @@ -14,7 +14,7 @@ loading = true; try { - const { status } = await api.auth.login.post({ password }); + const { status } = await api.auth.admin.login.post({ password }); if (status === 401) { error = "Invalid password"; @@ -22,11 +22,9 @@ } await goto("/"); - } - catch { + } catch { error = "Unable to reach the server. Please try again."; - } - finally { + } finally { loading = false; } }