Files
cameleer-server/docs/superpowers/plans/2026-04-26-auth-harmonization.md

1168 lines
45 KiB
Markdown
Raw Normal View History

# 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).