From 2d59c4b2e43c38e2865c4437e22dfa9e8971772d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Feb 2026 11:14:51 -0600 Subject: [PATCH] feat(#413): implement GET /auth/config discovery endpoint - Add getAuthConfig() to AuthService (email always, OIDC when enabled) - Add GET /auth/config public endpoint with Cache-Control: 5min - Place endpoint before catch-all to avoid interception Refs #413 Co-Authored-By: Claude Opus 4.6 --- apps/api/src/auth/auth.controller.spec.ts | 35 ++++++++++++++++++++ apps/api/src/auth/auth.controller.ts | 14 +++++++- apps/api/src/auth/auth.service.spec.ts | 39 +++++++++++++++++++++++ apps/api/src/auth/auth.service.ts | 17 +++++++++- 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts index eb11b52..079d498 100644 --- a/apps/api/src/auth/auth.controller.spec.ts +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -14,6 +14,7 @@ describe("AuthController", () => { const mockAuthService = { getAuth: vi.fn(), getNodeHandler: vi.fn().mockReturnValue(mockNodeHandler), + getAuthConfig: vi.fn(), }; beforeEach(async () => { @@ -124,6 +125,40 @@ describe("AuthController", () => { }); }); + describe("getConfig", () => { + it("should return auth config from service", () => { + const mockConfig = { + providers: [ + { id: "email", name: "Email", type: "credentials" as const }, + { id: "authentik", name: "Authentik", type: "oauth" as const }, + ], + }; + mockAuthService.getAuthConfig.mockReturnValue(mockConfig); + + const result = controller.getConfig(); + + expect(result).toEqual(mockConfig); + expect(mockAuthService.getAuthConfig).toHaveBeenCalled(); + }); + + it("should return correct response shape with only email provider", () => { + const mockConfig = { + providers: [{ id: "email", name: "Email", type: "credentials" as const }], + }; + mockAuthService.getAuthConfig.mockReturnValue(mockConfig); + + const result = controller.getConfig(); + + expect(result).toEqual(mockConfig); + expect(result.providers).toHaveLength(1); + expect(result.providers[0]).toEqual({ + id: "email", + name: "Email", + type: "credentials", + }); + }); + }); + describe("getSession", () => { it("should return user and session data", () => { const mockUser: AuthUser = { diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 2d38165..5dd76bf 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -4,6 +4,7 @@ import { Req, Res, Get, + Header, UseGuards, Request, Logger, @@ -12,7 +13,7 @@ import { } from "@nestjs/common"; import { Throttle } from "@nestjs/throttler"; import type { Request as ExpressRequest, Response as ExpressResponse } from "express"; -import type { AuthUser, AuthSession } from "@mosaic/shared"; +import type { AuthUser, AuthSession, AuthConfigResponse } from "@mosaic/shared"; import { AuthService } from "./auth.service"; import { AuthGuard } from "./guards/auth.guard"; import { CurrentUser } from "./decorators/current-user.decorator"; @@ -89,6 +90,17 @@ export class AuthController { return profile; } + /** + * Get available authentication providers. + * Public endpoint (no auth guard) so the frontend can discover login options + * before the user is authenticated. + */ + @Get("config") + @Header("Cache-Control", "public, max-age=300") + getConfig(): AuthConfigResponse { + return this.authService.getAuthConfig(); + } + /** * Handle all other auth routes (sign-in, sign-up, sign-out, etc.) * Delegates to BetterAuth diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index e0f0a81..a5b4e65 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -90,6 +90,45 @@ describe("AuthService", () => { }); }); + describe("getAuthConfig", () => { + it("should return only email provider when OIDC is disabled", () => { + delete process.env.OIDC_ENABLED; + + const result = service.getAuthConfig(); + + expect(result).toEqual({ + providers: [{ id: "email", name: "Email", type: "credentials" }], + }); + }); + + it("should return both email and authentik providers when OIDC is enabled", () => { + process.env.OIDC_ENABLED = "true"; + + const result = service.getAuthConfig(); + + expect(result).toEqual({ + providers: [ + { id: "email", name: "Email", type: "credentials" }, + { id: "authentik", name: "Authentik", type: "oauth" }, + ], + }); + + delete process.env.OIDC_ENABLED; + }); + + it("should return only email provider when OIDC_ENABLED is false", () => { + process.env.OIDC_ENABLED = "false"; + + const result = service.getAuthConfig(); + + expect(result).toEqual({ + providers: [{ id: "email", name: "Email", type: "credentials" }], + }); + + delete process.env.OIDC_ENABLED; + }); + }); + describe("verifySession", () => { const mockSessionData = { user: { diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index d97553f..d5012ca 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -2,8 +2,9 @@ import { Injectable, Logger } from "@nestjs/common"; import type { PrismaClient } from "@prisma/client"; import type { IncomingMessage, ServerResponse } from "http"; import { toNodeHandler } from "better-auth/node"; +import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared"; import { PrismaService } from "../prisma/prisma.service"; -import { createAuth, type Auth } from "./auth.config"; +import { createAuth, isOidcEnabled, type Auth } from "./auth.config"; @Injectable() export class AuthService { @@ -103,4 +104,18 @@ export class AuthService { return null; } } + + /** + * Get authentication configuration for the frontend. + * Returns available auth providers so the UI can render login options dynamically. + */ + getAuthConfig(): AuthConfigResponse { + const providers: AuthProviderConfig[] = [{ id: "email", name: "Email", type: "credentials" }]; + + if (isOidcEnabled()) { + providers.push({ id: "authentik", name: "Authentik", type: "oauth" }); + } + + return { providers }; + } }