feat(GRO-564): Better Auth Phase 2 Security Hardening

feat(GRO-564): Better Auth Phase 2 Security Hardening
This commit was merged in pull request #265.
This commit is contained in:
groombook-ceo[bot]
2026-04-11 23:07:36 +00:00
committed by GitHub
4 changed files with 65 additions and 22 deletions
+12
View File
@@ -90,6 +90,12 @@ export async function initAuth(): Promise<void> {
database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "database",
},
plugins: [
genericOAuth({
config: [
@@ -177,6 +183,12 @@ export async function initAuth(): Promise<void> {
}),
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "database",
},
account: {
storeStateStrategy: "cookie" as const,
},
+1 -20
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, isNull, staff } from "@groombook/db";
import { eq, getDb, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -90,25 +90,6 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
if (!fallbackRow) {
// Auto-link: staff record exists with matching email but no userId — link it now
if (jwt.email) {
const [linkedStaff] = await db
.select()
.from(staff)
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
if (linkedStaff) {
await db
.update(staff)
.set({ userId: jwt.sub })
.where(eq(staff.id, linkedStaff.id));
console.log(
`[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
);
c.set("staff", linkedStaff);
await next();
return;
}
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
+30
View File
@@ -18,6 +18,10 @@ const createStaffSchema = z.object({
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
const linkUserSchema = z.object({
userId: z.string().min(1),
});
staffRouter.get("/me", async (c) => {
const staffRow = c.get("staff");
return c.json(staffRow);
@@ -106,6 +110,32 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
return c.json(row);
});
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
const db = getDb();
const targetId = c.req.param("id");
const body = c.req.valid("json");
const currentStaff = c.get("staff");
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
}
const [existing] = await db
.select()
.from(staff)
.where(eq(staff.id, targetId))
.limit(1);
if (!existing) return c.json({ error: "Not found" }, 404);
const [updated] = await db
.update(staff)
.set({ userId: body.userId, updatedAt: new Date() })
.where(eq(staff.id, targetId))
.returning();
return c.json(updated);
});
staffRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
+22 -2
View File
@@ -1,4 +1,4 @@
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js";
@@ -18,7 +18,7 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
import { BrandingProvider, useBranding } from "./BrandingContext.js";
import { GlobalSearch } from "./components/GlobalSearch.js";
import { useSession, signIn } from "./lib/auth-client.js";
import { useSession, signIn, signOut } from "./lib/auth-client.js";
function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
@@ -181,6 +181,7 @@ const NAV_LINKS = [
function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const { branding } = useBranding();
const logoSrc = branding.logoBase64 && branding.logoMimeType
@@ -261,6 +262,25 @@ function AdminLayout() {
</Link>
);
})}
<button
onClick={async () => {
await signOut();
navigate("/login");
}}
style={{
marginLeft: "auto",
padding: "0.4rem 0.85rem",
borderRadius: 6,
border: "1px solid #e2e8f0",
background: "#fff",
color: "#4b5563",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
}}
>
Logout
</button>
</nav>
<main style={{ padding: "1.25rem 1.5rem" }}>
<Routes>