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:
40
HOWTO.md
40
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=<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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
11
clickhouse/init/04-oidc-config.sql
Normal file
11
clickhouse/init/04-oidc-config.sql
Normal 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);
|
||||
Reference in New Issue
Block a user