Move OIDC config from env vars to database with admin API
All checks were successful
CI / build (push) Successful in 1m9s
CI / docker (push) Successful in 41s
CI / deploy (push) Successful in 2m11s

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:
hsiegeln
2026-03-14 13:00:13 +01:00
parent a1e1c8f6ff
commit 9d2e6f30a7
12 changed files with 436 additions and 69 deletions

View File

@@ -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
) {}
}