Rename Java packages from com.cameleer3 to com.cameleer, module directories from cameleer3-* to cameleer-*, and all references throughout workflows, Dockerfiles, docs, migrations, and pom.xml. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
40 KiB
Logto OIDC Resource Server Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace Authentik with self-hosted Logto and add OIDC resource server support so the SaaS platform can call server APIs using M2M tokens.
Architecture: The server gains a dual-path JWT validation: try internal HMAC first, fall back to OIDC (Logto) token validation via JWKS. M2M authorization uses standard OAuth2 scope-based role mapping. Infrastructure swaps Authentik K8s manifests for Logto. All changes are additive — when CAMELEER_OIDC_ISSUER_URI is unset, the server behaves identically to today.
Tech Stack: Spring Boot 3.4.3, spring-boot-starter-oauth2-resource-server, Nimbus JOSE+JWT, Logto (ghcr.io/logto-io/logto), Kustomize, Gitea CI
File Structure
| File | Action | Responsibility |
|---|---|---|
cameleer-server-app/pom.xml |
Modify | Add oauth2-resource-server dependency |
cameleer-server-app/src/main/resources/application.yml |
Modify | Add OIDC issuer/audience properties |
cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java |
Modify | Add oidcIssuerUri, oidcAudience fields |
cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java |
Modify | Build OIDC decoder, pass to filter |
cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java |
Modify | Add OIDC fallback path |
cameleer-server-core/src/main/java/com/cameleer/server/core/security/OidcConfig.java |
Modify | Update default rolesClaim |
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/OidcConfigAdminController.java |
Modify | Update default rolesClaim |
deploy/authentik.yaml |
Delete | Remove Authentik deployment |
deploy/logto.yaml |
Create | Logto server + dedicated PostgreSQL |
.gitea/workflows/ci.yml |
Modify | Replace Authentik with Logto in CI |
HOWTO.md |
Modify | Replace Authentik docs with Logto |
CLAUDE.md |
Modify | Replace Authentik references |
docs/SERVER-CAPABILITIES.md |
Modify | Add OIDC resource server section |
Task 1: Add OAuth2 Resource Server Dependency
Files:
-
Modify:
cameleer-server-app/pom.xml:87-97 -
Step 1: Add the spring-boot-starter-oauth2-resource-server dependency
In cameleer-server-app/pom.xml, add after the existing spring-boot-starter-security dependency (line 87) and before the nimbus-jose-jwt dependency (line 88):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
The full dependencies section around that area should read:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.47</version>
</dependency>
- Step 2: Verify compilation
Run: mvn clean compile -pl cameleer-server-app -am -B
Expected: BUILD SUCCESS
- Step 3: Commit
git add cameleer-server-app/pom.xml
git commit -m "feat: add spring-boot-starter-oauth2-resource-server dependency"
Task 2: Add OIDC Properties
Files:
-
Modify:
cameleer-server-app/src/main/resources/application.yml:42-48 -
Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java -
Step 1: Add OIDC properties to application.yml
In application.yml, add two new properties under the existing security: block. After jwt-secret (line 48), add:
security:
access-token-expiry-ms: 3600000
refresh-token-expiry-ms: 604800000
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
bootstrap-token-previous: ${CAMELEER_AUTH_TOKEN_PREVIOUS:}
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-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
- Step 2: Add fields to SecurityProperties.java
Add oidcIssuerUri and oidcAudience fields with getters/setters. The complete file should be:
package com.cameleer.server.app.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Configuration properties for security settings.
* Bound from the {@code security.*} namespace in application.yml.
*/
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
private long accessTokenExpiryMs = 3_600_000;
private long refreshTokenExpiryMs = 604_800_000;
private String bootstrapToken;
private String bootstrapTokenPrevious;
private String uiUser;
private String uiPassword;
private String uiOrigin;
private String jwtSecret;
private String oidcIssuerUri;
private String oidcAudience;
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 String getOidcIssuerUri() { return oidcIssuerUri; }
public void setOidcIssuerUri(String oidcIssuerUri) { this.oidcIssuerUri = oidcIssuerUri; }
public String getOidcAudience() { return oidcAudience; }
public void setOidcAudience(String oidcAudience) { this.oidcAudience = oidcAudience; }
}
- Step 3: Verify compilation
Run: mvn clean compile -pl cameleer-server-app -am -B
Expected: BUILD SUCCESS
- Step 4: Commit
git add cameleer-server-app/src/main/resources/application.yml
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java
git commit -m "feat: add OIDC issuer URI and audience security properties"
Task 3: Add OIDC Fallback to JwtAuthenticationFilter
Files:
-
Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java -
Step 1: Update JwtAuthenticationFilter with OIDC fallback
The filter needs a new nullable oidcDecoder parameter, a tryInternalToken method (wrapping existing logic), a tryOidcToken fallback, and scope-based role extraction. The complete updated file:
package com.cameleer.server.app.security;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.JwtService;
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
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.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
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.
* <p>
* Tries internal HMAC validation first (agents, local users). If that fails and an
* OIDC {@link JwtDecoder} is configured, falls back to OIDC token validation
* (SaaS M2M tokens, external OIDC users). Scope-based role mapping for OIDC tokens.
* <p>
* Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig}
* to avoid double filter registration.
*/
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private static final String BEARER_PREFIX = "Bearer ";
public static final String JWT_RESULT_ATTR = "cameleer.jwt.result";
private final JwtService jwtService;
private final AgentRegistryService agentRegistryService;
private final JwtDecoder oidcDecoder;
public JwtAuthenticationFilter(JwtService jwtService,
AgentRegistryService agentRegistryService,
JwtDecoder oidcDecoder) {
this.jwtService = jwtService;
this.agentRegistryService = agentRegistryService;
this.oidcDecoder = oidcDecoder;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String token = extractToken(request);
if (token != null) {
if (tryInternalToken(token, request)) {
chain.doFilter(request, response);
return;
}
if (oidcDecoder != null) {
tryOidcToken(token, request);
}
}
chain.doFilter(request, response);
}
private boolean tryInternalToken(String token, HttpServletRequest request) {
try {
JwtValidationResult result = jwtService.validateAccessToken(token);
String subject = result.subject();
List<String> roles = result.roles();
if (!subject.startsWith("user:") && roles.isEmpty()) {
roles = List.of("AGENT");
}
List<GrantedAuthority> authorities = toAuthorities(roles);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(subject, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
request.setAttribute(JWT_RESULT_ATTR, result);
return true;
} catch (Exception e) {
log.debug("Internal JWT validation failed: {}", e.getMessage());
return false;
}
}
private void tryOidcToken(String token, HttpServletRequest request) {
try {
Jwt jwt = oidcDecoder.decode(token);
List<String> roles = extractRolesFromScopes(jwt);
List<GrantedAuthority> authorities = toAuthorities(roles);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
"oidc:" + jwt.getSubject(), null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
log.debug("OIDC token validation failed: {}", e.getMessage());
}
}
/**
* Maps OAuth2 scopes to server RBAC roles.
* Scopes are defined on the Logto API Resource for this server.
*/
private List<String> extractRolesFromScopes(Jwt jwt) {
String scopeStr = jwt.getClaimAsString("scope");
if (scopeStr == null || scopeStr.isBlank()) {
return List.of("VIEWER");
}
List<String> scopes = List.of(scopeStr.split(" "));
if (scopes.contains("admin")) return List.of("ADMIN");
if (scopes.contains("operator")) return List.of("OPERATOR");
if (scopes.contains("viewer")) return List.of("VIEWER");
return List.of("VIEWER");
}
private List<GrantedAuthority> toAuthorities(List<String> roles) {
return roles.stream()
.map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))
.toList();
}
private String extractToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
return authHeader.substring(BEARER_PREFIX.length());
}
return request.getParameter("token");
}
}
- Step 2: Note — do NOT compile yet
This change removes the 2-arg constructor, so SecurityConfig.java won't compile until Task 4 updates it. Do NOT commit yet — the filter and SecurityConfig changes are committed together in Task 4.
Task 4: Build OIDC Decoder in SecurityConfig
Files:
-
Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java -
Step 1: Update SecurityConfig to build OIDC decoder and pass to filter
The filterChain method needs an additional SecurityProperties parameter, an inline OIDC decoder builder, and must pass the decoder to the JwtAuthenticationFilter constructor. The complete updated file:
package com.cameleer.server.app.security;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.JwtService;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
import net.minidev.json.JSONObject;
import net.minidev.json.parser.JSONParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.method.configuration.EnableMethodSecurity;
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;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.util.List;
import java.util.Set;
/**
* Spring Security configuration for JWT-based stateless authentication with RBAC.
* <p>
* Public endpoints: health, agent registration, refresh, auth, API docs, Swagger UI, static resources.
* All other endpoints require a valid JWT access token with appropriate roles.
* <p>
* When {@code security.oidc-issuer-uri} is configured, builds an OIDC {@link JwtDecoder}
* for validating external access tokens (Logto M2M / OIDC user tokens) as a fallback
* after internal HMAC validation.
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
JwtService jwtService,
AgentRegistryService registryService,
SecurityProperties securityProperties,
CorsConfigurationSource corsConfigurationSource) throws Exception {
JwtDecoder oidcDecoder = null;
String issuer = securityProperties.getOidcIssuerUri();
if (issuer != null && !issuer.isBlank()) {
try {
oidcDecoder = buildOidcDecoder(securityProperties);
log.info("OIDC resource server enabled: issuer={}", issuer);
} catch (Exception e) {
log.error("Failed to initialize OIDC decoder for issuer={}: {}", issuer, e.getMessage());
}
}
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers(
"/api/v1/health",
"/api/v1/agents/register",
"/api/v1/agents/*/refresh",
"/api/v1/auth/**",
"/api/v1/api-docs/**",
"/api/v1/swagger-ui/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-ui.html",
"/error",
"/",
"/index.html",
"/config.js",
"/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")
.requestMatchers(HttpMethod.POST, "/api/v1/agents/*/replay").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")
// Application config endpoints
.requestMatchers(HttpMethod.GET, "/api/v1/config/*").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
.requestMatchers(HttpMethod.PUT, "/api/v1/config/*").hasAnyRole("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/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/agents/events-log").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/routes/**").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
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
)
.addFilterBefore(
new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder),
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
/**
* Builds an OIDC {@link JwtDecoder} for validating external access tokens.
* Discovers JWKS URI from the OIDC well-known endpoint. Handles Logto's
* {@code at+jwt} token type (RFC 9068) by accepting any JWT type.
*/
private JwtDecoder buildOidcDecoder(SecurityProperties properties) throws Exception {
String issuerUri = properties.getOidcIssuerUri();
// Discover JWKS URI and supported algorithms from OIDC discovery
String discoveryUrl = issuerUri.endsWith("/")
? issuerUri + ".well-known/openid-configuration"
: issuerUri + "/.well-known/openid-configuration";
URL url = new URI(discoveryUrl).toURL();
OIDCProviderMetadata metadata;
try (InputStream in = url.openStream()) {
JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(in);
metadata = OIDCProviderMetadata.parse(json);
}
URL jwksUri = metadata.getJWKSetURI().toURL();
// Build decoder supporting ES384 (Logto default) and ES256, RS256
var jwkSource = JWKSourceBuilder.create(jwksUri).build();
Set<JWSAlgorithm> algorithms = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
var keySelector = new JWSVerificationKeySelector<SecurityContext>(algorithms, jwkSource);
var processor = new DefaultJWTProcessor<SecurityContext>();
processor.setJWSKeySelector(keySelector);
// Accept any JWT type — Logto uses "at+jwt" (RFC 9068)
processor.setJWSTypeVerifier((type, ctx) -> { });
var decoder = new NimbusJwtDecoder(processor);
// Validate issuer + optionally audience
OAuth2TokenValidator<Jwt> validators;
String audience = properties.getOidcAudience();
if (audience != null && !audience.isBlank()) {
validators = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuerUri),
new JwtClaimValidator<List<String>>("aud",
aud -> aud != null && aud.contains(audience))
);
} else {
validators = JwtValidators.createDefaultWithIssuer(issuerUri);
}
decoder.setJwtValidator(validators);
log.info("OIDC decoder initialized: jwks={}", jwksUri);
return decoder;
}
@Bean
public CorsConfigurationSource corsConfigurationSource(SecurityProperties properties) {
CorsConfiguration config = new CorsConfiguration();
String origin = properties.getUiOrigin();
if (origin != null && !origin.isBlank()) {
config.setAllowedOrigins(List.of(origin));
} else {
config.setAllowedOrigins(List.of("http://localhost:5173"));
}
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
- Step 2: Verify compilation
Run: mvn clean compile -pl cameleer-server-app -am -B
Expected: BUILD SUCCESS
- Step 3: Run tests
Run: mvn test -pl cameleer-server-app -am -B
Expected: Tests pass (OIDC decoder won't be built since CAMELEER_OIDC_ISSUER_URI is empty in test config)
- Step 4: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java
git commit -m "feat: add OIDC resource server support with JWKS discovery and scope-based roles"
Task 5: Update OidcConfig Default RolesClaim
Files:
-
Modify:
cameleer-server-core/src/main/java/com/cameleer/server/core/security/OidcConfig.java:28 -
Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/OidcConfigAdminController.java:101 -
Step 1: Update OidcConfig.disabled() default
In OidcConfig.java, change the disabled() factory method's rolesClaim from "realm_access.roles" to "roles":
public static OidcConfig disabled() {
return new OidcConfig(false, "", "", "", "roles", List.of("VIEWER"), true, "name");
}
- Step 2: Update OidcConfigAdminController PUT handler default
In OidcConfigAdminController.java line 101, change the fallback from "realm_access.roles" to "roles":
request.rolesClaim() != null ? request.rolesClaim() : "roles",
- Step 3: Verify compilation
Run: mvn clean compile -B
Expected: BUILD SUCCESS
- Step 4: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/security/OidcConfig.java
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/OidcConfigAdminController.java
git commit -m "feat: update default rolesClaim to 'roles' for Logto compatibility"
Task 6: Replace Authentik with Logto Infrastructure
Files:
-
Delete:
deploy/authentik.yaml -
Create:
deploy/logto.yaml -
Step 1: Delete authentik.yaml
git rm deploy/authentik.yaml
- Step 2: Create deploy/logto.yaml
Create deploy/logto.yaml with Logto server + dedicated PostgreSQL:
# Logto OIDC Provider for Cameleer
# Provides external identity management with OAuth2/OIDC.
#
# After deployment:
# 1. Access Logto admin console at http://192.168.50.86:30952
# 2. Complete initial setup (create admin account)
# 3. Create an Application for Cameleer (see HOWTO.md)
# 4. Create an API Resource with scopes (admin, operator, viewer)
# 5. Create an M2M Application for the SaaS platform
# --- PostgreSQL for Logto ---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: logto-postgresql
namespace: cameleer
spec:
serviceName: logto-postgresql
replicas: 1
selector:
matchLabels:
app: logto-postgresql
template:
metadata:
labels:
app: logto-postgresql
spec:
containers:
- name: postgresql
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: logto
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: logto-credentials
key: PG_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: logto-credentials
key: PG_PASSWORD
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
subPath: pgdata
resources:
requests:
memory: "128Mi"
cpu: "50m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
exec:
command: ["pg_isready"]
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
exec:
command: ["pg_isready"]
initialDelaySeconds: 5
periodSeconds: 5
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: logto-postgresql
namespace: cameleer
spec:
clusterIP: None
selector:
app: logto-postgresql
ports:
- port: 5432
targetPort: 5432
# --- Logto Server ---
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: logto
namespace: cameleer
spec:
replicas: 1
selector:
matchLabels:
app: logto
template:
metadata:
labels:
app: logto
spec:
containers:
- name: logto
image: ghcr.io/logto-io/logto:latest
command: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
ports:
- containerPort: 3001
name: api
- containerPort: 3002
name: admin
env:
- name: TRUST_PROXY_HEADER
value: "1"
- name: DB_URL
value: "postgresql://$(PG_USER):$(PG_PASSWORD)@logto-postgresql:5432/logto"
- name: ENDPOINT
valueFrom:
secretKeyRef:
name: logto-credentials
key: ENDPOINT
- name: ADMIN_ENDPOINT
valueFrom:
secretKeyRef:
name: logto-credentials
key: ADMIN_ENDPOINT
- name: PG_USER
valueFrom:
secretKeyRef:
name: logto-credentials
key: PG_USER
- name: PG_PASSWORD
valueFrom:
secretKeyRef:
name: logto-credentials
key: PG_PASSWORD
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /api/status
port: 3001
initialDelaySeconds: 60
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /api/status
port: 3001
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: logto
namespace: cameleer
spec:
type: NodePort
selector:
app: logto
ports:
- port: 3001
targetPort: 3001
nodePort: 30951
name: api
- port: 3002
targetPort: 3002
nodePort: 30952
name: admin
- Step 3: Commit
git add deploy/logto.yaml
git commit -m "feat: replace Authentik with Logto K8s deployment"
Task 7: Update CI/CD Workflow
Files:
-
Modify:
.gitea/workflows/ci.yml -
Step 1: Replace Authentik credentials with Logto credentials in deploy-main
In .gitea/workflows/ci.yml, find the authentik-credentials secret creation (lines 213-218) and replace it:
Replace:
kubectl create secret generic authentik-credentials \
--namespace=cameleer \
--from-literal=PG_USER="${AUTHENTIK_PG_USER:-authentik}" \
--from-literal=PG_PASSWORD="${AUTHENTIK_PG_PASSWORD}" \
--from-literal=AUTHENTIK_SECRET_KEY="${AUTHENTIK_SECRET_KEY}" \
--dry-run=client -o yaml | kubectl apply -f -
With:
kubectl create secret generic logto-credentials \
--namespace=cameleer \
--from-literal=PG_USER="${LOGTO_PG_USER:-logto}" \
--from-literal=PG_PASSWORD="${LOGTO_PG_PASSWORD}" \
--from-literal=ENDPOINT="${LOGTO_ENDPOINT}" \
--from-literal=ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT}" \
--dry-run=client -o yaml | kubectl apply -f -
- Step 2: Replace Authentik deployment with Logto
Find lines 232-233:
Replace:
kubectl apply -f deploy/authentik.yaml
kubectl -n cameleer rollout status deployment/authentik-server --timeout=180s
With:
kubectl apply -f deploy/logto.yaml
kubectl -n cameleer rollout status deployment/logto --timeout=180s
- Step 3: Update env vars section
Find the env: block (lines 243-256). Replace the three Authentik secret references:
Replace:
AUTHENTIK_PG_USER: ${{ secrets.AUTHENTIK_PG_USER }}
AUTHENTIK_PG_PASSWORD: ${{ secrets.AUTHENTIK_PG_PASSWORD }}
AUTHENTIK_SECRET_KEY: ${{ secrets.AUTHENTIK_SECRET_KEY }}
With:
LOGTO_PG_USER: ${{ secrets.LOGTO_PG_USER }}
LOGTO_PG_PASSWORD: ${{ secrets.LOGTO_PG_PASSWORD }}
LOGTO_ENDPOINT: ${{ secrets.LOGTO_ENDPOINT }}
LOGTO_ADMIN_ENDPOINT: ${{ secrets.LOGTO_ADMIN_ENDPOINT }}
- Step 4: Update feature branch secret copying
In the deploy-feature job, the Copy secrets from cameleer namespace step (line 296) copies secrets including cameleer-auth. The logto-credentials secret does NOT need to be copied to feature namespaces — feature branches share the production Logto instance. No change needed here.
- Step 5: Commit
git add .gitea/workflows/ci.yml
git commit -m "ci: replace Authentik with Logto in deployment pipeline"
Task 8: Update Documentation
Files:
-
Modify:
HOWTO.md -
Modify:
CLAUDE.md -
Modify:
docs/SERVER-CAPABILITIES.md -
Step 1: Update HOWTO.md — replace Authentik Setup with Logto Setup
Replace the "Authentik Setup (OIDC Provider)" section (lines 159-180) with:
### Logto Setup (OIDC Provider)
Logto is deployed alongside the Cameleer stack. After first deployment:
1. **Initial setup**: Open `http://192.168.50.86:30952` (admin console) and create the admin account
2. **Create SPA application**: Applications → Create → Single Page App
- Name: `Cameleer UI`
- Redirect URI: `http://192.168.50.86:30090/oidc/callback` (or your UI URL)
- Note the **Client ID**
3. **Create API Resource**: API Resources → Create
- Name: `Cameleer Server API`
- Indicator: `https://cameleer.siegeln.net/api` (or your API URL)
- Add permissions: `admin`, `operator`, `viewer`
4. **Create M2M application** (for SaaS platform): Applications → Create → Machine-to-Machine
- Name: `Cameleer SaaS`
- Assign the API Resource created above with `admin` scope
- Note the **Client ID** and **Client Secret**
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://cameleer-logto:3001/oidc CAMELEER_OIDC_CLIENT_ID= CAMELEER_OIDC_CLIENT_SECRET=
6. **Configure resource server** (for M2M token validation):
CAMELEER_OIDC_ISSUER_URI=http://cameleer-logto:3001/oidc CAMELEER_OIDC_AUDIENCE=https://cameleer.siegeln.net/api
- Step 2: Update HOWTO.md — replace OIDC config example issuer
On line 141, replace the issuer URI in the OIDC admin config example:
Replace:
"issuerUri": "http://authentik:9000/application/o/cameleer/",
With:
"issuerUri": "http://cameleer-logto:3001/oidc",
- Step 3: Update HOWTO.md — replace infrastructure diagram
Replace the Authentik entries in the infrastructure overview (lines 448-451):
Replace:
Authentik Server (Deployment) ← NodePort 30950
Authentik Worker (Deployment)
Authentik PostgreSQL (StatefulSet, 1Gi) ← ClusterIP
Authentik Redis (Deployment) ← ClusterIP
With:
Logto Server (Deployment) ← NodePort 30951/30952
Logto PostgreSQL (StatefulSet, 1Gi) ← ClusterIP
- Step 4: Update HOWTO.md — replace access table
Replace the Authentik line in the access table (line 465):
Replace:
| Authentik | `http://192.168.50.86:30950` |
With:
| Logto API | `http://192.168.50.86:30951` |
| Logto Admin | `http://192.168.50.86:30952` |
- Step 5: Update HOWTO.md — replace secrets list
On line 471, replace the Authentik secrets with Logto secrets:
Replace AUTHENTIK_PG_USER, AUTHENTIK_PG_PASSWORD, AUTHENTIK_SECRET_KEY with LOGTO_PG_USER, LOGTO_PG_PASSWORD, LOGTO_ENDPOINT, LOGTO_ADMIN_ENDPOINT.
Also add CAMELEER_OIDC_ISSUER_URI and CAMELEER_OIDC_AUDIENCE (optional).
- Step 6: Update CLAUDE.md
On line 54, replace "Authentik" with "Logto":
Replace:
- K8s manifests in `deploy/` — Kustomize base + overlays (main/feature), shared infra (PostgreSQL, ClickHouse, Authentik) as top-level manifests
With:
- K8s manifests in `deploy/` — Kustomize base + overlays (main/feature), shared infra (PostgreSQL, ClickHouse, Logto) as top-level manifests
On line 45, add OIDC resource server note after the existing OIDC line:
Replace:
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table)
With:
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_OIDC_ISSUER_URI` is set. Scope-based role mapping: `admin`/`operator`/`viewer` scopes map to RBAC roles.
- Step 7: Update docs/SERVER-CAPABILITIES.md
After the existing "OIDC Integration" section (line 258), add the resource server section:
### OIDC Resource Server
When `CAMELEER_OIDC_ISSUER_URI` is configured, the server accepts external access tokens (e.g., Logto M2M tokens) in addition to internal HMAC JWTs. Dual-path validation: tries internal HMAC first, falls back to OIDC JWKS validation. OAuth2 scope-based role mapping: `admin` scope maps to ADMIN, `operator` to OPERATOR, `viewer` to VIEWER. Supports ES384, ES256, and RS256 algorithms. Handles RFC 9068 `at+jwt` token type.
| Variable | Purpose |
|----------|---------|
| `CAMELEER_OIDC_ISSUER_URI` | OIDC issuer URI for JWKS discovery |
| `CAMELEER_OIDC_AUDIENCE` | Expected audience (API resource indicator) |
Also update the Authentication table (line 232) to add:
| OIDC access token | Bearer token in Authorization header | SaaS M2M / external OIDC |
- Step 8: Commit
git add HOWTO.md CLAUDE.md docs/SERVER-CAPABILITIES.md
git commit -m "docs: replace Authentik with Logto, document OIDC resource server"
Task 9: Full Build Verification
- Step 1: Full compile + test
Run: mvn clean compile test-compile -B
Expected: BUILD SUCCESS
- Step 2: Run unit tests
Run: mvn test -B
Expected: All tests pass
- Step 3: Verify no Authentik references remain
Run: grep -ri "authentik" --include="*.java" --include="*.yml" --include="*.yaml" --include="*.md" .
Expected: Zero results in tracked files (only git history). The deleted deploy/authentik.yaml should not appear. If any remain in documentation or config, fix them.