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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<OidcConfig> 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<OidcConfig> 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<OidcConfig> 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<Void> deleteConfig() {
|
||||
configRepository.delete();
|
||||
tokenExchanger.invalidateCache();
|
||||
log.info("OIDC configuration deleted");
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private Map<String, Object> toResponse(OidcConfig config) {
|
||||
Map<String, Object> 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<String> defaultRoles
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user