Merge feature/auth-harmonization: capability-driven login UX
Replaces the prompt=none → /login?local trap with a deterministic capability endpoint (GET /api/v1/auth/capabilities). LoginPage renders SSO-primary or local form based on caps; ?local is the explicit admin-recovery escape hatch. Drops prompt=none from the SSO authorize URL per RFC 9700 §4.4. Adds Vitest + IT coverage and docs. MFA enrollment / enforcement deferred to issue #154.
This commit is contained in:
@@ -112,6 +112,12 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
|||||||
- `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag).
|
- `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag).
|
||||||
- `ServerMetricsAdminController` — `/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Visibility matches ClickHouse/Database admin: `@ConditionalOnProperty(infrastructureendpoints, matchIfMissing=true)` + class-level `@PreAuthorize("hasRole('ADMIN')")`. Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket.
|
- `ServerMetricsAdminController` — `/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Visibility matches ClickHouse/Database admin: `@ConditionalOnProperty(infrastructureendpoints, matchIfMissing=true)` + class-level `@PreAuthorize("hasRole('ADMIN')")`. Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
### Other (flat)
|
### Other (flat)
|
||||||
|
|
||||||
- `DetailController` — GET `/api/v1/executions/{executionId}` + processor snapshot endpoints.
|
- `DetailController` — GET `/api/v1/executions/{executionId}` + processor snapshot endpoints.
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
|
|||||||
- Log processor correlation: The agent sets `cameleer.processorId` in MDC, identifying which processor node emitted a log line.
|
- Log processor correlation: The agent sets `cameleer.processorId` in MDC, identifying which processor node emitted a log line.
|
||||||
- Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml
|
- Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml
|
||||||
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` (comma-separated) overrides `CAMELEER_SERVER_SECURITY_UIORIGIN` for multi-origin setups. Infrastructure access: `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false` disables Database and ClickHouse admin endpoints. Last-ADMIN guard: system prevents removal of the last ADMIN role (409 Conflict). Password policy: min 12 chars, 3-of-4 character classes, no username match. Brute-force protection: 5 failed attempts -> 15 min lockout. Token revocation: `token_revoked_before` column on users, checked in `JwtAuthenticationFilter`, set on password change.
|
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` (comma-separated) overrides `CAMELEER_SERVER_SECURITY_UIORIGIN` for multi-origin setups. Infrastructure access: `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false` disables Database and ClickHouse admin endpoints. Last-ADMIN guard: system prevents removal of the last ADMIN role (409 Conflict). Password policy: min 12 chars, 3-of-4 character classes, no username match. Brute-force protection: 5 failed attempts -> 15 min lockout. Token revocation: `token_revoked_before` column on users, checked in `JwtAuthenticationFilter`, set on password change.
|
||||||
|
- 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.
|
||||||
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` is set. Scope-based role mapping via `SystemRole.normalizeScope()`. System roles synced on every OIDC login via `applyClaimMappings()` in `OidcAuthController` (calls `clearManagedAssignments` + `assignManagedRole` on `RbacService`) — always overwrites managed role assignments; uses managed assignment origin to avoid touching group-inherited or directly-assigned roles. Supports ES384, ES256, RS256.
|
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` is set. Scope-based role mapping via `SystemRole.normalizeScope()`. System roles synced on every OIDC login via `applyClaimMappings()` in `OidcAuthController` (calls `clearManagedAssignments` + `assignManagedRole` on `RbacService`) — always overwrites managed role assignments; uses managed assignment origin to avoid touching group-inherited or directly-assigned roles. Supports ES384, ES256, RS256.
|
||||||
- OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. All provider-specific configuration is external — no provider-specific code in the server.
|
- OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. All provider-specific configuration is external — no provider-specific code in the server.
|
||||||
- Sensitive keys: Global enforced baseline for masking sensitive data in agent payloads. Merge rule: `final = global UNION per-app` (case-insensitive dedup, per-app can only add, never remove global keys).
|
- Sensitive keys: Global enforced baseline for masking sensitive data in agent payloads. Merge rule: `final = global UNION per-app` (case-insensitive dedup, per-app can only add, never remove global keys).
|
||||||
@@ -96,7 +97,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **cameleer-server** (9731 symbols, 24987 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **cameleer-server** (10530 symbols, 27383 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.cameleer.server.app.dto;
|
package com.cameleer.server.app.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
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")
|
@Schema(description = "Authentication capabilities reported to the SPA so it can render the login page deterministically")
|
||||||
public record AuthCapabilitiesResponse(
|
public record AuthCapabilitiesResponse(
|
||||||
@@ -12,7 +11,7 @@ public record AuthCapabilitiesResponse(
|
|||||||
@Schema(description = "OIDC interactive login")
|
@Schema(description = "OIDC interactive login")
|
||||||
public record Oidc(
|
public record Oidc(
|
||||||
@Schema(description = "Whether OIDC is configured AND enabled") boolean enabled,
|
@Schema(description = "Whether OIDC is configured AND enabled") boolean enabled,
|
||||||
@Schema(description = "Best-effort display label, e.g. \"Logto\", \"Keycloak\", \"Single Sign-On\"") @NotNull String providerName,
|
@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 = "When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set") boolean primary
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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() {
|
||||||
|
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().providerName()).isEqualTo("");
|
||||||
|
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();
|
||||||
|
assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void endpointIsUnauthenticated() {
|
||||||
|
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", String.class);
|
||||||
|
assertThat(resp.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
46
ui/src/api/queries/auth.test.tsx
Normal file
46
ui/src/api/queries/auth.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
import { api } from '../client';
|
||||||
|
import type { components } from '../schema';
|
||||||
|
|
||||||
export interface RoleSummary {
|
export interface RoleSummary {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,3 +48,18 @@ export function useMe(enabled = false) {
|
|||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
135
ui/src/api/schema.d.ts
vendored
135
ui/src/api/schema.d.ts
vendored
@@ -1131,10 +1131,10 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/** Get current license info */
|
/** Get current license state, invalid reason, and parsed envelope */
|
||||||
get: operations["getCurrent"];
|
get: operations["getCurrent"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Update license token at runtime */
|
/** Install or replace the license token at runtime */
|
||||||
post: operations["update_5"];
|
post: operations["update_5"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -1872,6 +1872,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/auth/capabilities": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Auth capabilities for the SPA login page */
|
||||||
|
get: operations["getCapabilities"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/agents/{id}/events": {
|
"/agents/{id}/events": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2005,6 +2022,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/license/usage": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["get_4"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/database/tables": {
|
"/admin/database/tables": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2194,6 +2227,12 @@ export interface components {
|
|||||||
color?: string;
|
color?: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
executionRetentionDays?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
logRetentionDays?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
metricRetentionDays?: number;
|
||||||
};
|
};
|
||||||
/** @description Per-application dashboard settings */
|
/** @description Per-application dashboard settings */
|
||||||
AppSettingsRequest: {
|
AppSettingsRequest: {
|
||||||
@@ -3594,6 +3633,29 @@ export interface components {
|
|||||||
effectiveRoles?: components["schemas"]["RoleSummary"][];
|
effectiveRoles?: components["schemas"]["RoleSummary"][];
|
||||||
effectiveGroups?: components["schemas"]["GroupSummary"][];
|
effectiveGroups?: components["schemas"]["GroupSummary"][];
|
||||||
};
|
};
|
||||||
|
/** @description Authentication capabilities reported to the SPA so it can render the login page deterministically */
|
||||||
|
AuthCapabilitiesResponse: {
|
||||||
|
/** @description OIDC interactive login capability */
|
||||||
|
oidc?: components["schemas"]["Oidc"];
|
||||||
|
/** @description Local username/password account capability */
|
||||||
|
localAccounts?: components["schemas"]["LocalAccounts"];
|
||||||
|
};
|
||||||
|
/** @description Local username/password accounts */
|
||||||
|
LocalAccounts: {
|
||||||
|
/** @description Whether the local form is reachable at all */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** @description When true, the SPA gates the local form behind ?local with an admin-recovery banner */
|
||||||
|
adminRecoveryOnly?: boolean;
|
||||||
|
};
|
||||||
|
/** @description OIDC interactive login */
|
||||||
|
Oidc: {
|
||||||
|
/** @description Whether OIDC is configured AND enabled */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** @description Best-effort display label, e.g. "Logto", "Keycloak", "Single Sign-On" */
|
||||||
|
providerName: string;
|
||||||
|
/** @description When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set */
|
||||||
|
primary?: boolean;
|
||||||
|
};
|
||||||
SseEmitter: {
|
SseEmitter: {
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
@@ -3651,18 +3713,6 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
roleCount?: number;
|
roleCount?: number;
|
||||||
};
|
};
|
||||||
LicenseInfo: {
|
|
||||||
tier?: string;
|
|
||||||
features?: ("topology" | "lineage" | "correlation" | "debugger" | "replay")[];
|
|
||||||
limits?: {
|
|
||||||
[key: string]: number;
|
|
||||||
};
|
|
||||||
/** Format: date-time */
|
|
||||||
issuedAt?: string;
|
|
||||||
/** Format: date-time */
|
|
||||||
expiresAt?: string;
|
|
||||||
expired?: boolean;
|
|
||||||
};
|
|
||||||
GroupDetail: {
|
GroupDetail: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -3832,7 +3882,7 @@ export interface components {
|
|||||||
username?: string;
|
username?: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG" | "RBAC" | "AGENT" | "OUTBOUND_CONNECTION_CHANGE" | "OUTBOUND_HTTP_TRUST_CHANGE" | "ALERT_RULE_CHANGE" | "ALERT_SILENCE_CHANGE" | "DEPLOYMENT";
|
category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG" | "RBAC" | "AGENT" | "OUTBOUND_CONNECTION_CHANGE" | "OUTBOUND_HTTP_TRUST_CHANGE" | "ALERT_RULE_CHANGE" | "ALERT_SILENCE_CHANGE" | "DEPLOYMENT" | "LICENSE";
|
||||||
target?: string;
|
target?: string;
|
||||||
detail?: {
|
detail?: {
|
||||||
[key: string]: Record<string, never>;
|
[key: string]: Record<string, never>;
|
||||||
@@ -4825,6 +4875,15 @@ export interface operations {
|
|||||||
"*/*": Record<string, never>;
|
"*/*": Record<string, never>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description jarRetentionCount exceeds license cap */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": Record<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
updateDefaultContainerConfig: {
|
updateDefaultContainerConfig: {
|
||||||
@@ -6553,7 +6612,9 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"*/*": components["schemas"]["LicenseInfo"];
|
"*/*": {
|
||||||
|
[key: string]: Record<string, never>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -7886,6 +7947,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getCapabilities: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Capabilities resolved */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["AuthCapabilitiesResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
events: {
|
events: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -8062,6 +8143,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
get_4: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": {
|
||||||
|
[key: string]: Record<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getTables: {
|
getTables: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -91,3 +91,32 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
156
ui/src/auth/LoginPage.test.tsx
Normal file
156
ui/src/auth/LoginPage.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
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 { useAuthStore } from './auth-store';
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSO button click: when /auth/oidc/config fails, button unlocks and error is set', async () => {
|
||||||
|
const setStateMock = vi.fn();
|
||||||
|
const useAuthStoreMock = vi.mocked(useAuthStore) as unknown as { setState: typeof setStateMock };
|
||||||
|
useAuthStoreMock.setState = setStateMock;
|
||||||
|
|
||||||
|
(apiClient.GET as any).mockImplementation((path: string) => {
|
||||||
|
if (path === '/auth/capabilities') return Promise.resolve({
|
||||||
|
data: { oidc: { enabled: true, providerName: 'Logto', primary: true }, localAccounts: { enabled: true, adminRecoveryOnly: true } },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
if (path === '/auth/oidc/config') return Promise.reject(new Error('network down'));
|
||||||
|
return Promise.resolve({ data: undefined, error: { message: 'unexpected' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LoginPage />, { wrapper: wrapper(['/login']) });
|
||||||
|
const btn = await screen.findByRole('button', { name: /sign in with logto/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
await waitFor(() => expect(setStateMock).toHaveBeenCalled());
|
||||||
|
const errorPayload = setStateMock.mock.calls[0][0];
|
||||||
|
expect(errorPayload.error).toMatch(/OIDC configuration unavailable/i);
|
||||||
|
// Button should not stay locked in "Redirecting…"
|
||||||
|
await waitFor(() => expect(btn).not.toHaveTextContent(/redirecting/i));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
import { type FormEvent, useMemo, useState } from 'react';
|
||||||
import { Navigate, useSearchParams } from 'react-router';
|
import { Link, Navigate, useSearchParams } from 'react-router';
|
||||||
import { useAuthStore } from './auth-store';
|
import { useAuthStore } from './auth-store';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
|
import { useAuthCapabilities } from '../api/queries/auth';
|
||||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||||
import brandLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
import brandLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
import styles from './LoginPage.module.css';
|
import styles from './LoginPage.module.css';
|
||||||
|
|
||||||
interface OidcInfo {
|
|
||||||
clientId: string;
|
|
||||||
authorizationEndpoint: string;
|
|
||||||
resource?: string;
|
|
||||||
additionalScopes?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logto org scopes required for role mapping in multi-tenant setups.
|
|
||||||
// Always requested, harmless for non-Logto providers (unknown scopes are ignored per OIDC spec).
|
|
||||||
const PLATFORM_SCOPES = ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'];
|
const PLATFORM_SCOPES = ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'];
|
||||||
|
|
||||||
const SUBTITLES = [
|
const SUBTITLES = [
|
||||||
@@ -53,66 +45,55 @@ export function LoginPage() {
|
|||||||
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [oidc, setOidc] = useState<OidcInfo | null>(null);
|
|
||||||
const [oidcLoading, setOidcLoading] = useState(false);
|
const [oidcLoading, setOidcLoading] = useState(false);
|
||||||
const autoRedirected = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: caps, isError: capsFailed, isLoading: capsLoading } = useAuthCapabilities();
|
||||||
api.GET('/auth/oidc/config')
|
|
||||||
.then(({ data }) => {
|
|
||||||
if (data?.authorizationEndpoint && data?.clientId) {
|
|
||||||
setOidc({
|
|
||||||
clientId: data.clientId,
|
|
||||||
authorizationEndpoint: data.authorizationEndpoint,
|
|
||||||
resource: data.resource ?? undefined,
|
|
||||||
additionalScopes: data.additionalScopes ?? undefined,
|
|
||||||
});
|
|
||||||
if (data.endSessionEndpoint) {
|
|
||||||
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-redirect to OIDC provider for SSO (skip if ?local is in URL)
|
|
||||||
useEffect(() => {
|
|
||||||
if (oidc && !forceLocal && !autoRedirected.current) {
|
|
||||||
autoRedirected.current = true;
|
|
||||||
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
|
||||||
const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(oidc.additionalScopes || [])];
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
response_type: 'code',
|
|
||||||
client_id: oidc.clientId,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
scope: scopes.join(' '),
|
|
||||||
prompt: 'none',
|
|
||||||
});
|
|
||||||
if (oidc.resource) params.set('resource', oidc.resource);
|
|
||||||
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
|
||||||
}
|
|
||||||
}, [oidc, forceLocal]);
|
|
||||||
|
|
||||||
if (isAuthenticated) return <Navigate to="/" replace />;
|
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||||
|
if (capsLoading) return null;
|
||||||
|
|
||||||
|
const oidcPrimary = caps?.oidc?.primary === true;
|
||||||
|
const adminRecoveryOnly = caps?.localAccounts?.adminRecoveryOnly === true;
|
||||||
|
const providerName = caps?.oidc?.providerName || 'Single Sign-On';
|
||||||
|
|
||||||
|
// Render decisions
|
||||||
|
const showSsoPrimary = oidcPrimary && adminRecoveryOnly && !forceLocal;
|
||||||
|
const showLocalForm = !oidcPrimary || forceLocal || !adminRecoveryOnly || capsFailed;
|
||||||
|
const showAdminRecoveryBanner = oidcPrimary && adminRecoveryOnly && forceLocal;
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
const handleSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
login(username, password);
|
login(username, password);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOidcLogin = () => {
|
const handleOidcLogin = async () => {
|
||||||
if (!oidc) return;
|
|
||||||
setOidcLoading(true);
|
setOidcLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.GET('/auth/oidc/config');
|
||||||
|
if (!data?.authorizationEndpoint || !data?.clientId) {
|
||||||
|
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 redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
||||||
const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(oidc.additionalScopes || [])];
|
const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])];
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
client_id: oidc.clientId,
|
client_id: data.clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
scope: scopes.join(' '),
|
scope: scopes.join(' '),
|
||||||
});
|
});
|
||||||
if (oidc.resource) params.set('resource', oidc.resource);
|
if (data.resource) params.set('resource', data.resource);
|
||||||
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
// Note: NO prompt=none. Per RFC 9700 §4.4, that's silent re-auth only;
|
||||||
|
// for first-time login it returns login_required and traps users on a local form.
|
||||||
|
window.location.href = `${data.authorizationEndpoint}?${params}`;
|
||||||
|
} catch {
|
||||||
|
useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' });
|
||||||
|
} finally {
|
||||||
|
setOidcLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -125,33 +106,45 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className={styles.subtitle}>{subtitle}</p>
|
<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 && (
|
{error && (
|
||||||
<div className={styles.error}>
|
<div className={styles.error}>
|
||||||
<Alert variant="error">{error}</Alert>
|
<Alert variant="error">{error}</Alert>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{oidc && (
|
{showSsoPrimary && (
|
||||||
<>
|
|
||||||
<div className={styles.socialSection}>
|
<div className={styles.socialSection}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="primary"
|
||||||
className={styles.ssoButton}
|
className={styles.ssoButton}
|
||||||
onClick={handleOidcLogin}
|
onClick={handleOidcLogin}
|
||||||
disabled={oidcLoading}
|
disabled={oidcLoading}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
|
{oidcLoading ? 'Redirecting\u2026' : `Sign in with ${providerName}`}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Link to="/login?local" className={styles.adminRecoveryLink}>
|
||||||
|
Admin recovery →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.divider}>
|
|
||||||
<div className={styles.dividerLine} />
|
|
||||||
<span className={styles.dividerText}>or</span>
|
|
||||||
<div className={styles.dividerLine} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showLocalForm && (
|
||||||
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
||||||
<FormField label="Username" htmlFor="login-username">
|
<FormField label="Username" htmlFor="login-username">
|
||||||
<Input
|
<Input
|
||||||
@@ -187,6 +180,7 @@ export function LoginPage() {
|
|||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ export function OidcCallback() {
|
|||||||
const errorParam = params.get('error');
|
const errorParam = params.get('error');
|
||||||
|
|
||||||
if (errorParam) {
|
if (errorParam) {
|
||||||
// prompt=none failed — no session, fall back to login form
|
|
||||||
if (errorParam === 'login_required' || errorParam === 'interaction_required') {
|
|
||||||
window.location.replace(`${config.basePath}login?local`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// consent_required — retry without prompt=none so user can grant scopes
|
// consent_required — retry without prompt=none so user can grant scopes
|
||||||
if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) {
|
if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) {
|
||||||
sessionStorage.setItem('oidc-consent-retry', '1');
|
sessionStorage.setItem('oidc-consent-retry', '1');
|
||||||
@@ -43,7 +38,7 @@ export function OidcCallback() {
|
|||||||
window.location.href = `${data.authorizationEndpoint}?${p}`;
|
window.location.href = `${data.authorizationEndpoint}?${p}`;
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
window.location.replace(`${config.basePath}login?local`);
|
useAuthStore.setState({ error: 'OIDC consent retry failed.', loading: false });
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -77,7 +72,7 @@ export function OidcCallback() {
|
|||||||
{error && (
|
{error && (
|
||||||
<>
|
<>
|
||||||
<Alert variant="error">{error}</Alert>
|
<Alert variant="error">{error}</Alert>
|
||||||
<Button variant="secondary" onClick={() => navigate('/login?local')} className={styles.backButton}>
|
<Button variant="secondary" onClick={() => navigate('/login')} className={styles.backButton}>
|
||||||
Back to Login
|
Back to Login
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -154,11 +154,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
const loginUrl = `${config.basePath}login?local`;
|
const loginUrl = `${config.basePath}login`;
|
||||||
if (endSessionEndpoint && idToken) {
|
if (endSessionEndpoint && idToken) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
id_token_hint: idToken,
|
id_token_hint: idToken,
|
||||||
post_logout_redirect_uri: `${window.location.origin}${config.basePath}login?local`,
|
post_logout_redirect_uri: `${window.location.origin}${config.basePath}login`,
|
||||||
});
|
});
|
||||||
fetch(`${endSessionEndpoint}?${params}`, { mode: 'no-cors' }).finally(() => {
|
fetch(`${endSessionEndpoint}?${params}`, { mode: 'no-cors' }).finally(() => {
|
||||||
window.location.href = loginUrl;
|
window.location.href = loginUrl;
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ type Fixtures = {
|
|||||||
export const test = base.extend<Fixtures>({
|
export const test = base.extend<Fixtures>({
|
||||||
loggedIn: [
|
loggedIn: [
|
||||||
async ({ page }, use) => {
|
async ({ page }, use) => {
|
||||||
// `?local` keeps the login page's auto-OIDC-redirect from firing so the
|
// Navigate to ?local to bypass the SSO-primary page and reach the local
|
||||||
// form-based login works even when an OIDC config happens to be present.
|
// form directly, so the fixture works regardless of whether OIDC is
|
||||||
|
// configured on the test server.
|
||||||
await page.goto('/login?local');
|
await page.goto('/login?local');
|
||||||
await page.getByLabel(/username/i).fill(ADMIN_USER);
|
await page.getByLabel(/username/i).fill(ADMIN_USER);
|
||||||
await page.getByLabel(/password/i).fill(ADMIN_PASS);
|
await page.getByLabel(/password/i).fill(ADMIN_PASS);
|
||||||
|
|||||||
Reference in New Issue
Block a user