9 tasks, TDD throughout. Backend: OidcProviderNameDeriver utility, AuthCapabilitiesResponse DTO, AuthCapabilitiesController. Frontend: useAuthCapabilities hook, capability-driven LoginPage rewrite, OidcCallback ?local trap removal. Plus docs and manual smoke for the original SaaS-provisioned tenant bug. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
45 KiB
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.
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
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
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
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
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
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
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
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
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:
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:
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
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
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
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
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
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
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
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:
.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
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:
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):
<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
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:
### 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:
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:
- 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
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).