- expiresIn: 7 days (was 24 hours) - updateAge: 2 hours idle timeout with sliding window - Explicit cookie attributes: httpOnly, secure in production, sameSite=lax - Existing sessions expire naturally under old rules Refs #414 Co-Authored-By: Claude Opus 4.6 <[email protected]>
393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import type { PrismaClient } from "@prisma/client";
|
|
|
|
// Mock better-auth modules to inspect genericOAuth plugin configuration
|
|
const mockGenericOAuth = vi.fn().mockReturnValue({ id: "generic-oauth" });
|
|
const mockBetterAuth = vi.fn().mockReturnValue({ handler: vi.fn() });
|
|
const mockPrismaAdapter = vi.fn().mockReturnValue({});
|
|
|
|
vi.mock("better-auth/plugins", () => ({
|
|
genericOAuth: (...args: unknown[]) => mockGenericOAuth(...args),
|
|
}));
|
|
|
|
vi.mock("better-auth", () => ({
|
|
betterAuth: (...args: unknown[]) => mockBetterAuth(...args),
|
|
}));
|
|
|
|
vi.mock("better-auth/adapters/prisma", () => ({
|
|
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
|
|
}));
|
|
|
|
import {
|
|
isOidcEnabled,
|
|
validateOidcConfig,
|
|
createAuth,
|
|
getTrustedOrigins,
|
|
} 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;
|
|
delete process.env.OIDC_REDIRECT_URI;
|
|
delete process.env.NODE_ENV;
|
|
delete process.env.NEXT_PUBLIC_APP_URL;
|
|
delete process.env.NEXT_PUBLIC_API_URL;
|
|
delete process.env.TRUSTED_ORIGINS;
|
|
});
|
|
|
|
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";
|
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
|
|
|
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";
|
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
|
|
|
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";
|
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
|
|
|
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
|
|
});
|
|
|
|
it("should throw when OIDC_REDIRECT_URI is missing", () => {
|
|
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
|
|
|
expect(() => validateOidcConfig()).toThrow("OIDC_REDIRECT_URI");
|
|
});
|
|
|
|
it("should throw when all required vars are missing", () => {
|
|
expect(() => validateOidcConfig()).toThrow(
|
|
"OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI"
|
|
);
|
|
});
|
|
|
|
it("should throw when vars are empty strings", () => {
|
|
process.env.OIDC_ISSUER = "";
|
|
process.env.OIDC_CLIENT_ID = "";
|
|
process.env.OIDC_CLIENT_SECRET = "";
|
|
process.env.OIDC_REDIRECT_URI = "";
|
|
|
|
expect(() => validateOidcConfig()).toThrow(
|
|
"OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI"
|
|
);
|
|
});
|
|
|
|
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";
|
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
|
|
|
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";
|
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
|
|
|
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";
|
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
|
|
|
expect(() => validateOidcConfig()).not.toThrow();
|
|
});
|
|
|
|
it("should suggest disabling OIDC in error message", () => {
|
|
expect(() => validateOidcConfig()).toThrow("OIDC_ENABLED=false");
|
|
});
|
|
|
|
describe("OIDC_REDIRECT_URI validation", () => {
|
|
beforeEach(() => {
|
|
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";
|
|
});
|
|
|
|
it("should throw when OIDC_REDIRECT_URI is not a valid URL", () => {
|
|
process.env.OIDC_REDIRECT_URI = "not-a-url";
|
|
|
|
expect(() => validateOidcConfig()).toThrow("OIDC_REDIRECT_URI must be a valid URL");
|
|
expect(() => validateOidcConfig()).toThrow("not-a-url");
|
|
});
|
|
|
|
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/callback", () => {
|
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback";
|
|
|
|
expect(() => validateOidcConfig()).toThrow(
|
|
'OIDC_REDIRECT_URI path must start with "/auth/callback"'
|
|
);
|
|
expect(() => validateOidcConfig()).toThrow("/oauth/callback");
|
|
});
|
|
|
|
it("should accept a valid OIDC_REDIRECT_URI with /auth/callback path", () => {
|
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
|
|
|
expect(() => validateOidcConfig()).not.toThrow();
|
|
});
|
|
|
|
it("should accept OIDC_REDIRECT_URI with exactly /auth/callback path", () => {
|
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback";
|
|
|
|
expect(() => validateOidcConfig()).not.toThrow();
|
|
});
|
|
|
|
it("should warn but not throw when using localhost in production", () => {
|
|
process.env.NODE_ENV = "production";
|
|
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
|
|
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
expect(() => validateOidcConfig()).not.toThrow();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("OIDC_REDIRECT_URI uses localhost")
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("should warn but not throw when using 127.0.0.1 in production", () => {
|
|
process.env.NODE_ENV = "production";
|
|
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/callback/authentik";
|
|
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
expect(() => validateOidcConfig()).not.toThrow();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("OIDC_REDIRECT_URI uses localhost")
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("should not warn about localhost when not in production", () => {
|
|
process.env.NODE_ENV = "development";
|
|
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
|
|
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
expect(() => validateOidcConfig()).not.toThrow();
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("createAuth - genericOAuth PKCE configuration", () => {
|
|
beforeEach(() => {
|
|
mockGenericOAuth.mockClear();
|
|
mockBetterAuth.mockClear();
|
|
mockPrismaAdapter.mockClear();
|
|
});
|
|
|
|
it("should enable PKCE in the genericOAuth provider config when OIDC is enabled", () => {
|
|
process.env.OIDC_ENABLED = "true";
|
|
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";
|
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
|
|
|
const mockPrisma = {} as PrismaClient;
|
|
createAuth(mockPrisma);
|
|
|
|
expect(mockGenericOAuth).toHaveBeenCalledOnce();
|
|
const callArgs = mockGenericOAuth.mock.calls[0][0] as {
|
|
config: Array<{ pkce?: boolean }>;
|
|
};
|
|
expect(callArgs.config[0].pkce).toBe(true);
|
|
});
|
|
|
|
it("should not call genericOAuth when OIDC is disabled", () => {
|
|
process.env.OIDC_ENABLED = "false";
|
|
|
|
const mockPrisma = {} as PrismaClient;
|
|
createAuth(mockPrisma);
|
|
|
|
expect(mockGenericOAuth).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("createAuth - session and cookie configuration", () => {
|
|
beforeEach(() => {
|
|
mockGenericOAuth.mockClear();
|
|
mockBetterAuth.mockClear();
|
|
mockPrismaAdapter.mockClear();
|
|
});
|
|
|
|
it("should configure session expiresIn to 7 days (604800 seconds)", () => {
|
|
const mockPrisma = {} as PrismaClient;
|
|
createAuth(mockPrisma);
|
|
|
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
|
const config = mockBetterAuth.mock.calls[0][0] as {
|
|
session: { expiresIn: number; updateAge: number };
|
|
};
|
|
expect(config.session.expiresIn).toBe(604800);
|
|
});
|
|
|
|
it("should configure session updateAge to 2 hours (7200 seconds)", () => {
|
|
const mockPrisma = {} as PrismaClient;
|
|
createAuth(mockPrisma);
|
|
|
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
|
const config = mockBetterAuth.mock.calls[0][0] as {
|
|
session: { expiresIn: number; updateAge: number };
|
|
};
|
|
expect(config.session.updateAge).toBe(7200);
|
|
});
|
|
|
|
it("should set httpOnly cookie attribute to true", () => {
|
|
const mockPrisma = {} as PrismaClient;
|
|
createAuth(mockPrisma);
|
|
|
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
|
const config = mockBetterAuth.mock.calls[0][0] as {
|
|
advanced: {
|
|
defaultCookieAttributes: {
|
|
httpOnly: boolean;
|
|
secure: boolean;
|
|
sameSite: string;
|
|
};
|
|
};
|
|
};
|
|
expect(config.advanced.defaultCookieAttributes.httpOnly).toBe(true);
|
|
});
|
|
|
|
it("should set sameSite cookie attribute to lax", () => {
|
|
const mockPrisma = {} as PrismaClient;
|
|
createAuth(mockPrisma);
|
|
|
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
|
const config = mockBetterAuth.mock.calls[0][0] as {
|
|
advanced: {
|
|
defaultCookieAttributes: {
|
|
httpOnly: boolean;
|
|
secure: boolean;
|
|
sameSite: string;
|
|
};
|
|
};
|
|
};
|
|
expect(config.advanced.defaultCookieAttributes.sameSite).toBe("lax");
|
|
});
|
|
|
|
it("should set secure cookie attribute to true in production", () => {
|
|
process.env.NODE_ENV = "production";
|
|
const mockPrisma = {} as PrismaClient;
|
|
createAuth(mockPrisma);
|
|
|
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
|
const config = mockBetterAuth.mock.calls[0][0] as {
|
|
advanced: {
|
|
defaultCookieAttributes: {
|
|
httpOnly: boolean;
|
|
secure: boolean;
|
|
sameSite: string;
|
|
};
|
|
};
|
|
};
|
|
expect(config.advanced.defaultCookieAttributes.secure).toBe(true);
|
|
});
|
|
|
|
it("should set secure cookie attribute to false in non-production", () => {
|
|
process.env.NODE_ENV = "development";
|
|
const mockPrisma = {} as PrismaClient;
|
|
createAuth(mockPrisma);
|
|
|
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
|
const config = mockBetterAuth.mock.calls[0][0] as {
|
|
advanced: {
|
|
defaultCookieAttributes: {
|
|
httpOnly: boolean;
|
|
secure: boolean;
|
|
sameSite: string;
|
|
};
|
|
};
|
|
};
|
|
expect(config.advanced.defaultCookieAttributes.secure).toBe(false);
|
|
});
|
|
});
|
|
});
|