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

@@ -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=<client-secret-from-step-2>
```
For K8s deployment, these are managed via the `cameleer-oidc` secret (see CI/CD section).
### User Management (ADMIN only)
```bash

View File

@@ -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;

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

View File

@@ -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.
* <p>
* 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<OidcConfig> 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.
* <p>
* 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<OidcConfig> 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<String> roles = resolveRoles(userId, oidcUser.roles());
List<String> 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<String> resolveRoles(String userId, List<String> oidcRoles) {
// 1. Check for admin-assigned override in user store
private List<String> resolveRoles(String userId, List<String> oidcRoles, OidcConfig config) {
Optional<UserInfo> 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) {}

View File

@@ -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.
* <p>
* 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<SecurityContext> 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<String> 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<String> roles = extractRoles(claims, oidcConfig.getRolesClaim());
List<String> 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<SecurityContext> getJwtProcessor() throws Exception {
private ConfigurableJWTProcessor<SecurityContext> getJwtProcessor(String issuerUri) throws Exception {
if (jwtProcessor == null) {
synchronized (this) {
if (jwtProcessor == null) {
OIDCProviderMetadata metadata = getProviderMetadata();
OIDCProviderMetadata metadata = getProviderMetadata(issuerUri);
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder
.create(metadata.getJWKSetURI().toURL())
.build();

View File

@@ -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.
* <p>
* 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());
}
};
}
}

View File

@@ -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<OidcConfig> find() {
List<OidcConfig> 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)
);
}
}

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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<String> defaultRoles
) {
public static OidcConfig disabled() {
return new OidcConfig(false, "", "", "", "realm_access.roles", List.of("VIEWER"));
}
}

View File

@@ -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<OidcConfig> find();
void save(OidcConfig config);
void delete();
}

View File

@@ -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);