From f1aa1ea19f956181a0966d315730d7b526774c0e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:46:55 +0200 Subject: [PATCH 1/5] 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) --- .../plans/2026-04-26-auth-harmonization.md | 1167 +++++++++++++++++ 1 file changed, 1167 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-auth-harmonization.md diff --git a/docs/superpowers/plans/2026-04-26-auth-harmonization.md b/docs/superpowers/plans/2026-04-26-auth-harmonization.md new file mode 100644 index 00000000..84f86093 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-auth-harmonization.md @@ -0,0 +1,1167 @@ +# 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. + * + *

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. + * + *

Unauthenticated by design — the SPA calls this before any sign-in attempt. + * Inherits permit-all from the {@code /api/v1/auth/**} matcher in + * {@link SecurityConfig}. + * + *

Future deferred work (issue #154) extends this same payload with MFA + * enrollment URL and password-reset URL fields. + */ +@RestController +@RequestMapping("/api/v1/auth") +@Tag(name = "Authentication", description = "Login and token refresh endpoints") +public class AuthCapabilitiesController { + + private final OidcConfigRepository oidcConfigRepository; + + public AuthCapabilitiesController(OidcConfigRepository oidcConfigRepository) { + this.oidcConfigRepository = oidcConfigRepository; + } + + @GetMapping("/capabilities") + @Operation(summary = "Auth capabilities for the SPA login page") + @ApiResponse(responseCode = "200", description = "Capabilities resolved") + public ResponseEntity getCapabilities() { + Optional config = oidcConfigRepository.find(); + boolean oidcEnabled = config.isPresent() && config.get().enabled(); + String providerName = oidcEnabled + ? OidcProviderNameDeriver.deriveName(config.get().issuerUri()) + : ""; + + var oidc = new AuthCapabilitiesResponse.Oidc(oidcEnabled, providerName, oidcEnabled); + var local = new AuthCapabilitiesResponse.LocalAccounts(true, oidcEnabled); + return ResponseEntity.ok(new AuthCapabilitiesResponse(oidc, local)); + } +} +``` + +- [ ] **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 {children}; +} + +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({ + 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 ( + + {children} + + ); + }; +} + +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(, { 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(, { 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(, { 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(, { 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(, { 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 ; + 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 ( +

+ +
+
+ + cameleer +
+

{subtitle}

+ + {capsFailed && ( +
+ Sign-in options couldn't load. Refresh or use the form below. +
+ )} + + {showAdminRecoveryBanner && ( +
+ + Admin recovery login. Use SSO for normal sign-in. + + ← Back to SSO +
+ )} + + {error && ( +
+ {error} +
+ )} + + {showSsoButton && (showSsoPrimary || !showLocalForm) && ( +
+ + + Admin recovery → + +
+ )} + + {showLocalForm && ( +
+ + setUsername(e.target.value)} + placeholder="Enter your username" + autoFocus + autoComplete="username" + disabled={loading} + /> + + + + setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + disabled={loading} + /> + + + +
+ )} +
+
+
+ ); +} +``` + +- [ ] **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 + +``` + +- [ ] **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). From ddb18c4f175bbfe6a0c45b38552530052c0d4669 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:53:31 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(auth):=20OidcProviderNameDeriver=20?= =?UTF-8?q?=E2=80=94=20issuer=20URI=20=E2=86=92=20display=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../app/security/OidcProviderNameDeriver.java | 41 ++++++++++++++ .../security/OidcProviderNameDeriverTest.java | 53 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcProviderNameDeriver.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcProviderNameDeriverTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcProviderNameDeriver.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcProviderNameDeriver.java new file mode 100644 index 00000000..511b8d31 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcProviderNameDeriver.java @@ -0,0 +1,41 @@ +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. + * + *

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; + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcProviderNameDeriverTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcProviderNameDeriverTest.java new file mode 100644 index 00000000..24d15871 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcProviderNameDeriverTest.java @@ -0,0 +1,53 @@ +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"); + } +} From f945d10d480f74a6f0a251cbac1dabb98250adef Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:57:09 +0200 Subject: [PATCH 3/5] feat(auth): AuthCapabilitiesResponse DTO --- .../app/dto/AuthCapabilitiesResponse.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java new file mode 100644 index 00000000..7466af68 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java @@ -0,0 +1,23 @@ +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 + ) {} +} From 2f7c6aa0059e19a3eeb9a3631763ced98b83ebd5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:59:20 +0200 Subject: [PATCH 4/5] fix(auth): @NotNull on AuthCapabilitiesResponse.Oidc.providerName --- .../com/cameleer/server/app/dto/AuthCapabilitiesResponse.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java index 7466af68..ef2c4b0a 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java @@ -1,6 +1,7 @@ package com.cameleer.server.app.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; @Schema(description = "Authentication capabilities reported to the SPA so it can render the login page deterministically") public record AuthCapabilitiesResponse( @@ -11,7 +12,7 @@ public record AuthCapabilitiesResponse( @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 = "Best-effort display label, e.g. \"Logto\", \"Keycloak\", \"Single Sign-On\"") @NotNull 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 ) {} From cd92036f91e06eb5540027f109fcb519f6d7b252 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:59:38 +0200 Subject: [PATCH 5/5] ci(minter): deploy license-minter JARs to Gitea Maven registry on push Adds at the parent POM and a push-only deploy step in the build job. Selects the parent + core + minter via -pl so both the plain library JAR and the Spring Boot fat CLI JAR are pushed with their full dep tree resolvable; server-app is excluded as a fat-jar runtime, not a library. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitea/workflows/ci.yml | 6 ++++++ pom.xml | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d88e43fa..e2451b4b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -84,6 +84,12 @@ jobs: - name: Build and Test run: mvn clean verify -DskipITs -U --batch-mode + - name: Deploy minter to Maven registry + if: github.event_name == 'push' + run: mvn deploy -DskipTests -DskipITs --batch-mode -pl .,cameleer-server-core,cameleer-license-minter + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + docker: needs: build runs-on: ubuntu-latest diff --git a/pom.xml b/pom.xml index c50c031e..06d1bfd4 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,17 @@ + + + gitea + https://gitea.siegeln.net/api/packages/cameleer/maven + + + gitea + https://gitea.siegeln.net/api/packages/cameleer/maven + + +