[CLOSED - split] auth_provider_config table + AES-256-GCM #207
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { encryptSecret, decryptSecret } from "@groombook/db";
|
||||||
|
|
||||||
|
describe("encryptSecret / decryptSecret", () => {
|
||||||
|
const originalEnv = process.env.BETTER_AUTH_SECRET;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.BETTER_AUTH_SECRET = "test-secret-key-for-unit-tests-32bytes!";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalEnv !== undefined) {
|
||||||
|
process.env.BETTER_AUTH_SECRET = originalEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env.BETTER_AUTH_SECRET;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encrypts and decrypts a simple secret", () => {
|
||||||
|
const plaintext = "my-client-secret-123";
|
||||||
|
const encrypted = encryptSecret(plaintext);
|
||||||
|
const decrypted = decryptSecret(encrypted);
|
||||||
|
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces output in iv:ciphertext:authTag format", () => {
|
||||||
|
const encrypted = encryptSecret("test");
|
||||||
|
const parts = encrypted.split(":");
|
||||||
|
|
||||||
|
expect(parts).toHaveLength(3);
|
||||||
|
// Each part should be valid base64
|
||||||
|
parts.forEach((part) => {
|
||||||
|
expect(() => Buffer.from(part, "base64")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("different plaintexts produce different ciphertexts", () => {
|
||||||
|
const encrypted1 = encryptSecret("secret1");
|
||||||
|
const encrypted2 = encryptSecret("secret2");
|
||||||
|
|
||||||
|
expect(encrypted1).not.toBe(encrypted2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("same plaintext produces different ciphertexts (due to random IV)", () => {
|
||||||
|
const encrypted1 = encryptSecret("same-secret");
|
||||||
|
const encrypted2 = encryptSecret("same-secret");
|
||||||
|
|
||||||
|
expect(encrypted1).not.toBe(encrypted2);
|
||||||
|
// But both should decrypt to the same value
|
||||||
|
expect(decryptSecret(encrypted1)).toBe("same-secret");
|
||||||
|
expect(decryptSecret(encrypted2)).toBe("same-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if BETTER_AUTH_SECRET is not set", () => {
|
||||||
|
delete process.env.BETTER_AUTH_SECRET;
|
||||||
|
|
||||||
|
expect(() => encryptSecret("test")).toThrow(
|
||||||
|
"BETTER_AUTH_SECRET environment variable is required"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when decrypting invalid format (wrong number of parts)", () => {
|
||||||
|
const encrypted = encryptSecret("test");
|
||||||
|
// Replace the last ":authTag" part by matching colon + non-colon chars at the end
|
||||||
|
const invalid = encrypted.replace(/:[^:]+$/, "");
|
||||||
|
|
||||||
|
expect(() => decryptSecret(invalid)).toThrow(
|
||||||
|
"Invalid encrypted value format: expected iv:ciphertext:authTag"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string secret", () => {
|
||||||
|
const plaintext = "";
|
||||||
|
const encrypted = encryptSecret(plaintext);
|
||||||
|
const decrypted = decryptSecret(encrypted);
|
||||||
|
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unicode secret", () => {
|
||||||
|
const plaintext = "密码🔐中文";
|
||||||
|
const encrypted = encryptSecret(plaintext);
|
||||||
|
const decrypted = decryptSecret(encrypted);
|
||||||
|
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles long secret", () => {
|
||||||
|
const plaintext = "a".repeat(10000);
|
||||||
|
const encrypted = encryptSecret(plaintext);
|
||||||
|
const decrypted = decryptSecret(encrypted);
|
||||||
|
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,7 @@ const RECEPTIONIST: StaffRow = {
|
|||||||
oidcSub: "oidc-receptionist-sub",
|
oidcSub: "oidc-receptionist-sub",
|
||||||
userId: "ba-user-receptionist",
|
userId: "ba-user-receptionist",
|
||||||
role: "receptionist",
|
role: "receptionist",
|
||||||
|
isSuperUser: false,
|
||||||
name: "Receptionist Rita",
|
name: "Receptionist Rita",
|
||||||
email: "receptionist@example.com",
|
email: "receptionist@example.com",
|
||||||
};
|
};
|
||||||
@@ -35,6 +36,7 @@ const GROOMER: StaffRow = {
|
|||||||
oidcSub: "oidc-groomer-sub",
|
oidcSub: "oidc-groomer-sub",
|
||||||
userId: "ba-user-groomer",
|
userId: "ba-user-groomer",
|
||||||
role: "groomer",
|
role: "groomer",
|
||||||
|
isSuperUser: false,
|
||||||
name: "Groomer Gary",
|
name: "Groomer Gary",
|
||||||
email: "groomer@example.com",
|
email: "groomer@example.com",
|
||||||
};
|
};
|
||||||
@@ -122,7 +124,7 @@ function buildWithStaff(
|
|||||||
|
|
||||||
// ─── Import middleware ────────────────────────────────────────────────────────
|
// ─── Import middleware ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const { resolveStaffMiddleware, requireRole } = await import(
|
const { resolveStaffMiddleware, requireRole, requireSuperUser } = await import(
|
||||||
"../middleware/rbac.js"
|
"../middleware/rbac.js"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -253,3 +255,74 @@ describe("requireRole", () => {
|
|||||||
expect(contentType).toContain("application/json");
|
expect(contentType).toContain("application/json");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── requireSuperUser tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("requireSuperUser", () => {
|
||||||
|
it("allows access when staff is a super user", async () => {
|
||||||
|
const app = buildWithStaff(MANAGER, requireSuperUser());
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows access when manager is also a super user", async () => {
|
||||||
|
// MANAGER has isSuperUser: true
|
||||||
|
const app = buildWithStaff(MANAGER, requireSuperUser());
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 for a non-super-user receptionist", async () => {
|
||||||
|
// RECEPTIONIST has isSuperUser: false
|
||||||
|
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/super user privileges required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 for a non-super-user groomer", async () => {
|
||||||
|
// GROOMER has isSuperUser: false
|
||||||
|
const app = buildWithStaff(GROOMER, requireSuperUser());
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 when staff record is not resolved", async () => {
|
||||||
|
// Manually remove staff from context to simulate unresolved staff
|
||||||
|
const testApp = new Hono<AppEnv>();
|
||||||
|
testApp.use("*", async (c, next) => {
|
||||||
|
c.set("jwtPayload", { sub: "test-sub" });
|
||||||
|
// Do NOT set staff - simulate unresolved staff
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
testApp.use("*", requireSuperUser());
|
||||||
|
testApp.get("/test", (c) => c.json({ ok: true }));
|
||||||
|
const res = await testApp.request("/test");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/staff record not resolved/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("receptionist cannot grant super user status on staff PATCH", async () => {
|
||||||
|
// This tests the inline guard in staff.ts handler, not the middleware itself,
|
||||||
|
// but we test requireSuperUser to verify the middleware correctly blocks
|
||||||
|
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
|
||||||
|
const res = await app.request("/test", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ isSuperUser: true }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/super user privileges required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 with JSON body for super user violation", async () => {
|
||||||
|
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
expect(contentType).toContain("application/json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
if (!manager) {
|
if (!manager) {
|
||||||
return c.json({ error: "Forbidden: no staff records found" }, 403);
|
return c.json({ error: "Forbidden: no staff records found" }, 403);
|
||||||
}
|
}
|
||||||
c.set("staff", { ...manager, isSuperUser: true });
|
c.set("staff", { ...manager, isSuperUser: manager.isSuperUser ?? false });
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.userId, devUserId));
|
.where(eq(staff.userId, devUserId));
|
||||||
if (row) {
|
if (row) {
|
||||||
c.set("staff", { ...row, isSuperUser: true });
|
c.set("staff", { ...row, isSuperUser: row.isSuperUser ?? false });
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
403
|
403
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
c.set("staff", { ...fallbackRow, isSuperUser: true });
|
c.set("staff", { ...fallbackRow, isSuperUser: fallbackRow.isSuperUser ?? false });
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { eq, getDb, businessSettings } from "@groombook/db";
|
import { eq, getDb, businessSettings } from "@groombook/db";
|
||||||
|
import { requireSuperUser } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const settingsRouter = new Hono();
|
export const settingsRouter = new Hono();
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ const updateSettingsSchema = z.object({
|
|||||||
// PATCH /api/admin/settings — update business settings
|
// PATCH /api/admin/settings — update business settings
|
||||||
settingsRouter.patch(
|
settingsRouter.patch(
|
||||||
"/",
|
"/",
|
||||||
|
requireSuperUser(),
|
||||||
zValidator("json", updateSettingsSchema),
|
zValidator("json", updateSettingsSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "auth_provider_config" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"display_name" text NOT NULL,
|
||||||
|
"issuer_url" text NOT NULL,
|
||||||
|
"internal_base_url" text,
|
||||||
|
"client_id" text NOT NULL,
|
||||||
|
"client_secret" text NOT NULL,
|
||||||
|
"scopes" text DEFAULT 'openid profile email' NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "auth_provider_config_provider_id_unique" UNIQUE("provider_id")
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -148,6 +148,13 @@
|
|||||||
"when": 1775050467192,
|
"when": 1775050467192,
|
||||||
"tag": "0020_typical_daimon_hellstrom",
|
"tag": "0020_typical_daimon_hellstrom",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775127727890,
|
||||||
|
"tag": "0021_classy_hedge_knight",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||||
|
|
||||||
|
const ALGORITHM = "aes-256-gcm";
|
||||||
|
const IV_LENGTH = 12; // 96-bit IV for GCM
|
||||||
|
const AUTH_TAG_LENGTH = 16; // 128-bit auth tag
|
||||||
|
const SALT_LENGTH = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
|
||||||
|
* BETTER_AUTH_SECRET is used as the password, with a random salt per-key.
|
||||||
|
*/
|
||||||
|
function deriveKey(secret: string): Buffer {
|
||||||
|
// Use a fixed salt derived from the package name for key derivation
|
||||||
|
// This gives us stable key derivation without storing an extra salt
|
||||||
|
const packageSalt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
|
||||||
|
return scryptSync(secret, packageSalt, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a plaintext string using AES-256-GCM.
|
||||||
|
* Returns a base64-encoded string in the format: iv:ciphertext:authTag
|
||||||
|
*/
|
||||||
|
export function encryptSecret(plaintext: string): string {
|
||||||
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = deriveKey(secret);
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
||||||
|
authTagLength: AUTH_TAG_LENGTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
let ciphertext = cipher.update(plaintext, "utf8");
|
||||||
|
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Format: base64(iv):base64(ciphertext):base64(authTag)
|
||||||
|
return [
|
||||||
|
iv.toString("base64"),
|
||||||
|
ciphertext.toString("base64"),
|
||||||
|
authTag.toString("base64"),
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a ciphertext string produced by encryptSecret.
|
||||||
|
* Expects the format: iv:ciphertext:authTag (all base64-encoded)
|
||||||
|
*/
|
||||||
|
export function decryptSecret(encrypted: string): string {
|
||||||
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = encrypted.split(":");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error("Invalid encrypted value format: expected iv:ciphertext:authTag");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ivBase64 = parts[0]!;
|
||||||
|
const ciphertextBase64 = parts[1]!;
|
||||||
|
const authTagBase64 = parts[2]!;
|
||||||
|
const iv = Buffer.from(ivBase64, "base64");
|
||||||
|
const ciphertext = Buffer.from(ciphertextBase64, "base64");
|
||||||
|
const authTag = Buffer.from(authTagBase64, "base64");
|
||||||
|
|
||||||
|
const key = deriveKey(secret);
|
||||||
|
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
||||||
|
authTagLength: AUTH_TAG_LENGTH,
|
||||||
|
});
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let plaintext = decipher.update(ciphertext);
|
||||||
|
plaintext = Buffer.concat([plaintext, decipher.final()]);
|
||||||
|
|
||||||
|
return plaintext.toString("utf8");
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import * as schema from "./schema.js";
|
|||||||
|
|
||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
|
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||||
|
export { encryptSecret, decryptSecret } from "./crypto.js";
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -405,3 +405,19 @@ export const waitlistEntries = pgTable(
|
|||||||
index("idx_waitlist_status").on(t.status),
|
index("idx_waitlist_status").on(t.status),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Auth Provider Config ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const authProviderConfig = pgTable("auth_provider_config", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
providerId: text("provider_id").notNull().unique(), // e.g. "authentik", "okta", "entra-id"
|
||||||
|
displayName: text("display_name").notNull(), // shown on login button
|
||||||
|
issuerUrl: text("issuer_url").notNull(), // OIDC issuer/discovery URL
|
||||||
|
internalBaseUrl: text("internal_base_url"), // for hairpin NAT / K8s internal routing
|
||||||
|
clientId: text("client_id").notNull(),
|
||||||
|
clientSecret: text("client_secret").notNull(), // AES-256-GCM encrypted using BETTER_AUTH_SECRET
|
||||||
|
scopes: text("scopes").notNull().default("openid profile email"),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user