From 4f6e7ea4dcb4dd99fa26bfc084440d771c07bd35 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:10:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20AuthCapabilitiesController=20?= =?UTF-8?q?=E2=80=94=20GET=20/api/v1/auth/capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../security/AuthCapabilitiesController.java | 52 ++++++++++ .../AuthCapabilitiesControllerIT.java | 96 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/security/AuthCapabilitiesController.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AuthCapabilitiesControllerIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/AuthCapabilitiesController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/AuthCapabilitiesController.java new file mode 100644 index 00000000..3f08b5ab --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/AuthCapabilitiesController.java @@ -0,0 +1,52 @@ +package com.cameleer.server.app.security; + +import com.cameleer.server.app.dto.AuthCapabilitiesResponse; +import com.cameleer.server.core.security.OidcConfig; +import com.cameleer.server.core.security.OidcConfigRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Optional; + +/** + * Reports auth capabilities so the SPA renders the login page deterministically + * instead of inferring from {@code GET /api/v1/auth/oidc/config} 200/404. + * + *

Unauthenticated by design — the SPA calls this before any sign-in attempt. + * Inherits permit-all from the {@code /api/v1/auth/**} matcher in + * {@link SecurityConfig}. + * + *

Future deferred work (issue #154) extends this same payload with MFA + * enrollment URL and password-reset URL fields. + */ +@RestController +@RequestMapping("/api/v1/auth") +@Tag(name = "Authentication", description = "Login and token refresh endpoints") +public class AuthCapabilitiesController { + + private final OidcConfigRepository oidcConfigRepository; + + public AuthCapabilitiesController(OidcConfigRepository oidcConfigRepository) { + this.oidcConfigRepository = oidcConfigRepository; + } + + @GetMapping("/capabilities") + @Operation(summary = "Auth capabilities for the SPA login page") + @ApiResponse(responseCode = "200", description = "Capabilities resolved") + public ResponseEntity getCapabilities() { + Optional config = oidcConfigRepository.find(); + boolean oidcEnabled = config.isPresent() && config.get().enabled(); + String providerName = oidcEnabled + ? OidcProviderNameDeriver.deriveName(config.get().issuerUri()) + : ""; + + var oidc = new AuthCapabilitiesResponse.Oidc(oidcEnabled, providerName, oidcEnabled); + var local = new AuthCapabilitiesResponse.LocalAccounts(true, oidcEnabled); + return ResponseEntity.ok(new AuthCapabilitiesResponse(oidc, local)); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AuthCapabilitiesControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AuthCapabilitiesControllerIT.java new file mode 100644 index 00000000..3e94ba01 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AuthCapabilitiesControllerIT.java @@ -0,0 +1,96 @@ +package com.cameleer.server.app.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.dto.AuthCapabilitiesResponse; +import com.cameleer.server.core.security.OidcConfig; +import com.cameleer.server.core.security.OidcConfigRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * Integration tests for {@link com.cameleer.server.app.security.AuthCapabilitiesController}. + * Mocks {@link OidcConfigRepository} so each test controls the OIDC state it observes. + */ +class AuthCapabilitiesControllerIT extends AbstractPostgresIT { + + @Autowired private TestRestTemplate restTemplate; + @MockBean private OidcConfigRepository oidcConfigRepository; + + @BeforeEach + void resetMock() { + when(oidcConfigRepository.find()).thenReturn(Optional.empty()); + } + + @Test + void noOidcConfig_returnsLocalOnlyCaps() { + when(oidcConfigRepository.find()).thenReturn(Optional.empty()); + + var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class); + + assertThat(resp.getStatusCode().value()).isEqualTo(200); + assertThat(resp.getBody()).isNotNull(); + assertThat(resp.getBody().oidc().enabled()).isFalse(); + assertThat(resp.getBody().oidc().providerName()).isEqualTo(""); + assertThat(resp.getBody().oidc().primary()).isFalse(); + assertThat(resp.getBody().localAccounts().enabled()).isTrue(); + assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isFalse(); + } + + @Test + void oidcDisabledRow_behavesLikeAbsent() { + OidcConfig disabled = new OidcConfig(false, "https://auth.logto.example/", "client-id", "secret", + "roles", List.of("VIEWER"), true, "name", "sub", "", List.of()); + when(oidcConfigRepository.find()).thenReturn(Optional.of(disabled)); + + var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class); + + assertThat(resp.getStatusCode().value()).isEqualTo(200); + assertThat(resp.getBody().oidc().enabled()).isFalse(); + assertThat(resp.getBody().oidc().primary()).isFalse(); + assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isFalse(); + } + + @Test + void oidcEnabledLogto_returnsOidcPrimaryWithProviderName() { + OidcConfig enabled = new OidcConfig(true, "https://auth.logto.example/", "client-id", "secret", + "roles", List.of("VIEWER"), true, "name", "sub", "", List.of()); + when(oidcConfigRepository.find()).thenReturn(Optional.of(enabled)); + + var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class); + + assertThat(resp.getStatusCode().value()).isEqualTo(200); + assertThat(resp.getBody().oidc().enabled()).isTrue(); + assertThat(resp.getBody().oidc().providerName()).isEqualTo("Logto"); + assertThat(resp.getBody().oidc().primary()).isTrue(); + assertThat(resp.getBody().localAccounts().enabled()).isTrue(); + assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isTrue(); + } + + @Test + void oidcEnabledUnknownProvider_returnsGenericProviderName() { + OidcConfig enabled = new OidcConfig(true, "https://idp.example.com/", "client-id", "secret", + "roles", List.of("VIEWER"), true, "name", "sub", "", List.of()); + when(oidcConfigRepository.find()).thenReturn(Optional.of(enabled)); + + var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class); + + assertThat(resp.getStatusCode().value()).isEqualTo(200); + assertThat(resp.getBody().oidc().providerName()).isEqualTo("Single Sign-On"); + assertThat(resp.getBody().oidc().primary()).isTrue(); + } + + @Test + void endpointIsUnauthenticated() { + var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", String.class); + assertThat(resp.getStatusCode().value()).isEqualTo(200); + } +}