Files
cameleer-server/docs/superpowers/plans/2026-04-26-auth-harmonization.md
hsiegeln f1aa1ea19f docs(auth): implementation plan for login routing harmonization
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>
2026-04-26 18:46:55 +02:00

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