# 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 org.springframework.boot spring-boot-starter-oauth2-resource-server ``` The full dependencies section around that area should read: ```xml org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-oauth2-resource-server com.nimbusds nimbus-jose-jwt 9.47 ``` - [ ] **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. *

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

* 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 roles = result.roles(); if (!subject.startsWith("user:") && roles.isEmpty()) { roles = List.of("AGENT"); } List 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 roles = extractRolesFromScopes(jwt); List 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 extractRolesFromScopes(Jwt jwt) { String scopeStr = jwt.getClaimAsString("scope"); if (scopeStr == null || scopeStr.isBlank()) { return List.of("VIEWER"); } List 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 toAuthorities(List 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. *

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

* 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 algorithms = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256); var keySelector = new JWSVerificationKeySelector(algorithms, jwkSource); var processor = new DefaultJWTProcessor(); 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 validators; String audience = properties.getOidcAudience(); if (audience != null && !audience.isBlank()) { validators = new DelegatingOAuth2TokenValidator<>( JwtValidators.createDefaultWithIssuer(issuerUri), new JwtClaimValidator>("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= 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: ```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.