feat(#413): implement GET /auth/config discovery endpoint
All checks were successful
ci/woodpecker/push/api Pipeline was successful

- 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 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 11:14:51 -06:00
parent a9090aca7f
commit 2d59c4b2e4
4 changed files with 103 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ describe("AuthController", () => {
const mockAuthService = { const mockAuthService = {
getAuth: vi.fn(), getAuth: vi.fn(),
getNodeHandler: vi.fn().mockReturnValue(mockNodeHandler), getNodeHandler: vi.fn().mockReturnValue(mockNodeHandler),
getAuthConfig: vi.fn(),
}; };
beforeEach(async () => { 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", () => { describe("getSession", () => {
it("should return user and session data", () => { it("should return user and session data", () => {
const mockUser: AuthUser = { const mockUser: AuthUser = {

View File

@@ -4,6 +4,7 @@ import {
Req, Req,
Res, Res,
Get, Get,
Header,
UseGuards, UseGuards,
Request, Request,
Logger, Logger,
@@ -12,7 +13,7 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler"; import { Throttle } from "@nestjs/throttler";
import type { Request as ExpressRequest, Response as ExpressResponse } from "express"; 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 { AuthService } from "./auth.service";
import { AuthGuard } from "./guards/auth.guard"; import { AuthGuard } from "./guards/auth.guard";
import { CurrentUser } from "./decorators/current-user.decorator"; import { CurrentUser } from "./decorators/current-user.decorator";
@@ -89,6 +90,17 @@ export class AuthController {
return profile; 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.) * Handle all other auth routes (sign-in, sign-up, sign-out, etc.)
* Delegates to BetterAuth * Delegates to BetterAuth

View File

@@ -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", () => { describe("verifySession", () => {
const mockSessionData = { const mockSessionData = {
user: { user: {

View File

@@ -2,8 +2,9 @@ import { Injectable, Logger } from "@nestjs/common";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { IncomingMessage, ServerResponse } from "http"; import type { IncomingMessage, ServerResponse } from "http";
import { toNodeHandler } from "better-auth/node"; import { toNodeHandler } from "better-auth/node";
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { createAuth, type Auth } from "./auth.config"; import { createAuth, isOidcEnabled, type Auth } from "./auth.config";
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -103,4 +104,18 @@ export class AuthService {
return null; 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 };
}
} }