feat(api): DB-first auth config loading with env-var fallback (GRO-389) #212

Merged
groombook-engineer[bot] merged 1 commits from feat/gro-389-auth-config into main 2026-04-02 19:58:17 +00:00
4 changed files with 328 additions and 54 deletions
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mutable state to control mock behavior per test
let dbSelectResult: unknown[] = [];
const mockEq = vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val }));
const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`);
vi.mock("@groombook/db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
get(target, prop) {
if (prop === "_name") return "auth_provider_config";
if (prop === "$inferSelect") return {};
return { table: "auth_provider_config", column: prop };
},
}
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => dbSelectResult,
[Symbol.iterator]: function* () {
for (const item of dbSelectResult) yield item;
},
0: dbSelectResult[0],
length: dbSelectResult.length,
}),
}),
}),
}),
authProviderConfig,
eq: mockEq,
decryptSecret: mockDecryptSecret,
};
});
async function reimportAuth() {
vi.resetModules();
vi.doMock("@groombook/db", () => ({
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => dbSelectResult,
[Symbol.iterator]: function* () {
for (const item of dbSelectResult) yield item;
},
0: dbSelectResult[0],
length: dbSelectResult.length,
}),
}),
}),
}),
authProviderConfig: {},
eq: mockEq,
decryptSecret: mockDecryptSecret,
}));
const mod = await import("../lib/auth.js");
return mod;
}
describe("auth init", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
dbSelectResult = [];
vi.clearAllMocks();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("falls back to env vars when DB returns empty", async () => {
process.env = {
...originalEnv,
OIDC_ISSUER: "https://issuer.example.com",
OIDC_CLIENT_ID: "test-client-id",
OIDC_CLIENT_SECRET: "test-client-secret",
BETTER_AUTH_SECRET: "test-secret",
BETTER_AUTH_URL: "http://localhost:3000",
NODE_ENV: "test",
};
const { initAuth, getAuth } = await reimportAuth();
await initAuth();
expect(getAuth()).toBeDefined();
});
it("uses DB config and decrypts clientSecret when DB has enabled provider", async () => {
const dbConfig = {
id: "config-id",
providerId: "okta",
displayName: "Okta",
issuerUrl: "https://okta.example.com",
internalBaseUrl: null,
clientId: "okta-client-id",
clientSecret: "encrypted:okta-secret",
scopes: "openid profile email",
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
dbSelectResult = [dbConfig];
process.env = {
...originalEnv,
BETTER_AUTH_SECRET: "test-secret",
BETTER_AUTH_URL: "http://localhost:3000",
NODE_ENV: "test",
};
const { initAuth, getAuth } = await reimportAuth();
await initAuth();
expect(getAuth()).toBeDefined();
expect(mockDecryptSecret).toHaveBeenCalledWith("encrypted:okta-secret");
});
it("throws when BETTER_AUTH_SECRET is missing and AUTH_DISABLED is not set", async () => {
process.env = {
...originalEnv,
OIDC_ISSUER: "",
OIDC_CLIENT_ID: "",
OIDC_CLIENT_SECRET: "",
NODE_ENV: "test",
};
delete process.env.BETTER_AUTH_SECRET;
delete process.env.AUTH_DISABLED;
const { initAuth } = await reimportAuth();
await expect(initAuth()).rejects.toThrow(
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
);
});
it("builds placeholder auth when AUTH_DISABLED=true without throwing", async () => {
process.env = {
...originalEnv,
AUTH_DISABLED: "true",
NODE_ENV: "test",
};
delete process.env.BETTER_AUTH_SECRET;
const { initAuth, getAuth } = await reimportAuth();
await expect(initAuth()).resolves.toBeUndefined();
expect(getAuth()).toBeDefined();
});
});
+3 -2
View File
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { auth } from "./lib/auth.js";
import { getAuth, initAuth } from "./lib/auth.js";
import { clientsRouter } from "./routes/clients.js";
import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js";
@@ -99,7 +99,7 @@ api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono();
authRouter.all("/*", (c) => auth.handler(c.req.raw));
authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
api.route("/auth", authRouter);
// ── Role guards ────────────────────────────────────────────────────────────────
@@ -168,6 +168,7 @@ api.route("/admin/seed", adminSeedRouter);
api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
console.log(`API server listening on port ${port}`);
serve({ fetch: app.fetch, port });
+171 -50
View File
@@ -1,61 +1,182 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { getDb } from "@groombook/db";
import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } from "@groombook/db";
const OIDC_ISSUER = process.env.OIDC_ISSUER;
const OIDC_INTERNAL_BASE = process.env.OIDC_INTERNAL_BASE; // e.g. http://authentik-server.auth.svc.cluster.local
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID;
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") {
throw new Error(
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
);
// Auth instance — initialized lazily via initAuth()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let authInstance: any = null;
let authInitPromise: Promise<void> | null = null;
/** Returns the current auth instance. Throws if not yet initialized. */
export function getAuth() {
if (!authInstance) {
throw new Error(
"Auth not initialized. Call initAuth() at startup before handling requests."
);
}
return authInstance;
}
export const auth = betterAuth({
database: drizzleAdapter(getDb(), {
provider: "pg",
}),
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
plugins: [
genericOAuth({
config: [
{
providerId: "authentik",
clientId: OIDC_CLIENT_ID ?? "",
clientSecret: OIDC_CLIENT_SECRET ?? "",
// When OIDC_INTERNAL_BASE is set, use explicit URLs to avoid hairpin NAT:
// - authorizationUrl: external (browser redirect, no server-side fetch)
// - tokenUrl/userInfoUrl: internal (server-to-server, avoids hairpin)
// When not set, fall back to discoveryUrl for local dev.
...(OIDC_INTERNAL_BASE
? {
authorizationUrl: `${new URL(OIDC_ISSUER!).origin}/application/o/authorize/`,
tokenUrl: `${OIDC_INTERNAL_BASE}/application/o/token/`,
userInfoUrl: `${OIDC_INTERNAL_BASE}/application/o/userinfo/`,
}
: {
discoveryUrl: OIDC_ISSUER
? `${OIDC_ISSUER}/.well-known/openid-configuration`
: undefined,
}),
scopes: ["openid", "profile", "email"],
/** Returns a promise that resolves when auth is initialized. */
export function getAuthPromise() {
return authInitPromise;
}
/**
* Initializes the Better-Auth instance.
*
* Config resolution chain:
* 1. Query auth_provider_config table for an enabled provider
* 2. If DB config exists → use it (decrypt clientSecret)
* 3. If no DB config → fall back to OIDC_* env vars
* 4. If neither → auth is unconfigured (getAuth() returns null, AUTH_DISABLED implied)
*
* Idempotent — subsequent calls return immediately after initialization completes.
*/
export async function initAuth(): Promise<void> {
if (authInstance) return; // Already initialized
if (authInitPromise) {
await authInitPromise;
return;
}
authInitPromise = (async () => {
// Guard: require BETTER_AUTH_SECRET unless explicitly in dev/demo mode
if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") {
throw new Error(
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
);
}
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
// config so auth.handler exists (middleware bypasses it anyway)
if (process.env.AUTH_DISABLED === "true") {
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
authInstance = betterAuth({
database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL,
plugins: [
genericOAuth({
config: [
{
providerId: "authentik",
clientId: "placeholder",
clientSecret: "placeholder",
discoveryUrl: undefined,
scopes: ["openid", "profile", "email"],
},
],
}),
],
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: { enabled: false },
},
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
});
return;
}
// Step 1: Try to load config from DB
const db = getDb();
const [dbConfig] = await db
.select()
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
let providerConfig: {
providerId: string;
clientId: string;
clientSecret: string;
issuerUrl: string;
internalBaseUrl?: string;
scopes: string;
};
if (dbConfig) {
// Step 2: Use DB config (decrypt clientSecret)
const decryptedSecret = decryptSecret(dbConfig.clientSecret);
providerConfig = {
providerId: dbConfig.providerId,
clientId: dbConfig.clientId,
clientSecret: decryptedSecret,
issuerUrl: dbConfig.issuerUrl,
internalBaseUrl: dbConfig.internalBaseUrl ?? undefined,
scopes: dbConfig.scopes,
};
console.log("[auth] Using DB config for provider:", dbConfig.providerId);
} else {
// Step 3: Fall back to env vars
const oidcIssuer = process.env.OIDC_ISSUER;
const oidcClientId = process.env.OIDC_CLIENT_ID;
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET;
if (!oidcIssuer || !oidcClientId || !oidcClientSecret) {
// Step 4: Neither DB config nor env vars — auth is unconfigured
console.warn(
"[auth] No auth provider configured. Set up auth_provider_config in DB or OIDC_* env vars."
);
return; // authInstance stays null — AUTH_DISABLED mode
}
providerConfig = {
providerId: "authentik",
clientId: oidcClientId,
clientSecret: oidcClientSecret,
issuerUrl: oidcIssuer,
internalBaseUrl: process.env.OIDC_INTERNAL_BASE,
scopes: "openid profile email",
};
console.log("[auth] Using env var config (no DB config found)");
}
// Build Better-Auth instance using resolved config
authInstance = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
plugins: [
genericOAuth({
config: [
{
providerId: providerConfig.providerId,
clientId: providerConfig.clientId,
clientSecret: providerConfig.clientSecret,
...(providerConfig.internalBaseUrl
? {
authorizationUrl: `${new URL(providerConfig.issuerUrl).origin}/application/o/authorize/`,
tokenUrl: `${providerConfig.internalBaseUrl}/application/o/token/`,
userInfoUrl: `${providerConfig.internalBaseUrl}/application/o/userinfo/`,
}
: {
discoveryUrl: `${providerConfig.issuerUrl}/.well-known/openid-configuration`,
}),
scopes: providerConfig.scopes.split(" ").filter(Boolean),
},
],
}),
],
}),
],
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
},
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
});
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
},
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
});
})();
await authInitPromise;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { auth } from "../lib/auth.js";
import { getAuth } from "../lib/auth.js";
export interface AuthUser {
id: string;
@@ -37,7 +37,7 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
return;
}
const session = await auth.api.getSession({
const session = await getAuth().api.getSession({
headers: c.req.raw.headers,
});