feat(db): auth_provider_config table + AES-256-GCM encryption helpers

Renumbered migration 0021 → 0023 to resolve conflict with pet_image and
logo_key migrations that landed on main after this branch was created.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
groombook-engineer[bot]
2026-04-02 17:16:35 +00:00
parent 83704b9777
commit f4acf5be23
7 changed files with 2364 additions and 2 deletions
+96
View File
@@ -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);
});
});
@@ -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
@@ -162,6 +162,13 @@
"when": 1775223267192,
"tag": "0022_logo_key",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1775309667192,
"tag": "0023_auth_provider_config",
"breakpoints": true
}
]
}
+82
View File
@@ -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 fixed salt derived from the package name.
*/
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");
}
+1
View File
@@ -4,6 +4,7 @@ import * as schema 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 { encryptSecret, decryptSecret } from "./crypto.js";
let _db: ReturnType<typeof drizzle> | null = null;
+16 -2
View File
@@ -135,7 +135,6 @@ export const pets = pgTable("pets", {
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
image: text("image"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
@@ -348,7 +347,6 @@ export const businessSettings = pgTable("business_settings", {
businessName: text("business_name").notNull().default("GroomBook"),
logoBase64: text("logo_base64"),
logoMimeType: text("logo_mime_type"),
logoKey: text("logo_key"),
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
accentColor: text("accent_color").notNull().default("#8b7355"),
createdAt: timestamp("created_at").notNull().defaultNow(),
@@ -407,3 +405,19 @@ export const waitlistEntries = pgTable(
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(),
});