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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,41 @@ const REQUIRED_OIDC_ENV_VARS = [
|
||||
"OIDC_REDIRECT_URI",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Resolve BetterAuth base URL from explicit auth URL or API URL.
|
||||
* BetterAuth uses this to generate absolute callback/error URLs.
|
||||
*/
|
||||
export function getBetterAuthBaseUrl(): string | undefined {
|
||||
const configured = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
if (!configured || configured.trim() === "") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error(
|
||||
"Missing BetterAuth base URL in production. Set BETTER_AUTH_URL (preferred) or NEXT_PUBLIC_API_URL."
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(configured);
|
||||
} catch (urlError: unknown) {
|
||||
const detail = urlError instanceof Error ? urlError.message : String(urlError);
|
||||
throw new Error(
|
||||
`BetterAuth base URL must be a valid URL. Current value: "${configured}". Parse error: ${detail}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`BetterAuth base URL must use https in production. Current value: "${configured}".`
|
||||
);
|
||||
}
|
||||
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OIDC authentication is enabled via environment variable
|
||||
*/
|
||||
@@ -59,17 +94,17 @@ export function validateOidcConfig(): void {
|
||||
);
|
||||
}
|
||||
|
||||
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/callback path
|
||||
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/oauth2/callback path
|
||||
validateRedirectUri();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the OIDC_REDIRECT_URI environment variable.
|
||||
* - Must be a parseable URL
|
||||
* - Path must start with /auth/callback
|
||||
* - Path must start with /auth/oauth2/callback
|
||||
* - Warns (but does not throw) if using localhost in production
|
||||
*
|
||||
* @throws Error if URL is invalid or path does not start with /auth/callback
|
||||
* @throws Error if URL is invalid or path does not start with /auth/oauth2/callback
|
||||
*/
|
||||
function validateRedirectUri(): void {
|
||||
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
||||
@@ -86,14 +121,14 @@ function validateRedirectUri(): void {
|
||||
throw new Error(
|
||||
`OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` +
|
||||
`Parse error: ${detail}. ` +
|
||||
`Example: "https://app.example.com/auth/callback/authentik".`
|
||||
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed.pathname.startsWith("/auth/callback")) {
|
||||
if (!parsed.pathname.startsWith("/auth/oauth2/callback")) {
|
||||
throw new Error(
|
||||
`OIDC_REDIRECT_URI path must start with "/auth/callback". Current path: "${parsed.pathname}". ` +
|
||||
`Example: "https://app.example.com/auth/callback/authentik".`
|
||||
`OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback". Current path: "${parsed.pathname}". ` +
|
||||
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,6 +155,7 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
||||
const clientId = process.env.OIDC_CLIENT_ID;
|
||||
const clientSecret = process.env.OIDC_CLIENT_SECRET;
|
||||
const issuer = process.env.OIDC_ISSUER;
|
||||
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
||||
|
||||
if (!clientId) {
|
||||
throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set.");
|
||||
@@ -130,6 +166,9 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
||||
if (!issuer) {
|
||||
throw new Error("OIDC_ISSUER is required when OIDC is enabled but was not set.");
|
||||
}
|
||||
if (!redirectUri) {
|
||||
throw new Error("OIDC_REDIRECT_URI is required when OIDC is enabled but was not set.");
|
||||
}
|
||||
|
||||
return [
|
||||
genericOAuth({
|
||||
@@ -139,6 +178,7 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
||||
clientId,
|
||||
clientSecret,
|
||||
discoveryUrl: `${issuer}.well-known/openid-configuration`,
|
||||
redirectURI: redirectUri,
|
||||
pkce: true,
|
||||
scopes: ["openid", "profile", "email"],
|
||||
},
|
||||
@@ -203,7 +243,10 @@ export function createAuth(prisma: PrismaClient) {
|
||||
// Validate OIDC configuration at startup - fail fast if misconfigured
|
||||
validateOidcConfig();
|
||||
|
||||
const baseURL = getBetterAuthBaseUrl();
|
||||
|
||||
return betterAuth({
|
||||
baseURL,
|
||||
basePath: "/auth",
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql",
|
||||
|
||||
@@ -102,11 +102,46 @@ describe("AuthController", () => {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
expect((err as HttpException).getResponse()).toBe(
|
||||
"Unable to complete authentication. Please try again in a moment.",
|
||||
"Unable to complete authentication. Please try again in a moment."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve better-call status and body for handler APIError", async () => {
|
||||
const apiError = {
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
message: "Invalid OAuth configuration",
|
||||
body: {
|
||||
message: "Invalid OAuth configuration",
|
||||
code: "INVALID_OAUTH_CONFIGURATION",
|
||||
},
|
||||
};
|
||||
mockNodeHandler.mockRejectedValueOnce(apiError);
|
||||
|
||||
const mockRequest = {
|
||||
method: "POST",
|
||||
url: "/auth/sign-in/oauth2",
|
||||
headers: {},
|
||||
ip: "192.168.1.10",
|
||||
socket: { remoteAddress: "192.168.1.10" },
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const mockResponse = {
|
||||
headersSent: false,
|
||||
} as unknown as ExpressResponse;
|
||||
|
||||
try {
|
||||
await controller.handleAuth(mockRequest, mockResponse);
|
||||
expect.unreachable("Expected HttpException to be thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
expect((err as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
|
||||
expect((err as HttpException).getResponse()).toMatchObject({
|
||||
message: "Invalid OAuth configuration",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should log warning and not throw when handler throws after headers sent", async () => {
|
||||
const handlerError = new Error("Stream interrupted");
|
||||
mockNodeHandler.mockRejectedValueOnce(handlerError);
|
||||
@@ -142,9 +177,7 @@ describe("AuthController", () => {
|
||||
headersSent: false,
|
||||
} as unknown as ExpressResponse;
|
||||
|
||||
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(
|
||||
HttpException,
|
||||
);
|
||||
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(HttpException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,7 +220,7 @@ describe("AuthController", () => {
|
||||
OIDC_CLIENT_SECRET: "test-client-secret",
|
||||
OIDC_CLIENT_ID: "test-client-id",
|
||||
OIDC_ISSUER: "https://auth.test.com/",
|
||||
OIDC_REDIRECT_URI: "https://app.test.com/auth/callback/authentik",
|
||||
OIDC_REDIRECT_URI: "https://app.test.com/auth/oauth2/callback/authentik",
|
||||
BETTER_AUTH_SECRET: "test-better-auth-secret",
|
||||
JWT_SECRET: "test-jwt-secret",
|
||||
CSRF_SECRET: "test-csrf-secret",
|
||||
@@ -296,11 +329,9 @@ describe("AuthController", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||
"Missing authentication context",
|
||||
"Missing authentication context"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -313,22 +344,18 @@ describe("AuthController", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||
"Missing authentication context",
|
||||
"Missing authentication context"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
|
||||
const mockRequest = {};
|
||||
|
||||
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||
"Missing authentication context",
|
||||
"Missing authentication context"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -401,9 +428,7 @@ describe("AuthController", () => {
|
||||
|
||||
await controller.handleAuth(mockRequest, mockResponse);
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("203.0.113.50"),
|
||||
);
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
||||
});
|
||||
|
||||
it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
|
||||
@@ -423,13 +448,9 @@ describe("AuthController", () => {
|
||||
|
||||
await controller.handleAuth(mockRequest, mockResponse);
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("203.0.113.50"),
|
||||
);
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
||||
// Ensure it does NOT contain the second IP in the extracted position
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.not.stringContaining("70.41.3.18"),
|
||||
);
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining("70.41.3.18"));
|
||||
});
|
||||
|
||||
it("should extract first IP from X-Forwarded-For as array", async () => {
|
||||
@@ -449,9 +470,7 @@ describe("AuthController", () => {
|
||||
|
||||
await controller.handleAuth(mockRequest, mockResponse);
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("203.0.113.50"),
|
||||
);
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
||||
});
|
||||
|
||||
it("should fallback to req.ip when no X-Forwarded-For header", async () => {
|
||||
@@ -471,9 +490,7 @@ describe("AuthController", () => {
|
||||
|
||||
await controller.handleAuth(mockRequest, mockResponse);
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("192.168.1.100"),
|
||||
);
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("192.168.1.100"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,6 +133,11 @@ export class AuthController {
|
||||
);
|
||||
|
||||
if (!res.headersSent) {
|
||||
const mappedError = this.mapToHttpException(error);
|
||||
if (mappedError) {
|
||||
throw mappedError;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
"Unable to complete authentication. Please try again in a moment.",
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
@@ -159,4 +164,45 @@ export class AuthController {
|
||||
// Fall back to direct IP
|
||||
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve known HTTP errors from BetterAuth/better-call instead of converting
|
||||
* every failure into a generic 500.
|
||||
*/
|
||||
private mapToHttpException(error: unknown): HttpException | null {
|
||||
if (error instanceof HttpException) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (!error || typeof error !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusCode = "statusCode" in error ? error.statusCode : undefined;
|
||||
if (!this.isHttpStatus(statusCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const responseBody = "body" in error && error.body !== undefined ? error.body : undefined;
|
||||
if (
|
||||
responseBody !== undefined &&
|
||||
responseBody !== null &&
|
||||
(typeof responseBody === "string" || typeof responseBody === "object")
|
||||
) {
|
||||
return new HttpException(responseBody, statusCode);
|
||||
}
|
||||
|
||||
const message =
|
||||
"message" in error && typeof error.message === "string" && error.message.length > 0
|
||||
? error.message
|
||||
: "Authentication request failed";
|
||||
return new HttpException(message, statusCode);
|
||||
}
|
||||
|
||||
private isHttpStatus(value: unknown): value is number {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
return false;
|
||||
}
|
||||
return value >= 400 && value <= 599;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user