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);
+ }
+}