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
+
+ * 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://logto:3001/oidc
+ CAMELEER_OIDC_CLIENT_ID=
>("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