From dedc1af0806852dd657b330873cf9dec2c9d5292 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 17 Feb 2026 23:37:49 -0600 Subject: [PATCH] fix(auth): restore BetterAuth OIDC flow across api/web/compose --- .env.example | 9 +- apps/api/src/auth/auth.config.spec.ts | 124 ++++++++++++++---- apps/api/src/auth/auth.config.ts | 57 +++++++- apps/api/src/auth/auth.controller.spec.ts | 81 +++++++----- apps/api/src/auth/auth.controller.ts | 46 +++++++ apps/web/src/app/(auth)/login/page.test.tsx | 2 +- apps/web/src/app/(auth)/login/page.tsx | 4 +- docker-compose.swarm.portainer.yml | 1 + docker-compose.yml | 3 +- docker/docker-compose.build.yml | 3 +- .../2-installation/3-docker-setup.md | 2 +- .../3-configuration/1-environment.md | 4 +- .../3-configuration/2-authentik.md | 32 ++--- .../3-configuration/3-docker.md | 2 +- docs/4-api/2-authentication/1-endpoints.md | 4 +- docs/PORTAINER-DEPLOYMENT.md | 6 +- docs/SWARM-DEPLOYMENT.md | 2 +- docs/plans/auth-frontend-remediation.md | 6 +- .../4-authentik-oidc-final-status.md | 4 +- docs/scratchpads/6-basic-web-ui.md | 4 +- .../86-authentik-oidc-integration.md | 2 +- docs/web-ui-implementation.md | 2 +- 22 files changed, 294 insertions(+), 106 deletions(-) diff --git a/.env.example b/.env.example index 8c656a4..c69e41e 100644 --- a/.env.example +++ b/.env.example @@ -70,9 +70,9 @@ OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/ OIDC_CLIENT_ID=your-client-id-here OIDC_CLIENT_SECRET=your-client-secret-here # Redirect URI must match what's configured in Authentik -# Development: http://localhost:3001/auth/callback/authentik -# Production: https://api.mosaicstack.dev/auth/callback/authentik -OIDC_REDIRECT_URI=http://localhost:3001/auth/callback/authentik +# Development: http://localhost:3001/auth/oauth2/callback/authentik +# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik +OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik # Authentik PostgreSQL Database AUTHENTIK_POSTGRES_USER=authentik @@ -116,6 +116,9 @@ JWT_EXPIRATION=24h # This is used by BetterAuth for session management and CSRF protection # Example: openssl rand -base64 32 BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS +# Optional explicit BetterAuth origin for callback/error URL generation. +# When empty, backend falls back to NEXT_PUBLIC_API_URL. +BETTER_AUTH_URL= # Trusted Origins (comma-separated list of additional trusted origins for CORS and auth) # These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically diff --git a/apps/api/src/auth/auth.config.spec.ts b/apps/api/src/auth/auth.config.spec.ts index 794ebb6..0485eb6 100644 --- a/apps/api/src/auth/auth.config.spec.ts +++ b/apps/api/src/auth/auth.config.spec.ts @@ -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"); + }); + }); }); diff --git a/apps/api/src/auth/auth.config.ts b/apps/api/src/auth/auth.config.ts index d668eb8..d8597fa 100644 --- a/apps/api/src/auth/auth.config.ts +++ b/apps/api/src/auth/auth.config.ts @@ -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[] { 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[] { 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[] { 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", diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts index 2bec348..8f54a32 100644 --- a/apps/api/src/auth/auth.controller.spec.ts +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -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")); }); }); }); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 0152b81..573dda5 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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; + } } diff --git a/apps/web/src/app/(auth)/login/page.test.tsx b/apps/web/src/app/(auth)/login/page.test.tsx index d2b8d57..98a7cce 100644 --- a/apps/web/src/app/(auth)/login/page.test.tsx +++ b/apps/web/src/app/(auth)/login/page.test.tsx @@ -276,7 +276,7 @@ describe("LoginPage", (): void => { expect(mockOAuth2).toHaveBeenCalledWith({ providerId: "authentik", - callbackURL: "/", + callbackURL: "http://localhost:3000/", }); }); diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 1565d8d..195cdcf 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -113,7 +113,9 @@ function LoginPageContent(): ReactElement { const handleOAuthLogin = useCallback((providerId: string): void => { setOauthLoading(providerId); setError(null); - signIn.oauth2({ providerId, callbackURL: "/" }).catch((err: unknown) => { + const callbackURL = + typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/"; + signIn.oauth2({ providerId, callbackURL }).catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message); setError("Unable to connect to the sign-in provider. Please try again in a moment."); diff --git a/docker-compose.swarm.portainer.yml b/docker-compose.swarm.portainer.yml index 0636064..3ff9a63 100644 --- a/docker-compose.swarm.portainer.yml +++ b/docker-compose.swarm.portainer.yml @@ -116,6 +116,7 @@ services: JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret} JWT_EXPIRATION: ${JWT_EXPIRATION:-24h} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + BETTER_AUTH_URL: ${BETTER_AUTH_URL:-} CSRF_SECRET: ${CSRF_SECRET} # External services OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT} diff --git a/docker-compose.yml b/docker-compose.yml index 255e9fd..210f556 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -362,12 +362,13 @@ services: OIDC_ISSUER: ${OIDC_ISSUER} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET} - OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/oauth2/callback/authentik} # JWT JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret} JWT_EXPIRATION: ${JWT_EXPIRATION:-24h} # Better Auth BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + BETTER_AUTH_URL: ${BETTER_AUTH_URL:-} # Encryption (required for federation credentials/private keys) ENCRYPTION_KEY: ${ENCRYPTION_KEY} # Ollama (optional) diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index bd45fa9..cacefce 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -378,12 +378,13 @@ services: OIDC_ISSUER: ${OIDC_ISSUER} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET} - OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/oauth2/callback/authentik} # JWT JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret} JWT_EXPIRATION: ${JWT_EXPIRATION:-24h} # Better Auth BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + BETTER_AUTH_URL: ${BETTER_AUTH_URL:-} # Security CSRF_SECRET: ${CSRF_SECRET} ENCRYPTION_KEY: ${ENCRYPTION_KEY} diff --git a/docs/1-getting-started/2-installation/3-docker-setup.md b/docs/1-getting-started/2-installation/3-docker-setup.md index c5a03f7..0d2c666 100644 --- a/docs/1-getting-started/2-installation/3-docker-setup.md +++ b/docs/1-getting-started/2-installation/3-docker-setup.md @@ -219,7 +219,7 @@ JWT_EXPIRATION=24h OIDC_ISSUER=https://auth.example.com/application/o/mosaic/ OIDC_CLIENT_ID=prod-client-id OIDC_CLIENT_SECRET=prod-client-secret -OIDC_REDIRECT_URI=https://mosaic.example.com/auth/callback +OIDC_REDIRECT_URI=https://mosaic.example.com/auth/oauth2/callback/authentik ``` ### Compose Override for Production diff --git a/docs/1-getting-started/3-configuration/1-environment.md b/docs/1-getting-started/3-configuration/1-environment.md index 1cd827b..28b4505 100644 --- a/docs/1-getting-started/3-configuration/1-environment.md +++ b/docs/1-getting-started/3-configuration/1-environment.md @@ -89,7 +89,7 @@ OIDC_CLIENT_ID=your-client-id OIDC_CLIENT_SECRET=your-client-secret # Callback URL (must match Authentik configuration) -OIDC_REDIRECT_URI=http://localhost:3001/auth/callback +OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik ``` See [Authentik Setup](2-authentik.md) for complete OIDC configuration. @@ -229,7 +229,7 @@ JWT_EXPIRATION=24h OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/ OIDC_CLIENT_ID=your-client-id OIDC_CLIENT_SECRET=your-client-secret -OIDC_REDIRECT_URI=http://localhost:3001/auth/callback +OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik # ====================== # Cache diff --git a/docs/1-getting-started/3-configuration/2-authentik.md b/docs/1-getting-started/3-configuration/2-authentik.md index dceee03..15a4bbe 100644 --- a/docs/1-getting-started/3-configuration/2-authentik.md +++ b/docs/1-getting-started/3-configuration/2-authentik.md @@ -54,17 +54,17 @@ Sign up at [goauthentik.io](https://goauthentik.io) for managed Authentik. 4. **Configure Provider:** - | Field | Value | - | ------------------------------ | ----------------------------------------------- | - | **Name** | Mosaic Stack | - | **Authorization flow** | default-provider-authorization-implicit-consent | - | **Client type** | Confidential | - | **Client ID** | (auto-generated, save this) | - | **Client Secret** | (auto-generated, save this) | - | **Redirect URIs** | `http://localhost:3001/auth/callback` | - | **Scopes** | `openid`, `email`, `profile` | - | **Subject mode** | Based on User's UUID | - | **Include claims in id_token** | ✅ Enabled | + | Field | Value | + | ------------------------------ | ------------------------------------------------------ | + | **Name** | Mosaic Stack | + | **Authorization flow** | default-provider-authorization-implicit-consent | + | **Client type** | Confidential | + | **Client ID** | (auto-generated, save this) | + | **Client Secret** | (auto-generated, save this) | + | **Redirect URIs** | `http://localhost:3001/auth/oauth2/callback/authentik` | + | **Scopes** | `openid`, `email`, `profile` | + | **Subject mode** | Based on User's UUID | + | **Include claims in id_token** | ✅ Enabled | 5. **Click "Create"** @@ -96,7 +96,7 @@ Update your `.env` file: OIDC_ISSUER=http://localhost:9000/application/o/mosaic-stack/ OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= -OIDC_REDIRECT_URI=http://localhost:3001/auth/callback +OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik ``` **Important Notes:** @@ -113,7 +113,7 @@ For production deployments: OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/ OIDC_CLIENT_ID=prod-client-id OIDC_CLIENT_SECRET=prod-client-secret -OIDC_REDIRECT_URI=https://mosaic.example.com/auth/callback +OIDC_REDIRECT_URI=https://mosaic.example.com/auth/oauth2/callback/authentik ``` Update Authentik redirect URIs to match your production URL. @@ -143,7 +143,7 @@ docker compose restart api ```bash # Initiate OIDC flow -curl http://localhost:3001/auth/callback/authentik +curl http://localhost:3001/auth/oauth2/callback/authentik # This will return a redirect URL to Authentik ``` @@ -223,8 +223,8 @@ Customize Authentik's login page: ```bash # Ensure exact match (including http vs https) -# In Authentik: http://localhost:3001/auth/callback -# In .env: OIDC_REDIRECT_URI=http://localhost:3001/auth/callback +# In Authentik: http://localhost:3001/auth/oauth2/callback/authentik +# In .env: OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik ``` ### Error: "Invalid client credentials" diff --git a/docs/1-getting-started/3-configuration/3-docker.md b/docs/1-getting-started/3-configuration/3-docker.md index 767b90d..4cfc6b4 100644 --- a/docs/1-getting-started/3-configuration/3-docker.md +++ b/docs/1-getting-started/3-configuration/3-docker.md @@ -89,7 +89,7 @@ AUTHENTIK_PORT_HTTPS=9443 OIDC_ISSUER=http://localhost:9000/application/o/mosaic-stack/ OIDC_CLIENT_ID=your-client-id-here OIDC_CLIENT_SECRET=your-client-secret-here -OIDC_REDIRECT_URI=http://localhost:3001/auth/callback +OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik ``` **Bootstrap Credentials:** diff --git a/docs/4-api/2-authentication/1-endpoints.md b/docs/4-api/2-authentication/1-endpoints.md index 754dd01..e8a13de 100644 --- a/docs/4-api/2-authentication/1-endpoints.md +++ b/docs/4-api/2-authentication/1-endpoints.md @@ -191,7 +191,7 @@ Authorization: Bearer {session_token} OAuth callback handler for Authentik (and other OIDC providers). ```http -GET /auth/callback/authentik +GET /auth/oauth2/callback/authentik ``` **Query Parameters:** @@ -226,7 +226,7 @@ This endpoint is called by the OIDC provider after successful authentication. 1. User clicks "Sign in with Authentik" 2. Frontend redirects to Authentik 3. User authenticates with Authentik -4. Authentik redirects to /auth/callback/authentik +4. Authentik redirects to /auth/oauth2/callback/authentik 5. Server exchanges code for tokens 6. Server creates/updates user 7. Server creates session diff --git a/docs/PORTAINER-DEPLOYMENT.md b/docs/PORTAINER-DEPLOYMENT.md index 3015964..e331ee7 100644 --- a/docs/PORTAINER-DEPLOYMENT.md +++ b/docs/PORTAINER-DEPLOYMENT.md @@ -111,7 +111,7 @@ If using private registry images from `git.mosaicstack.dev`: OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/ - OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik + OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/oauth2/callback/authentik OLLAMA_ENDPOINT=http://10.1.1.42:11434 ``` @@ -163,7 +163,7 @@ ENCRYPTION_KEY=<64-char-hex> # openssl rand -hex 32 OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/ -OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik +OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/oauth2/callback/authentik # External Ollama OLLAMA_ENDPOINT=http://10.1.1.42:11434 @@ -352,7 +352,7 @@ Update environment variables: ```bash NEXT_PUBLIC_APP_URL=https://mosaic.example.com NEXT_PUBLIC_API_URL=https://api.example.com -OIDC_REDIRECT_URI=https://api.example.com/auth/callback/authentik +OIDC_REDIRECT_URI=https://api.example.com/auth/oauth2/callback/authentik ``` ### Resource Limits diff --git a/docs/SWARM-DEPLOYMENT.md b/docs/SWARM-DEPLOYMENT.md index 86ba0dc..e389f24 100644 --- a/docs/SWARM-DEPLOYMENT.md +++ b/docs/SWARM-DEPLOYMENT.md @@ -369,7 +369,7 @@ sleep 30 OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/ OIDC_CLIENT_ID=your-client-id OIDC_CLIENT_SECRET=your-client-secret - OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik + OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/oauth2/callback/authentik ``` ### Using External PostgreSQL diff --git a/docs/plans/auth-frontend-remediation.md b/docs/plans/auth-frontend-remediation.md index ae98b61..3e457a8 100644 --- a/docs/plans/auth-frontend-remediation.md +++ b/docs/plans/auth-frontend-remediation.md @@ -152,7 +152,7 @@ States: Add `OIDC_REDIRECT_URI` to `REQUIRED_OIDC_ENV_VARS`. Add URL format validation: - Must be a valid URL -- Path must start with `/auth/callback` +- Path must start with `/auth/oauth2/callback` - Warn if using `localhost` in production **Tests to add:** Missing var, invalid URL, invalid path, valid URL. @@ -716,9 +716,9 @@ Browser NestJS API Authentik ├────────────────────────────────────────────────────►│ │ │ User authenticates│ │◄────────────────────────────────────────────────────┤ - │ 302 → /auth/callback/authentik?code=X │ + │ 302 → /auth/oauth2/callback/authentik?code=X │ │ │ │ - │ 5. GET /auth/callback/authentik?code=X │ + │ 5. GET /auth/oauth2/callback/authentik?code=X │ ├───────────────────────────►│ │ │ BetterAuth exchanges code │ │ ├───────────────────────►│ diff --git a/docs/scratchpads/4-authentik-oidc-final-status.md b/docs/scratchpads/4-authentik-oidc-final-status.md index 8c00233..6330847 100644 --- a/docs/scratchpads/4-authentik-oidc-final-status.md +++ b/docs/scratchpads/4-authentik-oidc-final-status.md @@ -166,7 +166,7 @@ To use the authentication system, configure these environment variables: OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/ OIDC_CLIENT_ID=your-client-id OIDC_CLIENT_SECRET=your-client-secret -OIDC_REDIRECT_URI=http://localhost:3001/auth/callback +OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik # JWT Session Management JWT_SECRET=change-this-to-a-random-secret-in-production @@ -186,7 +186,7 @@ BetterAuth provides these endpoints automatically: - `POST /auth/sign-up` - User registration - `POST /auth/sign-out` - Logout - `GET /auth/session` - Get current session -- `GET /auth/callback/authentik` - OAuth callback handler +- `GET /auth/oauth2/callback/authentik` - OAuth callback handler - `GET /auth/profile` - Get authenticated user profile (custom) --- diff --git a/docs/scratchpads/6-basic-web-ui.md b/docs/scratchpads/6-basic-web-ui.md index cc25219..1e8b664 100644 --- a/docs/scratchpads/6-basic-web-ui.md +++ b/docs/scratchpads/6-basic-web-ui.md @@ -188,7 +188,7 @@ All components must follow TDD (tests first), achieve 85%+ coverage, and use PDA ### Existing Auth Implementation (from Issue #4) - BetterAuth is configured in the API (`apps/api/src/auth/`) -- Endpoints: `/auth/callback/authentik`, `/auth/session`, `/auth/profile` +- Endpoints: `/auth/oauth2/callback/authentik`, `/auth/session`, `/auth/profile` - Shared types available in `@mosaic/shared` package - Session-based auth with JWT tokens @@ -313,7 +313,7 @@ Based on existing backend (from Issue #4): - `GET /auth/session` - Get current session - `GET /auth/profile` - Get user profile - `POST /auth/sign-out` - Logout -- `GET /auth/callback/authentik` - OIDC callback (redirect from Authentik) +- `GET /auth/oauth2/callback/authentik` - OIDC callback (redirect from Authentik) ### Tasks (to be implemented in future issue) diff --git a/docs/scratchpads/86-authentik-oidc-integration.md b/docs/scratchpads/86-authentik-oidc-integration.md index 1152fde..87866a0 100644 --- a/docs/scratchpads/86-authentik-oidc-integration.md +++ b/docs/scratchpads/86-authentik-oidc-integration.md @@ -161,7 +161,7 @@ Enhance `ConnectionService` to handle OIDC-based authentication: **Integration Tests**: - POST /auth/initiate starts OIDC flow with correct params -- GET /auth/callback handles OIDC response and creates identity +- GET /auth/oauth2/callback/:providerId handles OIDC response and creates identity - POST /auth/validate validates tokens from federated instances - GET /auth/identities returns user's federated identities - Federated requests with valid tokens are authenticated diff --git a/docs/web-ui-implementation.md b/docs/web-ui-implementation.md index 3adf33a..a325d7a 100644 --- a/docs/web-ui-implementation.md +++ b/docs/web-ui-implementation.md @@ -100,7 +100,7 @@ apps/web/src/ - `GET /auth/session` - Get current session - `POST /auth/sign-out` - Logout -- `GET /auth/callback/authentik` - OIDC callback +- `GET /auth/oauth2/callback/authentik` - OIDC callback ### Future Endpoints (Mock Data Ready) -- 2.49.1