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