diff --git a/docs/superpowers/plans/2026-04-05-auth-overhaul.md b/docs/superpowers/plans/2026-04-05-auth-overhaul.md
new file mode 100644
index 0000000..56d6346
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-05-auth-overhaul.md
@@ -0,0 +1,1809 @@
+# Auth Overhaul 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 the incoherent three-system auth in cameleer-saas with Logto-centric architecture, and add OIDC resource server support to cameleer3-server for M2M.
+
+**Architecture:** Logto is the single identity provider for all humans. Spring OAuth2 Resource Server validates Logto JWTs in both the SaaS platform and cameleer3-server. Agents authenticate with per-environment API keys exchanged for server-issued JWTs. Ed25519 command signing is unchanged. Zero trust: every service validates tokens independently via JWKS.
+
+**Tech Stack:** Spring Boot 3.4, Spring Security OAuth2 Resource Server, Nimbus JOSE+JWT, Logto, React + @logto/react, Zustand, PostgreSQL, Flyway
+
+**Spec:** `docs/superpowers/specs/2026-04-05-auth-overhaul-design.md`
+
+**Repos:**
+- cameleer3-server: `C:\Users\Hendrik\Documents\projects\cameleer3-server` (Phase 1)
+- cameleer-saas: `C:\Users\Hendrik\Documents\projects\cameleer-saas` (Phases 2-3)
+- cameleer3 (agent): NO CHANGES
+
+---
+
+## Phase 1: cameleer3-server — OIDC Resource Server Support
+
+All Phase 1 work is in `C:\Users\Hendrik\Documents\projects\cameleer3-server`.
+
+### Task 1: Add OAuth2 Resource Server dependency and config properties
+
+**Files:**
+- Modify: `cameleer3-server-app/pom.xml`
+- Modify: `cameleer3-server-app/src/main/resources/application.yml`
+- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java`
+
+- [ ] **Step 1: Add dependency to pom.xml**
+
+In `cameleer3-server-app/pom.xml`, add after the `spring-boot-starter-security` dependency (around line 88):
+
+```xml
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-resource-server
+
+```
+
+- [ ] **Step 2: Add OIDC properties to application.yml**
+
+In `cameleer3-server-app/src/main/resources/application.yml`, add two new properties under the `security:` block (after line 52):
+
+```yaml
+ oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
+ oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
+```
+
+- [ ] **Step 3: Add fields to SecurityProperties.java**
+
+In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java`, add after the `jwtSecret` field (line 19):
+
+```java
+private String oidcIssuerUri;
+private String oidcAudience;
+
+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 4: Verify build compiles**
+
+Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw compile -pl cameleer3-server-app -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cameleer3-server-app/pom.xml cameleer3-server-app/src/main/resources/application.yml cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
+git commit -m "feat: add oauth2-resource-server dependency and OIDC config properties"
+```
+
+---
+
+### Task 2: Add conditional OIDC JwtDecoder bean
+
+**Files:**
+- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcJwtDecoderBeanTest.java`:
+
+```java
+package com.cameleer3.server.app.security;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OidcJwtDecoderBeanTest {
+
+ @Test
+ void shouldNotCreateDecoderWhenIssuerUriBlank() {
+ var properties = new SecurityProperties();
+ properties.setBootstrapToken("test-token");
+ properties.setOidcIssuerUri("");
+
+ var config = new SecurityBeanConfig();
+ JwtDecoder decoder = config.oidcJwtDecoder(properties);
+
+ assertThat(decoder).isNull();
+ }
+
+ @Test
+ void shouldNotCreateDecoderWhenIssuerUriNull() {
+ var properties = new SecurityProperties();
+ properties.setBootstrapToken("test-token");
+ properties.setOidcIssuerUri(null);
+
+ var config = new SecurityBeanConfig();
+ JwtDecoder decoder = config.oidcJwtDecoder(properties);
+
+ assertThat(decoder).isNull();
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=OidcJwtDecoderBeanTest -q`
+Expected: FAIL — method `oidcJwtDecoder` does not exist
+
+- [ ] **Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig**
+
+In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java`, add these imports at the top:
+
+```java
+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 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 java.net.URL;
+import java.util.List;
+```
+
+Add this method to the class:
+
+```java
+/**
+ * Creates an OIDC-aware JwtDecoder when {@code security.oidc-issuer-uri} is configured.
+ * Returns {@code null} when not configured, so the filter skips OIDC validation.
+ *
+ * Handles Logto's {@code typ: at+jwt} (RFC 9068) by disabling type verification.
+ * Discovers JWKS URI from the issuer's well-known endpoint.
+ */
+public JwtDecoder oidcJwtDecoder(SecurityProperties properties) {
+ String issuerUri = properties.getOidcIssuerUri();
+ if (issuerUri == null || issuerUri.isBlank()) {
+ return null;
+ }
+
+ try {
+ String jwksUri = issuerUri.replaceAll("/+$", "") + "/jwks";
+ var jwkSource = JWKSourceBuilder.create(new URL(jwksUri)).build();
+ var keySelector = new JWSVerificationKeySelector(
+ JWSAlgorithm.ES384, jwkSource);
+
+ var processor = new DefaultJWTProcessor();
+ processor.setJWSKeySelector(keySelector);
+ // Accept both "JWT" and "at+jwt" token types (Logto uses at+jwt per RFC 9068)
+ processor.setJWSTypeVerifier((type, context) -> { });
+
+ var decoder = new NimbusJwtDecoder(processor);
+
+ OAuth2TokenValidator validator;
+ String audience = properties.getOidcAudience();
+ if (audience != null && !audience.isBlank()) {
+ validator = new DelegatingOAuth2TokenValidator<>(
+ JwtValidators.createDefaultWithIssuer(issuerUri),
+ new JwtClaimValidator>("aud",
+ aud -> aud != null && aud.contains(audience)));
+ } else {
+ validator = JwtValidators.createDefaultWithIssuer(issuerUri);
+ }
+ decoder.setJwtValidator(validator);
+
+ return decoder;
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to create OIDC JwtDecoder for " + issuerUri, e);
+ }
+}
+```
+
+- [ ] **Step 4: Wire the bean with @Bean annotation**
+
+Now wrap the method call in a proper `@Bean` method. Add to `SecurityBeanConfig`:
+
+```java
+@Bean
+public JwtDecoder oidcJwtDecoder(SecurityProperties properties) {
+ // body is the method above
+}
+```
+
+Actually, rename the existing method to `createOidcJwtDecoder` (private) and add the `@Bean` method that calls it:
+
+Replace the method added in step 3 — make it a `@Bean` directly. The method signature stays the same, just add `@Bean` annotation. Spring will call it; if properties are blank, it returns `null`, and `@Autowired(required = false)` in SecurityConfig will receive `null`.
+
+Note: Spring won't register a bean that returns `null` from a `@Bean` method — it throws. So instead, we should NOT use `@Bean` for this. Keep it as a factory method called from `SecurityConfig`. Remove the `@Bean` annotation and keep the method public.
+
+Update the test to match: the test calls `config.oidcJwtDecoder(properties)` directly, which returns `null` when not configured. This is correct.
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=OidcJwtDecoderBeanTest -q`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcJwtDecoderBeanTest.java
+git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token validation"
+```
+
+---
+
+### Task 3: Update JwtAuthenticationFilter with OIDC fallback
+
+**Files:**
+- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtAuthenticationFilterOidcTest.java`:
+
+```java
+package com.cameleer3.server.app.security;
+
+import com.cameleer3.server.core.agent.AgentRegistryService;
+import com.cameleer3.server.core.security.InvalidTokenException;
+import com.cameleer3.server.core.security.JwtService;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class JwtAuthenticationFilterOidcTest {
+
+ private JwtService jwtService;
+ private AgentRegistryService registryService;
+ private JwtDecoder oidcDecoder;
+ private JwtAuthenticationFilter filter;
+ private FilterChain chain;
+
+ @BeforeEach
+ void setUp() {
+ SecurityContextHolder.clearContext();
+ jwtService = mock(JwtService.class);
+ registryService = mock(AgentRegistryService.class);
+ oidcDecoder = mock(JwtDecoder.class);
+ filter = new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder);
+ chain = mock(FilterChain.class);
+ }
+
+ @Test
+ void shouldFallBackToOidcWhenHmacFails() throws ServletException, IOException {
+ var request = new MockHttpServletRequest();
+ request.addHeader("Authorization", "Bearer oidc-token");
+ var response = new MockHttpServletResponse();
+
+ when(jwtService.validateAccessToken("oidc-token"))
+ .thenThrow(new InvalidTokenException("bad sig"));
+
+ Jwt jwt = Jwt.withTokenValue("oidc-token")
+ .header("alg", "ES384")
+ .claim("sub", "user-123")
+ .claim("client_id", "m2m-app-id")
+ .issuedAt(Instant.now())
+ .expiresAt(Instant.now().plusSeconds(3600))
+ .build();
+ when(oidcDecoder.decode("oidc-token")).thenReturn(jwt);
+
+ filter.doFilterInternal(request, response, chain);
+
+ var auth = SecurityContextHolder.getContext().getAuthentication();
+ assertThat(auth).isNotNull();
+ assertThat(auth.getName()).isEqualTo("oidc:user-123");
+ verify(chain).doFilter(request, response);
+ }
+
+ @Test
+ void shouldGrantAdminForM2mToken() throws ServletException, IOException {
+ var request = new MockHttpServletRequest();
+ request.addHeader("Authorization", "Bearer m2m-token");
+ var response = new MockHttpServletResponse();
+
+ when(jwtService.validateAccessToken("m2m-token"))
+ .thenThrow(new InvalidTokenException("bad sig"));
+
+ // M2M token: client_id == sub
+ Jwt jwt = Jwt.withTokenValue("m2m-token")
+ .header("alg", "ES384")
+ .claim("sub", "m2m-app-id")
+ .claim("client_id", "m2m-app-id")
+ .issuedAt(Instant.now())
+ .expiresAt(Instant.now().plusSeconds(3600))
+ .build();
+ when(oidcDecoder.decode("m2m-token")).thenReturn(jwt);
+
+ filter.doFilterInternal(request, response, chain);
+
+ var auth = SecurityContextHolder.getContext().getAuthentication();
+ assertThat(auth).isNotNull();
+ assertThat(auth.getAuthorities()).anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
+ }
+
+ @Test
+ void shouldSkipOidcWhenDecoderIsNull() throws ServletException, IOException {
+ filter = new JwtAuthenticationFilter(jwtService, registryService, null);
+ var request = new MockHttpServletRequest();
+ request.addHeader("Authorization", "Bearer bad-token");
+ var response = new MockHttpServletResponse();
+
+ when(jwtService.validateAccessToken("bad-token"))
+ .thenThrow(new InvalidTokenException("bad"));
+
+ filter.doFilterInternal(request, response, chain);
+
+ assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
+ verify(chain).doFilter(request, response);
+ }
+
+ @Test
+ void shouldPreferHmacOverOidc() throws ServletException, IOException {
+ var request = new MockHttpServletRequest();
+ request.addHeader("Authorization", "Bearer hmac-token");
+ var response = new MockHttpServletResponse();
+
+ when(jwtService.validateAccessToken("hmac-token"))
+ .thenReturn(new JwtService.JwtValidationResult(
+ "agent-1", "my-app", "prod", List.of("AGENT")));
+
+ filter.doFilterInternal(request, response, chain);
+
+ var auth = SecurityContextHolder.getContext().getAuthentication();
+ assertThat(auth.getName()).isEqualTo("agent-1");
+ // OIDC decoder should never be called
+ verifyNoInteractions(oidcDecoder);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
+Expected: FAIL — constructor doesn't accept 3 args
+
+- [ ] **Step 3: Update JwtAuthenticationFilter**
+
+Replace `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java` with:
+
+```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 supports two token types:
+ *
+ * - Internal HMAC-SHA256 tokens (agents, local users) — validated by {@link JwtService}
+ * - OIDC tokens from Logto (SaaS M2M, OIDC users) — validated by {@link JwtDecoder} via JWKS
+ *
+ * Internal tokens are tried first. OIDC is a fallback when configured.
+ *
+ * Not annotated {@code @Component} — constructed explicitly in {@link SecurityConfig}.
+ */
+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);
+ String subject = jwt.getSubject();
+ List roles = extractRolesFromOidcToken(jwt);
+ List authorities = toAuthorities(roles);
+ UsernamePasswordAuthenticationToken auth =
+ new UsernamePasswordAuthenticationToken("oidc:" + subject, null, authorities);
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ } catch (Exception e) {
+ log.debug("OIDC token validation failed: {}", e.getMessage());
+ }
+ }
+
+ private List extractRolesFromOidcToken(Jwt jwt) {
+ String sub = jwt.getSubject();
+ Object clientId = jwt.getClaim("client_id");
+ if (clientId != null && clientId.toString().equals(sub)) {
+ return List.of("ADMIN");
+ }
+ 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 4: Run tests**
+
+Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
+Expected: PASS (all 4 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtAuthenticationFilterOidcTest.java
+git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
+```
+
+---
+
+### Task 4: Wire OIDC decoder into SecurityConfig
+
+**Files:**
+- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java`
+- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java`
+
+- [ ] **Step 1: Add OIDC decoder bean creation to SecurityBeanConfig**
+
+In `SecurityBeanConfig.java`, add this bean method:
+
+```java
+@Bean
+public JwtDecoder oidcJwtDecoder(SecurityProperties properties) {
+ return createOidcJwtDecoder(properties);
+}
+```
+
+Wait — Spring does not allow `@Bean` methods to return `null`. Instead, make the decoder optional. Create a holder:
+
+Actually, the simplest approach: create the decoder in SecurityConfig directly, not as a bean. In `SecurityConfig.java`, inject `SecurityProperties` and call the factory method from `SecurityBeanConfig`.
+
+Better approach: keep the factory in `SecurityBeanConfig` as a plain method (not `@Bean`), and have `SecurityConfig` call it.
+
+In `SecurityBeanConfig.java`, make `oidcJwtDecoder` public but NOT a `@Bean` (keep it as written in Task 2 — no `@Bean` annotation).
+
+- [ ] **Step 2: Update SecurityConfig to accept and use optional decoder**
+
+In `SecurityConfig.java`, update the `filterChain` method signature to accept `SecurityBeanConfig`:
+
+Replace the `filterChain` method. Change the parameter list from:
+
+```java
+public SecurityFilterChain filterChain(HttpSecurity http,
+ JwtService jwtService,
+ AgentRegistryService registryService,
+ CorsConfigurationSource corsConfigurationSource)
+```
+
+to:
+
+```java
+public SecurityFilterChain filterChain(HttpSecurity http,
+ JwtService jwtService,
+ AgentRegistryService registryService,
+ CorsConfigurationSource corsConfigurationSource,
+ SecurityProperties securityProperties,
+ SecurityBeanConfig securityBeanConfig)
+```
+
+Then update the filter construction line from:
+
+```java
+.addFilterBefore(
+ new JwtAuthenticationFilter(jwtService, registryService),
+ UsernamePasswordAuthenticationFilter.class
+);
+```
+
+to:
+
+```java
+.addFilterBefore(
+ new JwtAuthenticationFilter(jwtService, registryService,
+ securityBeanConfig.oidcJwtDecoder(securityProperties)),
+ UsernamePasswordAuthenticationFilter.class
+);
+```
+
+Add import:
+```java
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+```
+
+- [ ] **Step 3: Run existing tests**
+
+Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -q`
+Expected: All existing tests PASS (no OIDC env vars set, decoder is null, filter behaves as before)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
+git commit -m "feat: wire optional OIDC JwtDecoder into security filter chain"
+```
+
+---
+
+## Phase 2: cameleer-saas — Backend + Frontend Rewrite
+
+All Phase 2 work is in `C:\Users\Hendrik\Documents\projects\cameleer-saas`.
+
+### Task 5: Delete dead auth files
+
+**Files:**
+- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java`
+- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java`
+- Delete: `src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java`
+- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java`
+- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java`
+- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java`
+- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java`
+- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java`
+- Delete: `src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java`
+- Delete: `src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java`
+- Delete: `src/main/resources/db/migration/V001__create_users_table.sql`
+- Delete: `src/main/resources/db/migration/V002__create_roles_and_permissions.sql`
+- Delete: `src/main/resources/db/migration/V003__seed_default_roles.sql`
+
+- [ ] **Step 1: Delete all dead files**
+
+```bash
+cd /c/Users/Hendrik/Documents/projects/cameleer-saas
+rm -f src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java
+rm -f src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java
+rm -f src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java
+rm -f src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java
+rm -f src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java
+rm -f src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java
+rm -f src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java
+rm -f src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java
+rm -f src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java
+rm -f src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java
+rm -f src/main/resources/db/migration/V001__create_users_table.sql
+rm -f src/main/resources/db/migration/V002__create_roles_and_permissions.sql
+rm -f src/main/resources/db/migration/V003__seed_default_roles.sql
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add -A
+git commit -m "chore: delete dead auth code — users/roles/JWTs/ForwardAuth live in Logto now"
+```
+
+---
+
+### Task 6: Clean database migrations (greenfield)
+
+**Files:**
+- Create: `src/main/resources/db/migration/V001__create_tenants.sql` (contents from old V005)
+- Create: `src/main/resources/db/migration/V002__create_licenses.sql` (contents from old V006)
+- Create: `src/main/resources/db/migration/V003__create_environments.sql` (modified — no bootstrap_token)
+- Create: `src/main/resources/db/migration/V004__create_api_keys.sql` (new)
+- Create: `src/main/resources/db/migration/V005__create_apps.sql` (contents from old V008+V010)
+- Create: `src/main/resources/db/migration/V006__create_deployments.sql` (contents from old V009)
+- Create: `src/main/resources/db/migration/V007__create_audit_log.sql` (contents from old V004)
+- Delete: old V004–V010 files
+
+- [ ] **Step 1: Remove old migrations**
+
+```bash
+cd /c/Users/Hendrik/Documents/projects/cameleer-saas
+rm -f src/main/resources/db/migration/V004__create_audit_log.sql
+rm -f src/main/resources/db/migration/V005__create_tenants.sql
+rm -f src/main/resources/db/migration/V006__create_licenses.sql
+rm -f src/main/resources/db/migration/V007__create_environments.sql
+rm -f src/main/resources/db/migration/V008__create_apps.sql
+rm -f src/main/resources/db/migration/V009__create_deployments.sql
+rm -f src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql
+```
+
+- [ ] **Step 2: Create V001__create_tenants.sql**
+
+Write `src/main/resources/db/migration/V001__create_tenants.sql`:
+
+```sql
+CREATE TABLE tenants (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL,
+ slug VARCHAR(100) NOT NULL UNIQUE,
+ tier VARCHAR(20) NOT NULL DEFAULT 'LOW',
+ status VARCHAR(20) NOT NULL DEFAULT 'PROVISIONING',
+ logto_org_id VARCHAR(255),
+ stripe_customer_id VARCHAR(255),
+ stripe_subscription_id VARCHAR(255),
+ settings JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_tenants_slug ON tenants (slug);
+CREATE INDEX idx_tenants_status ON tenants (status);
+CREATE INDEX idx_tenants_logto_org_id ON tenants (logto_org_id);
+```
+
+- [ ] **Step 3: Create V002__create_licenses.sql**
+
+Write `src/main/resources/db/migration/V002__create_licenses.sql`:
+
+```sql
+CREATE TABLE licenses (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ tier VARCHAR(20) NOT NULL,
+ features JSONB NOT NULL DEFAULT '{}',
+ limits JSONB NOT NULL DEFAULT '{}',
+ issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ revoked_at TIMESTAMPTZ,
+ token TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_licenses_tenant_id ON licenses (tenant_id);
+CREATE INDEX idx_licenses_expires_at ON licenses (expires_at);
+```
+
+- [ ] **Step 4: Create V003__create_environments.sql (no bootstrap_token)**
+
+Write `src/main/resources/db/migration/V003__create_environments.sql`:
+
+```sql
+CREATE TABLE environments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ slug VARCHAR(100) NOT NULL,
+ display_name VARCHAR(255) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(tenant_id, slug)
+);
+
+CREATE INDEX idx_environments_tenant_id ON environments(tenant_id);
+```
+
+- [ ] **Step 5: Create V004__create_api_keys.sql**
+
+Write `src/main/resources/db/migration/V004__create_api_keys.sql`:
+
+```sql
+CREATE TABLE api_keys (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
+ key_hash VARCHAR(64) NOT NULL,
+ key_prefix VARCHAR(12) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ revoked_at TIMESTAMPTZ
+);
+
+CREATE INDEX idx_api_keys_env ON api_keys(environment_id);
+CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
+```
+
+- [ ] **Step 6: Create V005__create_apps.sql (includes exposed_port)**
+
+Write `src/main/resources/db/migration/V005__create_apps.sql`:
+
+```sql
+CREATE TABLE apps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
+ slug VARCHAR(100) NOT NULL,
+ display_name VARCHAR(255) NOT NULL,
+ jar_storage_path VARCHAR(500),
+ jar_checksum VARCHAR(64),
+ jar_original_filename VARCHAR(255),
+ jar_size_bytes BIGINT,
+ exposed_port INTEGER,
+ current_deployment_id UUID,
+ previous_deployment_id UUID,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(environment_id, slug)
+);
+
+CREATE INDEX idx_apps_environment_id ON apps(environment_id);
+```
+
+- [ ] **Step 7: Create V006__create_deployments.sql**
+
+Write `src/main/resources/db/migration/V006__create_deployments.sql`:
+
+```sql
+CREATE TABLE deployments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
+ version INTEGER NOT NULL,
+ image_ref VARCHAR(500) NOT NULL,
+ desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
+ observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING',
+ orchestrator_metadata JSONB DEFAULT '{}',
+ error_message TEXT,
+ deployed_at TIMESTAMPTZ,
+ stopped_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(app_id, version)
+);
+
+CREATE INDEX idx_deployments_app_id ON deployments(app_id);
+```
+
+- [ ] **Step 8: Create V007__create_audit_log.sql**
+
+Write `src/main/resources/db/migration/V007__create_audit_log.sql`:
+
+```sql
+CREATE TABLE audit_log (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ actor_id UUID,
+ actor_email VARCHAR(255),
+ tenant_id UUID,
+ action VARCHAR(100) NOT NULL,
+ resource VARCHAR(500),
+ environment VARCHAR(50),
+ source_ip VARCHAR(45),
+ result VARCHAR(20) NOT NULL DEFAULT 'SUCCESS',
+ metadata JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_audit_log_tenant ON audit_log (tenant_id, created_at DESC);
+CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
+CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
+```
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A
+git commit -m "chore: greenfield migrations — remove user/role tables, add api_keys, drop bootstrap_token"
+```
+
+---
+
+### Task 7: Rewrite SecurityConfig + JwtAuthenticationConverter
+
+**Files:**
+- Rewrite: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java`
+- Modify: `src/main/resources/application.yml`
+
+- [ ] **Step 1: Rewrite SecurityConfig.java**
+
+Replace the entire file `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java` with:
+
+```java
+package net.siegeln.cameleer.saas.config;
+
+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 org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+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.http.SessionCreationPolicy;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+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.oauth2.server.resource.authentication.JwtAuthenticationConverter;
+import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
+import org.springframework.security.web.SecurityFilterChain;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+@Configuration
+@EnableWebSecurity
+@EnableMethodSecurity
+public class SecurityConfig {
+
+ private final TenantResolutionFilter tenantResolutionFilter;
+
+ public SecurityConfig(TenantResolutionFilter tenantResolutionFilter) {
+ this.tenantResolutionFilter = tenantResolutionFilter;
+ }
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(csrf -> csrf.disable())
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/actuator/health").permitAll()
+ .requestMatchers("/api/config").permitAll()
+ .requestMatchers("/", "/index.html", "/login", "/callback",
+ "/environments/**", "/license", "/admin/**").permitAll()
+ .requestMatchers("/assets/**", "/favicon.ico").permitAll()
+ .anyRequest().authenticated()
+ )
+ .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
+ jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
+ .addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class);
+
+ return http.build();
+ }
+
+ @Bean
+ public JwtAuthenticationConverter jwtAuthenticationConverter() {
+ var converter = new JwtAuthenticationConverter();
+ converter.setJwtGrantedAuthoritiesConverter(jwt -> {
+ List authorities = new ArrayList<>();
+
+ // Global roles (e.g., platform-admin)
+ var roles = jwt.getClaimAsStringList("roles");
+ if (roles != null) {
+ roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
+ }
+
+ // Org roles (e.g., admin, member)
+ var orgRoles = jwt.getClaimAsStringList("organization_roles");
+ if (orgRoles != null) {
+ orgRoles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_org_" + r)));
+ }
+
+ return authorities;
+ });
+ return converter;
+ }
+
+ @Bean
+ public JwtDecoder jwtDecoder(
+ @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
+ @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri) throws Exception {
+ var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build();
+ var keySelector = new JWSVerificationKeySelector(
+ JWSAlgorithm.ES384, jwkSource);
+
+ var processor = new DefaultJWTProcessor();
+ processor.setJWSKeySelector(keySelector);
+ processor.setJWSTypeVerifier((type, context) -> { /* accept JWT and at+jwt */ });
+
+ var decoder = new NimbusJwtDecoder(processor);
+ if (issuerUri != null && !issuerUri.isEmpty()) {
+ decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri));
+ }
+ return decoder;
+ }
+}
+```
+
+- [ ] **Step 2: Clean application.yml — remove dead JWT config**
+
+In `src/main/resources/application.yml`, remove the entire `cameleer.jwt` block (lines 32-35):
+
+```yaml
+ jwt:
+ expiration: 86400 # 24 hours in seconds
+ private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
+ public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}
+```
+
+Also remove `bootstrap-token` from the `runtime` block (line 52):
+
+```yaml
+ bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java src/main/resources/application.yml
+git commit -m "feat: rewrite SecurityConfig — single filter chain, Logto OAuth2 Resource Server"
+```
+
+---
+
+### Task 8: Rewrite MeController (JWT claims only)
+
+**Files:**
+- Rewrite: `src/main/java/net/siegeln/cameleer/saas/config/MeController.java`
+
+- [ ] **Step 1: Rewrite MeController**
+
+Replace `src/main/java/net/siegeln/cameleer/saas/config/MeController.java` with:
+
+```java
+package net.siegeln.cameleer.saas.config;
+
+import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
+import net.siegeln.cameleer.saas.tenant.TenantService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+public class MeController {
+
+ private final TenantService tenantService;
+ private final LogtoManagementClient logtoClient;
+
+ public MeController(TenantService tenantService, LogtoManagementClient logtoClient) {
+ this.tenantService = tenantService;
+ this.logtoClient = logtoClient;
+ }
+
+ @GetMapping("/api/me")
+ public ResponseEntity