feat(#413): implement GET /auth/config discovery endpoint
All checks were successful
ci/woodpecker/push/api Pipeline was successful
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:
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user