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

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
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):

        <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 cameleer3-server-app -am -B Expected: BUILD SUCCESS

  • Step 3: Commit
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:

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.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
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:

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:

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
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":

    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 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

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.