feat(auth): AuthCapabilitiesController — GET /api/v1/auth/capabilities
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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}.
|
||||||
|
*
|
||||||
|
* <p>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<AuthCapabilitiesResponse> getCapabilities() {
|
||||||
|
Optional<OidcConfig> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user