Implement full discord OAuth api flow
This commit is contained in:
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user