diff --git a/.env.example b/.env.example index 5337042..c0205d9 100644 --- a/.env.example +++ b/.env.example @@ -49,7 +49,12 @@ KNOWLEDGE_CACHE_TTL=300 # ====================== # Authentication (Authentik OIDC) # ====================== -# Authentik Server URLs +# Set to 'true' to enable OIDC authentication with Authentik +# When enabled, OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET are required +OIDC_ENABLED=false + +# Authentik Server URLs (required when OIDC_ENABLED=true) +# OIDC_ISSUER must end with a trailing slash (/) OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/ OIDC_CLIENT_ID=your-client-id-here OIDC_CLIENT_SECRET=your-client-secret-here diff --git a/apps/api/src/auth/auth.config.spec.ts b/apps/api/src/auth/auth.config.spec.ts new file mode 100644 index 0000000..cdf422c --- /dev/null +++ b/apps/api/src/auth/auth.config.spec.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { isOidcEnabled, validateOidcConfig } from "./auth.config"; + +describe("auth.config", () => { + // Store original env vars to restore after each test + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear relevant env vars before each test + delete process.env.OIDC_ENABLED; + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; + }); + + afterEach(() => { + // Restore original env vars + process.env = { ...originalEnv }; + }); + + describe("isOidcEnabled", () => { + it("should return false when OIDC_ENABLED is not set", () => { + expect(isOidcEnabled()).toBe(false); + }); + + it("should return false when OIDC_ENABLED is 'false'", () => { + process.env.OIDC_ENABLED = "false"; + expect(isOidcEnabled()).toBe(false); + }); + + it("should return false when OIDC_ENABLED is '0'", () => { + process.env.OIDC_ENABLED = "0"; + expect(isOidcEnabled()).toBe(false); + }); + + it("should return false when OIDC_ENABLED is empty string", () => { + process.env.OIDC_ENABLED = ""; + expect(isOidcEnabled()).toBe(false); + }); + + it("should return true when OIDC_ENABLED is 'true'", () => { + process.env.OIDC_ENABLED = "true"; + expect(isOidcEnabled()).toBe(true); + }); + + it("should return true when OIDC_ENABLED is '1'", () => { + process.env.OIDC_ENABLED = "1"; + expect(isOidcEnabled()).toBe(true); + }); + }); + + describe("validateOidcConfig", () => { + describe("when OIDC is disabled", () => { + it("should not throw when OIDC_ENABLED is not set", () => { + expect(() => validateOidcConfig()).not.toThrow(); + }); + + it("should not throw when OIDC_ENABLED is false even if vars are missing", () => { + process.env.OIDC_ENABLED = "false"; + // Intentionally not setting any OIDC vars + expect(() => validateOidcConfig()).not.toThrow(); + }); + }); + + describe("when OIDC is enabled", () => { + beforeEach(() => { + process.env.OIDC_ENABLED = "true"; + }); + + it("should throw when OIDC_ISSUER is missing", () => { + process.env.OIDC_CLIENT_ID = "test-client-id"; + process.env.OIDC_CLIENT_SECRET = "test-client-secret"; + + expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER"); + expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled"); + }); + + it("should throw when OIDC_CLIENT_ID is missing", () => { + process.env.OIDC_ISSUER = "https://auth.example.com/"; + process.env.OIDC_CLIENT_SECRET = "test-client-secret"; + + expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID"); + }); + + it("should throw when OIDC_CLIENT_SECRET is missing", () => { + process.env.OIDC_ISSUER = "https://auth.example.com/"; + process.env.OIDC_CLIENT_ID = "test-client-id"; + + expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET"); + }); + + it("should throw when all required vars are missing", () => { + expect(() => validateOidcConfig()).toThrow( + "OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET" + ); + }); + + it("should throw when vars are empty strings", () => { + process.env.OIDC_ISSUER = ""; + process.env.OIDC_CLIENT_ID = ""; + process.env.OIDC_CLIENT_SECRET = ""; + + expect(() => validateOidcConfig()).toThrow( + "OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET" + ); + }); + + it("should throw when vars are whitespace only", () => { + process.env.OIDC_ISSUER = " "; + process.env.OIDC_CLIENT_ID = "test-client-id"; + process.env.OIDC_CLIENT_SECRET = "test-client-secret"; + + expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER"); + }); + + it("should throw when OIDC_ISSUER does not end with trailing slash", () => { + process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic"; + process.env.OIDC_CLIENT_ID = "test-client-id"; + process.env.OIDC_CLIENT_SECRET = "test-client-secret"; + + expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash"); + expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic"); + }); + + it("should not throw with valid complete configuration", () => { + process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/"; + process.env.OIDC_CLIENT_ID = "test-client-id"; + process.env.OIDC_CLIENT_SECRET = "test-client-secret"; + + expect(() => validateOidcConfig()).not.toThrow(); + }); + + it("should suggest disabling OIDC in error message", () => { + expect(() => validateOidcConfig()).toThrow("OIDC_ENABLED=false"); + }); + }); + }); +}); diff --git a/apps/api/src/auth/auth.config.ts b/apps/api/src/auth/auth.config.ts index 8abefed..e07b2e4 100644 --- a/apps/api/src/auth/auth.config.ts +++ b/apps/api/src/auth/auth.config.ts @@ -3,7 +3,85 @@ import { prismaAdapter } from "better-auth/adapters/prisma"; import { genericOAuth } from "better-auth/plugins"; import type { PrismaClient } from "@prisma/client"; +/** + * Required OIDC environment variables when OIDC is enabled + */ +const REQUIRED_OIDC_ENV_VARS = ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"] as const; + +/** + * Check if OIDC authentication is enabled via environment variable + */ +export function isOidcEnabled(): boolean { + const enabled = process.env.OIDC_ENABLED; + return enabled === "true" || enabled === "1"; +} + +/** + * Validates OIDC configuration at startup. + * Throws an error if OIDC is enabled but required environment variables are missing. + * + * @throws Error if OIDC is enabled but required vars are missing or empty + */ +export function validateOidcConfig(): void { + if (!isOidcEnabled()) { + // OIDC is disabled, no validation needed + return; + } + + const missingVars: string[] = []; + + for (const envVar of REQUIRED_OIDC_ENV_VARS) { + const value = process.env[envVar]; + if (!value || value.trim() === "") { + missingVars.push(envVar); + } + } + + if (missingVars.length > 0) { + throw new Error( + `OIDC authentication is enabled (OIDC_ENABLED=true) but required environment variables are missing or empty: ${missingVars.join(", ")}. ` + + `Either set these variables or disable OIDC by setting OIDC_ENABLED=false.` + ); + } + + // Additional validation: OIDC_ISSUER should end with a trailing slash for proper discovery URL + const issuer = process.env.OIDC_ISSUER; + if (issuer && !issuer.endsWith("/")) { + throw new Error( + `OIDC_ISSUER must end with a trailing slash (/). Current value: "${issuer}". ` + + `The discovery URL is constructed by appending ".well-known/openid-configuration" to the issuer.` + ); + } +} + +/** + * Get OIDC plugins configuration. + * Returns empty array if OIDC is disabled, otherwise returns configured OAuth plugin. + */ +function getOidcPlugins(): ReturnType[] { + if (!isOidcEnabled()) { + return []; + } + + return [ + genericOAuth({ + config: [ + { + providerId: "authentik", + clientId: process.env.OIDC_CLIENT_ID ?? "", + clientSecret: process.env.OIDC_CLIENT_SECRET ?? "", + discoveryUrl: `${process.env.OIDC_ISSUER ?? ""}.well-known/openid-configuration`, + scopes: ["openid", "profile", "email"], + }, + ], + }), + ]; +} + export function createAuth(prisma: PrismaClient) { + // Validate OIDC configuration at startup - fail fast if misconfigured + validateOidcConfig(); + return betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", @@ -11,19 +89,7 @@ export function createAuth(prisma: PrismaClient) { emailAndPassword: { enabled: true, // Enable for now, can be disabled later }, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "authentik", - clientId: process.env.OIDC_CLIENT_ID ?? "", - clientSecret: process.env.OIDC_CLIENT_SECRET ?? "", - discoveryUrl: `${process.env.OIDC_ISSUER ?? ""}.well-known/openid-configuration`, - scopes: ["openid", "profile", "email"], - }, - ], - }), - ], + plugins: [...getOidcPlugins()], session: { expiresIn: 60 * 60 * 24, // 24 hours updateAge: 60 * 60 * 24, // 24 hours