fix(auth): restore BetterAuth OIDC flow across api/web/compose
This commit is contained in:
@@ -18,7 +18,13 @@ vi.mock("better-auth/adapters/prisma", () => ({
|
||||
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
|
||||
}));
|
||||
|
||||
import { isOidcEnabled, validateOidcConfig, createAuth, getTrustedOrigins } from "./auth.config";
|
||||
import {
|
||||
isOidcEnabled,
|
||||
validateOidcConfig,
|
||||
createAuth,
|
||||
getTrustedOrigins,
|
||||
getBetterAuthBaseUrl,
|
||||
} from "./auth.config";
|
||||
|
||||
describe("auth.config", () => {
|
||||
// Store original env vars to restore after each test
|
||||
@@ -32,6 +38,7 @@ describe("auth.config", () => {
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
delete process.env.OIDC_REDIRECT_URI;
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.BETTER_AUTH_URL;
|
||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
delete process.env.NEXT_PUBLIC_API_URL;
|
||||
delete process.env.TRUSTED_ORIGINS;
|
||||
@@ -95,7 +102,7 @@ describe("auth.config", () => {
|
||||
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";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||
|
||||
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
||||
expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled");
|
||||
@@ -104,7 +111,7 @@ describe("auth.config", () => {
|
||||
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";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||
|
||||
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID");
|
||||
});
|
||||
@@ -112,7 +119,7 @@ describe("auth.config", () => {
|
||||
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";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||
|
||||
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
|
||||
});
|
||||
@@ -146,7 +153,7 @@ describe("auth.config", () => {
|
||||
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";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||
|
||||
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
||||
});
|
||||
@@ -155,7 +162,7 @@ describe("auth.config", () => {
|
||||
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";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||
|
||||
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash");
|
||||
expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic");
|
||||
@@ -165,7 +172,7 @@ describe("auth.config", () => {
|
||||
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";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||
|
||||
expect(() => validateOidcConfig()).not.toThrow();
|
||||
});
|
||||
@@ -189,30 +196,30 @@ describe("auth.config", () => {
|
||||
expect(() => validateOidcConfig()).toThrow("Parse error:");
|
||||
});
|
||||
|
||||
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/callback", () => {
|
||||
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/oauth2/callback", () => {
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback";
|
||||
|
||||
expect(() => validateOidcConfig()).toThrow(
|
||||
'OIDC_REDIRECT_URI path must start with "/auth/callback"'
|
||||
'OIDC_REDIRECT_URI path must start with "/auth/oauth2/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";
|
||||
it("should accept a valid OIDC_REDIRECT_URI with /auth/oauth2/callback path", () => {
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/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";
|
||||
it("should accept OIDC_REDIRECT_URI with exactly /auth/oauth2/callback path", () => {
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/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";
|
||||
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
@@ -226,7 +233,7 @@ describe("auth.config", () => {
|
||||
|
||||
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";
|
||||
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/oauth2/callback/authentik";
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
@@ -240,7 +247,7 @@ describe("auth.config", () => {
|
||||
|
||||
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";
|
||||
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
@@ -265,16 +272,19 @@ describe("auth.config", () => {
|
||||
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";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||
|
||||
const mockPrisma = {} as PrismaClient;
|
||||
createAuth(mockPrisma);
|
||||
|
||||
expect(mockGenericOAuth).toHaveBeenCalledOnce();
|
||||
const callArgs = mockGenericOAuth.mock.calls[0][0] as {
|
||||
config: Array<{ pkce?: boolean }>;
|
||||
config: Array<{ pkce?: boolean; redirectURI?: string }>;
|
||||
};
|
||||
expect(callArgs.config[0].pkce).toBe(true);
|
||||
expect(callArgs.config[0].redirectURI).toBe(
|
||||
"https://app.example.com/auth/oauth2/callback/authentik"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not call genericOAuth when OIDC is disabled", () => {
|
||||
@@ -290,7 +300,7 @@ describe("auth.config", () => {
|
||||
process.env.OIDC_ENABLED = "true";
|
||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||
// OIDC_CLIENT_ID deliberately not set
|
||||
|
||||
// validateOidcConfig will throw first, so we need to bypass it
|
||||
@@ -307,7 +317,7 @@ describe("auth.config", () => {
|
||||
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_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||
// OIDC_CLIENT_SECRET deliberately not set
|
||||
|
||||
const mockPrisma = {} as PrismaClient;
|
||||
@@ -318,7 +328,7 @@ describe("auth.config", () => {
|
||||
process.env.OIDC_ENABLED = "true";
|
||||
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";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||
// OIDC_ISSUER deliberately not set
|
||||
|
||||
const mockPrisma = {} as PrismaClient;
|
||||
@@ -354,8 +364,7 @@ describe("auth.config", () => {
|
||||
});
|
||||
|
||||
it("should parse TRUSTED_ORIGINS comma-separated values", () => {
|
||||
process.env.TRUSTED_ORIGINS =
|
||||
"https://app.mosaicstack.dev,https://api.mosaicstack.dev";
|
||||
process.env.TRUSTED_ORIGINS = "https://app.mosaicstack.dev,https://api.mosaicstack.dev";
|
||||
|
||||
const origins = getTrustedOrigins();
|
||||
|
||||
@@ -364,8 +373,7 @@ describe("auth.config", () => {
|
||||
});
|
||||
|
||||
it("should trim whitespace from TRUSTED_ORIGINS entries", () => {
|
||||
process.env.TRUSTED_ORIGINS =
|
||||
" https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
|
||||
process.env.TRUSTED_ORIGINS = " https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
|
||||
|
||||
const origins = getTrustedOrigins();
|
||||
|
||||
@@ -552,6 +560,7 @@ describe("auth.config", () => {
|
||||
|
||||
it("should set secure cookie attribute to true in production", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||
const mockPrisma = {} as PrismaClient;
|
||||
createAuth(mockPrisma);
|
||||
|
||||
@@ -624,4 +633,69 @@ describe("auth.config", () => {
|
||||
expect(config.advanced.defaultCookieAttributes.domain).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBetterAuthBaseUrl", () => {
|
||||
it("should prefer BETTER_AUTH_URL when set", () => {
|
||||
process.env.BETTER_AUTH_URL = "https://auth-base.example.com";
|
||||
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||
|
||||
expect(getBetterAuthBaseUrl()).toBe("https://auth-base.example.com");
|
||||
});
|
||||
|
||||
it("should fall back to NEXT_PUBLIC_API_URL when BETTER_AUTH_URL is not set", () => {
|
||||
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||
|
||||
expect(getBetterAuthBaseUrl()).toBe("https://api.example.com");
|
||||
});
|
||||
|
||||
it("should throw when base URL is invalid", () => {
|
||||
process.env.BETTER_AUTH_URL = "not-a-url";
|
||||
|
||||
expect(() => getBetterAuthBaseUrl()).toThrow("BetterAuth base URL must be a valid URL");
|
||||
});
|
||||
|
||||
it("should throw when base URL is missing in production", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
expect(() => getBetterAuthBaseUrl()).toThrow("Missing BetterAuth base URL in production");
|
||||
});
|
||||
|
||||
it("should throw when base URL is not https in production", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.BETTER_AUTH_URL = "http://api.example.com";
|
||||
|
||||
expect(() => getBetterAuthBaseUrl()).toThrow(
|
||||
"BetterAuth base URL must use https in production"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAuth - baseURL wiring", () => {
|
||||
beforeEach(() => {
|
||||
mockBetterAuth.mockClear();
|
||||
mockPrismaAdapter.mockClear();
|
||||
});
|
||||
|
||||
it("should pass BETTER_AUTH_URL into BetterAuth config", () => {
|
||||
process.env.BETTER_AUTH_URL = "https://api.mosaicstack.dev";
|
||||
|
||||
const mockPrisma = {} as PrismaClient;
|
||||
createAuth(mockPrisma);
|
||||
|
||||
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
||||
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
|
||||
expect(config.baseURL).toBe("https://api.mosaicstack.dev");
|
||||
});
|
||||
|
||||
it("should pass NEXT_PUBLIC_API_URL into BetterAuth config when BETTER_AUTH_URL is absent", () => {
|
||||
process.env.NEXT_PUBLIC_API_URL = "https://api.fallback.dev";
|
||||
|
||||
const mockPrisma = {} as PrismaClient;
|
||||
createAuth(mockPrisma);
|
||||
|
||||
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
||||
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
|
||||
expect(config.baseURL).toBe("https://api.fallback.dev");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user