Implement full discord OAuth api flow

This commit is contained in:
Dominic Ferrando
2026-08-16 01:51:12 -04:00
parent 8db1db2ec6
commit 17af2f3d1f
5 changed files with 70 additions and 41 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import { Printful, PrintfulClient, PrintfulError, Webflow, WebflowClient, Webflo
import { env } from "../util"; import { env } from "../util";
const DOMAIN = env.NODE_ENV === "development" ? const DOMAIN = env.NODE_ENV === "development" ?
"dev.api.bladeandbrawn.com" : "api-dev.bladeandbrawn.com" :
"api.bladeandbrawn.com"; "api.bladeandbrawn.com";
const PRINTFUL_WEBHOOK_URL = env.NODE_ENV === "development" ? const PRINTFUL_WEBHOOK_URL = env.NODE_ENV === "development" ?
+61 -32
View File
@@ -27,6 +27,7 @@ import { Value } from "@sinclair/typebox/value";
// ----------------------- // -----------------------
const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec
const JWT_EXP = "1d"; const JWT_EXP = "1d";
const JWT_EXP_SECONDS = 60 * 60 * 24; // keep in sync with JWT_EXP; used for cookie maxAge
// SERVICES // SERVICES
// ----------------------- // -----------------------
@@ -52,12 +53,25 @@ const authPlugin = new Elysia({ name: "auth" })
.use(jwt({ name: "jwt", secret: env.AUTH_SECRET })) .use(jwt({ name: "jwt", secret: env.AUTH_SECRET }))
.guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) })
.macro({ .macro({
auth: { authAdmin: {
async resolve({ jwt, cookie: { auth } }) { async resolve({ jwt, cookie: { auth } }) {
const token = auth.value && await jwt.verify(auth.value); 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() }; 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, /^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.com$/i,
// testing // testing
/^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.webflow\.io$/i, /^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.webflow\.io$/i,
/^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn-test\.webflow\.io$/i,
// development // development
"http://localhost:5173", "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) .use(authPlugin)
.error({ .error({
@@ -124,14 +144,14 @@ export const app = new Elysia()
.get("/health", () => ({ status: "ok" })) .get("/health", () => ({ status: "ok" }))
// AUTHENTICATION // 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")); const match = crypto.timingSafeEqual(sha256Sum(body.password), Buffer.from(env.ADMIN_PASSWORD, "hex"));
if (!match) throw status(401, "Invalid credentials"); if (!match) throw status(401, "Invalid credentials");
auth.set({ auth.set({
value: await jwt.sign({ sessionId: randomUUIDv7(), exp: JWT_EXP }), value: await jwt.sign({ role: "admin", sessionId: randomUUIDv7(), exp: JWT_EXP }),
path: "/", path: "/",
maxAge: 60 * 60 * 24 * 7, maxAge: JWT_EXP_SECONDS,
sameSite: "lax", sameSite: "lax",
httpOnly: true, httpOnly: true,
secure: env.NODE_ENV === "production", secure: env.NODE_ENV === "production",
@@ -142,10 +162,10 @@ export const app = new Elysia()
}, { body: t.Object({ password: t.String() }) }) }, { body: t.Object({ password: t.String() }) })
.group("/auth/discord", (app) => app .group("/auth/discord", (app) => app
.get("/login", async ({ cookie: { discordOAuthState } }) => { .get("/login", async ({ cookie: { authDiscord } }) => {
const state = crypto.randomUUID(); const state = crypto.randomUUID();
discordOAuthState.set({ authDiscord.set({
value: state, value: state,
path: "/", path: "/",
maxAge: 60 * 10, // 10 min maxAge: 60 * 10, // 10 min
@@ -163,11 +183,11 @@ export const app = new Elysia()
return redirect(url.toString(), 302); 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.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) 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", { const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
method: "POST", method: "POST",
@@ -182,7 +202,10 @@ export const app = new Elysia()
redirect_uri: env.BOT_REDIRECT_URL redirect_uri: env.BOT_REDIRECT_URL
}).toString() }).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(); const tokenResPayload = await tokenRes.json();
Value.Assert(t.Object({ access_token: t.String() }), tokenResPayload); 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", { const identityRes = await fetch("https://discord.com/api/users/@me", {
headers: { "Authorization": `Bearer ${tokenResPayload.access_token}` } 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(); const identityResPayload = await identityRes.json();
Value.Assert(t.Object({ id: t.String(), email: t.Optional(t.String()), username: t.String() }), identityResPayload); Value.Assert(t.Object({ id: t.String(), email: t.Optional(t.String()), username: t.String() }), identityResPayload);
// Create the account // Create the account
const existingAccount = await s.Accounts.get(`@${identityResPayload.id}`); let accountId = (await s.Accounts.get(`@${identityResPayload.id}`))?.id;
if (!existingAccount) { if (!accountId) {
await s.Accounts.create( accountId = (await s.Accounts.create(
identityResPayload.id, identityResPayload.id,
identityResPayload.email, identityResPayload.email,
identityResPayload.username identityResPayload.username
); )).id;
} }
// auth.set({ // Authenticate
// value: await jwt.sign({ sessionId: randomUUIDv7(), exp: JWT_EXP }), auth.set({
// path: "/", value: await jwt.sign({ role: "user", sessionId: randomUUIDv7(), accountId, exp: JWT_EXP }),
// maxAge: 60 * 60 * 24 * 7, path: "/",
// sameSite: "lax", maxAge: JWT_EXP_SECONDS,
// httpOnly: true, sameSite: env.NODE_ENV === "production" ? "lax" : "none",
// secure: env.NODE_ENV === "production", httpOnly: true,
// domain: env.NODE_ENV === "production" ? secure: true,
// ".bladeandbrawn.com" : domain: env.NODE_ENV === "production" ?
// undefined ".bladeandbrawn.com" :
// }); undefined
});
return redirect(env.BOT_LOGIN_REDIRECT_URL, 302);
}, { }, {
query: t.Object({ query: t.Object({
code: t.Optional(t.String()), code: t.Optional(t.String()),
@@ -238,7 +267,7 @@ export const app = new Elysia()
}), }),
}) })
// Authenticated // Authenticated
.guard({ auth: true }) .guard({ authAdmin: true })
.get("/standards/config", async () => { .get("/standards/config", async () => {
return await s.Calculator.Standards.Config.get(); return await s.Calculator.Standards.Config.get();
}) })
@@ -248,7 +277,7 @@ export const app = new Elysia()
body: t.Object({ standardsConfigId: t.String() }) 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 } }) => { .post("/configs", async ({ body: { name, datasetId, params } }) => {
return await s.Standards.Configs.create(name, datasetId, params); return await s.Standards.Configs.create(name, datasetId, params);
}, { }, {
@@ -288,7 +317,7 @@ export const app = new Elysia()
) )
// COMMERCE // COMMERCE
.group("/commerce", { auth: true }, (app) => app .group("/commerce", { authAdmin: true }, (app) => app
.group("/products", (app) => app .group("/products", (app) => app
.get("/", async ({ query }) => { .get("/", async ({ query }) => {
const [pProducts, wProducts] = await Promise.all([ const [pProducts, wProducts] = await Promise.all([
@@ -392,7 +421,7 @@ export const app = new Elysia()
) )
// EVENTS // EVENTS
.group("/events", { auth: true }, (app) => app .group("/events", { authAdmin: true }, (app) => app
.get("/", async ({ query }) => { .get("/", async ({ query }) => {
const events = await s.Events.list({ const events = await s.Events.list({
filter: { status: query.status, type: query.type, group: query.group }, 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() }) params: t.Object({ id: t.String() })
}) })
// Authenticated // Authenticated
.guard({ auth: true }) .guard({ authAdmin: true })
.get("/:id", async ({ params: { id } }) => { .get("/:id", async ({ params: { id } }) => {
const account = await s.Accounts.get(id); const account = await s.Accounts.get(id);
if (!account) throw new NotFoundError("Account not found"); if (!account) throw new NotFoundError("Account not found");
+4 -3
View File
@@ -14,18 +14,19 @@ export class AccountsService {
} }
async create(discordId: string, email?: string, name?: string) { async create(discordId: string, email?: string, name?: string) {
await db.insertInto("accounts") return await db.insertInto("accounts")
.values({ .values({
discord_id: discordId, discord_id: discordId,
name: name ?? "", name: name ?? "",
email: email ?? null email: email ?? null
}) })
.execute(); .returning("id")
.executeTakeFirstOrThrow();
} }
async get(id: string) { async get(id: string) {
return await (db).selectFrom("accounts") return await (db).selectFrom("accounts")
.select(["discord_id", "name", "email", "gender"]) .select(["id", "discord_id", "name", "email", "gender"])
.where(...AccountsService.idComparison(id)) .where(...AccountsService.idComparison(id))
.executeTakeFirst(); .executeTakeFirst();
} }
+1
View File
@@ -29,6 +29,7 @@ export const env = {
BOT_CLIENT_ID: requireEnv("BOT_CLIENT_ID"), BOT_CLIENT_ID: requireEnv("BOT_CLIENT_ID"),
BOT_CLIENT_SECRET: requireEnv("BOT_CLIENT_SECRET"), BOT_CLIENT_SECRET: requireEnv("BOT_CLIENT_SECRET"),
BOT_REDIRECT_URL: requireEnv("BOT_REDIRECT_URL"), 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); export const WORKER_COUNT = Math.min(os.availableParallelism(), +env.MAX_WORKER_COUNT);
+3 -5
View File
@@ -14,7 +14,7 @@
loading = true; loading = true;
try { try {
const { status } = await api.auth.login.post({ password }); const { status } = await api.auth.admin.login.post({ password });
if (status === 401) { if (status === 401) {
error = "Invalid password"; error = "Invalid password";
@@ -22,11 +22,9 @@
} }
await goto("/"); await goto("/");
} } catch {
catch {
error = "Unable to reach the server. Please try again."; error = "Unable to reach the server. Please try again.";
} } finally {
finally {
loading = false; loading = false;
} }
} }