Files
cameleer-server/docs/superpowers/plans/2026-04-05-logto-oidc-resource-server.md
hsiegeln ac680b7f3f
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m7s
CI / docker (push) Successful in 1m33s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m51s
SonarQube / sonarqube (push) Successful in 3m28s
refactor: prefix all third-party service names with cameleer-
Rename all Docker/K8s service names, DNS hostnames, secrets, volumes,
and manifest files to use the cameleer- prefix, making it clear which
software package each container belongs to.

Services renamed:
- postgres → cameleer-postgres
- clickhouse → cameleer-clickhouse
- logto → cameleer-logto
- logto-postgresql → cameleer-logto-postgresql
- traefik (service) → cameleer-traefik
- postgres-external → cameleer-postgres-external

Secrets renamed:
- postgres-credentials → cameleer-postgres-credentials
- clickhouse-credentials → cameleer-clickhouse-credentials
- logto-credentials → cameleer-logto-credentials

Volumes renamed:
- pgdata → cameleer-pgdata
- chdata → cameleer-chdata
- certs → cameleer-certs
- bootstrapdata → cameleer-bootstrapdata

K8s manifests renamed:
- deploy/postgres.yaml → deploy/cameleer-postgres.yaml
- deploy/clickhouse.yaml → deploy/cameleer-clickhouse.yaml
- deploy/logto.yaml → deploy/cameleer-logto.yaml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:51:08 +02:00

1069 lines
40 KiB
Markdown

# 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 |
|------|--------|---------------|
| `cameleer3-server-app/pom.xml` | Modify | Add oauth2-resource-server dependency |
| `cameleer3-server-app/src/main/resources/application.yml` | Modify | Add OIDC issuer/audience properties |
| `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java` | Modify | Add oidcIssuerUri, oidcAudience fields |
| `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java` | Modify | Build OIDC decoder, pass to filter |
| `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java` | Modify | Add OIDC fallback path |
| `cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java` | Modify | Update default rolesClaim |
| `cameleer3-server-app/src/main/java/com/cameleer3/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: `cameleer3-server-app/pom.xml:87-97`
- [ ] **Step 1: Add the spring-boot-starter-oauth2-resource-server dependency**
In `cameleer3-server-app/pom.xml`, add after the existing `spring-boot-starter-security` dependency (line 87) and before the `nimbus-jose-jwt` dependency (line 88):
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
```
The full dependencies section around that area should read:
```xml
<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 cameleer3-server-app -am -B`
Expected: BUILD SUCCESS
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/pom.xml
git commit -m "feat: add spring-boot-starter-oauth2-resource-server dependency"
```
---
### Task 2: Add OIDC Properties
**Files:**
- Modify: `cameleer3-server-app/src/main/resources/application.yml:42-48`
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/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:
```yaml
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:
```java
package com.cameleer3.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 cameleer3-server-app -am -B`
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-app/src/main/resources/application.yml
git add cameleer3-server-app/src/main/java/com/cameleer3/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: `cameleer3-server-app/src/main/java/com/cameleer3/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:
```java
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;
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: `cameleer3-server-app/src/main/java/com/cameleer3/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:
```java
package com.cameleer3.server.app.security;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.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 cameleer3-server-app -am -B`
Expected: BUILD SUCCESS
- [ ] **Step 3: Run tests**
Run: `mvn test -pl cameleer3-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**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
git add cameleer3-server-app/src/main/java/com/cameleer3/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: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java:28`
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/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"`:
```java
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"`:
```java
request.rolesClaim() != null ? request.rolesClaim() : "roles",
```
- [ ] **Step 3: Verify compilation**
Run: `mvn clean compile -B`
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java
git add cameleer3-server-app/src/main/java/com/cameleer3/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**
```bash
git rm deploy/authentik.yaml
```
- [ ] **Step 2: Create deploy/logto.yaml**
Create `deploy/logto.yaml` with Logto server + dedicated PostgreSQL:
```yaml
# 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**
```bash
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:
```yaml
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:
```yaml
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:
```yaml
kubectl apply -f deploy/authentik.yaml
kubectl -n cameleer rollout status deployment/authentik-server --timeout=180s
```
With:
```yaml
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:
```yaml
AUTHENTIK_PG_USER: ${{ secrets.AUTHENTIK_PG_USER }}
AUTHENTIK_PG_PASSWORD: ${{ secrets.AUTHENTIK_PG_PASSWORD }}
AUTHENTIK_SECRET_KEY: ${{ secrets.AUTHENTIK_SECRET_KEY }}
```
With:
```yaml
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**
```bash
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:
```markdown
### 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=<client-id-from-step-2>
CAMELEER_OIDC_CLIENT_SECRET=<not-needed-for-public-spa>
```
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:
```json
"issuerUri": "http://authentik:9000/application/o/cameleer/",
```
With:
```json
"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:
```markdown
### 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:
```markdown
| OIDC access token | Bearer token in Authorization header | SaaS M2M / external OIDC |
```
- [ ] **Step 8: Commit**
```bash
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.