feat(GRO-564): Better Auth Phase 2 security hardening
- Add logout button to admin layout header (signOut from better-auth) - AUTH_DISABLED production guard already present in auth.ts middleware - Remove automatic email-based staff-user linking (security fix) - Add PATCH /api/staff/:id/link-user endpoint for manual linking by admins - Add rate limiting to Better Auth (10 req/min, database storage) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user