From 9d2e6f30a7bae81b7ecab2bd4ee6a11e206ed73f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:00:13 +0100 Subject: [PATCH] Move OIDC config from env vars to database with admin API OIDC provider settings (issuer, client ID/secret, roles claim) are now stored in ClickHouse and managed via admin REST API at /api/v1/admin/oidc. This allows runtime configuration from the UI without server restarts. - New oidc_config table (ReplacingMergeTree, singleton row) - OidcConfig record + OidcConfigRepository interface in core - ClickHouseOidcConfigRepository implementation - OidcConfigAdminController: GET/PUT/DELETE config, POST test connectivity, client_secret masked in responses - OidcTokenExchanger: reads config from DB, invalidateCache() on config change - OidcAuthController: always registered (no @ConditionalOnProperty), returns 404 when OIDC not configured - Startup seeder: env vars seed DB on first boot only, then admin API takes over - HOWTO.md updated with admin OIDC config API examples Co-Authored-By: Claude Opus 4.6 (1M context) --- HOWTO.md | 40 ++++- .../server/app/config/ClickHouseConfig.java | 3 +- .../controller/OidcConfigAdminController.java | 148 ++++++++++++++++++ .../app/security/OidcAuthController.java | 56 ++++--- .../app/security/OidcTokenExchanger.java | 84 ++++++---- .../app/security/SecurityBeanConfig.java | 41 ++++- .../ClickHouseOidcConfigRepository.java | 67 ++++++++ .../resources/clickhouse/04-oidc-config.sql | 11 ++ .../server/app/AbstractClickHouseIT.java | 2 +- .../server/core/security/OidcConfig.java | 26 +++ .../core/security/OidcConfigRepository.java | 16 ++ clickhouse/init/04-oidc-config.sql | 11 ++ 12 files changed, 436 insertions(+), 69 deletions(-) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseOidcConfigRepository.java create mode 100644 cameleer3-server-app/src/main/resources/clickhouse/04-oidc-config.sql create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfigRepository.java create mode 100644 clickhouse/init/04-oidc-config.sql diff --git a/HOWTO.md b/HOWTO.md index 22542f54..3ab0bf57 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -109,10 +109,10 @@ The env-var local user gets `ADMIN` role. Agents get `AGENT` role at registratio ### OIDC Login (Optional) -When `CAMELEER_OIDC_ENABLED=true`, the server supports external identity providers (e.g. Authentik, Keycloak): +OIDC configuration is stored in ClickHouse and managed via the admin API or UI. The SPA checks if OIDC is available: ```bash -# 1. SPA checks if OIDC is available +# 1. SPA checks if OIDC is available (returns 404 if not configured) curl -s http://localhost:8081/api/v1/auth/oidc/config # Returns: { "issuer": "...", "clientId": "...", "authorizationEndpoint": "..." } @@ -125,6 +125,38 @@ curl -s -X POST http://localhost:8081/api/v1/auth/oidc/callback \ Local login remains available as fallback even when OIDC is enabled. +### OIDC Admin Configuration (ADMIN only) + +OIDC settings are managed at runtime via the admin API. No server restart needed. + +```bash +# Get current OIDC config +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8081/api/v1/admin/oidc + +# Save OIDC config (client_secret: send "********" to keep existing, or new value to update) +curl -s -X PUT http://localhost:8081/api/v1/admin/oidc \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "enabled": true, + "issuerUri": "http://authentik:9000/application/o/cameleer/", + "clientId": "your-client-id", + "clientSecret": "your-client-secret", + "rolesClaim": "realm_access.roles", + "defaultRoles": ["VIEWER"] + }' + +# Test OIDC provider connectivity +curl -s -X POST http://localhost:8081/api/v1/admin/oidc/test \ + -H "Authorization: Bearer $TOKEN" + +# Delete OIDC config (disables OIDC) +curl -s -X DELETE http://localhost:8081/api/v1/admin/oidc \ + -H "Authorization: Bearer $TOKEN" +``` + +**Initial provisioning**: OIDC can also be seeded from `CAMELEER_OIDC_*` env vars on first startup (when DB is empty). After that, the admin API takes over. + ### Authentik Setup (OIDC Provider) Authentik is deployed alongside the Cameleer stack. After first deployment: @@ -140,7 +172,7 @@ Authentik is deployed alongside the Cameleer stack. After first deployment: - Name: `Cameleer` - Provider: select `Cameleer` (created above) 4. **Configure roles** (optional): Create groups in Authentik and map them to Cameleer roles via the `roles-claim` config. Default claim path is `realm_access.roles`. For Authentik, you may need to customize the OIDC scope to include group claims. -5. **Set env vars** on the Cameleer server: +5. **Configure Cameleer**: Use the admin API (`PUT /api/v1/admin/oidc`) or set env vars for initial seeding: ``` CAMELEER_OIDC_ENABLED=true CAMELEER_OIDC_ISSUER=http://authentik:9000/application/o/cameleer/ @@ -148,8 +180,6 @@ Authentik is deployed alongside the Cameleer stack. After first deployment: CAMELEER_OIDC_CLIENT_SECRET= ``` -For K8s deployment, these are managed via the `cameleer-oidc` secret (see CI/CD section). - ### User Management (ADMIN only) ```bash diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ClickHouseConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ClickHouseConfig.java index aefa57b9..5a36d5de 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ClickHouseConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ClickHouseConfig.java @@ -26,7 +26,8 @@ public class ClickHouseConfig { private static final Logger log = LoggerFactory.getLogger(ClickHouseConfig.class); private static final String[] SCHEMA_FILES = { - "clickhouse/01-schema.sql", "clickhouse/02-search-columns.sql", "clickhouse/03-users.sql" + "clickhouse/01-schema.sql", "clickhouse/02-search-columns.sql", + "clickhouse/03-users.sql", "clickhouse/04-oidc-config.sql" }; private final DataSource dataSource; 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 new file mode 100644 index 00000000..e79f60e6 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java @@ -0,0 +1,148 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.security.OidcTokenExchanger; +import com.cameleer3.server.core.security.OidcConfig; +import com.cameleer3.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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Admin endpoints for managing OIDC provider configuration. + * Protected by {@code ROLE_ADMIN} via SecurityConfig URL patterns ({@code /api/v1/admin/**}). + */ +@RestController +@RequestMapping("/api/v1/admin/oidc") +@Tag(name = "OIDC Config Admin", description = "OIDC provider configuration (ADMIN only)") +public class OidcConfigAdminController { + + private static final Logger log = LoggerFactory.getLogger(OidcConfigAdminController.class); + + private final OidcConfigRepository configRepository; + private final OidcTokenExchanger tokenExchanger; + + public OidcConfigAdminController(OidcConfigRepository configRepository, + OidcTokenExchanger tokenExchanger) { + this.configRepository = configRepository; + this.tokenExchanger = tokenExchanger; + } + + @GetMapping + @Operation(summary = "Get OIDC configuration") + @ApiResponse(responseCode = "200", description = "Current OIDC configuration (client_secret masked)") + public ResponseEntity getConfig() { + Optional config = configRepository.find(); + if (config.isEmpty()) { + return ResponseEntity.ok(Map.of("configured", false)); + } + return ResponseEntity.ok(toResponse(config.get())); + } + + @PutMapping + @Operation(summary = "Save OIDC configuration") + @ApiResponse(responseCode = "200", description = "Configuration saved") + @ApiResponse(responseCode = "400", description = "Invalid configuration") + public ResponseEntity saveConfig(@RequestBody OidcConfigRequest request) { + // Resolve client_secret: if masked or empty, preserve existing + String clientSecret = request.clientSecret(); + if (clientSecret == null || clientSecret.isBlank() || clientSecret.equals("********")) { + Optional existing = configRepository.find(); + clientSecret = existing.map(OidcConfig::clientSecret).orElse(""); + } + + if (request.enabled() && (request.issuerUri() == null || request.issuerUri().isBlank())) { + return ResponseEntity.badRequest() + .body(Map.of("message", "issuerUri is required when OIDC is enabled")); + } + if (request.enabled() && (request.clientId() == null || request.clientId().isBlank())) { + return ResponseEntity.badRequest() + .body(Map.of("message", "clientId is required when OIDC is enabled")); + } + + OidcConfig config = new OidcConfig( + request.enabled(), + request.issuerUri() != null ? request.issuerUri() : "", + request.clientId() != null ? request.clientId() : "", + clientSecret, + request.rolesClaim() != null ? request.rolesClaim() : "realm_access.roles", + request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER") + ); + + configRepository.save(config); + tokenExchanger.invalidateCache(); + + log.info("OIDC configuration updated: enabled={}, issuer={}", config.enabled(), config.issuerUri()); + return ResponseEntity.ok(toResponse(config)); + } + + @PostMapping("/test") + @Operation(summary = "Test OIDC provider connectivity") + @ApiResponse(responseCode = "200", description = "Provider reachable") + @ApiResponse(responseCode = "400", description = "Provider unreachable or misconfigured") + public ResponseEntity testConnection() { + Optional config = configRepository.find(); + if (config.isEmpty() || !config.get().enabled()) { + return ResponseEntity.badRequest() + .body(Map.of("message", "OIDC is not configured or disabled")); + } + + try { + tokenExchanger.invalidateCache(); + String authEndpoint = tokenExchanger.getAuthorizationEndpoint(); + return ResponseEntity.ok(Map.of( + "status", "ok", + "authorizationEndpoint", authEndpoint + )); + } catch (Exception e) { + log.warn("OIDC connectivity test failed: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(Map.of("message", "Failed to reach OIDC provider: " + e.getMessage())); + } + } + + @DeleteMapping + @Operation(summary = "Delete OIDC configuration") + @ApiResponse(responseCode = "204", description = "Configuration deleted") + public ResponseEntity deleteConfig() { + configRepository.delete(); + tokenExchanger.invalidateCache(); + log.info("OIDC configuration deleted"); + return ResponseEntity.noContent().build(); + } + + private Map toResponse(OidcConfig config) { + Map map = new LinkedHashMap<>(); + map.put("configured", true); + map.put("enabled", config.enabled()); + map.put("issuerUri", config.issuerUri()); + map.put("clientId", config.clientId()); + map.put("clientSecretSet", !config.clientSecret().isBlank()); + map.put("rolesClaim", config.rolesClaim()); + map.put("defaultRoles", config.defaultRoles()); + return map; + } + + public record OidcConfigRequest( + boolean enabled, + String issuerUri, + String clientId, + String clientSecret, + String rolesClaim, + List defaultRoles + ) {} +} 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 029b11c8..4fb15ed7 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 @@ -1,11 +1,12 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.core.security.JwtService; +import com.cameleer3.server.core.security.OidcConfig; +import com.cameleer3.server.core.security.OidcConfigRepository; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -22,46 +23,50 @@ import java.util.Optional; /** * OIDC authentication endpoints for the UI. *

- * Only active when {@code security.oidc.enabled=true}. - * The SPA initiates the authorization code flow, then sends the code here - * for server-side token exchange (keeping client_secret secure). + * Always registered — returns 404 when OIDC is not configured or disabled. + * Configuration is read from the database (managed via admin UI). */ @RestController @RequestMapping("/api/v1/auth/oidc") -@ConditionalOnProperty(prefix = "security.oidc", name = "enabled", havingValue = "true") public class OidcAuthController { private static final Logger log = LoggerFactory.getLogger(OidcAuthController.class); private final OidcTokenExchanger tokenExchanger; + private final OidcConfigRepository configRepository; private final JwtService jwtService; private final UserRepository userRepository; - private final SecurityProperties properties; public OidcAuthController(OidcTokenExchanger tokenExchanger, + OidcConfigRepository configRepository, JwtService jwtService, - UserRepository userRepository, - SecurityProperties properties) { + UserRepository userRepository) { this.tokenExchanger = tokenExchanger; + this.configRepository = configRepository; this.jwtService = jwtService; this.userRepository = userRepository; - this.properties = properties; } /** * Returns OIDC configuration for the SPA to initiate the authorization code flow. + * Returns 404 if OIDC is not configured or disabled. */ @GetMapping("/config") public ResponseEntity getConfig() { + Optional config = configRepository.find(); + if (config.isEmpty() || !config.get().enabled()) { + return ResponseEntity.notFound().build(); + } + try { - SecurityProperties.Oidc oidc = properties.getOidc(); + OidcConfig oidc = config.get(); return ResponseEntity.ok(Map.of( - "issuer", oidc.getIssuerUri(), - "clientId", oidc.getClientId(), + "issuer", oidc.issuerUri(), + "clientId", oidc.clientId(), "authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint() )); } catch (Exception e) { - log.error("Failed to retrieve OIDC config: {}", e.getMessage()); + log.error("Failed to retrieve OIDC provider metadata: {}", e.getMessage()); return ResponseEntity.internalServerError() .body(Map.of("message", "Failed to retrieve OIDC provider metadata")); } @@ -69,30 +74,28 @@ public class OidcAuthController { /** * Exchanges an OIDC authorization code for internal Cameleer JWTs. - *

- * Role resolution priority: - * 1. ClickHouse user table (admin-assigned override) - * 2. OIDC token claim - * 3. Default roles from config */ @PostMapping("/callback") public ResponseEntity callback(@RequestBody CallbackRequest request) { + Optional config = configRepository.find(); + if (config.isEmpty() || !config.get().enabled()) { + return ResponseEntity.notFound().build(); + } + try { OidcTokenExchanger.OidcUserInfo oidcUser = tokenExchanger.exchange(request.code(), request.redirectUri()); String userId = "oidc:" + oidcUser.subject(); - String issuerHost = URI.create(properties.getOidc().getIssuerUri()).getHost(); + String issuerHost = URI.create(config.get().issuerUri()).getHost(); String provider = "oidc:" + issuerHost; // Resolve roles: DB override > OIDC claim > default - List roles = resolveRoles(userId, oidcUser.roles()); + List roles = resolveRoles(userId, oidcUser.roles(), config.get()); - // Upsert user userRepository.upsert(new UserInfo( userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now())); - // Issue internal tokens String accessToken = jwtService.createAccessToken(userId, "ui", roles); String refreshToken = jwtService.createRefreshToken(userId, "ui", roles); @@ -107,20 +110,15 @@ public class OidcAuthController { } } - private List resolveRoles(String userId, List oidcRoles) { - // 1. Check for admin-assigned override in user store + private List resolveRoles(String userId, List oidcRoles, OidcConfig config) { Optional existing = userRepository.findById(userId); if (existing.isPresent() && !existing.get().roles().isEmpty()) { return existing.get().roles(); } - - // 2. Roles from OIDC token if (!oidcRoles.isEmpty()) { return oidcRoles; } - - // 3. Default roles - return properties.getOidc().getDefaultRoles(); + return config.defaultRoles(); } public record CallbackRequest(String code, String redirectUri) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java index 48097f4c..19321f89 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java @@ -1,10 +1,10 @@ package com.cameleer3.server.app.security; -import com.nimbusds.jose.JOSEException; +import com.cameleer3.server.core.security.OidcConfig; +import com.cameleer3.server.core.security.OidcConfigRepository; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.jwk.source.JWKSourceBuilder; -import com.nimbusds.jose.proc.BadJOSEException; import com.nimbusds.jose.proc.JWSKeySelector; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; @@ -23,9 +23,9 @@ import com.nimbusds.oauth2.sdk.id.Issuer; import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; import java.net.URI; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -34,37 +34,38 @@ import java.util.Set; /** * Exchanges OIDC authorization codes for validated user information. *

- * Fetches and caches the OIDC provider discovery metadata, exchanges the auth code - * for tokens at the provider's token endpoint, and validates the id_token using JWKS. + * Reads OIDC configuration from the database via {@link OidcConfigRepository}. + * Caches provider metadata and JWKS processor per issuer URI; cache is invalidated + * when {@link #invalidateCache()} is called (e.g. after config update). */ +@Component public class OidcTokenExchanger { private static final Logger log = LoggerFactory.getLogger(OidcTokenExchanger.class); - private final SecurityProperties.Oidc oidcConfig; + private final OidcConfigRepository configRepository; + + private volatile String cachedIssuerUri; private volatile OIDCProviderMetadata providerMetadata; private volatile ConfigurableJWTProcessor jwtProcessor; - public OidcTokenExchanger(SecurityProperties.Oidc oidcConfig) { - this.oidcConfig = oidcConfig; + public OidcTokenExchanger(OidcConfigRepository configRepository) { + this.configRepository = configRepository; } public record OidcUserInfo(String subject, String email, String name, List roles) {} /** * Exchanges an authorization code for validated user info. - * - * @param code the authorization code from the OIDC provider - * @param redirectUri the redirect URI used in the authorization request - * @return validated user information from the id_token */ public OidcUserInfo exchange(String code, String redirectUri) throws Exception { - OIDCProviderMetadata metadata = getProviderMetadata(); + OidcConfig config = getConfig(); + + OIDCProviderMetadata metadata = getProviderMetadata(config.issuerUri()); - // Exchange code for tokens ClientAuthentication clientAuth = new ClientSecretBasic( - new ClientID(oidcConfig.getClientId()), - new Secret(oidcConfig.getClientSecret()) + new ClientID(config.clientId()), + new Secret(config.clientSecret()) ); TokenRequest tokenRequest = new TokenRequest( @@ -80,15 +81,13 @@ public class OidcTokenExchanger { throw new IllegalStateException("OIDC token exchange failed: " + error); } - // Extract id_token from successful response String idTokenStr = tokenResponse.toSuccessResponse().toJSONObject() .getAsString("id_token"); if (idTokenStr == null) { throw new IllegalStateException("OIDC response missing id_token"); } - // Validate id_token - JWTClaimsSet claims = getJwtProcessor().process(idTokenStr, null); + JWTClaimsSet claims = getJwtProcessor(config.issuerUri()).process(idTokenStr, null); String subject = claims.getSubject(); String email = claims.getStringClaim("email"); @@ -97,17 +96,42 @@ public class OidcTokenExchanger { name = claims.getStringClaim("preferred_username"); } - List roles = extractRoles(claims, oidcConfig.getRolesClaim()); + List roles = extractRoles(claims, config.rolesClaim()); log.info("OIDC user authenticated: sub={}, email={}", subject, email); return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles); } /** - * Returns the provider's authorization endpoint for the SPA to initiate the flow. + * Returns the provider's authorization endpoint for the SPA. */ public String getAuthorizationEndpoint() throws Exception { - return getProviderMetadata().getAuthorizationEndpointURI().toString(); + OidcConfig config = getConfig(); + return getProviderMetadata(config.issuerUri()).getAuthorizationEndpointURI().toString(); + } + + /** + * Invalidates cached provider metadata and JWKS processor. + * Call after OIDC configuration is updated in the database. + */ + public void invalidateCache() { + synchronized (this) { + providerMetadata = null; + jwtProcessor = null; + cachedIssuerUri = null; + log.info("OIDC provider cache invalidated"); + } + } + + /** + * Returns the current OIDC config from the database. + * + * @throws IllegalStateException if OIDC is not configured or disabled + */ + public OidcConfig getConfig() { + return configRepository.find() + .filter(OidcConfig::enabled) + .orElseThrow(() -> new IllegalStateException("OIDC is not configured or disabled")); } @SuppressWarnings("unchecked") @@ -131,24 +155,26 @@ public class OidcTokenExchanger { return Collections.emptyList(); } - private OIDCProviderMetadata getProviderMetadata() throws Exception { - if (providerMetadata == null) { + private OIDCProviderMetadata getProviderMetadata(String issuerUri) throws Exception { + if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) { synchronized (this) { - if (providerMetadata == null) { - Issuer issuer = new Issuer(oidcConfig.getIssuerUri()); + if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) { + Issuer issuer = new Issuer(issuerUri); providerMetadata = OIDCProviderMetadata.resolve(issuer); - log.info("OIDC provider metadata loaded from {}", oidcConfig.getIssuerUri()); + cachedIssuerUri = issuerUri; + jwtProcessor = null; // Reset processor when issuer changes + log.info("OIDC provider metadata loaded from {}", issuerUri); } } } return providerMetadata; } - private ConfigurableJWTProcessor getJwtProcessor() throws Exception { + private ConfigurableJWTProcessor getJwtProcessor(String issuerUri) throws Exception { if (jwtProcessor == null) { synchronized (this) { if (jwtProcessor == null) { - OIDCProviderMetadata metadata = getProviderMetadata(); + OIDCProviderMetadata metadata = getProviderMetadata(issuerUri); JWKSource jwkSource = JWKSourceBuilder .create(metadata.getJWKSetURI().toURL()) .build(); 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 57fa8119..55990e6b 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 @@ -1,21 +1,29 @@ package com.cameleer3.server.app.security; +import com.cameleer3.server.core.security.OidcConfig; +import com.cameleer3.server.core.security.OidcConfigRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.List; + /** * Configuration class that creates security service beans and validates * that required security properties are set. *

* Fails fast on startup if {@code CAMELEER_AUTH_TOKEN} is not set. + * Seeds OIDC config from env vars into ClickHouse if DB is empty. */ @Configuration @EnableConfigurationProperties(SecurityProperties.class) public class SecurityBeanConfig { + private static final Logger log = LoggerFactory.getLogger(SecurityBeanConfig.class); + @Bean public JwtServiceImpl jwtService(SecurityProperties properties) { return new JwtServiceImpl(properties); @@ -42,9 +50,34 @@ public class SecurityBeanConfig { }; } + /** + * Seeds OIDC config from env vars into the database if the DB has no config yet. + * This allows initial provisioning via env vars, after which the admin UI takes over. + */ @Bean - @ConditionalOnProperty(prefix = "security.oidc", name = "enabled", havingValue = "true") - public OidcTokenExchanger oidcTokenExchanger(SecurityProperties properties) { - return new OidcTokenExchanger(properties.getOidc()); + public InitializingBean oidcConfigSeeder(SecurityProperties properties, + OidcConfigRepository configRepository) { + return () -> { + if (configRepository.find().isPresent()) { + log.debug("OIDC config already present in database, skipping env var seed"); + return; + } + + SecurityProperties.Oidc envOidc = properties.getOidc(); + if (envOidc.isEnabled() + && envOidc.getIssuerUri() != null && !envOidc.getIssuerUri().isBlank() + && envOidc.getClientId() != null && !envOidc.getClientId().isBlank()) { + OidcConfig config = new OidcConfig( + true, + envOidc.getIssuerUri(), + envOidc.getClientId(), + envOidc.getClientSecret() != null ? envOidc.getClientSecret() : "", + envOidc.getRolesClaim(), + envOidc.getDefaultRoles() + ); + 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 new file mode 100644 index 00000000..87119693 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseOidcConfigRepository.java @@ -0,0 +1,67 @@ +package com.cameleer3.server.app.storage; + +import com.cameleer3.server.core.security.OidcConfig; +import com.cameleer3.server.core.security.OidcConfigRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * ClickHouse implementation of {@link OidcConfigRepository}. + * Singleton row with {@code config_id = 'default'}, using ReplacingMergeTree. + */ +@Repository +public class ClickHouseOidcConfigRepository implements OidcConfigRepository { + + private final JdbcTemplate jdbc; + + public ClickHouseOidcConfigRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public Optional find() { + List results = jdbc.query( + "SELECT enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles " + + "FROM oidc_config FINAL WHERE config_id = 'default'", + this::mapRow + ); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + @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'))", + config.enabled(), + config.issuerUri(), + config.clientId(), + config.clientSecret(), + config.rolesClaim(), + config.defaultRoles().toArray(new String[0]) + ); + } + + @Override + public void delete() { + jdbc.update("DELETE FROM oidc_config WHERE config_id = 'default'"); + } + + private OidcConfig mapRow(ResultSet rs, int rowNum) throws SQLException { + String[] rolesArray = (String[]) rs.getArray("default_roles").getArray(); + return new OidcConfig( + rs.getBoolean("enabled"), + rs.getString("issuer_uri"), + rs.getString("client_id"), + rs.getString("client_secret"), + rs.getString("roles_claim"), + Arrays.asList(rolesArray) + ); + } +} diff --git a/cameleer3-server-app/src/main/resources/clickhouse/04-oidc-config.sql b/cameleer3-server-app/src/main/resources/clickhouse/04-oidc-config.sql new file mode 100644 index 00000000..3c0275b6 --- /dev/null +++ b/cameleer3-server-app/src/main/resources/clickhouse/04-oidc-config.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS oidc_config ( + config_id String DEFAULT 'default', + enabled Bool DEFAULT false, + issuer_uri String DEFAULT '', + client_id String DEFAULT '', + client_secret String DEFAULT '', + roles_claim String DEFAULT 'realm_access.roles', + default_roles Array(LowCardinality(String)), + updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC') +) ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (config_id); diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java index 07389dc1..63a40f27 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java @@ -56,7 +56,7 @@ public abstract class AbstractClickHouseIT { } // Load all schema files in order - String[] schemaFiles = {"01-schema.sql", "02-search-columns.sql", "03-users.sql"}; + String[] schemaFiles = {"01-schema.sql", "02-search-columns.sql", "03-users.sql", "04-oidc-config.sql"}; try (Connection conn = DriverManager.getConnection( CLICKHOUSE.getJdbcUrl(), 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 new file mode 100644 index 00000000..7557250b --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java @@ -0,0 +1,26 @@ +package com.cameleer3.server.core.security; + +import java.util.List; + +/** + * Persisted OIDC provider configuration. + * + * @param enabled whether OIDC login is active + * @param issuerUri OIDC discovery issuer URL + * @param clientId OAuth2 client ID + * @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 + */ +public record OidcConfig( + boolean enabled, + String issuerUri, + String clientId, + String clientSecret, + String rolesClaim, + List defaultRoles +) { + public static OidcConfig disabled() { + return new OidcConfig(false, "", "", "", "realm_access.roles", List.of("VIEWER")); + } +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfigRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfigRepository.java new file mode 100644 index 00000000..c8cef8e2 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfigRepository.java @@ -0,0 +1,16 @@ +package com.cameleer3.server.core.security; + +import java.util.Optional; + +/** + * Persistence interface for OIDC provider configuration. + * Only one configuration is active at a time (singleton row). + */ +public interface OidcConfigRepository { + + Optional find(); + + void save(OidcConfig config); + + void delete(); +} diff --git a/clickhouse/init/04-oidc-config.sql b/clickhouse/init/04-oidc-config.sql new file mode 100644 index 00000000..3c0275b6 --- /dev/null +++ b/clickhouse/init/04-oidc-config.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS oidc_config ( + config_id String DEFAULT 'default', + enabled Bool DEFAULT false, + issuer_uri String DEFAULT '', + client_id String DEFAULT '', + client_secret String DEFAULT '', + roles_claim String DEFAULT 'realm_access.roles', + default_roles Array(LowCardinality(String)), + updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC') +) ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (config_id);