From e9ef97bc208503ada08a201360fc61f500b79a30 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:25:24 +0200 Subject: [PATCH] docs: add Logto OIDC resource server spec and implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-05-logto-oidc-resource-server.md | 1068 +++++++++++++++++ ...04-05-logto-oidc-resource-server-design.md | 220 ++++ 2 files changed, 1288 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-05-logto-oidc-resource-server.md create mode 100644 docs/superpowers/specs/2026-04-05-logto-oidc-resource-server-design.md diff --git a/docs/superpowers/plans/2026-04-05-logto-oidc-resource-server.md b/docs/superpowers/plans/2026-04-05-logto-oidc-resource-server.md new file mode 100644 index 00000000..0236744c --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-logto-oidc-resource-server.md @@ -0,0 +1,1068 @@ +# 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://logto:3001/oidc + CAMELEER_OIDC_CLIENT_ID= + CAMELEER_OIDC_CLIENT_SECRET= + ``` +6. **Configure resource server** (for M2M token validation): + ``` + CAMELEER_OIDC_ISSUER_URI=http://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://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. diff --git a/docs/superpowers/specs/2026-04-05-logto-oidc-resource-server-design.md b/docs/superpowers/specs/2026-04-05-logto-oidc-resource-server-design.md new file mode 100644 index 00000000..353d8f4d --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-logto-oidc-resource-server-design.md @@ -0,0 +1,220 @@ +# Replace Authentik with Logto + Add OIDC Resource Server Support + +## Context + +Cameleer3 Server uses Authentik as its OIDC provider for external identity federation. The SaaS platform (cameleer-saas) has adopted Logto as its identity provider. To align the stack: + +1. **Replace Authentik with Logto** — self-hosted Logto in the K8s cluster, replacing the Authentik deployment +2. **Add OIDC resource server support** — the server must accept Logto access tokens (asymmetric JWT, ES384) in addition to its own internal HMAC JWTs, so the SaaS platform can call server APIs using M2M tokens + +The server currently has comprehensive OIDC support for the **authorization code flow** (UI users log in via external provider, exchange code for internal JWT). The new capability is orthogonal: **resource server** mode where the server directly validates and accepts external access tokens as Bearer tokens. + +## Design Decisions + +- **M2M authorization uses OAuth2 scope-based role mapping** (not client ID allowlists or user-claim detection). Logto API Resources define permissions (scopes). The server maps token scopes to its RBAC roles: `admin` scope -> ADMIN, `operator` -> OPERATOR, `viewer` -> VIEWER. +- **OIDC decoder created inline** in `SecurityConfig.filterChain()` with a blank check on the issuer URI. No conditional bean registration — avoids `@ConditionalOnProperty` issues with empty-string defaults. +- **JWKS URI discovered** from the OIDC well-known endpoint (not hardcoded). Logto's JWKS is at `issuer/oidc/jwks`. +- **`at+jwt` type handling**: Custom type verifier that accepts any JWT type, matching the cameleer-saas workaround for RFC 9068 tokens. +- **Zero breaking changes**: When `CAMELEER_OIDC_ISSUER_URI` is not set, the server behaves identically to today. + +--- + +## Part 1: Infrastructure — Replace Authentik with Logto + +### Delete +- `deploy/authentik.yaml` (288 lines — PostgreSQL, Redis, Authentik server, Authentik worker) + +### Create: `deploy/logto.yaml` +Self-hosted Logto deployment: +- **Logto PostgreSQL StatefulSet** — dedicated database for identity data (isolated from app data) +- **Logto server container** (`ghcr.io/logto-io/logto`) — ports 3001 (API/OIDC) and 3002 (admin console) +- **K8s Services** — NodePort for external access (matching Authentik's pattern) +- **Credentials** from `logto-credentials` secret + +### CI/CD Changes (`.gitea/workflows/ci.yml`) +- Replace `authentik-credentials` secret -> `logto-credentials` (LOGTO_PG_USER, LOGTO_PG_PASSWORD, LOGTO_ENDPOINT) +- Replace `kubectl apply -f deploy/authentik.yaml` -> `deploy/logto.yaml` +- Replace rollout wait for authentik-server -> logto +- Remove `AUTHENTIK_PG_USER`, `AUTHENTIK_PG_PASSWORD`, `AUTHENTIK_SECRET_KEY` secret refs +- Add OIDC resource server env vars to cameleer-auth secret or server deployment: `CAMELEER_OIDC_ISSUER_URI`, `CAMELEER_OIDC_AUDIENCE` + +--- + +## Part 2: Server — OIDC Resource Server Support + +### Change 1: Add dependency +**File:** `cameleer3-server-app/pom.xml` + +```xml + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + +``` + +### Change 2: Add OIDC properties +**File:** `cameleer3-server-app/src/main/resources/application.yml` + +```yaml +security: + # ... existing properties unchanged ... + oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:} + oidc-audience: ${CAMELEER_OIDC_AUDIENCE:} +``` + +**File:** `SecurityProperties.java` + +Add fields: +```java +private String oidcIssuerUri; // Logto issuer URI for M2M token validation +private String oidcAudience; // Expected audience (API resource indicator) +// + getters/setters +``` + +### Change 3: Build OIDC decoder inline in SecurityConfig +**File:** `SecurityConfig.java` + +Build the OIDC `JwtDecoder` inline in the `filterChain()` method. When the issuer URI is blank/null, no decoder is created and the server behaves as before. + +The decoder: +1. Discovers JWKS URI from the OIDC well-known endpoint +2. Builds a `NimbusJwtDecoder` with a custom type verifier (accepts `at+jwt` per RFC 9068) +3. Validates issuer and optionally audience + +```java +private org.springframework.security.oauth2.jwt.JwtDecoder buildOidcDecoder( + SecurityProperties properties) { + // Build decoder with at+jwt type workaround (RFC 9068) + // Discover JWKS URI from well-known endpoint, not hardcoded + var jwkSource = JWKSourceBuilder.create(new URL(jwksUri)).build(); + var keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.ES384, jwkSource); + var processor = new DefaultJWTProcessor<>(); + processor.setJWSKeySelector(keySelector); + processor.setJWSTypeVerifier((type, ctx) -> { /* accept any type */ }); + var decoder = new NimbusJwtDecoder(processor); + + // Validate issuer + audience + OAuth2TokenValidator validators; + if (properties.getOidcAudience() != null && !properties.getOidcAudience().isBlank()) { + validators = new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(properties.getOidcIssuerUri()), + new JwtClaimValidator>("aud", + aud -> aud != null && aud.contains(properties.getOidcAudience())) + ); + } else { + validators = JwtValidators.createDefaultWithIssuer(properties.getOidcIssuerUri()); + } + decoder.setJwtValidator(validators); + return decoder; +} +``` + +### Change 4: Modify JwtAuthenticationFilter for OIDC fallback +**File:** `JwtAuthenticationFilter.java` + +Current: extracts Bearer token, validates with JwtService (HMAC), sets auth context. + +New: try HMAC first. If fails AND OIDC decoder is configured, try validating as Logto token. Map scopes to roles. + +```java +private boolean tryInternalToken(String token, HttpServletRequest request) { + try { + JwtValidationResult result = jwtService.validateAccessToken(token); + // ... existing auth setup (unchanged) ... + return true; + } catch (Exception e) { + return false; + } +} + +private void tryOidcToken(String token, HttpServletRequest request) { + try { + var jwt = oidcDecoder.decode(token); + List roles = extractRolesFromOidcToken(jwt); + List authorities = roles.stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) + .collect(Collectors.toList()); + var auth = new UsernamePasswordAuthenticationToken( + "oidc:" + jwt.getSubject(), null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (Exception e) { + log.debug("OIDC token validation failed: {}", e.getMessage()); + } +} + +private List extractRolesFromOidcToken( + org.springframework.security.oauth2.jwt.Jwt jwt) { + // Scope-based role mapping (OAuth2 standard) + List scopes = jwt.getClaimAsStringList("scope"); + if (scopes == null) { + String scopeStr = jwt.getClaimAsString("scope"); + scopes = scopeStr != null ? List.of(scopeStr.split(" ")) : List.of(); + } + 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"); // safe default +} +``` + +--- + +## Part 3: OidcConfig Defaults + Documentation + +### OidcConfig defaults for Logto +**File:** `OidcConfig.java` + +Update `disabled()` factory: `rolesClaim` from `realm_access.roles` to `roles` (Logto convention). + +**File:** `OidcConfigAdminController.java` + +Update PUT handler default: `rolesClaim` from `realm_access.roles` to `roles`. + +### Documentation updates + +**HOWTO.md:** Replace "Authentik Setup" section with "Logto Setup" — provisioning, OIDC config values, API resource creation, M2M app setup, scope configuration. + +**CLAUDE.md:** +- Replace "Authentik" with "Logto" in shared infra description +- Add OIDC resource server note + +**SERVER-CAPABILITIES.md:** Add section documenting dual-path JWT validation (HMAC internal + OIDC external) and scope-to-role mapping. + +--- + +## New Environment Variables + +| Variable | Purpose | Required | +|----------|---------|----------| +| `CAMELEER_OIDC_ISSUER_URI` | Logto issuer URI (e.g., `http://logto:3001/oidc`) | No — when blank, no OIDC resource server | +| `CAMELEER_OIDC_AUDIENCE` | Expected audience / API resource indicator | No — when blank, audience not validated | + +## Files Changed + +| File | Action | +|------|--------| +| `deploy/authentik.yaml` | Delete | +| `deploy/logto.yaml` | Create | +| `.gitea/workflows/ci.yml` | Modify (Authentik -> Logto) | +| `cameleer3-server-app/pom.xml` | Modify (add dependency) | +| `application.yml` | Modify (add OIDC properties) | +| `SecurityProperties.java` | Modify (add fields) | +| `SecurityConfig.java` | Modify (build decoder, pass to filter) | +| `JwtAuthenticationFilter.java` | Modify (add OIDC fallback) | +| `OidcConfig.java` | Modify (default rolesClaim) | +| `OidcConfigAdminController.java` | Modify (default rolesClaim) | +| `HOWTO.md` | Modify (Authentik -> Logto docs) | +| `CLAUDE.md` | Modify (Authentik -> Logto refs) | +| `SERVER-CAPABILITIES.md` | Modify (add OIDC resource server) | + +## Verification + +1. **No OIDC configured**: Start server without `CAMELEER_OIDC_ISSUER_URI` -> behaves identically to today (internal HMAC only) +2. **M2M token accepted**: Configure issuer + audience, send Logto M2M token with `admin` scope -> ADMIN access +3. **Scope mapping**: M2M token with `viewer` scope -> VIEWER access only +4. **Invalid token rejected**: Random JWT -> 401 +5. **Wrong audience rejected**: Valid Logto token for different API resource -> 401 +6. **Internal tokens still work**: Agent registration, heartbeat, UI login -> unchanged +7. **OIDC login flow unchanged**: Code exchange via admin-configured OIDC -> still works +8. **Logto deployment healthy**: `kubectl get pods` shows Logto running, admin console accessible +9. **CI deploys Logto**: Push to main -> Logto deployed instead of Authentik