roles) {}
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
index 777885ff..37393f70 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
@@ -2,6 +2,7 @@ package com.cameleer3.server.app.security;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.security.JwtService;
+import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -9,6 +10,8 @@ import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -19,6 +22,9 @@ import java.util.List;
* JWT authentication filter that extracts and validates JWT tokens from
* the {@code Authorization: Bearer} header or the {@code token} query parameter.
*
+ * Populates Spring Security {@code GrantedAuthority} from the JWT {@code roles} claim.
+ * Agent tokens without roles get {@code ROLE_AGENT}; UI tokens get authorities from the claim.
+ *
* Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig}
* to avoid double filter registration.
*/
@@ -43,15 +49,24 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (token != null) {
try {
- String subject = jwtService.validateAndExtractAgentId(token);
+ JwtValidationResult result = jwtService.validateAccessToken(token);
+ String subject = result.subject();
+
if (subject.startsWith("ui:")) {
- // UI user token — authenticate directly without agent registry lookup
+ // UI user token — authenticate with roles from JWT
+ List authorities = toAuthorities(result.roles());
UsernamePasswordAuthenticationToken auth =
- new UsernamePasswordAuthenticationToken(subject, null, List.of());
+ new UsernamePasswordAuthenticationToken(subject, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
} else if (agentRegistryService.findById(subject) != null) {
+ // Agent token — use roles from JWT, default to AGENT if empty
+ List roles = result.roles();
+ if (roles.isEmpty()) {
+ roles = List.of("AGENT");
+ }
+ List authorities = toAuthorities(roles);
UsernamePasswordAuthenticationToken auth =
- new UsernamePasswordAuthenticationToken(subject, null, List.of());
+ new UsernamePasswordAuthenticationToken(subject, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
} else {
log.debug("JWT valid but agent not found: {}", subject);
@@ -64,14 +79,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
chain.doFilter(request, response);
}
+ private List toAuthorities(List roles) {
+ return roles.stream()
+ .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))
+ .toList();
+ }
+
private String extractToken(HttpServletRequest request) {
- // Check Authorization header first
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
return authHeader.substring(BEARER_PREFIX.length());
}
-
- // Fall back to query parameter (for SSE EventSource API)
return request.getParameter("token");
}
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java
index 86310484..40cb29a6 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java
@@ -11,21 +11,28 @@ import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.security.SecureRandom;
import java.text.ParseException;
import java.time.Instant;
+import java.util.Base64;
import java.util.Date;
+import java.util.List;
/**
* HMAC-SHA256 JWT implementation using Nimbus JOSE+JWT.
*
- * Generates a random 256-bit secret in the constructor (ephemeral per server instance).
- * Creates access tokens (1h default) and refresh tokens (7d default) with type claims
- * to distinguish between the two.
+ * If {@code security.jwt-secret} is configured, uses that secret (Base64-decoded or raw bytes
+ * if not valid Base64). Otherwise generates a random 256-bit secret (ephemeral per server instance).
+ *
+ * Embeds a {@code roles} array claim in every token for RBAC.
*/
public class JwtServiceImpl implements JwtService {
+ private static final Logger log = LoggerFactory.getLogger(JwtServiceImpl.class);
+
private final byte[] secret;
private final JWSSigner signer;
private final JWSVerifier verifier;
@@ -33,8 +40,17 @@ public class JwtServiceImpl implements JwtService {
public JwtServiceImpl(SecurityProperties properties) {
this.properties = properties;
- this.secret = new byte[32]; // 256-bit
- new SecureRandom().nextBytes(secret);
+
+ String configuredSecret = properties.getJwtSecret();
+ if (configuredSecret != null && !configuredSecret.isBlank()) {
+ this.secret = decodeSecret(configuredSecret);
+ log.info("JWT signing secret loaded from configuration");
+ } else {
+ this.secret = new byte[32]; // 256-bit
+ new SecureRandom().nextBytes(secret);
+ log.info("JWT signing secret generated randomly (tokens will not survive restarts)");
+ }
+
try {
this.signer = new MACSigner(secret);
this.verifier = new MACVerifier(secret);
@@ -44,31 +60,38 @@ public class JwtServiceImpl implements JwtService {
}
@Override
- public String createAccessToken(String agentId, String group) {
- return createToken(agentId, group, "access", properties.getAccessTokenExpiryMs());
+ public String createAccessToken(String subject, String group, List roles) {
+ return createToken(subject, group, roles, "access", properties.getAccessTokenExpiryMs());
}
@Override
- public String createRefreshToken(String agentId, String group) {
- return createToken(agentId, group, "refresh", properties.getRefreshTokenExpiryMs());
+ public String createRefreshToken(String subject, String group, List roles) {
+ return createToken(subject, group, roles, "refresh", properties.getRefreshTokenExpiryMs());
}
@Override
- public String validateAndExtractAgentId(String token) {
+ public JwtValidationResult validateAccessToken(String token) {
return validateToken(token, "access");
}
@Override
- public String validateRefreshToken(String token) {
+ public JwtValidationResult validateRefreshToken(String token) {
return validateToken(token, "refresh");
}
- private String createToken(String agentId, String group, String type, long expiryMs) {
+ @Override
+ public String validateAndExtractAgentId(String token) {
+ return validateAccessToken(token).subject();
+ }
+
+ private String createToken(String subject, String group, List roles,
+ String type, long expiryMs) {
Instant now = Instant.now();
JWTClaimsSet claims = new JWTClaimsSet.Builder()
- .subject(agentId)
+ .subject(subject)
.claim("group", group)
.claim("type", type)
+ .claim("roles", roles)
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plusMillis(expiryMs)))
.build();
@@ -82,7 +105,8 @@ public class JwtServiceImpl implements JwtService {
return signedJWT.serialize();
}
- private String validateToken(String token, String expectedType) {
+ @SuppressWarnings("unchecked")
+ private JwtValidationResult validateToken(String token, String expectedType) {
try {
SignedJWT signedJWT = SignedJWT.parse(token);
@@ -92,13 +116,11 @@ public class JwtServiceImpl implements JwtService {
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
- // Check expiration
Date expiration = claims.getExpirationTime();
if (expiration == null || expiration.before(new Date())) {
throw new InvalidTokenException("Token has expired");
}
- // Check type
String type = claims.getStringClaim("type");
if (!expectedType.equals(type)) {
throw new InvalidTokenException(
@@ -107,14 +129,48 @@ public class JwtServiceImpl implements JwtService {
String subject = claims.getSubject();
if (subject == null || subject.isBlank()) {
- throw new InvalidTokenException("Token has no subject (agentId)");
+ throw new InvalidTokenException("Token has no subject");
}
- return subject;
+ String group = claims.getStringClaim("group");
+
+ // Extract roles — may be absent in legacy tokens
+ List roles;
+ Object rolesClaim = claims.getClaim("roles");
+ if (rolesClaim instanceof List>) {
+ roles = ((List>) rolesClaim).stream()
+ .map(Object::toString)
+ .toList();
+ } else {
+ roles = List.of();
+ }
+
+ return new JwtValidationResult(subject, group, roles);
} catch (ParseException e) {
throw new InvalidTokenException("Failed to parse JWT", e);
} catch (JOSEException e) {
throw new InvalidTokenException("Failed to verify JWT signature", e);
}
}
+
+ private static byte[] decodeSecret(String secret) {
+ try {
+ byte[] decoded = Base64.getDecoder().decode(secret);
+ if (decoded.length >= 32) {
+ return decoded;
+ }
+ } catch (IllegalArgumentException ignored) {
+ // Not valid Base64 — use raw bytes
+ }
+ byte[] raw = secret.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ if (raw.length < 32) {
+ // Pad to 32 bytes with SHA-256 hash
+ try {
+ return java.security.MessageDigest.getInstance("SHA-256").digest(raw);
+ } catch (java.security.NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ }
+ return raw;
+ }
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java
new file mode 100644
index 00000000..029b11c8
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java
@@ -0,0 +1,127 @@
+package com.cameleer3.server.app.security;
+
+import com.cameleer3.server.core.security.JwtService;
+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;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.net.URI;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * OIDC authentication endpoints for the UI.
+ *
+ * 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).
+ */
+@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 JwtService jwtService;
+ private final UserRepository userRepository;
+ private final SecurityProperties properties;
+
+ public OidcAuthController(OidcTokenExchanger tokenExchanger,
+ JwtService jwtService,
+ UserRepository userRepository,
+ SecurityProperties properties) {
+ this.tokenExchanger = tokenExchanger;
+ this.jwtService = jwtService;
+ this.userRepository = userRepository;
+ this.properties = properties;
+ }
+
+ /**
+ * Returns OIDC configuration for the SPA to initiate the authorization code flow.
+ */
+ @GetMapping("/config")
+ public ResponseEntity> getConfig() {
+ try {
+ SecurityProperties.Oidc oidc = properties.getOidc();
+ return ResponseEntity.ok(Map.of(
+ "issuer", oidc.getIssuerUri(),
+ "clientId", oidc.getClientId(),
+ "authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint()
+ ));
+ } catch (Exception e) {
+ log.error("Failed to retrieve OIDC config: {}", e.getMessage());
+ return ResponseEntity.internalServerError()
+ .body(Map.of("message", "Failed to retrieve OIDC provider metadata"));
+ }
+ }
+
+ /**
+ * Exchanges an OIDC authorization code for internal Cameleer JWTs.
+ *
+ * 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) {
+ try {
+ OidcTokenExchanger.OidcUserInfo oidcUser =
+ tokenExchanger.exchange(request.code(), request.redirectUri());
+
+ String userId = "oidc:" + oidcUser.subject();
+ String issuerHost = URI.create(properties.getOidc().getIssuerUri()).getHost();
+ String provider = "oidc:" + issuerHost;
+
+ // Resolve roles: DB override > OIDC claim > default
+ List roles = resolveRoles(userId, oidcUser.roles());
+
+ // 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);
+
+ return ResponseEntity.ok(Map.of(
+ "accessToken", accessToken,
+ "refreshToken", refreshToken
+ ));
+ } catch (Exception e) {
+ log.error("OIDC callback failed: {}", e.getMessage(), e);
+ return ResponseEntity.status(401)
+ .body(Map.of("message", "OIDC authentication failed: " + e.getMessage()));
+ }
+ }
+
+ private List resolveRoles(String userId, List oidcRoles) {
+ // 1. Check for admin-assigned override in user store
+ Optional 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();
+ }
+
+ public record CallbackRequest(String code, String redirectUri) {}
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java
new file mode 100644
index 00000000..48097f4c
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java
@@ -0,0 +1,168 @@
+package com.cameleer3.server.app.security;
+
+import com.nimbusds.jose.JOSEException;
+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;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
+import com.nimbusds.jwt.proc.DefaultJWTProcessor;
+import com.nimbusds.oauth2.sdk.AuthorizationCode;
+import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
+import com.nimbusds.oauth2.sdk.TokenRequest;
+import com.nimbusds.oauth2.sdk.TokenResponse;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
+import com.nimbusds.oauth2.sdk.auth.Secret;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Exchanges OIDC authorization codes for validated user information.
+ *
+ * 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.
+ */
+public class OidcTokenExchanger {
+
+ private static final Logger log = LoggerFactory.getLogger(OidcTokenExchanger.class);
+
+ private final SecurityProperties.Oidc oidcConfig;
+ private volatile OIDCProviderMetadata providerMetadata;
+ private volatile ConfigurableJWTProcessor jwtProcessor;
+
+ public OidcTokenExchanger(SecurityProperties.Oidc oidcConfig) {
+ this.oidcConfig = oidcConfig;
+ }
+
+ public record OidcUserInfo(String subject, String email, String name, List 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();
+
+ // Exchange code for tokens
+ ClientAuthentication clientAuth = new ClientSecretBasic(
+ new ClientID(oidcConfig.getClientId()),
+ new Secret(oidcConfig.getClientSecret())
+ );
+
+ TokenRequest tokenRequest = new TokenRequest(
+ metadata.getTokenEndpointURI(),
+ clientAuth,
+ new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri))
+ );
+
+ TokenResponse tokenResponse = TokenResponse.parse(tokenRequest.toHTTPRequest().send());
+
+ if (!tokenResponse.indicatesSuccess()) {
+ String error = tokenResponse.toErrorResponse().getErrorObject().getDescription();
+ 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);
+
+ String subject = claims.getSubject();
+ String email = claims.getStringClaim("email");
+ String name = claims.getStringClaim("name");
+ if (name == null) {
+ name = claims.getStringClaim("preferred_username");
+ }
+
+ List roles = extractRoles(claims, oidcConfig.getRolesClaim());
+
+ 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.
+ */
+ public String getAuthorizationEndpoint() throws Exception {
+ return getProviderMetadata().getAuthorizationEndpointURI().toString();
+ }
+
+ @SuppressWarnings("unchecked")
+ private List extractRoles(JWTClaimsSet claims, String claimPath) {
+ try {
+ String[] parts = claimPath.split("\\.");
+ Object current = claims.getClaim(parts[0]);
+
+ for (int i = 1; i < parts.length && current instanceof Map; i++) {
+ current = ((Map) current).get(parts[i]);
+ }
+
+ if (current instanceof List>) {
+ return ((List>) current).stream()
+ .map(Object::toString)
+ .toList();
+ }
+ } catch (Exception e) {
+ log.debug("Could not extract roles from claim path '{}': {}", claimPath, e.getMessage());
+ }
+ return Collections.emptyList();
+ }
+
+ private OIDCProviderMetadata getProviderMetadata() throws Exception {
+ if (providerMetadata == null) {
+ synchronized (this) {
+ if (providerMetadata == null) {
+ Issuer issuer = new Issuer(oidcConfig.getIssuerUri());
+ providerMetadata = OIDCProviderMetadata.resolve(issuer);
+ log.info("OIDC provider metadata loaded from {}", oidcConfig.getIssuerUri());
+ }
+ }
+ }
+ return providerMetadata;
+ }
+
+ private ConfigurableJWTProcessor getJwtProcessor() throws Exception {
+ if (jwtProcessor == null) {
+ synchronized (this) {
+ if (jwtProcessor == null) {
+ OIDCProviderMetadata metadata = getProviderMetadata();
+ JWKSource jwkSource = JWKSourceBuilder
+ .create(metadata.getJWKSetURI().toURL())
+ .build();
+
+ Set expectedAlgs = Set.of(JWSAlgorithm.RS256, JWSAlgorithm.ES256);
+ JWSKeySelector keySelector =
+ new JWSVerificationKeySelector<>(expectedAlgs, jwkSource);
+
+ ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>();
+ processor.setJWSKeySelector(keySelector);
+ jwtProcessor = processor;
+ }
+ }
+ }
+ return jwtProcessor;
+ }
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
index 3dd05fd6..57fa8119 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
@@ -1,6 +1,7 @@
package com.cameleer3.server.app.security;
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;
@@ -40,4 +41,10 @@ public class SecurityBeanConfig {
}
};
}
+
+ @Bean
+ @ConditionalOnProperty(prefix = "security.oidc", name = "enabled", havingValue = "true")
+ public OidcTokenExchanger oidcTokenExchanger(SecurityProperties properties) {
+ return new OidcTokenExchanger(properties.getOidc());
+ }
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
index f3e1711b..2a6f786b 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
@@ -4,6 +4,8 @@ import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.security.JwtService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
@@ -15,15 +17,13 @@ import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
-import org.springframework.http.HttpStatus;
-
import java.util.List;
/**
- * Spring Security configuration for JWT-based stateless authentication.
+ * Spring Security configuration for JWT-based stateless authentication with RBAC.
*
* Public endpoints: health, agent registration, refresh, auth, API docs, Swagger UI, static resources.
- * All other endpoints require a valid JWT access token.
+ * All other endpoints require a valid JWT access token with appropriate roles.
*/
@Configuration
@EnableWebSecurity
@@ -41,6 +41,7 @@ public class SecurityConfig {
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
+ // Public endpoints
.requestMatchers(
"/api/v1/health",
"/api/v1/agents/register",
@@ -58,6 +59,32 @@ public class SecurityConfig {
"/favicon.svg",
"/assets/**"
).permitAll()
+
+ // Agent-only endpoints
+ .requestMatchers("/api/v1/data/**").hasRole("AGENT")
+ .requestMatchers("/api/v1/agents/*/heartbeat").hasRole("AGENT")
+ .requestMatchers("/api/v1/agents/*/events").hasRole("AGENT")
+ .requestMatchers("/api/v1/agents/*/commands/*/ack").hasRole("AGENT")
+
+ // Command endpoints — operator+ only
+ .requestMatchers(HttpMethod.POST, "/api/v1/agents/*/commands").hasAnyRole("OPERATOR", "ADMIN")
+ .requestMatchers(HttpMethod.POST, "/api/v1/agents/groups/*/commands").hasAnyRole("OPERATOR", "ADMIN")
+ .requestMatchers(HttpMethod.POST, "/api/v1/agents/commands").hasAnyRole("OPERATOR", "ADMIN")
+
+ // Search endpoints
+ .requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
+ .requestMatchers(HttpMethod.POST, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
+
+ // Read-only data endpoints — viewer+
+ .requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
+ .requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
+ .requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
+ .requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
+
+ // Admin endpoints
+ .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
+
+ // Everything else requires authentication
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
index caad27ea..e867098b 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
@@ -2,6 +2,8 @@ package com.cameleer3.server.app.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import java.util.List;
+
/**
* Configuration properties for security settings.
* Bound from the {@code security.*} namespace in application.yml.
@@ -16,60 +18,47 @@ public class SecurityProperties {
private String uiUser;
private String uiPassword;
private String uiOrigin;
+ private String jwtSecret;
+ private Oidc oidc = new Oidc();
- public long getAccessTokenExpiryMs() {
- return accessTokenExpiryMs;
+ public static class Oidc {
+ private boolean enabled = false;
+ private String issuerUri;
+ private String clientId;
+ private String clientSecret;
+ private String rolesClaim = "realm_access.roles";
+ private List defaultRoles = List.of("VIEWER");
+
+ public boolean isEnabled() { return enabled; }
+ public void setEnabled(boolean enabled) { this.enabled = enabled; }
+ public String getIssuerUri() { return issuerUri; }
+ public void setIssuerUri(String issuerUri) { this.issuerUri = issuerUri; }
+ public String getClientId() { return clientId; }
+ public void setClientId(String clientId) { this.clientId = clientId; }
+ public String getClientSecret() { return clientSecret; }
+ public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; }
+ public String getRolesClaim() { return rolesClaim; }
+ public void setRolesClaim(String rolesClaim) { this.rolesClaim = rolesClaim; }
+ public List getDefaultRoles() { return defaultRoles; }
+ public void setDefaultRoles(List defaultRoles) { this.defaultRoles = defaultRoles; }
}
- public void setAccessTokenExpiryMs(long accessTokenExpiryMs) {
- this.accessTokenExpiryMs = accessTokenExpiryMs;
- }
-
- public long getRefreshTokenExpiryMs() {
- return refreshTokenExpiryMs;
- }
-
- public void setRefreshTokenExpiryMs(long refreshTokenExpiryMs) {
- this.refreshTokenExpiryMs = refreshTokenExpiryMs;
- }
-
- public String getBootstrapToken() {
- return bootstrapToken;
- }
-
- public void setBootstrapToken(String bootstrapToken) {
- this.bootstrapToken = bootstrapToken;
- }
-
- public String getBootstrapTokenPrevious() {
- return bootstrapTokenPrevious;
- }
-
- public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) {
- this.bootstrapTokenPrevious = bootstrapTokenPrevious;
- }
-
- public String getUiUser() {
- return uiUser;
- }
-
- public void setUiUser(String uiUser) {
- this.uiUser = uiUser;
- }
-
- public String getUiPassword() {
- return uiPassword;
- }
-
- public void setUiPassword(String uiPassword) {
- this.uiPassword = uiPassword;
- }
-
- public String getUiOrigin() {
- return uiOrigin;
- }
-
- public void setUiOrigin(String uiOrigin) {
- this.uiOrigin = uiOrigin;
- }
+ public long getAccessTokenExpiryMs() { return accessTokenExpiryMs; }
+ public void setAccessTokenExpiryMs(long accessTokenExpiryMs) { this.accessTokenExpiryMs = accessTokenExpiryMs; }
+ public long getRefreshTokenExpiryMs() { return refreshTokenExpiryMs; }
+ public void setRefreshTokenExpiryMs(long refreshTokenExpiryMs) { this.refreshTokenExpiryMs = refreshTokenExpiryMs; }
+ public String getBootstrapToken() { return bootstrapToken; }
+ public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; }
+ public String getBootstrapTokenPrevious() { return bootstrapTokenPrevious; }
+ public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) { this.bootstrapTokenPrevious = bootstrapTokenPrevious; }
+ public String getUiUser() { return uiUser; }
+ public void setUiUser(String uiUser) { this.uiUser = uiUser; }
+ public String getUiPassword() { return uiPassword; }
+ public void setUiPassword(String uiPassword) { this.uiPassword = uiPassword; }
+ public String getUiOrigin() { return uiOrigin; }
+ public void setUiOrigin(String uiOrigin) { this.uiOrigin = uiOrigin; }
+ public String getJwtSecret() { return jwtSecret; }
+ public void setJwtSecret(String jwtSecret) { this.jwtSecret = jwtSecret; }
+ public Oidc getOidc() { return oidc; }
+ public void setOidc(Oidc oidc) { this.oidc = oidc; }
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java
index 7012009e..df13d3a0 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java
@@ -1,6 +1,9 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.core.security.JwtService;
+import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
+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.http.ResponseEntity;
@@ -9,14 +12,16 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import java.time.Instant;
+import java.util.List;
import java.util.Map;
/**
- * Authentication endpoints for the UI.
+ * Authentication endpoints for the UI (local credentials).
*
* Validates credentials against environment-configured username/password,
- * then issues JWTs with {@code ui:} prefixed subjects to distinguish
- * UI users from agent tokens in {@link JwtAuthenticationFilter}.
+ * then issues JWTs with {@code ui:} prefixed subjects and ADMIN roles.
+ * Upserts the user into the user store on login.
*/
@RestController
@RequestMapping("/api/v1/auth")
@@ -26,10 +31,13 @@ public class UiAuthController {
private final JwtService jwtService;
private final SecurityProperties properties;
+ private final UserRepository userRepository;
- public UiAuthController(JwtService jwtService, SecurityProperties properties) {
+ public UiAuthController(JwtService jwtService, SecurityProperties properties,
+ UserRepository userRepository) {
this.jwtService = jwtService;
this.properties = properties;
+ this.userRepository = userRepository;
}
@PostMapping("/login")
@@ -50,8 +58,18 @@ public class UiAuthController {
}
String subject = "ui:" + request.username();
- String accessToken = jwtService.createAccessToken(subject, "ui");
- String refreshToken = jwtService.createRefreshToken(subject, "ui");
+ List roles = List.of("ADMIN");
+
+ // Upsert local user into store
+ try {
+ userRepository.upsert(new UserInfo(
+ subject, "local", "", request.username(), roles, Instant.now()));
+ } catch (Exception e) {
+ log.warn("Failed to upsert local user to store (login continues): {}", e.getMessage());
+ }
+
+ String accessToken = jwtService.createAccessToken(subject, "ui", roles);
+ String refreshToken = jwtService.createRefreshToken(subject, "ui", roles);
log.info("UI user logged in: {}", request.username());
return ResponseEntity.ok(Map.of(
@@ -63,13 +81,15 @@ public class UiAuthController {
@PostMapping("/refresh")
public ResponseEntity> refresh(@RequestBody RefreshRequest request) {
try {
- String subject = jwtService.validateRefreshToken(request.refreshToken());
- if (!subject.startsWith("ui:")) {
+ JwtValidationResult result = jwtService.validateRefreshToken(request.refreshToken());
+ if (!result.subject().startsWith("ui:")) {
return ResponseEntity.status(401).body(Map.of("message", "Not a UI token"));
}
- String accessToken = jwtService.createAccessToken(subject, "ui");
- String refreshToken = jwtService.createRefreshToken(subject, "ui");
+ // Preserve roles from the refresh token
+ List roles = result.roles();
+ String accessToken = jwtService.createAccessToken(result.subject(), "ui", roles);
+ String refreshToken = jwtService.createRefreshToken(result.subject(), "ui", roles);
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseUserRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseUserRepository.java
new file mode 100644
index 00000000..0d11d062
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseUserRepository.java
@@ -0,0 +1,89 @@
+package com.cameleer3.server.app.storage;
+
+import com.cameleer3.server.core.security.UserInfo;
+import com.cameleer3.server.core.security.UserRepository;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * ClickHouse implementation of {@link UserRepository}.
+ *
+ * Uses ReplacingMergeTree — reads use {@code FINAL} to get the latest version.
+ */
+@Repository
+public class ClickHouseUserRepository implements UserRepository {
+
+ private final JdbcTemplate jdbc;
+
+ public ClickHouseUserRepository(JdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ @Override
+ public Optional findById(String userId) {
+ List results = jdbc.query(
+ "SELECT user_id, provider, email, display_name, roles, created_at "
+ + "FROM users FINAL WHERE user_id = ?",
+ this::mapRow,
+ userId
+ );
+ return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
+ }
+
+ @Override
+ public List findAll() {
+ return jdbc.query(
+ "SELECT user_id, provider, email, display_name, roles, created_at FROM users FINAL ORDER BY user_id",
+ this::mapRow
+ );
+ }
+
+ @Override
+ public void upsert(UserInfo user) {
+ jdbc.update(
+ "INSERT INTO users (user_id, provider, email, display_name, roles, updated_at) VALUES (?, ?, ?, ?, ?, now64(3, 'UTC'))",
+ user.userId(),
+ user.provider(),
+ user.email(),
+ user.displayName(),
+ user.roles().toArray(new String[0])
+ );
+ }
+
+ @Override
+ public void updateRoles(String userId, List roles) {
+ // ReplacingMergeTree: insert a new row with updated_at to supersede the old one.
+ // Copy existing fields, update roles.
+ jdbc.update(
+ "INSERT INTO users (user_id, provider, email, display_name, roles, created_at, updated_at) "
+ + "SELECT user_id, provider, email, display_name, ?, created_at, now64(3, 'UTC') "
+ + "FROM users FINAL WHERE user_id = ?",
+ roles.toArray(new String[0]),
+ userId
+ );
+ }
+
+ @Override
+ public void delete(String userId) {
+ jdbc.update("DELETE FROM users WHERE user_id = ?", userId);
+ }
+
+ private UserInfo mapRow(ResultSet rs, int rowNum) throws SQLException {
+ String[] rolesArray = (String[]) rs.getArray("roles").getArray();
+ return new UserInfo(
+ rs.getString("user_id"),
+ rs.getString("provider"),
+ rs.getString("email"),
+ rs.getString("display_name"),
+ Arrays.asList(rolesArray),
+ rs.getTimestamp("created_at").toInstant()
+ );
+ }
+}
diff --git a/cameleer3-server-app/src/main/resources/application.yml b/cameleer3-server-app/src/main/resources/application.yml
index de2b76bf..31974bae 100644
--- a/cameleer3-server-app/src/main/resources/application.yml
+++ b/cameleer3-server-app/src/main/resources/application.yml
@@ -40,6 +40,14 @@ security:
ui-user: ${CAMELEER_UI_USER:admin}
ui-password: ${CAMELEER_UI_PASSWORD:admin}
ui-origin: ${CAMELEER_UI_ORIGIN:http://localhost:5173}
+ jwt-secret: ${CAMELEER_JWT_SECRET:}
+ oidc:
+ enabled: ${CAMELEER_OIDC_ENABLED:false}
+ issuer-uri: ${CAMELEER_OIDC_ISSUER:}
+ client-id: ${CAMELEER_OIDC_CLIENT_ID:}
+ client-secret: ${CAMELEER_OIDC_CLIENT_SECRET:}
+ roles-claim: ${CAMELEER_OIDC_ROLES_CLAIM:realm_access.roles}
+ default-roles: ${CAMELEER_OIDC_DEFAULT_ROLES:VIEWER}
springdoc:
api-docs:
diff --git a/cameleer3-server-app/src/main/resources/clickhouse/03-users.sql b/cameleer3-server-app/src/main/resources/clickhouse/03-users.sql
new file mode 100644
index 00000000..9dc7ce7a
--- /dev/null
+++ b/cameleer3-server-app/src/main/resources/clickhouse/03-users.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS users (
+ user_id String,
+ provider LowCardinality(String),
+ email String DEFAULT '',
+ display_name String DEFAULT '',
+ roles Array(LowCardinality(String)),
+ created_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'),
+ updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC')
+) ENGINE = ReplacingMergeTree(updated_at)
+ORDER BY (user_id);
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java
index a4a27597..07389dc1 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java
@@ -56,7 +56,7 @@ public abstract class AbstractClickHouseIT {
}
// Load all schema files in order
- String[] schemaFiles = {"01-schema.sql", "02-search-columns.sql"};
+ String[] schemaFiles = {"01-schema.sql", "02-search-columns.sql", "03-users.sql"};
try (Connection conn = DriverManager.getConnection(
CLICKHOUSE.getJdbcUrl(),
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java
index 99784949..1fb9ba8d 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java
@@ -5,6 +5,8 @@ import com.cameleer3.server.core.security.JwtService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.util.List;
+
import static org.junit.jupiter.api.Assertions.*;
/**
@@ -50,8 +52,8 @@ class JwtServiceTest {
@Test
void createRefreshToken_canBeValidatedWithRefreshMethod() {
String token = jwtService.createRefreshToken("agent-2", "group-b");
- String agentId = jwtService.validateRefreshToken(token);
- assertEquals("agent-2", agentId);
+ JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
+ assertEquals("agent-2", result.subject());
}
@Test
@@ -70,6 +72,50 @@ class JwtServiceTest {
"Refresh validation should reject access tokens");
}
+ @Test
+ void accessToken_rolesRoundTrip() {
+ List roles = List.of("ADMIN", "OPERATOR");
+ String token = jwtService.createAccessToken("ui:admin", "ui", roles);
+ JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
+ assertEquals("ui:admin", result.subject());
+ assertEquals("ui", result.group());
+ assertEquals(roles, result.roles());
+ }
+
+ @Test
+ void refreshToken_rolesRoundTrip() {
+ List roles = List.of("AGENT");
+ String token = jwtService.createRefreshToken("agent-1", "default", roles);
+ JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
+ assertEquals("agent-1", result.subject());
+ assertEquals("default", result.group());
+ assertEquals(roles, result.roles());
+ }
+
+ @Test
+ void legacyToken_emptyRoles() {
+ // Backward compat: tokens without explicit roles get empty list
+ String token = jwtService.createAccessToken("agent-1", "default");
+ JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
+ assertEquals(List.of(), result.roles());
+ }
+
+ @Test
+ void configuredJwtSecret_producesStableTokens() {
+ SecurityProperties props = new SecurityProperties();
+ props.setAccessTokenExpiryMs(3_600_000);
+ props.setRefreshTokenExpiryMs(604_800_000);
+ props.setJwtSecret("my-test-secret-that-is-at-least-32-bytes");
+ JwtService svc1 = new JwtServiceImpl(props);
+ JwtService svc2 = new JwtServiceImpl(props);
+
+ String token = svc1.createAccessToken("agent-1", "default", List.of("AGENT"));
+ // Token created by svc1 should be validatable by svc2 (same secret)
+ JwtService.JwtValidationResult result = svc2.validateAccessToken(token);
+ assertEquals("agent-1", result.subject());
+ assertEquals(List.of("AGENT"), result.roles());
+ }
+
@Test
void validateAndExtractAgentId_rejectsExpiredToken() {
// Create a service with 0ms expiry to produce already-expired tokens
diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java
index dc8f5318..c3afda2a 100644
--- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java
+++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java
@@ -1,48 +1,60 @@
package com.cameleer3.server.core.security;
+import java.util.List;
+
/**
* Service for creating and validating JSON Web Tokens (JWT).
*
* Access tokens are short-lived (default 1 hour) and used for API authentication.
* Refresh tokens are longer-lived (default 7 days) and used to obtain new access tokens.
+ * Tokens carry a {@code roles} claim for role-based access control.
*/
public interface JwtService {
/**
- * Creates a signed access JWT with the given agent ID and group.
+ * Validated JWT payload.
*
- * @param agentId the agent identifier (becomes the {@code sub} claim)
- * @param group the agent group (becomes the {@code group} claim)
- * @return a signed JWT string
+ * @param subject the {@code sub} claim (agent ID or {@code ui:})
+ * @param group the {@code group} claim
+ * @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
*/
- String createAccessToken(String agentId, String group);
+ record JwtValidationResult(String subject, String group, List roles) {}
/**
- * Creates a signed refresh JWT with the given agent ID and group.
- *
- * @param agentId the agent identifier (becomes the {@code sub} claim)
- * @param group the agent group (becomes the {@code group} claim)
- * @return a signed JWT string
+ * Creates a signed access JWT with the given subject, group, and roles.
*/
- String createRefreshToken(String agentId, String group);
+ String createAccessToken(String subject, String group, List roles);
/**
- * Validates an access token and extracts the agent ID.
- * Rejects expired tokens and tokens that are not of type "access".
+ * Creates a signed refresh JWT with the given subject, group, and roles.
+ */
+ String createRefreshToken(String subject, String group, List roles);
+
+ /**
+ * Validates an access token and returns the full validation result.
*
- * @param token the JWT string to validate
- * @return the agent ID from the {@code sub} claim
* @throws InvalidTokenException if the token is invalid, expired, or not an access token
*/
- String validateAndExtractAgentId(String token);
+ JwtValidationResult validateAccessToken(String token);
/**
- * Validates a refresh token and extracts the agent ID.
- * Rejects expired tokens and tokens that are not of type "refresh".
+ * Validates a refresh token and returns the full validation result.
*
- * @param token the JWT string to validate
- * @return the agent ID from the {@code sub} claim
* @throws InvalidTokenException if the token is invalid, expired, or not a refresh token
*/
- String validateRefreshToken(String token);
+ JwtValidationResult validateRefreshToken(String token);
+
+ // --- Backward-compatible defaults (delegate to role-aware methods) ---
+
+ default String createAccessToken(String subject, String group) {
+ return createAccessToken(subject, group, List.of());
+ }
+
+ default String createRefreshToken(String subject, String group) {
+ return createRefreshToken(subject, group, List.of());
+ }
+
+ default String validateAndExtractAgentId(String token) {
+ return validateAccessToken(token).subject();
+ }
}
diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java
new file mode 100644
index 00000000..ffabd508
--- /dev/null
+++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java
@@ -0,0 +1,23 @@
+package com.cameleer3.server.core.security;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * Represents a persisted user in the system.
+ *
+ * @param userId unique identifier (e.g. OIDC {@code sub} or {@code ui:})
+ * @param provider authentication provider ({@code "local"}, {@code "oidc:"})
+ * @param email user email (may be empty)
+ * @param displayName display name (may be empty)
+ * @param roles assigned roles (e.g. {@code ["ADMIN"]}, {@code ["VIEWER"]})
+ * @param createdAt first creation timestamp
+ */
+public record UserInfo(
+ String userId,
+ String provider,
+ String email,
+ String displayName,
+ List roles,
+ Instant createdAt
+) {}
diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java
new file mode 100644
index 00000000..70d7bb4b
--- /dev/null
+++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java
@@ -0,0 +1,20 @@
+package com.cameleer3.server.core.security;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Persistence interface for user management.
+ */
+public interface UserRepository {
+
+ Optional findById(String userId);
+
+ List findAll();
+
+ void upsert(UserInfo user);
+
+ void updateRoles(String userId, List roles);
+
+ void delete(String userId);
+}
diff --git a/clickhouse/init/03-users.sql b/clickhouse/init/03-users.sql
new file mode 100644
index 00000000..9dc7ce7a
--- /dev/null
+++ b/clickhouse/init/03-users.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS users (
+ user_id String,
+ provider LowCardinality(String),
+ email String DEFAULT '',
+ display_name String DEFAULT '',
+ roles Array(LowCardinality(String)),
+ created_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'),
+ updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC')
+) ENGINE = ReplacingMergeTree(updated_at)
+ORDER BY (user_id);