# 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
* 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
* 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>("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=