diff --git a/apps/api/src/__tests__/auth.test.ts b/apps/api/src/__tests__/auth.test.ts new file mode 100644 index 0000000..7b4db22 --- /dev/null +++ b/apps/api/src/__tests__/auth.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e51436b..6fa1df3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 }); diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 8467513..b7d285c 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -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 | 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 { + 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; +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index dbdbb1f..1417614 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -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, });