1168 lines
45 KiB
Markdown
1168 lines
45 KiB
Markdown
|
|
# Auth Harmonization (Login Routing) Implementation Plan
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Replace today's `prompt=none → /login?local` trap with a capability-driven login UX so a fresh SaaS-provisioned tenant server lands users on Logto's hosted sign-in page, while preserving `?local` as an explicit admin-recovery escape hatch.
|
||
|
|
|
||
|
|
**Architecture:** New unauthenticated `GET /api/v1/auth/capabilities` endpoint reports `{oidc:{enabled, providerName, primary}, localAccounts:{enabled, adminRecoveryOnly}}`. SPA renders LoginPage deterministically based on the response. Silent SSO (`prompt=none`) is removed; the IdP itself decides whether to silent-redirect when a session exists. `OidcCallback` no longer falls back to the local form on `login_required`.
|
||
|
|
|
||
|
|
**Tech Stack:** Spring Boot 3.4 (Java 17, JUnit 5, AssertJ, Spring Boot Test + Testcontainers via `AbstractPostgresIT`, `@MockBean`), React 18 / TypeScript / TanStack Query / openapi-typescript / Vitest + React Testing Library, react-router v6.
|
||
|
|
|
||
|
|
**Spec:** `docs/superpowers/specs/2026-04-26-auth-harmonization-design.md`
|
||
|
|
|
||
|
|
**Deferred / out of scope:** MFA enrollment + enforcement, password reset for local accounts — tracked in [issue #154](https://gitea.siegeln.net/cameleer/cameleer-server/issues/154).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File Structure
|
||
|
|
|
||
|
|
| File | Purpose | Status |
|
||
|
|
|---|---|---|
|
||
|
|
| `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcProviderNameDeriver.java` | Pure utility — issuer URI → display label (Logto / Keycloak / Auth0 / Okta / Single Sign-On) | New |
|
||
|
|
| `cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java` | DTO record `{oidc:{enabled, providerName, primary}, localAccounts:{enabled, adminRecoveryOnly}}` | New |
|
||
|
|
| `cameleer-server-app/src/main/java/com/cameleer/server/app/security/AuthCapabilitiesController.java` | `GET /api/v1/auth/capabilities` (permit-all) | New |
|
||
|
|
| `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcProviderNameDeriverTest.java` | JUnit, no Spring — pattern-match coverage | New |
|
||
|
|
| `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AuthCapabilitiesControllerIT.java` | IT — extends `AbstractPostgresIT`, `@MockBean OidcConfigRepository`, drives via `TestRestTemplate` | New |
|
||
|
|
| `ui/src/api/queries/auth.ts` | `useAuthCapabilities()` hook — `staleTime: Infinity` | New |
|
||
|
|
| `ui/src/api/queries/auth.test.tsx` | Vitest — hook returns the capabilities body | New |
|
||
|
|
| `ui/src/auth/LoginPage.tsx` | Rewrite render switch on capabilities; drop `prompt=none`; lazy `/auth/oidc/config` on click; admin-recovery banner | Modify |
|
||
|
|
| `ui/src/auth/LoginPage.test.tsx` | Vitest + RTL — four render states + authorize URL has no `prompt=none` | New |
|
||
|
|
| `ui/src/auth/LoginPage.module.css` | Add `.adminRecoveryBanner` + secondary `?local` link styles | Modify |
|
||
|
|
| `ui/src/auth/OidcCallback.tsx` | Delete `login_required`/`interaction_required` → `?local` redirect block; replace with retry render | Modify |
|
||
|
|
| `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts` | Regenerated via `npm run generate-api:live` after the controller is up | Regenerated |
|
||
|
|
| `.claude/rules/app-classes.md` | New "Auth (flat)" subsection documenting the controller | Modify |
|
||
|
|
| `CLAUDE.md` | One-paragraph note under "Security" on the capability-gated login model | Modify |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 1: `OidcProviderNameDeriver` utility (TDD)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcProviderNameDeriver.java`
|
||
|
|
- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcProviderNameDeriverTest.java`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test**
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.cameleer.server.app.security;
|
||
|
|
|
||
|
|
import org.junit.jupiter.api.Test;
|
||
|
|
|
||
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
|
|
||
|
|
/** Unit tests for {@link OidcProviderNameDeriver}. No Spring context. */
|
||
|
|
class OidcProviderNameDeriverTest {
|
||
|
|
|
||
|
|
@Test
|
||
|
|
void logtoIssuer_returnsLogto() {
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName("https://auth.logto.example/")).isEqualTo("Logto");
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName("https://logto.cameleer.local")).isEqualTo("Logto");
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
void keycloakIssuer_returnsKeycloak() {
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName("https://keycloak.example/realms/cameleer")).isEqualTo("Keycloak");
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
void auth0Issuer_returnsAuth0() {
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName("https://example.auth0.com/")).isEqualTo("Auth0");
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
void oktaIssuer_returnsOkta() {
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName("https://dev-123.okta.com/")).isEqualTo("Okta");
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName("https://login.oktapreview.com/")).isEqualTo("Okta");
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
void unknownIssuer_returnsGenericLabel() {
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName("https://idp.example.com/")).isEqualTo("Single Sign-On");
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
void blankOrNullIssuer_returnsGenericLabel() {
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName("")).isEqualTo("Single Sign-On");
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName(null)).isEqualTo("Single Sign-On");
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName(" ")).isEqualTo("Single Sign-On");
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
void malformedUri_returnsGenericLabel() {
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName("not a url")).isEqualTo("Single Sign-On");
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
void caseInsensitiveMatching() {
|
||
|
|
assertThat(OidcProviderNameDeriver.deriveName("https://AUTH.LOGTO.EXAMPLE/")).isEqualTo("Logto");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `mvn -pl cameleer-server-app test -Dtest=OidcProviderNameDeriverTest -DfailIfNoTests=false`
|
||
|
|
|
||
|
|
Expected: COMPILATION FAILURE — `OidcProviderNameDeriver` symbol not found.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Write minimal implementation**
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.cameleer.server.app.security;
|
||
|
|
|
||
|
|
import java.net.URI;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Pure utility — derives a display label for an OIDC provider from its issuer URI.
|
||
|
|
* Used by {@link AuthCapabilitiesController} so the SPA can render
|
||
|
|
* "Sign in with {providerName}" on the login page.
|
||
|
|
*
|
||
|
|
* <p>Pattern-match only — never network-discover. If the issuer doesn't match a
|
||
|
|
* known vendor pattern, we return the generic "Single Sign-On" label rather than
|
||
|
|
* leaking hostnames into the UI.
|
||
|
|
*/
|
||
|
|
public final class OidcProviderNameDeriver {
|
||
|
|
|
||
|
|
private static final String GENERIC = "Single Sign-On";
|
||
|
|
|
||
|
|
private OidcProviderNameDeriver() {}
|
||
|
|
|
||
|
|
public static String deriveName(String issuerUri) {
|
||
|
|
if (issuerUri == null || issuerUri.isBlank()) {
|
||
|
|
return GENERIC;
|
||
|
|
}
|
||
|
|
String host;
|
||
|
|
try {
|
||
|
|
URI uri = URI.create(issuerUri.trim());
|
||
|
|
host = uri.getHost();
|
||
|
|
} catch (IllegalArgumentException e) {
|
||
|
|
return GENERIC;
|
||
|
|
}
|
||
|
|
if (host == null || host.isBlank()) {
|
||
|
|
return GENERIC;
|
||
|
|
}
|
||
|
|
String h = host.toLowerCase();
|
||
|
|
if (h.contains("logto")) return "Logto";
|
||
|
|
if (h.contains("keycloak")) return "Keycloak";
|
||
|
|
if (h.endsWith("auth0.com")) return "Auth0";
|
||
|
|
if (h.endsWith("okta.com") || h.endsWith("oktapreview.com")) return "Okta";
|
||
|
|
return GENERIC;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run test to verify it passes**
|
||
|
|
|
||
|
|
Run: `mvn -pl cameleer-server-app test -Dtest=OidcProviderNameDeriverTest`
|
||
|
|
|
||
|
|
Expected: 7 tests passing.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcProviderNameDeriver.java \
|
||
|
|
cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcProviderNameDeriverTest.java
|
||
|
|
git commit -m "feat(auth): OidcProviderNameDeriver — issuer URI → display label"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 2: `AuthCapabilitiesResponse` DTO
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java`
|
||
|
|
|
||
|
|
DTO has no behaviour, so no isolated unit test — it's exercised by the controller IT in Task 3.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the DTO**
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.cameleer.server.app.dto;
|
||
|
|
|
||
|
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||
|
|
|
||
|
|
@Schema(description = "Authentication capabilities reported to the SPA so it can render the login page deterministically")
|
||
|
|
public record AuthCapabilitiesResponse(
|
||
|
|
@Schema(description = "OIDC interactive login capability") Oidc oidc,
|
||
|
|
@Schema(description = "Local username/password account capability") LocalAccounts localAccounts
|
||
|
|
) {
|
||
|
|
|
||
|
|
@Schema(description = "OIDC interactive login")
|
||
|
|
public record Oidc(
|
||
|
|
@Schema(description = "Whether OIDC is configured AND enabled") boolean enabled,
|
||
|
|
@Schema(description = "Best-effort display label, e.g. \"Logto\", \"Keycloak\", \"Single Sign-On\"") String providerName,
|
||
|
|
@Schema(description = "When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set") boolean primary
|
||
|
|
) {}
|
||
|
|
|
||
|
|
@Schema(description = "Local username/password accounts")
|
||
|
|
public record LocalAccounts(
|
||
|
|
@Schema(description = "Whether the local form is reachable at all") boolean enabled,
|
||
|
|
@Schema(description = "When true, the SPA gates the local form behind ?local with an admin-recovery banner") boolean adminRecoveryOnly
|
||
|
|
) {}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify it compiles**
|
||
|
|
|
||
|
|
Run: `mvn -pl cameleer-server-app compile -q`
|
||
|
|
|
||
|
|
Expected: BUILD SUCCESS.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java
|
||
|
|
git commit -m "feat(auth): AuthCapabilitiesResponse DTO"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 3: `AuthCapabilitiesController` (TDD)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/AuthCapabilitiesController.java`
|
||
|
|
- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AuthCapabilitiesControllerIT.java`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing IT**
|
||
|
|
|
||
|
|
```java
|
||
|
|
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() {
|
||
|
|
// No Authorization header — must still return 200, not 401.
|
||
|
|
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", String.class);
|
||
|
|
assertThat(resp.getStatusCode().value()).isEqualTo(200);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run the IT to verify it fails**
|
||
|
|
|
||
|
|
Run: `mvn -pl cameleer-server-app verify -Dit.test=AuthCapabilitiesControllerIT -DfailIfNoTests=false`
|
||
|
|
|
||
|
|
Expected: COMPILATION FAILURE — `AuthCapabilitiesController` not found.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement the controller**
|
||
|
|
|
||
|
|
```java
|
||
|
|
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));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run the IT to verify it passes**
|
||
|
|
|
||
|
|
Run: `mvn -pl cameleer-server-app verify -Dit.test=AuthCapabilitiesControllerIT`
|
||
|
|
|
||
|
|
Expected: 5 tests passing.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run the broader test suite to confirm nothing else regressed**
|
||
|
|
|
||
|
|
Run: `mvn -pl cameleer-server-app verify -DskipITs=false`
|
||
|
|
|
||
|
|
Expected: BUILD SUCCESS, no regressions.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/AuthCapabilitiesController.java \
|
||
|
|
cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AuthCapabilitiesControllerIT.java
|
||
|
|
git commit -m "feat(auth): AuthCapabilitiesController — GET /api/v1/auth/capabilities"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 4: Regenerate OpenAPI types for the SPA
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify (regenerated): `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts`
|
||
|
|
|
||
|
|
Per `CLAUDE.md`: every controller change requires regenerating the SPA types.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Start the backend**
|
||
|
|
|
||
|
|
In one terminal:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
mvn -pl cameleer-server-app spring-boot:run
|
||
|
|
```
|
||
|
|
|
||
|
|
Wait for the line `Started CameleerServerApplication in N.NNN seconds`. The server listens on `:8081`.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Regenerate types**
|
||
|
|
|
||
|
|
In another terminal:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd ui && npm run generate-api:live
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: `openapi.json` updated, `schema.d.ts` regenerated. The new path `/api/v1/auth/capabilities` should appear in `openapi.json`.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify the type is exposed**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
grep -c "/api/v1/auth/capabilities" ui/src/api/openapi.json
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: at least `1`.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Stop the backend**
|
||
|
|
|
||
|
|
Ctrl-C in the backend terminal.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
|
||
|
|
git commit -m "chore(api): regenerate OpenAPI types for /auth/capabilities"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 5: `useAuthCapabilities()` hook (TDD)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `ui/src/api/queries/auth.ts`
|
||
|
|
- Test: `ui/src/api/queries/auth.test.tsx`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test**
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
|
|
import { renderHook, waitFor } from '@testing-library/react';
|
||
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||
|
|
import type { ReactNode } from 'react';
|
||
|
|
|
||
|
|
vi.mock('../client', () => ({ api: { GET: vi.fn() } }));
|
||
|
|
|
||
|
|
import { api as apiClient } from '../client';
|
||
|
|
import { useAuthCapabilities } from './auth';
|
||
|
|
|
||
|
|
function wrapper({ children }: { children: ReactNode }) {
|
||
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
|
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('useAuthCapabilities', () => {
|
||
|
|
beforeEach(() => vi.clearAllMocks());
|
||
|
|
|
||
|
|
it('returns the capabilities body on success', async () => {
|
||
|
|
(apiClient.GET as any).mockResolvedValue({
|
||
|
|
data: {
|
||
|
|
oidc: { enabled: true, providerName: 'Logto', primary: true },
|
||
|
|
localAccounts: { enabled: true, adminRecoveryOnly: true },
|
||
|
|
},
|
||
|
|
error: null,
|
||
|
|
});
|
||
|
|
|
||
|
|
const { result } = renderHook(() => useAuthCapabilities(), { wrapper });
|
||
|
|
|
||
|
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||
|
|
expect(result.current.data?.oidc.enabled).toBe(true);
|
||
|
|
expect(result.current.data?.oidc.providerName).toBe('Logto');
|
||
|
|
expect(result.current.data?.localAccounts.adminRecoveryOnly).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('exposes error state when the request fails', async () => {
|
||
|
|
(apiClient.GET as any).mockResolvedValue({
|
||
|
|
data: undefined,
|
||
|
|
error: { message: 'boom' },
|
||
|
|
});
|
||
|
|
|
||
|
|
const { result } = renderHook(() => useAuthCapabilities(), { wrapper });
|
||
|
|
|
||
|
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
|
|
||
|
|
Run: `cd ui && npx vitest run src/api/queries/auth.test.tsx`
|
||
|
|
|
||
|
|
Expected: FAIL — `useAuthCapabilities` cannot be imported (file doesn't exist).
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement the hook**
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { useQuery } from '@tanstack/react-query';
|
||
|
|
import { api } from '../client';
|
||
|
|
import type { components } from '../schema';
|
||
|
|
|
||
|
|
export type AuthCapabilities = components['schemas']['AuthCapabilitiesResponse'];
|
||
|
|
|
||
|
|
export function useAuthCapabilities() {
|
||
|
|
return useQuery<AuthCapabilities>({
|
||
|
|
queryKey: ['auth', 'capabilities'],
|
||
|
|
queryFn: async () => {
|
||
|
|
const { data, error } = await api.GET('/auth/capabilities');
|
||
|
|
if (error || !data) throw new Error('Failed to load auth capabilities');
|
||
|
|
return data as AuthCapabilities;
|
||
|
|
},
|
||
|
|
staleTime: Infinity,
|
||
|
|
retry: false,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
|
|
||
|
|
Run: `cd ui && npx vitest run src/api/queries/auth.test.tsx`
|
||
|
|
|
||
|
|
Expected: 2 tests passing.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/api/queries/auth.ts ui/src/api/queries/auth.test.tsx
|
||
|
|
git commit -m "feat(ui): useAuthCapabilities hook"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 6: Rewrite `LoginPage.tsx` (TDD)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `ui/src/auth/LoginPage.tsx`
|
||
|
|
- Modify: `ui/src/auth/LoginPage.module.css`
|
||
|
|
- Test: `ui/src/auth/LoginPage.test.tsx`
|
||
|
|
|
||
|
|
The four render states from the spec §4:
|
||
|
|
|
||
|
|
| Caps state | `?local` | Result |
|
||
|
|
|---|---|---|
|
||
|
|
| `oidc.primary=true, adminRecoveryOnly=true` | absent | SSO button only, small "Admin recovery →" link |
|
||
|
|
| `oidc.primary=true, adminRecoveryOnly=true` | present | local form + amber banner + "Back to SSO" link |
|
||
|
|
| `oidc.enabled=false` | either | local form only |
|
||
|
|
| caps load failed | either | local form + degraded banner |
|
||
|
|
|
||
|
|
Crucial change: SSO button click builds the authorize URL **without** `prompt=none` and lazily fetches `/auth/oidc/config` only on click.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing test**
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||
|
|
import { MemoryRouter } from 'react-router';
|
||
|
|
import type { ReactNode } from 'react';
|
||
|
|
|
||
|
|
vi.mock('../api/client', () => ({ api: { GET: vi.fn() } }));
|
||
|
|
vi.mock('./auth-store', () => ({
|
||
|
|
useAuthStore: Object.assign(
|
||
|
|
() => ({ isAuthenticated: false, login: vi.fn(), loading: false, error: null }),
|
||
|
|
{ setState: vi.fn() }
|
||
|
|
),
|
||
|
|
}));
|
||
|
|
|
||
|
|
import { api as apiClient } from '../api/client';
|
||
|
|
import { LoginPage } from './LoginPage';
|
||
|
|
|
||
|
|
function wrapper(initialEntries: string[]) {
|
||
|
|
return ({ children }: { children: ReactNode }) => {
|
||
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
|
|
return (
|
||
|
|
<QueryClientProvider client={qc}>
|
||
|
|
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
|
||
|
|
</QueryClientProvider>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function mockCaps(body: any) {
|
||
|
|
(apiClient.GET as any).mockImplementation((path: string) => {
|
||
|
|
if (path === '/auth/capabilities') return Promise.resolve({ data: body, error: null });
|
||
|
|
if (path === '/auth/oidc/config') return Promise.resolve({
|
||
|
|
data: {
|
||
|
|
clientId: 'spa-client',
|
||
|
|
authorizationEndpoint: 'https://auth.logto.example/oidc/auth',
|
||
|
|
resource: 'https://api.cameleer.local',
|
||
|
|
additionalScopes: [],
|
||
|
|
},
|
||
|
|
error: null,
|
||
|
|
});
|
||
|
|
return Promise.resolve({ data: undefined, error: { message: 'unexpected' } });
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('LoginPage', () => {
|
||
|
|
beforeEach(() => vi.clearAllMocks());
|
||
|
|
|
||
|
|
it('SSO primary, no ?local: renders SSO button only and admin-recovery link, no local form', async () => {
|
||
|
|
mockCaps({
|
||
|
|
oidc: { enabled: true, providerName: 'Logto', primary: true },
|
||
|
|
localAccounts: { enabled: true, adminRecoveryOnly: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<LoginPage />, { wrapper: wrapper(['/login']) });
|
||
|
|
|
||
|
|
expect(await screen.findByRole('button', { name: /sign in with logto/i })).toBeInTheDocument();
|
||
|
|
expect(screen.queryByLabelText(/username/i)).toBeNull();
|
||
|
|
expect(screen.queryByLabelText(/password/i)).toBeNull();
|
||
|
|
expect(screen.getByRole('link', { name: /admin recovery/i })).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('SSO primary, ?local present: renders local form with amber recovery banner and back-to-SSO link', async () => {
|
||
|
|
mockCaps({
|
||
|
|
oidc: { enabled: true, providerName: 'Logto', primary: true },
|
||
|
|
localAccounts: { enabled: true, adminRecoveryOnly: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<LoginPage />, { wrapper: wrapper(['/login?local']) });
|
||
|
|
|
||
|
|
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||
|
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||
|
|
expect(screen.getByText(/admin recovery/i)).toBeInTheDocument();
|
||
|
|
expect(screen.getByRole('link', { name: /back to sso/i })).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('OIDC disabled: renders local form only, no SSO button', async () => {
|
||
|
|
mockCaps({
|
||
|
|
oidc: { enabled: false, providerName: '', primary: false },
|
||
|
|
localAccounts: { enabled: true, adminRecoveryOnly: false },
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<LoginPage />, { wrapper: wrapper(['/login']) });
|
||
|
|
|
||
|
|
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||
|
|
expect(screen.queryByRole('button', { name: /sign in with/i })).toBeNull();
|
||
|
|
expect(screen.queryByText(/admin recovery/i)).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('capabilities request fails: renders degraded local form with warning banner', async () => {
|
||
|
|
(apiClient.GET as any).mockImplementation((path: string) => {
|
||
|
|
if (path === '/auth/capabilities') return Promise.resolve({ data: undefined, error: { message: 'fail' } });
|
||
|
|
return Promise.resolve({ data: undefined, error: { message: 'unexpected' } });
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<LoginPage />, { wrapper: wrapper(['/login']) });
|
||
|
|
|
||
|
|
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||
|
|
expect(screen.getByText(/sign-in options couldn't load/i)).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('SSO button click: navigates to authorize URL WITHOUT prompt=none', async () => {
|
||
|
|
mockCaps({
|
||
|
|
oidc: { enabled: true, providerName: 'Logto', primary: true },
|
||
|
|
localAccounts: { enabled: true, adminRecoveryOnly: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
const originalLocation = window.location;
|
||
|
|
const hrefSetter = vi.fn();
|
||
|
|
Object.defineProperty(window, 'location', {
|
||
|
|
configurable: true,
|
||
|
|
value: { ...originalLocation, get href() { return ''; }, set href(v: string) { hrefSetter(v); } },
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
render(<LoginPage />, { wrapper: wrapper(['/login']) });
|
||
|
|
const btn = await screen.findByRole('button', { name: /sign in with logto/i });
|
||
|
|
fireEvent.click(btn);
|
||
|
|
|
||
|
|
await waitFor(() => expect(hrefSetter).toHaveBeenCalled());
|
||
|
|
const url: string = hrefSetter.mock.calls[0][0];
|
||
|
|
expect(url).toMatch(/^https:\/\/auth\.logto\.example\/oidc\/auth\?/);
|
||
|
|
expect(url).not.toMatch(/prompt=none/);
|
||
|
|
expect(url).toMatch(/response_type=code/);
|
||
|
|
expect(url).toMatch(/client_id=spa-client/);
|
||
|
|
expect(url).toMatch(/scope=/);
|
||
|
|
} finally {
|
||
|
|
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
|
|
||
|
|
Run: `cd ui && npx vitest run src/auth/LoginPage.test.tsx`
|
||
|
|
|
||
|
|
Expected: FAIL — current `LoginPage.tsx` still uses the old `/auth/oidc/config`-only flow with `prompt=none`. The "Sign in with Logto" button name and admin-recovery link don't exist yet.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement the new LoginPage**
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { type FormEvent, useMemo, useState } from 'react';
|
||
|
|
import { Link, Navigate, useSearchParams } from 'react-router';
|
||
|
|
import { useAuthStore } from './auth-store';
|
||
|
|
import { api } from '../api/client';
|
||
|
|
import { config } from '../config';
|
||
|
|
import { useAuthCapabilities } from '../api/queries/auth';
|
||
|
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||
|
|
import brandLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||
|
|
import styles from './LoginPage.module.css';
|
||
|
|
|
||
|
|
const PLATFORM_SCOPES = ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'];
|
||
|
|
|
||
|
|
const SUBTITLES = [
|
||
|
|
"Prove you're not a mirage",
|
||
|
|
"Only authorized cameleers beyond this dune",
|
||
|
|
"Halt, traveler — state your business",
|
||
|
|
"The caravan doesn't move without credentials",
|
||
|
|
"No hitchhikers on this caravan",
|
||
|
|
"This oasis requires a password",
|
||
|
|
"Camels remember faces. We use passwords.",
|
||
|
|
"You shall not pass... without logging in",
|
||
|
|
"The desert is vast. Your session has expired.",
|
||
|
|
"Another day, another dune to authenticate",
|
||
|
|
"Papers, please. The caravan master is watching.",
|
||
|
|
"Trust, but verify — ancient cameleer proverb",
|
||
|
|
"Even the Silk Road had checkpoints",
|
||
|
|
"Your camel is parked outside. Now identify yourself.",
|
||
|
|
"One does not simply walk into the dashboard",
|
||
|
|
"The sands shift, but your password shouldn't",
|
||
|
|
"Unauthorized access? In this economy?",
|
||
|
|
"Welcome back, weary traveler",
|
||
|
|
"The dashboard awaits on the other side of this dune",
|
||
|
|
"Keep calm and authenticate",
|
||
|
|
"Who goes there? Friend or rogue exchange?",
|
||
|
|
"Access denied looks the same in every desert",
|
||
|
|
"May your routes be green and your tokens valid",
|
||
|
|
"Forgot your password? That's between you and the dunes.",
|
||
|
|
"No ticket, no caravan",
|
||
|
|
];
|
||
|
|
|
||
|
|
export function LoginPage() {
|
||
|
|
const { isAuthenticated, login, loading, error } = useAuthStore();
|
||
|
|
const [searchParams] = useSearchParams();
|
||
|
|
const forceLocal = searchParams.has('local');
|
||
|
|
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
||
|
|
const [username, setUsername] = useState('');
|
||
|
|
const [password, setPassword] = useState('');
|
||
|
|
const [oidcLoading, setOidcLoading] = useState(false);
|
||
|
|
|
||
|
|
const { data: caps, isError: capsFailed, isLoading: capsLoading } = useAuthCapabilities();
|
||
|
|
|
||
|
|
if (isAuthenticated) return <Navigate to="/" replace />;
|
||
|
|
if (capsLoading) return null;
|
||
|
|
|
||
|
|
const oidcEnabled = caps?.oidc.enabled === true;
|
||
|
|
const adminRecoveryOnly = caps?.localAccounts.adminRecoveryOnly === true;
|
||
|
|
const providerName = caps?.oidc.providerName || 'Single Sign-On';
|
||
|
|
|
||
|
|
// Render decision
|
||
|
|
const showSsoButton = oidcEnabled;
|
||
|
|
const showLocalForm = !oidcEnabled || forceLocal || !adminRecoveryOnly || capsFailed;
|
||
|
|
const showAdminRecoveryBanner = oidcEnabled && adminRecoveryOnly && forceLocal;
|
||
|
|
const showSsoPrimary = oidcEnabled && adminRecoveryOnly && !forceLocal;
|
||
|
|
|
||
|
|
const handleSubmit = (e: FormEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
login(username, password);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleOidcLogin = async () => {
|
||
|
|
setOidcLoading(true);
|
||
|
|
const { data } = await api.GET('/auth/oidc/config');
|
||
|
|
if (!data?.authorizationEndpoint || !data?.clientId) {
|
||
|
|
setOidcLoading(false);
|
||
|
|
useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (data.endSessionEndpoint) {
|
||
|
|
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
|
||
|
|
}
|
||
|
|
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
||
|
|
const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])];
|
||
|
|
const params = new URLSearchParams({
|
||
|
|
response_type: 'code',
|
||
|
|
client_id: data.clientId,
|
||
|
|
redirect_uri: redirectUri,
|
||
|
|
scope: scopes.join(' '),
|
||
|
|
});
|
||
|
|
if (data.resource) params.set('resource', data.resource);
|
||
|
|
// Note: NO prompt=none. Per RFC 9700 §4.4, that's silent re-auth only;
|
||
|
|
// for first-time login it returns login_required, which traps users.
|
||
|
|
window.location.href = `${data.authorizationEndpoint}?${params}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={styles.page}>
|
||
|
|
<Card className={styles.card}>
|
||
|
|
<div className={styles.loginForm}>
|
||
|
|
<div className={styles.logo}>
|
||
|
|
<img src={brandLogo} alt="" className={styles.logoImg} />
|
||
|
|
cameleer
|
||
|
|
</div>
|
||
|
|
<p className={styles.subtitle}>{subtitle}</p>
|
||
|
|
|
||
|
|
{capsFailed && (
|
||
|
|
<div className={styles.error}>
|
||
|
|
<Alert variant="warning">Sign-in options couldn't load. Refresh or use the form below.</Alert>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{showAdminRecoveryBanner && (
|
||
|
|
<div className={styles.adminRecoveryBanner}>
|
||
|
|
<Alert variant="warning">
|
||
|
|
Admin recovery login. Use SSO for normal sign-in.
|
||
|
|
</Alert>
|
||
|
|
<Link to="/login" className={styles.backToSsoLink}>← Back to SSO</Link>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<div className={styles.error}>
|
||
|
|
<Alert variant="error">{error}</Alert>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{showSsoButton && (showSsoPrimary || !showLocalForm) && (
|
||
|
|
<div className={styles.socialSection}>
|
||
|
|
<Button
|
||
|
|
variant="primary"
|
||
|
|
className={styles.ssoButton}
|
||
|
|
onClick={handleOidcLogin}
|
||
|
|
disabled={oidcLoading}
|
||
|
|
type="button"
|
||
|
|
>
|
||
|
|
{oidcLoading ? 'Redirecting…' : `Sign in with ${providerName}`}
|
||
|
|
</Button>
|
||
|
|
<Link to="/login?local" className={styles.adminRecoveryLink}>
|
||
|
|
Admin recovery →
|
||
|
|
</Link>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{showLocalForm && (
|
||
|
|
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
||
|
|
<FormField label="Username" htmlFor="login-username">
|
||
|
|
<Input
|
||
|
|
id="login-username"
|
||
|
|
value={username}
|
||
|
|
onChange={(e) => setUsername(e.target.value)}
|
||
|
|
placeholder="Enter your username"
|
||
|
|
autoFocus
|
||
|
|
autoComplete="username"
|
||
|
|
disabled={loading}
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
|
||
|
|
<FormField label="Password" htmlFor="login-password">
|
||
|
|
<Input
|
||
|
|
id="login-password"
|
||
|
|
type="password"
|
||
|
|
value={password}
|
||
|
|
onChange={(e) => setPassword(e.target.value)}
|
||
|
|
placeholder="••••••••"
|
||
|
|
autoComplete="current-password"
|
||
|
|
disabled={loading}
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant="primary"
|
||
|
|
type="submit"
|
||
|
|
loading={loading}
|
||
|
|
disabled={loading || !username || !password}
|
||
|
|
className={styles.submitButton}
|
||
|
|
>
|
||
|
|
Sign in
|
||
|
|
</Button>
|
||
|
|
</form>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Add the banner styles**
|
||
|
|
|
||
|
|
Append to `ui/src/auth/LoginPage.module.css`:
|
||
|
|
|
||
|
|
```css
|
||
|
|
.adminRecoveryBanner {
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.adminRecoveryBanner .backToSsoLink {
|
||
|
|
display: inline-block;
|
||
|
|
margin-top: 0.5rem;
|
||
|
|
color: var(--accent);
|
||
|
|
text-decoration: none;
|
||
|
|
font-size: 0.875rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.adminRecoveryBanner .backToSsoLink:hover {
|
||
|
|
text-decoration: underline;
|
||
|
|
}
|
||
|
|
|
||
|
|
.adminRecoveryLink {
|
||
|
|
display: inline-block;
|
||
|
|
margin-top: 0.75rem;
|
||
|
|
color: var(--text-muted);
|
||
|
|
font-size: 0.8125rem;
|
||
|
|
text-decoration: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.adminRecoveryLink:hover {
|
||
|
|
color: var(--accent);
|
||
|
|
text-decoration: underline;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
||
|
|
|
||
|
|
Run: `cd ui && npx vitest run src/auth/LoginPage.test.tsx`
|
||
|
|
|
||
|
|
Expected: 5 tests passing.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Run the full UI test suite**
|
||
|
|
|
||
|
|
Run: `cd ui && npm run test -- --run`
|
||
|
|
|
||
|
|
Expected: BUILD SUCCESS, no regressions.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/auth/LoginPage.tsx ui/src/auth/LoginPage.module.css ui/src/auth/LoginPage.test.tsx
|
||
|
|
git commit -m "feat(ui): capability-driven LoginPage; drop prompt=none silent SSO"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 7: Strip the `?local` trap from `OidcCallback.tsx`
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `ui/src/auth/OidcCallback.tsx`
|
||
|
|
|
||
|
|
The `error=login_required` / `interaction_required` → `/login?local` block can no longer arise (we never request `prompt=none`). If it does (stale tab), show the error with a retry — never trap on the local form.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Apply the edit**
|
||
|
|
|
||
|
|
Replace lines 22-27 (the existing `if (errorParam === 'login_required' || errorParam === 'interaction_required') { ... }` block) with the simpler error path. The full updated `useEffect` body becomes:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
useEffect(() => {
|
||
|
|
if (exchanged.current) return;
|
||
|
|
exchanged.current = true;
|
||
|
|
|
||
|
|
const params = new URLSearchParams(window.location.search);
|
||
|
|
const code = params.get('code');
|
||
|
|
const errorParam = params.get('error');
|
||
|
|
|
||
|
|
if (errorParam) {
|
||
|
|
// consent_required — retry without prompt=none so user can grant scopes
|
||
|
|
if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) {
|
||
|
|
sessionStorage.setItem('oidc-consent-retry', '1');
|
||
|
|
api.GET('/auth/oidc/config').then(({ data }) => {
|
||
|
|
if (data?.authorizationEndpoint && data?.clientId) {
|
||
|
|
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
||
|
|
const PLATFORM_SCOPES = ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'];
|
||
|
|
const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])];
|
||
|
|
const p = new URLSearchParams({
|
||
|
|
response_type: 'code',
|
||
|
|
client_id: data.clientId,
|
||
|
|
redirect_uri: redirectUri,
|
||
|
|
scope: scopes.join(' '),
|
||
|
|
});
|
||
|
|
if (data.resource) p.set('resource', data.resource);
|
||
|
|
window.location.href = `${data.authorizationEndpoint}?${p}`;
|
||
|
|
}
|
||
|
|
}).catch(() => {
|
||
|
|
useAuthStore.setState({ error: 'OIDC consent retry failed.', loading: false });
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
sessionStorage.removeItem('oidc-consent-retry');
|
||
|
|
useAuthStore.setState({
|
||
|
|
error: params.get('error_description') || errorParam,
|
||
|
|
loading: false,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sessionStorage.removeItem('oidc-consent-retry');
|
||
|
|
|
||
|
|
if (!code) {
|
||
|
|
useAuthStore.setState({ error: 'No authorization code received', loading: false });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
||
|
|
loginWithOidcCode(code, redirectUri);
|
||
|
|
}, [loginWithOidcCode]);
|
||
|
|
```
|
||
|
|
|
||
|
|
The "Back to Login" button below already navigates to `/login?local` — change it to plain `/login` so the user lands on whatever the capabilities endpoint says is the right page (SSO-primary, with `?local` link still visible):
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<Button variant="secondary" onClick={() => navigate('/login')} className={styles.backButton}>
|
||
|
|
Back to Login
|
||
|
|
</Button>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify the file compiles cleanly**
|
||
|
|
|
||
|
|
Run: `cd ui && npm run typecheck`
|
||
|
|
|
||
|
|
Expected: no TypeScript errors.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Run the broader UI tests**
|
||
|
|
|
||
|
|
Run: `cd ui && npm run test -- --run`
|
||
|
|
|
||
|
|
Expected: BUILD SUCCESS.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/auth/OidcCallback.tsx
|
||
|
|
git commit -m "fix(ui): drop OidcCallback ?local trap on login_required"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 8: Update docs
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `.claude/rules/app-classes.md`
|
||
|
|
- Modify: `CLAUDE.md`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Update `.claude/rules/app-classes.md`**
|
||
|
|
|
||
|
|
Find the section "### Other (flat)" at the end of the controller list. Above it, insert a new subsection:
|
||
|
|
|
||
|
|
```markdown
|
||
|
|
### Auth (flat)
|
||
|
|
|
||
|
|
- `UiAuthController` — `/api/v1/auth` (login, refresh, me). Local username/password against env-var admin or DB BCrypt hash. Lockout after 5 failed attempts.
|
||
|
|
- `OidcAuthController` — `/api/v1/auth/oidc` (config, callback). Code → token exchange. Roles via custom JWT claim, claim mapping rules, or default roles.
|
||
|
|
- `AuthCapabilitiesController` — `GET /api/v1/auth/capabilities` (unauthenticated). Reports `{oidc:{enabled, providerName, primary}, localAccounts:{enabled, adminRecoveryOnly}}` so the SPA renders the login page deterministically. `oidc.primary == oidc.enabled`; `localAccounts.adminRecoveryOnly == oidc.primary`. `providerName` is best-effort label via `OidcProviderNameDeriver` (Logto / Keycloak / Auth0 / Okta / Single Sign-On). The SPA hides the local form behind `?local` when `adminRecoveryOnly` is true.
|
||
|
|
```
|
||
|
|
|
||
|
|
If the existing list already has these entries (e.g., the `UiAuthController` / `OidcAuthController` lines are documented under `security/`), add only the `AuthCapabilitiesController` line in that same section. Verify with:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
grep -n "AuthCapabilitiesController\|UiAuthController" .claude/rules/app-classes.md
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Update `CLAUDE.md`**
|
||
|
|
|
||
|
|
Find the `## Key Conventions` section. After the existing line about Security (search for `Security: JWT auth with RBAC`), insert:
|
||
|
|
|
||
|
|
```markdown
|
||
|
|
- Login routing: `GET /api/v1/auth/capabilities` (unauthenticated) tells the SPA whether OIDC is the primary entry point. When OIDC is configured, the SSO button is the primary CTA and the local form is hidden behind `?local` (admin-recovery escape hatch). Per RFC 9700 §4.4 we do **not** use `prompt=none` for primary login — that returns `login_required` for first-time users and traps them on a local form.
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add .claude/rules/app-classes.md CLAUDE.md
|
||
|
|
git commit -m "docs(auth): document AuthCapabilitiesController + login routing"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 9: Manual smoke (the original bug)
|
||
|
|
|
||
|
|
This is the bug-reproduction scenario from the spec §1. Skip if not running cameleer-saas locally; otherwise verify before declaring done.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Start cameleer-saas locally**
|
||
|
|
|
||
|
|
Per `cameleer-saas/HOWTO.md` — typically `docker-compose up -d` then `mvnw spring-boot:run`.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Provision a fresh tenant**
|
||
|
|
|
||
|
|
Sign in to `https://platform.cameleer.local/` as the vendor admin. Navigate to **Tenants → New tenant**. Fill in slug `harm-test`, name `Harm Test`. Submit.
|
||
|
|
|
||
|
|
Expected: tenant becomes ACTIVE, server container `cameleer-server-harm-test` healthy.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Test the broken-before flow**
|
||
|
|
|
||
|
|
Open a private/incognito window with no Logto session. Navigate to `https://harm-test.cameleer.local/` (or whatever the SaaS-resolved server dashboard URL is).
|
||
|
|
|
||
|
|
Expected (post-fix):
|
||
|
|
- Land directly on Logto's hosted sign-in page — **not** on a local form trap.
|
||
|
|
- "Forgot password?" link visible (Logto's, from the SaaS rework).
|
||
|
|
- Sign in with the admin credentials seeded by the SaaS provisioner.
|
||
|
|
- Redirected back to `https://harm-test.cameleer.local/` and authenticated.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Test admin recovery**
|
||
|
|
|
||
|
|
Open another private window. Navigate directly to `https://harm-test.cameleer.local/login?local`.
|
||
|
|
|
||
|
|
Expected:
|
||
|
|
- Local form rendered with amber "Admin recovery login. Use SSO for normal sign-in." banner.
|
||
|
|
- "← Back to SSO" link visible.
|
||
|
|
- Form rejects Logto credentials with 401 (correct — Logto users are not in the local DB).
|
||
|
|
|
||
|
|
- [ ] **Step 5: Test the standalone-no-OIDC case**
|
||
|
|
|
||
|
|
Run a standalone server without OIDC configured (no DB row in `oidc_config`). Navigate to `/login`.
|
||
|
|
|
||
|
|
Expected:
|
||
|
|
- Local form rendered with no SSO button, no admin-recovery banner — the simplest experience for built-in deployments.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit nothing — this is verification only**
|
||
|
|
|
||
|
|
If the smoke fails, file a bug; otherwise no commit.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Self-Review
|
||
|
|
|
||
|
|
After all tasks, fresh-eyes pass:
|
||
|
|
|
||
|
|
**Spec coverage:**
|
||
|
|
- §2 capability-gated detection → Task 3 (controller logic) + Task 6 (SPA render switch). ✓
|
||
|
|
- §2 OIDC primary, local admin-recovery only → Task 6 SPA states + tests. ✓
|
||
|
|
- §2 prompt=none removed → Task 6 (SSO click handler omits it) + dedicated test. ✓
|
||
|
|
- §2 ?local escape hatch → Task 6 SPA states + Task 9 manual smoke step 4. ✓
|
||
|
|
- §3 capability endpoint shape → Tasks 1-3 (deriver + DTO + controller + IT). ✓
|
||
|
|
- §3 failure mode (caps non-200) → Task 6 test "capabilities request fails: renders degraded local form". ✓
|
||
|
|
- §4 LoginPage branching → Task 6 four-state tests. ✓
|
||
|
|
- §4 OidcCallback trap removal → Task 7. ✓
|
||
|
|
- §5 backend changes → Tasks 1, 2, 3. ✓
|
||
|
|
- §6 frontend changes → Tasks 5, 6, 7. ✓
|
||
|
|
- §7 docs changes → Task 8. ✓
|
||
|
|
- §8 backend tests → Tasks 1, 3 (5 deriver + 5 IT cases). ✓
|
||
|
|
- §8 frontend tests → Tasks 5, 6 (hook + 5 LoginPage cases). ✓
|
||
|
|
- §8 manual smoke → Task 9. ✓
|
||
|
|
|
||
|
|
**Placeholder scan:** No "TBD"/"TODO"/"implement later" — all code is concrete. No "add appropriate error handling" — error paths are explicit (degraded banner, 401 on bad creds).
|
||
|
|
|
||
|
|
**Type consistency:** `AuthCapabilitiesResponse.Oidc` and `AuthCapabilitiesResponse.LocalAccounts` referenced consistently in Tasks 2, 3, 5, 6. SPA uses `caps.oidc.enabled` / `caps.oidc.primary` / `caps.oidc.providerName` / `caps.localAccounts.enabled` / `caps.localAccounts.adminRecoveryOnly` consistently across hook and component. Authorize URL params (`response_type`, `client_id`, `redirect_uri`, `scope`, `resource`) match between the SSO click handler and the consent retry path (Task 7).
|