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) ### 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 ```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 curl -s http://localhost:8081/api/v1/auth/oidc/config
# Returns: { "issuer": "...", "clientId": "...", "authorizationEndpoint": "..." } # 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. 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 Setup (OIDC Provider)
Authentik is deployed alongside the Cameleer stack. After first deployment: 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` - Name: `Cameleer`
- Provider: select `Cameleer` (created above) - 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. 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_ENABLED=true
CAMELEER_OIDC_ISSUER=http://authentik:9000/application/o/cameleer/ 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> 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) ### User Management (ADMIN only)
```bash ```bash

View File

@@ -26,7 +26,8 @@ public class ClickHouseConfig {
private static final Logger log = LoggerFactory.getLogger(ClickHouseConfig.class); private static final Logger log = LoggerFactory.getLogger(ClickHouseConfig.class);
private static final String[] SCHEMA_FILES = { 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; 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; package com.cameleer3.server.app.security;
import com.cameleer3.server.core.security.JwtService; 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.UserInfo;
import com.cameleer3.server.core.security.UserRepository; import com.cameleer3.server.core.security.UserRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -22,46 +23,50 @@ import java.util.Optional;
/** /**
* OIDC authentication endpoints for the UI. * OIDC authentication endpoints for the UI.
* <p> * <p>
* Only active when {@code security.oidc.enabled=true}. * Always registered — returns 404 when OIDC is not configured or disabled.
* The SPA initiates the authorization code flow, then sends the code here * Configuration is read from the database (managed via admin UI).
* for server-side token exchange (keeping client_secret secure).
*/ */
@RestController @RestController
@RequestMapping("/api/v1/auth/oidc") @RequestMapping("/api/v1/auth/oidc")
@ConditionalOnProperty(prefix = "security.oidc", name = "enabled", havingValue = "true")
public class OidcAuthController { public class OidcAuthController {
private static final Logger log = LoggerFactory.getLogger(OidcAuthController.class); private static final Logger log = LoggerFactory.getLogger(OidcAuthController.class);
private final OidcTokenExchanger tokenExchanger; private final OidcTokenExchanger tokenExchanger;
private final OidcConfigRepository configRepository;
private final JwtService jwtService; private final JwtService jwtService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final SecurityProperties properties;
public OidcAuthController(OidcTokenExchanger tokenExchanger, public OidcAuthController(OidcTokenExchanger tokenExchanger,
OidcConfigRepository configRepository,
JwtService jwtService, JwtService jwtService,
UserRepository userRepository, UserRepository userRepository) {
SecurityProperties properties) {
this.tokenExchanger = tokenExchanger; this.tokenExchanger = tokenExchanger;
this.configRepository = configRepository;
this.jwtService = jwtService; this.jwtService = jwtService;
this.userRepository = userRepository; this.userRepository = userRepository;
this.properties = properties;
} }
/** /**
* Returns OIDC configuration for the SPA to initiate the authorization code flow. * Returns OIDC configuration for the SPA to initiate the authorization code flow.
* Returns 404 if OIDC is not configured or disabled.
*/ */
@GetMapping("/config") @GetMapping("/config")
public ResponseEntity<?> getConfig() { public ResponseEntity<?> getConfig() {
Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty() || !config.get().enabled()) {
return ResponseEntity.notFound().build();
}
try { try {
SecurityProperties.Oidc oidc = properties.getOidc(); OidcConfig oidc = config.get();
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"issuer", oidc.getIssuerUri(), "issuer", oidc.issuerUri(),
"clientId", oidc.getClientId(), "clientId", oidc.clientId(),
"authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint() "authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint()
)); ));
} catch (Exception e) { } 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() return ResponseEntity.internalServerError()
.body(Map.of("message", "Failed to retrieve OIDC provider metadata")); .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. * 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") @PostMapping("/callback")
public ResponseEntity<?> callback(@RequestBody CallbackRequest request) { public ResponseEntity<?> callback(@RequestBody CallbackRequest request) {
Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty() || !config.get().enabled()) {
return ResponseEntity.notFound().build();
}
try { try {
OidcTokenExchanger.OidcUserInfo oidcUser = OidcTokenExchanger.OidcUserInfo oidcUser =
tokenExchanger.exchange(request.code(), request.redirectUri()); tokenExchanger.exchange(request.code(), request.redirectUri());
String userId = "oidc:" + oidcUser.subject(); 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; String provider = "oidc:" + issuerHost;
// Resolve roles: DB override > OIDC claim > default // 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( userRepository.upsert(new UserInfo(
userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now())); userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now()));
// Issue internal tokens
String accessToken = jwtService.createAccessToken(userId, "ui", roles); String accessToken = jwtService.createAccessToken(userId, "ui", roles);
String refreshToken = jwtService.createRefreshToken(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) { private List<String> resolveRoles(String userId, List<String> oidcRoles, OidcConfig config) {
// 1. Check for admin-assigned override in user store
Optional<UserInfo> existing = userRepository.findById(userId); Optional<UserInfo> existing = userRepository.findById(userId);
if (existing.isPresent() && !existing.get().roles().isEmpty()) { if (existing.isPresent() && !existing.get().roles().isEmpty()) {
return existing.get().roles(); return existing.get().roles();
} }
// 2. Roles from OIDC token
if (!oidcRoles.isEmpty()) { if (!oidcRoles.isEmpty()) {
return oidcRoles; return oidcRoles;
} }
return config.defaultRoles();
// 3. Default roles
return properties.getOidc().getDefaultRoles();
} }
public record CallbackRequest(String code, String redirectUri) {} public record CallbackRequest(String code, String redirectUri) {}

View File

@@ -1,10 +1,10 @@
package com.cameleer3.server.app.security; 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.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder; import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.BadJOSEException;
import com.nimbusds.jose.proc.JWSKeySelector; import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext; 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 com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.net.URI; import java.net.URI;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -34,37 +34,38 @@ import java.util.Set;
/** /**
* Exchanges OIDC authorization codes for validated user information. * Exchanges OIDC authorization codes for validated user information.
* <p> * <p>
* Fetches and caches the OIDC provider discovery metadata, exchanges the auth code * Reads OIDC configuration from the database via {@link OidcConfigRepository}.
* for tokens at the provider's token endpoint, and validates the id_token using JWKS. * 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 { public class OidcTokenExchanger {
private static final Logger log = LoggerFactory.getLogger(OidcTokenExchanger.class); 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 OIDCProviderMetadata providerMetadata;
private volatile ConfigurableJWTProcessor<SecurityContext> jwtProcessor; private volatile ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
public OidcTokenExchanger(SecurityProperties.Oidc oidcConfig) { public OidcTokenExchanger(OidcConfigRepository configRepository) {
this.oidcConfig = oidcConfig; this.configRepository = configRepository;
} }
public record OidcUserInfo(String subject, String email, String name, List<String> roles) {} public record OidcUserInfo(String subject, String email, String name, List<String> roles) {}
/** /**
* Exchanges an authorization code for validated user info. * 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 { 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( ClientAuthentication clientAuth = new ClientSecretBasic(
new ClientID(oidcConfig.getClientId()), new ClientID(config.clientId()),
new Secret(oidcConfig.getClientSecret()) new Secret(config.clientSecret())
); );
TokenRequest tokenRequest = new TokenRequest( TokenRequest tokenRequest = new TokenRequest(
@@ -80,15 +81,13 @@ public class OidcTokenExchanger {
throw new IllegalStateException("OIDC token exchange failed: " + error); throw new IllegalStateException("OIDC token exchange failed: " + error);
} }
// Extract id_token from successful response
String idTokenStr = tokenResponse.toSuccessResponse().toJSONObject() String idTokenStr = tokenResponse.toSuccessResponse().toJSONObject()
.getAsString("id_token"); .getAsString("id_token");
if (idTokenStr == null) { if (idTokenStr == null) {
throw new IllegalStateException("OIDC response missing id_token"); throw new IllegalStateException("OIDC response missing id_token");
} }
// Validate id_token JWTClaimsSet claims = getJwtProcessor(config.issuerUri()).process(idTokenStr, null);
JWTClaimsSet claims = getJwtProcessor().process(idTokenStr, null);
String subject = claims.getSubject(); String subject = claims.getSubject();
String email = claims.getStringClaim("email"); String email = claims.getStringClaim("email");
@@ -97,17 +96,42 @@ public class OidcTokenExchanger {
name = claims.getStringClaim("preferred_username"); 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); log.info("OIDC user authenticated: sub={}, email={}", subject, email);
return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles); 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 { 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") @SuppressWarnings("unchecked")
@@ -131,24 +155,26 @@ public class OidcTokenExchanger {
return Collections.emptyList(); return Collections.emptyList();
} }
private OIDCProviderMetadata getProviderMetadata() throws Exception { private OIDCProviderMetadata getProviderMetadata(String issuerUri) throws Exception {
if (providerMetadata == null) { if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
synchronized (this) { synchronized (this) {
if (providerMetadata == null) { if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
Issuer issuer = new Issuer(oidcConfig.getIssuerUri()); Issuer issuer = new Issuer(issuerUri);
providerMetadata = OIDCProviderMetadata.resolve(issuer); 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; return providerMetadata;
} }
private ConfigurableJWTProcessor<SecurityContext> getJwtProcessor() throws Exception { private ConfigurableJWTProcessor<SecurityContext> getJwtProcessor(String issuerUri) throws Exception {
if (jwtProcessor == null) { if (jwtProcessor == null) {
synchronized (this) { synchronized (this) {
if (jwtProcessor == null) { if (jwtProcessor == null) {
OIDCProviderMetadata metadata = getProviderMetadata(); OIDCProviderMetadata metadata = getProviderMetadata(issuerUri);
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder JWKSource<SecurityContext> jwkSource = JWKSourceBuilder
.create(metadata.getJWKSetURI().toURL()) .create(metadata.getJWKSetURI().toURL())
.build(); .build();

View File

@@ -1,21 +1,29 @@
package com.cameleer3.server.app.security; 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.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.util.List;
/** /**
* Configuration class that creates security service beans and validates * Configuration class that creates security service beans and validates
* that required security properties are set. * that required security properties are set.
* <p> * <p>
* Fails fast on startup if {@code CAMELEER_AUTH_TOKEN} is not 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 @Configuration
@EnableConfigurationProperties(SecurityProperties.class) @EnableConfigurationProperties(SecurityProperties.class)
public class SecurityBeanConfig { public class SecurityBeanConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityBeanConfig.class);
@Bean @Bean
public JwtServiceImpl jwtService(SecurityProperties properties) { public JwtServiceImpl jwtService(SecurityProperties properties) {
return new JwtServiceImpl(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 @Bean
@ConditionalOnProperty(prefix = "security.oidc", name = "enabled", havingValue = "true") public InitializingBean oidcConfigSeeder(SecurityProperties properties,
public OidcTokenExchanger oidcTokenExchanger(SecurityProperties properties) { OidcConfigRepository configRepository) {
return new OidcTokenExchanger(properties.getOidc()); 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 // 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( try (Connection conn = DriverManager.getConnection(
CLICKHOUSE.getJdbcUrl(), 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);