From 0c47ac9b1a2660ce56a2f85e8a1e11742d933fdb Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:56:02 +0100 Subject: [PATCH] Add OIDC admin config page with auto-signup toggle Backend: add autoSignup field to OidcConfig, ClickHouse schema, repository, and admin controller. Gate OIDC login when auto-signup is disabled and user is not pre-created (returns 403). Frontend: add OIDC admin page with full CRUD (save/test/delete), role-gated Admin nav link parsed from JWT, and matching design system styles. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controller/OidcConfigAdminController.java | 7 +- .../app/security/OidcAuthController.java | 12 +- .../app/security/SecurityBeanConfig.java | 3 +- .../ClickHouseOidcConfigRepository.java | 12 +- .../server/core/security/OidcConfig.java | 6 +- clickhouse/init/04-oidc-config.sql | 1 + ui/src/api/queries/oidc-admin.ts | 80 +++++ ui/src/auth/auth-store.ts | 14 + ui/src/components/layout/TopNav.tsx | 9 +- ui/src/pages/admin/OidcAdminPage.module.css | 335 ++++++++++++++++++ ui/src/pages/admin/OidcAdminPage.tsx | 335 ++++++++++++++++++ ui/src/router.tsx | 2 + 12 files changed, 802 insertions(+), 14 deletions(-) create mode 100644 ui/src/api/queries/oidc-admin.ts create mode 100644 ui/src/pages/admin/OidcAdminPage.module.css create mode 100644 ui/src/pages/admin/OidcAdminPage.tsx diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java index e79f60e6..f77eca32 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java @@ -80,7 +80,8 @@ public class OidcConfigAdminController { request.clientId() != null ? request.clientId() : "", clientSecret, request.rolesClaim() != null ? request.rolesClaim() : "realm_access.roles", - request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER") + request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER"), + request.autoSignup() ); configRepository.save(config); @@ -134,6 +135,7 @@ public class OidcConfigAdminController { map.put("clientSecretSet", !config.clientSecret().isBlank()); map.put("rolesClaim", config.rolesClaim()); map.put("defaultRoles", config.defaultRoles()); + map.put("autoSignup", config.autoSignup()); return map; } @@ -143,6 +145,7 @@ public class OidcConfigAdminController { String clientId, String clientSecret, String rolesClaim, - List defaultRoles + List defaultRoles, + boolean autoSignup ) {} } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java index 4fb15ed7..44adb0d5 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java @@ -90,8 +90,15 @@ public class OidcAuthController { String issuerHost = URI.create(config.get().issuerUri()).getHost(); String provider = "oidc:" + issuerHost; + // Check auto-signup gate: if disabled, user must already exist + Optional existingUser = userRepository.findById(userId); + if (!config.get().autoSignup() && existingUser.isEmpty()) { + return ResponseEntity.status(403) + .body(Map.of("message", "Account not provisioned. Contact your administrator.")); + } + // Resolve roles: DB override > OIDC claim > default - List roles = resolveRoles(userId, oidcUser.roles(), config.get()); + List roles = resolveRoles(existingUser, oidcUser.roles(), config.get()); userRepository.upsert(new UserInfo( userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now())); @@ -110,8 +117,7 @@ public class OidcAuthController { } } - private List resolveRoles(String userId, List oidcRoles, OidcConfig config) { - Optional existing = userRepository.findById(userId); + private List resolveRoles(Optional existing, List oidcRoles, OidcConfig config) { if (existing.isPresent() && !existing.get().roles().isEmpty()) { return existing.get().roles(); } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java index 55990e6b..72fbb9f0 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java @@ -73,7 +73,8 @@ public class SecurityBeanConfig { envOidc.getClientId(), envOidc.getClientSecret() != null ? envOidc.getClientSecret() : "", envOidc.getRolesClaim(), - envOidc.getDefaultRoles() + envOidc.getDefaultRoles(), + true ); configRepository.save(config); log.info("OIDC config seeded from environment variables: issuer={}", envOidc.getIssuerUri()); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseOidcConfigRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseOidcConfigRepository.java index 87119693..22143c24 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseOidcConfigRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseOidcConfigRepository.java @@ -27,7 +27,7 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository { @Override public Optional find() { List results = jdbc.query( - "SELECT enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles " + "SELECT enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, auto_signup " + "FROM oidc_config FINAL WHERE config_id = 'default'", this::mapRow ); @@ -37,14 +37,15 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository { @Override public void save(OidcConfig config) { jdbc.update( - "INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, updated_at) " - + "VALUES ('default', ?, ?, ?, ?, ?, ?, now64(3, 'UTC'))", + "INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, auto_signup, updated_at) " + + "VALUES ('default', ?, ?, ?, ?, ?, ?, ?, now64(3, 'UTC'))", config.enabled(), config.issuerUri(), config.clientId(), config.clientSecret(), config.rolesClaim(), - config.defaultRoles().toArray(new String[0]) + config.defaultRoles().toArray(new String[0]), + config.autoSignup() ); } @@ -61,7 +62,8 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository { rs.getString("client_id"), rs.getString("client_secret"), rs.getString("roles_claim"), - Arrays.asList(rolesArray) + Arrays.asList(rolesArray), + rs.getBoolean("auto_signup") ); } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java index 7557250b..4338f334 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java @@ -11,6 +11,7 @@ import java.util.List; * @param clientSecret OAuth2 client secret (stored server-side only) * @param rolesClaim dot-separated path to roles in the id_token (e.g. {@code realm_access.roles}) * @param defaultRoles fallback roles for new users with no OIDC role claim + * @param autoSignup whether new OIDC users are automatically created on first login */ public record OidcConfig( boolean enabled, @@ -18,9 +19,10 @@ public record OidcConfig( String clientId, String clientSecret, String rolesClaim, - List defaultRoles + List defaultRoles, + boolean autoSignup ) { public static OidcConfig disabled() { - return new OidcConfig(false, "", "", "", "realm_access.roles", List.of("VIEWER")); + return new OidcConfig(false, "", "", "", "realm_access.roles", List.of("VIEWER"), true); } } diff --git a/clickhouse/init/04-oidc-config.sql b/clickhouse/init/04-oidc-config.sql index 3c0275b6..8f967733 100644 --- a/clickhouse/init/04-oidc-config.sql +++ b/clickhouse/init/04-oidc-config.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS oidc_config ( client_secret String DEFAULT '', roles_claim String DEFAULT 'realm_access.roles', default_roles Array(LowCardinality(String)), + auto_signup Bool DEFAULT true, updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC') ) ENGINE = ReplacingMergeTree(updated_at) ORDER BY (config_id); diff --git a/ui/src/api/queries/oidc-admin.ts b/ui/src/api/queries/oidc-admin.ts new file mode 100644 index 00000000..3dbd3b1a --- /dev/null +++ b/ui/src/api/queries/oidc-admin.ts @@ -0,0 +1,80 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { config } from '../../config'; +import { useAuthStore } from '../../auth/auth-store'; + +export interface OidcConfigResponse { + configured: boolean; + enabled?: boolean; + issuerUri?: string; + clientId?: string; + clientSecretSet?: boolean; + rolesClaim?: string; + defaultRoles?: string[]; + autoSignup?: boolean; +} + +export interface OidcConfigRequest { + enabled: boolean; + issuerUri: string; + clientId: string; + clientSecret: string; + rolesClaim: string; + defaultRoles: string[]; + autoSignup: boolean; +} + +interface TestResult { + status: string; + authorizationEndpoint: string; +} + +async function adminFetch(path: string, options?: RequestInit): Promise { + const token = useAuthStore.getState().accessToken; + const res = await fetch(`${config.apiBaseUrl}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options?.headers, + }, + }); + if (res.status === 204) return undefined as T; + const body = await res.json(); + if (!res.ok) throw new Error(body.message || `Request failed (${res.status})`); + return body as T; +} + +export function useOidcConfig() { + return useQuery({ + queryKey: ['admin', 'oidc'], + queryFn: () => adminFetch('/admin/oidc'), + }); +} + +export function useSaveOidcConfig() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: OidcConfigRequest) => + adminFetch('/admin/oidc', { + method: 'PUT', + body: JSON.stringify(data), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }), + }); +} + +export function useTestOidcConnection() { + return useMutation({ + mutationFn: () => + adminFetch('/admin/oidc/test', { method: 'POST' }), + }); +} + +export function useDeleteOidcConfig() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + adminFetch('/admin/oidc', { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }), + }); +} diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts index fb302f64..35bf9d95 100644 --- a/ui/src/auth/auth-store.ts +++ b/ui/src/auth/auth-store.ts @@ -5,6 +5,7 @@ interface AuthState { accessToken: string | null; refreshToken: string | null; username: string | null; + roles: string[]; isAuthenticated: boolean; error: string | null; loading: boolean; @@ -13,6 +14,15 @@ interface AuthState { logout: () => void; } +function parseRolesFromJwt(token: string): string[] { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return Array.isArray(payload.roles) ? payload.roles : []; + } catch { + return []; + } +} + function loadTokens() { return { accessToken: localStorage.getItem('cameleer-access-token'), @@ -39,6 +49,7 @@ export const useAuthStore = create((set, get) => ({ accessToken: initial.accessToken, refreshToken: initial.refreshToken, username: initial.username, + roles: initial.accessToken ? parseRolesFromJwt(initial.accessToken) : [], isAuthenticated: !!initial.accessToken, error: null, loading: false, @@ -61,6 +72,7 @@ export const useAuthStore = create((set, get) => ({ accessToken, refreshToken, username, + roles: parseRolesFromJwt(accessToken), isAuthenticated: true, loading: false, }); @@ -88,6 +100,7 @@ export const useAuthStore = create((set, get) => ({ set({ accessToken: data.accessToken, refreshToken: data.refreshToken, + roles: parseRolesFromJwt(data.accessToken), isAuthenticated: true, }); return true; @@ -102,6 +115,7 @@ export const useAuthStore = create((set, get) => ({ accessToken: null, refreshToken: null, username: null, + roles: [], isAuthenticated: false, error: null, }); diff --git a/ui/src/components/layout/TopNav.tsx b/ui/src/components/layout/TopNav.tsx index 461bf6eb..d19e547f 100644 --- a/ui/src/components/layout/TopNav.tsx +++ b/ui/src/components/layout/TopNav.tsx @@ -5,7 +5,7 @@ import styles from './TopNav.module.css'; export function TopNav() { const { theme, toggle } = useThemeStore(); - const { username, logout } = useAuthStore(); + const { username, roles, logout } = useAuthStore(); return (