# Logto OIDC Resource Server Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace Authentik with self-hosted Logto and add OIDC resource server support so the SaaS platform can call server APIs using M2M tokens.

**Architecture:** The server gains a dual-path JWT validation: try internal HMAC first, fall back to OIDC (Logto) token validation via JWKS. M2M authorization uses standard OAuth2 scope-based role mapping. Infrastructure swaps Authentik K8s manifests for Logto. All changes are additive — when `CAMELEER_OIDC_ISSUER_URI` is unset, the server behaves identically to today.

**Tech Stack:** Spring Boot 3.4.3, spring-boot-starter-oauth2-resource-server, Nimbus JOSE+JWT, Logto (ghcr.io/logto-io/logto), Kustomize, Gitea CI

---

## File Structure

| File | Action | Responsibility |
|------|--------|---------------|
| `cameleer-server-app/pom.xml` | Modify | Add oauth2-resource-server dependency |
| `cameleer-server-app/src/main/resources/application.yml` | Modify | Add OIDC issuer/audience properties |
| `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java` | Modify | Add oidcIssuerUri, oidcAudience fields |
| `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` | Modify | Build OIDC decoder, pass to filter |
| `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` | Modify | Add OIDC fallback path |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/security/OidcConfig.java` | Modify | Update default rolesClaim |
| `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/OidcConfigAdminController.java` | Modify | Update default rolesClaim |
| `deploy/authentik.yaml` | Delete | Remove Authentik deployment |
| `deploy/logto.yaml` | Create | Logto server + dedicated PostgreSQL |
| `.gitea/workflows/ci.yml` | Modify | Replace Authentik with Logto in CI |
| `HOWTO.md` | Modify | Replace Authentik docs with Logto |
| `CLAUDE.md` | Modify | Replace Authentik references |
| `docs/SERVER-CAPABILITIES.md` | Modify | Add OIDC resource server section |

---

### Task 1: Add OAuth2 Resource Server Dependency

**Files:**
- Modify: `cameleer-server-app/pom.xml:87-97`

- [ ] **Step 1: Add the spring-boot-starter-oauth2-resource-server dependency**

In `cameleer-server-app/pom.xml`, add after the existing `spring-boot-starter-security` dependency (line 87) and before the `nimbus-jose-jwt` dependency (line 88):

```xml
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>
```

The full dependencies section around that area should read:

```xml
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>com.nimbusds</groupId>
            <artifactId>nimbus-jose-jwt</artifactId>
            <version>9.47</version>
        </dependency>
```

- [ ] **Step 2: Verify compilation**

Run: `mvn clean compile -pl cameleer-server-app -am -B`
Expected: BUILD SUCCESS

- [ ] **Step 3: Commit**

```bash
git add cameleer-server-app/pom.xml
git commit -m "feat: add spring-boot-starter-oauth2-resource-server dependency"
```

---

### Task 2: Add OIDC Properties

**Files:**
- Modify: `cameleer-server-app/src/main/resources/application.yml:42-48`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java`

- [ ] **Step 1: Add OIDC properties to application.yml**

In `application.yml`, add two new properties under the existing `security:` block. After `jwt-secret` (line 48), add:

```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.cameleer.server.app.security;

import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * Configuration properties for security settings.
 * Bound from the {@code security.*} namespace in application.yml.
 */
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {

    private long accessTokenExpiryMs = 3_600_000;
    private long refreshTokenExpiryMs = 604_800_000;
    private String bootstrapToken;
    private String bootstrapTokenPrevious;
    private String uiUser;
    private String uiPassword;
    private String uiOrigin;
    private String jwtSecret;
    private String oidcIssuerUri;
    private String oidcAudience;

    public long getAccessTokenExpiryMs() { return accessTokenExpiryMs; }
    public void setAccessTokenExpiryMs(long accessTokenExpiryMs) { this.accessTokenExpiryMs = accessTokenExpiryMs; }
    public long getRefreshTokenExpiryMs() { return refreshTokenExpiryMs; }
    public void setRefreshTokenExpiryMs(long refreshTokenExpiryMs) { this.refreshTokenExpiryMs = refreshTokenExpiryMs; }
    public String getBootstrapToken() { return bootstrapToken; }
    public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; }
    public String getBootstrapTokenPrevious() { return bootstrapTokenPrevious; }
    public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) { this.bootstrapTokenPrevious = bootstrapTokenPrevious; }
    public String getUiUser() { return uiUser; }
    public void setUiUser(String uiUser) { this.uiUser = uiUser; }
    public String getUiPassword() { return uiPassword; }
    public void setUiPassword(String uiPassword) { this.uiPassword = uiPassword; }
    public String getUiOrigin() { return uiOrigin; }
    public void setUiOrigin(String uiOrigin) { this.uiOrigin = uiOrigin; }
    public String getJwtSecret() { return jwtSecret; }
    public void setJwtSecret(String jwtSecret) { this.jwtSecret = jwtSecret; }
    public String getOidcIssuerUri() { return oidcIssuerUri; }
    public void setOidcIssuerUri(String oidcIssuerUri) { this.oidcIssuerUri = oidcIssuerUri; }
    public String getOidcAudience() { return oidcAudience; }
    public void setOidcAudience(String oidcAudience) { this.oidcAudience = oidcAudience; }
}
```

- [ ] **Step 3: Verify compilation**

Run: `mvn clean compile -pl cameleer-server-app -am -B`
Expected: BUILD SUCCESS

- [ ] **Step 4: Commit**

```bash
git add cameleer-server-app/src/main/resources/application.yml
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java
git commit -m "feat: add OIDC issuer URI and audience security properties"
```

---

### Task 3: Add OIDC Fallback to JwtAuthenticationFilter

**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java`

- [ ] **Step 1: Update JwtAuthenticationFilter with OIDC fallback**

The filter needs a new nullable `oidcDecoder` parameter, a `tryInternalToken` method (wrapping existing logic), a `tryOidcToken` fallback, and scope-based role extraction. The complete updated file:

```java
package com.cameleer.server.app.security;

import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.JwtService;
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

/**
 * JWT authentication filter that extracts and validates JWT tokens from
 * the {@code Authorization: Bearer} header or the {@code token} query parameter.
 * <p>
 * Tries internal HMAC validation first (agents, local users). If that fails and an
 * OIDC {@link JwtDecoder} is configured, falls back to OIDC token validation
 * (SaaS M2M tokens, external OIDC users). Scope-based role mapping for OIDC tokens.
 * <p>
 * Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig}
 * to avoid double filter registration.
 */
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private static final String BEARER_PREFIX = "Bearer ";
    public static final String JWT_RESULT_ATTR = "cameleer.jwt.result";

    private final JwtService jwtService;
    private final AgentRegistryService agentRegistryService;
    private final JwtDecoder oidcDecoder;

    public JwtAuthenticationFilter(JwtService jwtService,
                                    AgentRegistryService agentRegistryService,
                                    JwtDecoder oidcDecoder) {
        this.jwtService = jwtService;
        this.agentRegistryService = agentRegistryService;
        this.oidcDecoder = oidcDecoder;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
        String token = extractToken(request);

        if (token != null) {
            if (tryInternalToken(token, request)) {
                chain.doFilter(request, response);
                return;
            }
            if (oidcDecoder != null) {
                tryOidcToken(token, request);
            }
        }

        chain.doFilter(request, response);
    }

    private boolean tryInternalToken(String token, HttpServletRequest request) {
        try {
            JwtValidationResult result = jwtService.validateAccessToken(token);
            String subject = result.subject();

            List<String> roles = result.roles();
            if (!subject.startsWith("user:") && roles.isEmpty()) {
                roles = List.of("AGENT");
            }
            List<GrantedAuthority> authorities = toAuthorities(roles);
            UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(subject, null, authorities);
            SecurityContextHolder.getContext().setAuthentication(auth);
            request.setAttribute(JWT_RESULT_ATTR, result);
            return true;
        } catch (Exception e) {
            log.debug("Internal JWT validation failed: {}", e.getMessage());
            return false;
        }
    }

    private void tryOidcToken(String token, HttpServletRequest request) {
        try {
            Jwt jwt = oidcDecoder.decode(token);
            List<String> roles = extractRolesFromScopes(jwt);
            List<GrantedAuthority> authorities = toAuthorities(roles);
            UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(
                            "oidc:" + jwt.getSubject(), null, authorities);
            SecurityContextHolder.getContext().setAuthentication(auth);
        } catch (Exception e) {
            log.debug("OIDC token validation failed: {}", e.getMessage());
        }
    }

    /**
     * Maps OAuth2 scopes to server RBAC roles.
     * Scopes are defined on the Logto API Resource for this server.
     */
    private List<String> extractRolesFromScopes(Jwt jwt) {
        String scopeStr = jwt.getClaimAsString("scope");
        if (scopeStr == null || scopeStr.isBlank()) {
            return List.of("VIEWER");
        }
        List<String> scopes = List.of(scopeStr.split(" "));
        if (scopes.contains("admin")) return List.of("ADMIN");
        if (scopes.contains("operator")) return List.of("OPERATOR");
        if (scopes.contains("viewer")) return List.of("VIEWER");
        return List.of("VIEWER");
    }

    private List<GrantedAuthority> toAuthorities(List<String> roles) {
        return roles.stream()
                .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))
                .toList();
    }

    private String extractToken(HttpServletRequest request) {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
            return authHeader.substring(BEARER_PREFIX.length());
        }
        return request.getParameter("token");
    }
}
```

- [ ] **Step 2: Note — do NOT compile yet**

This change removes the 2-arg constructor, so `SecurityConfig.java` won't compile until Task 4 updates it. Do NOT commit yet — the filter and SecurityConfig changes are committed together in Task 4.

---

### Task 4: Build OIDC Decoder in SecurityConfig

**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java`

- [ ] **Step 1: Update SecurityConfig to build OIDC decoder and pass to filter**

The `filterChain` method needs an additional `SecurityProperties` parameter, an inline OIDC decoder builder, and must pass the decoder to the `JwtAuthenticationFilter` constructor. The complete updated file:

```java
package com.cameleer.server.app.security;

import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.JwtService;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
import net.minidev.json.JSONObject;
import net.minidev.json.parser.JSONParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.util.List;
import java.util.Set;

/**
 * Spring Security configuration for JWT-based stateless authentication with RBAC.
 * <p>
 * Public endpoints: health, agent registration, refresh, auth, API docs, Swagger UI, static resources.
 * All other endpoints require a valid JWT access token with appropriate roles.
 * <p>
 * When {@code security.oidc-issuer-uri} is configured, builds an OIDC {@link JwtDecoder}
 * for validating external access tokens (Logto M2M / OIDC user tokens) as a fallback
 * after internal HMAC validation.
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                            JwtService jwtService,
                                            AgentRegistryService registryService,
                                            SecurityProperties securityProperties,
                                            CorsConfigurationSource corsConfigurationSource) throws Exception {
        JwtDecoder oidcDecoder = null;
        String issuer = securityProperties.getOidcIssuerUri();
        if (issuer != null && !issuer.isBlank()) {
            try {
                oidcDecoder = buildOidcDecoder(securityProperties);
                log.info("OIDC resource server enabled: issuer={}", issuer);
            } catch (Exception e) {
                log.error("Failed to initialize OIDC decoder for issuer={}: {}", issuer, e.getMessage());
            }
        }

        http
                .cors(cors -> cors.configurationSource(corsConfigurationSource))
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        // Public endpoints
                        .requestMatchers(
                                "/api/v1/health",
                                "/api/v1/agents/register",
                                "/api/v1/agents/*/refresh",
                                "/api/v1/auth/**",
                                "/api/v1/api-docs/**",
                                "/api/v1/swagger-ui/**",
                                "/swagger-ui/**",
                                "/v3/api-docs/**",
                                "/swagger-ui.html",
                                "/error",
                                "/",
                                "/index.html",
                                "/config.js",
                                "/favicon.svg",
                                "/assets/**"
                        ).permitAll()

                        // Agent-only endpoints
                        .requestMatchers("/api/v1/data/**").hasRole("AGENT")
                        .requestMatchers("/api/v1/agents/*/heartbeat").hasRole("AGENT")
                        .requestMatchers("/api/v1/agents/*/events").hasRole("AGENT")
                        .requestMatchers("/api/v1/agents/*/commands/*/ack").hasRole("AGENT")

                        // Command endpoints — operator+ only
                        .requestMatchers(HttpMethod.POST, "/api/v1/agents/*/commands").hasAnyRole("OPERATOR", "ADMIN")
                        .requestMatchers(HttpMethod.POST, "/api/v1/agents/groups/*/commands").hasAnyRole("OPERATOR", "ADMIN")
                        .requestMatchers(HttpMethod.POST, "/api/v1/agents/commands").hasAnyRole("OPERATOR", "ADMIN")
                        .requestMatchers(HttpMethod.POST, "/api/v1/agents/*/replay").hasAnyRole("OPERATOR", "ADMIN")

                        // Search endpoints
                        .requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
                        .requestMatchers(HttpMethod.POST, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")

                        // Application config endpoints
                        .requestMatchers(HttpMethod.GET, "/api/v1/config/*").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
                        .requestMatchers(HttpMethod.PUT, "/api/v1/config/*").hasAnyRole("OPERATOR", "ADMIN")

                        // Read-only data endpoints — viewer+
                        .requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
                        .requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
                        .requestMatchers(HttpMethod.GET, "/api/v1/agents/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
                        .requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
                        .requestMatchers(HttpMethod.GET, "/api/v1/agents/events-log").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
                        .requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
                        .requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")

                        // Admin endpoints
                        .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")

                        // Everything else requires authentication
                        .anyRequest().authenticated()
                )
                .exceptionHandling(ex -> ex
                        .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
                )
                .addFilterBefore(
                        new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder),
                        UsernamePasswordAuthenticationFilter.class
                );

        return http.build();
    }

    /**
     * Builds an OIDC {@link JwtDecoder} for validating external access tokens.
     * Discovers JWKS URI from the OIDC well-known endpoint. Handles Logto's
     * {@code at+jwt} token type (RFC 9068) by accepting any JWT type.
     */
    private JwtDecoder buildOidcDecoder(SecurityProperties properties) throws Exception {
        String issuerUri = properties.getOidcIssuerUri();

        // Discover JWKS URI and supported algorithms from OIDC discovery
        String discoveryUrl = issuerUri.endsWith("/")
                ? issuerUri + ".well-known/openid-configuration"
                : issuerUri + "/.well-known/openid-configuration";
        URL url = new URI(discoveryUrl).toURL();
        OIDCProviderMetadata metadata;
        try (InputStream in = url.openStream()) {
            JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(in);
            metadata = OIDCProviderMetadata.parse(json);
        }
        URL jwksUri = metadata.getJWKSetURI().toURL();

        // Build decoder supporting ES384 (Logto default) and ES256, RS256
        var jwkSource = JWKSourceBuilder.create(jwksUri).build();
        Set<JWSAlgorithm> algorithms = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
        var keySelector = new JWSVerificationKeySelector<SecurityContext>(algorithms, jwkSource);
        var processor = new DefaultJWTProcessor<SecurityContext>();
        processor.setJWSKeySelector(keySelector);
        // Accept any JWT type — Logto uses "at+jwt" (RFC 9068)
        processor.setJWSTypeVerifier((type, ctx) -> { });
        var decoder = new NimbusJwtDecoder(processor);

        // Validate issuer + optionally audience
        OAuth2TokenValidator<Jwt> validators;
        String audience = properties.getOidcAudience();
        if (audience != null && !audience.isBlank()) {
            validators = new DelegatingOAuth2TokenValidator<>(
                    JwtValidators.createDefaultWithIssuer(issuerUri),
                    new JwtClaimValidator<List<String>>("aud",
                            aud -> aud != null && aud.contains(audience))
            );
        } else {
            validators = JwtValidators.createDefaultWithIssuer(issuerUri);
        }
        decoder.setJwtValidator(validators);

        log.info("OIDC decoder initialized: jwks={}", jwksUri);
        return decoder;
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource(SecurityProperties properties) {
        CorsConfiguration config = new CorsConfiguration();
        String origin = properties.getUiOrigin();
        if (origin != null && !origin.isBlank()) {
            config.setAllowedOrigins(List.of(origin));
        } else {
            config.setAllowedOrigins(List.of("http://localhost:5173"));
        }
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}
```

- [ ] **Step 2: Verify compilation**

Run: `mvn clean compile -pl cameleer-server-app -am -B`
Expected: BUILD SUCCESS

- [ ] **Step 3: Run tests**

Run: `mvn test -pl cameleer-server-app -am -B`
Expected: Tests pass (OIDC decoder won't be built since `CAMELEER_OIDC_ISSUER_URI` is empty in test config)

- [ ] **Step 4: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java
git commit -m "feat: add OIDC resource server support with JWKS discovery and scope-based roles"
```

---

### Task 5: Update OidcConfig Default RolesClaim

**Files:**
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/security/OidcConfig.java:28`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/OidcConfigAdminController.java:101`

- [ ] **Step 1: Update OidcConfig.disabled() default**

In `OidcConfig.java`, change the `disabled()` factory method's `rolesClaim` from `"realm_access.roles"` to `"roles"`:

```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 cameleer-server-core/src/main/java/com/cameleer/server/core/security/OidcConfig.java
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/OidcConfigAdminController.java
git commit -m "feat: update default rolesClaim to 'roles' for Logto compatibility"
```

---

### Task 6: Replace Authentik with Logto Infrastructure

**Files:**
- Delete: `deploy/authentik.yaml`
- Create: `deploy/logto.yaml`

- [ ] **Step 1: Delete authentik.yaml**

```bash
git rm deploy/authentik.yaml
```

- [ ] **Step 2: Create deploy/logto.yaml**

Create `deploy/logto.yaml` with Logto server + dedicated PostgreSQL:

```yaml
# Logto OIDC Provider for Cameleer
# Provides external identity management with OAuth2/OIDC.
#
# After deployment:
#   1. Access Logto admin console at http://192.168.50.86:30952
#   2. Complete initial setup (create admin account)
#   3. Create an Application for Cameleer (see HOWTO.md)
#   4. Create an API Resource with scopes (admin, operator, viewer)
#   5. Create an M2M Application for the SaaS platform

# --- PostgreSQL for Logto ---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: logto-postgresql
  namespace: cameleer
spec:
  serviceName: logto-postgresql
  replicas: 1
  selector:
    matchLabels:
      app: logto-postgresql
  template:
    metadata:
      labels:
        app: logto-postgresql
    spec:
      containers:
        - name: postgresql
          image: postgres:16-alpine
          ports:
            - containerPort: 5432
          env:
            - name: POSTGRES_DB
              value: logto
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: logto-credentials
                  key: PG_USER
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: logto-credentials
                  key: PG_PASSWORD
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
              subPath: pgdata
          resources:
            requests:
              memory: "128Mi"
              cpu: "50m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          livenessProbe:
            exec:
              command: ["pg_isready"]
            initialDelaySeconds: 15
            periodSeconds: 10
          readinessProbe:
            exec:
              command: ["pg_isready"]
            initialDelaySeconds: 5
            periodSeconds: 5
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
  name: logto-postgresql
  namespace: cameleer
spec:
  clusterIP: None
  selector:
    app: logto-postgresql
  ports:
    - port: 5432
      targetPort: 5432

# --- Logto Server ---
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: logto
  namespace: cameleer
spec:
  replicas: 1
  selector:
    matchLabels:
      app: logto
  template:
    metadata:
      labels:
        app: logto
    spec:
      containers:
        - name: logto
          image: ghcr.io/logto-io/logto:latest
          command: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
          ports:
            - containerPort: 3001
              name: api
            - containerPort: 3002
              name: admin
          env:
            - name: TRUST_PROXY_HEADER
              value: "1"
            - name: DB_URL
              value: "postgresql://$(PG_USER):$(PG_PASSWORD)@logto-postgresql:5432/logto"
            - name: ENDPOINT
              valueFrom:
                secretKeyRef:
                  name: logto-credentials
                  key: ENDPOINT
            - name: ADMIN_ENDPOINT
              valueFrom:
                secretKeyRef:
                  name: logto-credentials
                  key: ADMIN_ENDPOINT
            - name: PG_USER
              valueFrom:
                secretKeyRef:
                  name: logto-credentials
                  key: PG_USER
            - name: PG_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: logto-credentials
                  key: PG_PASSWORD
          resources:
            requests:
              memory: "256Mi"
              cpu: "100m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /api/status
              port: 3001
            initialDelaySeconds: 60
            periodSeconds: 15
            timeoutSeconds: 5
            failureThreshold: 5
          readinessProbe:
            httpGet:
              path: /api/status
              port: 3001
            initialDelaySeconds: 30
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
  name: logto
  namespace: cameleer
spec:
  type: NodePort
  selector:
    app: logto
  ports:
    - port: 3001
      targetPort: 3001
      nodePort: 30951
      name: api
    - port: 3002
      targetPort: 3002
      nodePort: 30952
      name: admin
```

- [ ] **Step 3: Commit**

```bash
git add deploy/logto.yaml
git commit -m "feat: replace Authentik with Logto K8s deployment"
```

---

### Task 7: Update CI/CD Workflow

**Files:**
- Modify: `.gitea/workflows/ci.yml`

- [ ] **Step 1: Replace Authentik credentials with Logto credentials in deploy-main**

In `.gitea/workflows/ci.yml`, find the `authentik-credentials` secret creation (lines 213-218) and replace it:

Replace:
```yaml
          kubectl create secret generic authentik-credentials \
            --namespace=cameleer \
            --from-literal=PG_USER="${AUTHENTIK_PG_USER:-authentik}" \
            --from-literal=PG_PASSWORD="${AUTHENTIK_PG_PASSWORD}" \
            --from-literal=AUTHENTIK_SECRET_KEY="${AUTHENTIK_SECRET_KEY}" \
            --dry-run=client -o yaml | kubectl apply -f -
```

With:
```yaml
          kubectl create secret generic logto-credentials \
            --namespace=cameleer \
            --from-literal=PG_USER="${LOGTO_PG_USER:-logto}" \
            --from-literal=PG_PASSWORD="${LOGTO_PG_PASSWORD}" \
            --from-literal=ENDPOINT="${LOGTO_ENDPOINT}" \
            --from-literal=ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT}" \
            --dry-run=client -o yaml | kubectl apply -f -
```

- [ ] **Step 2: Replace Authentik deployment with Logto**

Find lines 232-233:

Replace:
```yaml
          kubectl apply -f deploy/authentik.yaml
          kubectl -n cameleer rollout status deployment/authentik-server --timeout=180s
```

With:
```yaml
          kubectl apply -f deploy/logto.yaml
          kubectl -n cameleer rollout status deployment/logto --timeout=180s
```

- [ ] **Step 3: Update env vars section**

Find the `env:` block (lines 243-256). Replace the three Authentik secret references:

Replace:
```yaml
          AUTHENTIK_PG_USER: ${{ secrets.AUTHENTIK_PG_USER }}
          AUTHENTIK_PG_PASSWORD: ${{ secrets.AUTHENTIK_PG_PASSWORD }}
          AUTHENTIK_SECRET_KEY: ${{ secrets.AUTHENTIK_SECRET_KEY }}
```

With:
```yaml
          LOGTO_PG_USER: ${{ secrets.LOGTO_PG_USER }}
          LOGTO_PG_PASSWORD: ${{ secrets.LOGTO_PG_PASSWORD }}
          LOGTO_ENDPOINT: ${{ secrets.LOGTO_ENDPOINT }}
          LOGTO_ADMIN_ENDPOINT: ${{ secrets.LOGTO_ADMIN_ENDPOINT }}
```

- [ ] **Step 4: Update feature branch secret copying**

In the `deploy-feature` job, the `Copy secrets from cameleer namespace` step (line 296) copies secrets including `cameleer-auth`. The `logto-credentials` secret does NOT need to be copied to feature namespaces — feature branches share the production Logto instance. No change needed here.

- [ ] **Step 5: Commit**

```bash
git add .gitea/workflows/ci.yml
git commit -m "ci: replace Authentik with Logto in deployment pipeline"
```

---

### Task 8: Update Documentation

**Files:**
- Modify: `HOWTO.md`
- Modify: `CLAUDE.md`
- Modify: `docs/SERVER-CAPABILITIES.md`

- [ ] **Step 1: Update HOWTO.md — replace Authentik Setup with Logto Setup**

Replace the "Authentik Setup (OIDC Provider)" section (lines 159-180) with:

```markdown
### Logto Setup (OIDC Provider)

Logto is deployed alongside the Cameleer stack. After first deployment:

1. **Initial setup**: Open `http://192.168.50.86:30952` (admin console) and create the admin account
2. **Create SPA application**: Applications → Create → Single Page App
   - Name: `Cameleer UI`
   - Redirect URI: `http://192.168.50.86:30090/oidc/callback` (or your UI URL)
   - Note the **Client ID**
3. **Create API Resource**: API Resources → Create
   - Name: `Cameleer Server API`
   - Indicator: `https://cameleer.siegeln.net/api` (or your API URL)
   - Add permissions: `admin`, `operator`, `viewer`
4. **Create M2M application** (for SaaS platform): Applications → Create → Machine-to-Machine
   - Name: `Cameleer SaaS`
   - Assign the API Resource created above with `admin` scope
   - Note the **Client ID** and **Client Secret**
5. **Configure Cameleer**: Use the admin API (`PUT /api/v1/admin/oidc`) or set env vars for initial seeding:
   ```
   CAMELEER_OIDC_ENABLED=true
   CAMELEER_OIDC_ISSUER=http://cameleer-logto:3001/oidc
   CAMELEER_OIDC_CLIENT_ID=<client-id-from-step-2>
   CAMELEER_OIDC_CLIENT_SECRET=<not-needed-for-public-spa>
   ```
6. **Configure resource server** (for M2M token validation):
   ```
   CAMELEER_OIDC_ISSUER_URI=http://cameleer-logto:3001/oidc
   CAMELEER_OIDC_AUDIENCE=https://cameleer.siegeln.net/api
   ```
```

- [ ] **Step 2: Update HOWTO.md — replace OIDC config example issuer**

On line 141, replace the issuer URI in the OIDC admin config example:

Replace:
```json
    "issuerUri": "http://authentik:9000/application/o/cameleer/",
```

With:
```json
    "issuerUri": "http://cameleer-logto:3001/oidc",
```

- [ ] **Step 3: Update HOWTO.md — replace infrastructure diagram**

Replace the Authentik entries in the infrastructure overview (lines 448-451):

Replace:
```
  Authentik Server (Deployment)            ← NodePort 30950
  Authentik Worker (Deployment)
  Authentik PostgreSQL (StatefulSet, 1Gi)  ← ClusterIP
  Authentik Redis (Deployment)             ← ClusterIP
```

With:
```
  Logto Server (Deployment)               ← NodePort 30951/30952
  Logto PostgreSQL (StatefulSet, 1Gi)     ← ClusterIP
```

- [ ] **Step 4: Update HOWTO.md — replace access table**

Replace the Authentik line in the access table (line 465):

Replace:
```
| Authentik | `http://192.168.50.86:30950` |
```

With:
```
| Logto API | `http://192.168.50.86:30951` |
| Logto Admin | `http://192.168.50.86:30952` |
```

- [ ] **Step 5: Update HOWTO.md — replace secrets list**

On line 471, replace the Authentik secrets with Logto secrets:

Replace `AUTHENTIK_PG_USER`, `AUTHENTIK_PG_PASSWORD`, `AUTHENTIK_SECRET_KEY` with `LOGTO_PG_USER`, `LOGTO_PG_PASSWORD`, `LOGTO_ENDPOINT`, `LOGTO_ADMIN_ENDPOINT`.

Also add `CAMELEER_OIDC_ISSUER_URI` and `CAMELEER_OIDC_AUDIENCE` (optional).

- [ ] **Step 6: Update CLAUDE.md**

On line 54, replace "Authentik" with "Logto":

Replace:
```
- K8s manifests in `deploy/` — Kustomize base + overlays (main/feature), shared infra (PostgreSQL, ClickHouse, Authentik) as top-level manifests
```

With:
```
- K8s manifests in `deploy/` — Kustomize base + overlays (main/feature), shared infra (PostgreSQL, ClickHouse, Logto) as top-level manifests
```

On line 45, add OIDC resource server note after the existing OIDC line:

Replace:
```
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table)
```

With:
```
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_OIDC_ISSUER_URI` is set. Scope-based role mapping: `admin`/`operator`/`viewer` scopes map to RBAC roles.
```

- [ ] **Step 7: Update docs/SERVER-CAPABILITIES.md**

After the existing "OIDC Integration" section (line 258), add the resource server section:

```markdown

### OIDC Resource Server

When `CAMELEER_OIDC_ISSUER_URI` is configured, the server accepts external access tokens (e.g., Logto M2M tokens) in addition to internal HMAC JWTs. Dual-path validation: tries internal HMAC first, falls back to OIDC JWKS validation. OAuth2 scope-based role mapping: `admin` scope maps to ADMIN, `operator` to OPERATOR, `viewer` to VIEWER. Supports ES384, ES256, and RS256 algorithms. Handles RFC 9068 `at+jwt` token type.

| Variable | Purpose |
|----------|---------|
| `CAMELEER_OIDC_ISSUER_URI` | OIDC issuer URI for JWKS discovery |
| `CAMELEER_OIDC_AUDIENCE` | Expected audience (API resource indicator) |
```

Also update the Authentication table (line 232) to add:

```markdown
| OIDC access token | Bearer token in Authorization header | SaaS M2M / external OIDC |
```

- [ ] **Step 8: Commit**

```bash
git add HOWTO.md CLAUDE.md docs/SERVER-CAPABILITIES.md
git commit -m "docs: replace Authentik with Logto, document OIDC resource server"
```

---

### Task 9: Full Build Verification

- [ ] **Step 1: Full compile + test**

Run: `mvn clean compile test-compile -B`
Expected: BUILD SUCCESS

- [ ] **Step 2: Run unit tests**

Run: `mvn test -B`
Expected: All tests pass

- [ ] **Step 3: Verify no Authentik references remain**

Run: `grep -ri "authentik" --include="*.java" --include="*.yml" --include="*.yaml" --include="*.md" .`

Expected: Zero results in tracked files (only git history). The deleted `deploy/authentik.yaml` should not appear. If any remain in documentation or config, fix them.
