# 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 cameleer-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 cameleer-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:**
- cameleer-server: `C:\Users\Hendrik\Documents\projects\cameleer-server` (Phase 1)
- cameleer-saas: `C:\Users\Hendrik\Documents\projects\cameleer-saas` (Phases 2-3)
- cameleer (agent): NO CHANGES

---

## Phase 1: cameleer-server — OIDC Resource Server Support

All Phase 1 work is in `C:\Users\Hendrik\Documents\projects\cameleer-server`.

### Task 1: Add OAuth2 Resource Server dependency and config properties

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

- [ ] **Step 1: Add dependency to pom.xml**

In `cameleer-server-app/pom.xml`, add after the `spring-boot-starter-security` dependency (around line 88):

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

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

In `cameleer-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 `cameleer-server-app/src/main/java/com/cameleer/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/cameleer-server && ./mvnw compile -pl cameleer-server-app -q`
Expected: BUILD SUCCESS

- [ ] **Step 5: Commit**

```bash
git add cameleer-server-app/pom.xml cameleer-server-app/src/main/resources/application.yml cameleer-server-app/src/main/java/com/cameleer/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: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`

- [ ] **Step 1: Write the failing test**

Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java`:

```java
package com.cameleer.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/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
Expected: FAIL — method `oidcJwtDecoder` does not exist

- [ ] **Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig**

In `cameleer-server-app/src/main/java/com/cameleer/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.
 * <p>
 * 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<SecurityContext>(
                JWSAlgorithm.ES384, jwkSource);

        var processor = new DefaultJWTProcessor<SecurityContext>();
        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<Jwt> validator;
        String audience = properties.getOidcAudience();
        if (audience != null && !audience.isBlank()) {
            validator = new DelegatingOAuth2TokenValidator<>(
                    JwtValidators.createDefaultWithIssuer(issuerUri),
                    new JwtClaimValidator<List<String>>("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/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java cameleer-server-app/src/test/java/com/cameleer/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: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java`

- [ ] **Step 1: Write the failing test**

Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java`:

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

import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.InvalidTokenException;
import com.cameleer.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/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
Expected: FAIL — constructor doesn't accept 3 args

- [ ] **Step 3: Update JwtAuthenticationFilter**

Replace `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` with:

```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 supports two token types:
 * <ol>
 *   <li>Internal HMAC-SHA256 tokens (agents, local users) — validated by {@link JwtService}</li>
 *   <li>OIDC tokens from Logto (SaaS M2M, OIDC users) — validated by {@link JwtDecoder} via JWKS</li>
 * </ol>
 * Internal tokens are tried first. OIDC is a fallback when configured.
 * <p>
 * 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<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);
            String subject = jwt.getSubject();
            List<String> roles = extractRolesFromOidcToken(jwt);
            List<GrantedAuthority> 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<String> 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<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 4: Run tests**

Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
Expected: PASS (all 4 tests)

- [ ] **Step 5: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java
git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
```

---

### Task 4: Wire OIDC decoder into SecurityConfig

**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/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/cameleer-server && ./mvnw test -pl cameleer-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 cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java cameleer-server-app/src/main/java/com/cameleer/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<GrantedAuthority> 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<SecurityContext>(
                JWSAlgorithm.ES384, jwkSource);

        var processor = new DefaultJWTProcessor<SecurityContext>();
        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<Map<String, Object>> me(Authentication authentication) {
        if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
            return ResponseEntity.status(401).build();
        }

        Jwt jwt = jwtAuth.getToken();
        String userId = jwt.getSubject();

        // Read org from JWT claims (Logto includes organization_id in org-scoped tokens)
        String orgId = jwt.getClaimAsString("organization_id");

        // Check platform admin via global roles in token
        List<String> globalRoles = jwt.getClaimAsStringList("roles");
        boolean isPlatformAdmin = globalRoles != null && globalRoles.contains("platform-admin");

        // If org-scoped token, resolve single tenant
        if (orgId != null) {
            var tenant = tenantService.getByLogtoOrgId(orgId).orElse(null);
            List<Map<String, Object>> tenants = tenant != null
                    ? List.of(Map.<String, Object>of(
                            "id", tenant.getId().toString(),
                            "name", tenant.getName(),
                            "slug", tenant.getSlug(),
                            "logtoOrgId", tenant.getLogtoOrgId()))
                    : List.of();

            return ResponseEntity.ok(Map.of(
                    "userId", userId,
                    "isPlatformAdmin", isPlatformAdmin,
                    "tenants", tenants));
        }

        // Non-org-scoped token: enumerate orgs via Management API (cold-start only)
        List<Map<String, String>> logtoOrgs = logtoClient.getUserOrganizations(userId);
        List<Map<String, Object>> tenants = logtoOrgs.stream()
                .map(org -> tenantService.getByLogtoOrgId(org.get("id"))
                        .map(t -> Map.<String, Object>of(
                                "id", t.getId().toString(),
                                "name", t.getName(),
                                "slug", t.getSlug(),
                                "logtoOrgId", t.getLogtoOrgId()))
                        .orElse(null))
                .filter(t -> t != null)
                .toList();

        return ResponseEntity.ok(Map.of(
                "userId", userId,
                "isPlatformAdmin", isPlatformAdmin,
                "tenants", tenants));
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add src/main/java/net/siegeln/cameleer/saas/config/MeController.java
git commit -m "feat: rewrite MeController — read from JWT claims, Management API only for cold start"
```

---

### Task 9: Rewrite TenantController authorization

**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java`

- [ ] **Step 1: Rewrite TenantController**

Replace `src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java` with:

```java
package net.siegeln.cameleer.saas.tenant;

import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/api/tenants")
public class TenantController {

    private final TenantService tenantService;

    public TenantController(TenantService tenantService) {
        this.tenantService = tenantService;
    }

    @GetMapping
    @PreAuthorize("hasRole('platform-admin')")
    public ResponseEntity<List<TenantResponse>> listAll() {
        List<TenantResponse> tenants = tenantService.findAll().stream()
                .map(this::toResponse).toList();
        return ResponseEntity.ok(tenants);
    }

    @PostMapping
    @PreAuthorize("hasRole('platform-admin')")
    public ResponseEntity<TenantResponse> create(@Valid @RequestBody CreateTenantRequest request,
                                                  Authentication authentication) {
        try {
            String sub = authentication.getName();
            UUID actorId;
            try {
                actorId = UUID.fromString(sub);
            } catch (IllegalArgumentException e) {
                actorId = UUID.nameUUIDFromBytes(sub.getBytes());
            }

            var entity = tenantService.create(request, actorId);
            return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
        } catch (IllegalArgumentException e) {
            return ResponseEntity.status(HttpStatus.CONFLICT).build();
        }
    }

    @GetMapping("/{id}")
    public ResponseEntity<TenantResponse> getById(@PathVariable UUID id) {
        return tenantService.getById(id)
                .map(entity -> ResponseEntity.ok(toResponse(entity)))
                .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping("/by-slug/{slug}")
    public ResponseEntity<TenantResponse> getBySlug(@PathVariable String slug) {
        return tenantService.getBySlug(slug)
                .map(entity -> ResponseEntity.ok(toResponse(entity)))
                .orElse(ResponseEntity.notFound().build());
    }

    private TenantResponse toResponse(TenantEntity entity) {
        return new TenantResponse(
                entity.getId(),
                entity.getName(),
                entity.getSlug(),
                entity.getTier().name(),
                entity.getStatus().name(),
                entity.getCreatedAt(),
                entity.getUpdatedAt()
        );
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java
git commit -m "feat: replace manual Logto role check with @PreAuthorize in TenantController"
```

---

### Task 10: Add ApiKeyEntity + repository + service

**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java`
- Create: `src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java`

- [ ] **Step 1: Write the failing test**

Create `src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java`:

```java
package net.siegeln.cameleer.saas.apikey;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ApiKeyServiceTest {

    @Test
    void generatedKeyShouldHaveCmkPrefix() {
        var service = new ApiKeyService(null);
        var key = service.generate();

        assertThat(key.plaintext()).startsWith("cmk_");
        assertThat(key.prefix()).hasSize(12);
        assertThat(key.keyHash()).hasSize(64); // SHA-256 hex
    }

    @Test
    void generatedKeyHashShouldBeConsistent() {
        var service = new ApiKeyService(null);
        var key = service.generate();

        String rehash = ApiKeyService.sha256Hex(key.plaintext());
        assertThat(rehash).isEqualTo(key.keyHash());
    }

    @Test
    void twoGeneratedKeysShouldDiffer() {
        var service = new ApiKeyService(null);
        var key1 = service.generate();
        var key2 = service.generate();

        assertThat(key1.plaintext()).isNotEqualTo(key2.plaintext());
        assertThat(key1.keyHash()).isNotEqualTo(key2.keyHash());
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw test -Dtest=ApiKeyServiceTest -q`
Expected: FAIL — class not found

- [ ] **Step 3: Create ApiKeyEntity**

Create `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java`:

```java
package net.siegeln.cameleer.saas.apikey;

import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "api_keys")
public class ApiKeyEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "environment_id", nullable = false)
    private UUID environmentId;

    @Column(name = "key_hash", nullable = false, length = 64)
    private String keyHash;

    @Column(name = "key_prefix", nullable = false, length = 12)
    private String keyPrefix;

    @Column(name = "status", nullable = false, length = 20)
    private String status = "ACTIVE";

    @Column(name = "created_at", nullable = false)
    private Instant createdAt;

    @Column(name = "revoked_at")
    private Instant revokedAt;

    @PrePersist
    protected void onCreate() {
        if (createdAt == null) createdAt = Instant.now();
    }

    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }
    public UUID getEnvironmentId() { return environmentId; }
    public void setEnvironmentId(UUID environmentId) { this.environmentId = environmentId; }
    public String getKeyHash() { return keyHash; }
    public void setKeyHash(String keyHash) { this.keyHash = keyHash; }
    public String getKeyPrefix() { return keyPrefix; }
    public void setKeyPrefix(String keyPrefix) { this.keyPrefix = keyPrefix; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getRevokedAt() { return revokedAt; }
    public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
}
```

- [ ] **Step 4: Create ApiKeyRepository**

Create `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java`:

```java
package net.siegeln.cameleer.saas.apikey;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface ApiKeyRepository extends JpaRepository<ApiKeyEntity, UUID> {
    Optional<ApiKeyEntity> findByKeyHashAndStatus(String keyHash, String status);
    List<ApiKeyEntity> findByEnvironmentId(UUID environmentId);
    List<ApiKeyEntity> findByEnvironmentIdAndStatus(UUID environmentId, String status);
}
```

- [ ] **Step 5: Create ApiKeyService**

Create `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java`:

```java
package net.siegeln.cameleer.saas.apikey;

import org.springframework.stereotype.Service;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Service
public class ApiKeyService {

    public record GeneratedKey(String plaintext, String keyHash, String prefix) {}

    private final ApiKeyRepository repository;

    public ApiKeyService(ApiKeyRepository repository) {
        this.repository = repository;
    }

    public GeneratedKey generate() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        String plaintext = "cmk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
        String hash = sha256Hex(plaintext);
        String prefix = plaintext.substring(0, 12);
        return new GeneratedKey(plaintext, hash, prefix);
    }

    public ApiKeyEntity createForEnvironment(UUID environmentId) {
        var key = generate();
        var entity = new ApiKeyEntity();
        entity.setEnvironmentId(environmentId);
        entity.setKeyHash(key.keyHash());
        entity.setKeyPrefix(key.prefix());
        return repository.save(entity);
    }

    public GeneratedKey createForEnvironmentReturningPlaintext(UUID environmentId) {
        var key = generate();
        var entity = new ApiKeyEntity();
        entity.setEnvironmentId(environmentId);
        entity.setKeyHash(key.keyHash());
        entity.setKeyPrefix(key.prefix());
        repository.save(entity);
        return key;
    }

    public Optional<ApiKeyEntity> validate(String plaintext) {
        String hash = sha256Hex(plaintext);
        return repository.findByKeyHashAndStatus(hash, "ACTIVE");
    }

    public GeneratedKey rotate(UUID environmentId) {
        // Mark existing active keys as ROTATED
        List<ApiKeyEntity> active = repository.findByEnvironmentIdAndStatus(environmentId, "ACTIVE");
        for (var k : active) {
            k.setStatus("ROTATED");
        }
        repository.saveAll(active);

        return createForEnvironmentReturningPlaintext(environmentId);
    }

    public void revoke(UUID keyId) {
        repository.findById(keyId).ifPresent(k -> {
            k.setStatus("REVOKED");
            k.setRevokedAt(Instant.now());
            repository.save(k);
        });
    }

    public List<ApiKeyEntity> listByEnvironment(UUID environmentId) {
        return repository.findByEnvironmentId(environmentId);
    }

    public static String sha256Hex(String input) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
            StringBuilder hex = new StringBuilder(64);
            for (byte b : hash) {
                hex.append(String.format("%02x", b));
            }
            return hex.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("SHA-256 not available", e);
        }
    }
}
```

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

Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw test -Dtest=ApiKeyServiceTest -q`
Expected: PASS (all 3 tests)

- [ ] **Step 7: Commit**

```bash
git add src/main/java/net/siegeln/cameleer/saas/apikey/ src/test/java/net/siegeln/cameleer/saas/apikey/
git commit -m "feat: add API key entity, repository, and service with SHA-256 hashing"
```

---

### Task 11: Update EnvironmentEntity and EnvironmentService

**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentEntity.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentService.java`

- [ ] **Step 1: Remove bootstrap_token from EnvironmentEntity**

In `EnvironmentEntity.java`, remove the `bootstrapToken` field (lines 24-25) and its getter/setter (lines 56-57):

Remove:
```java
    @Column(name = "bootstrap_token", nullable = false, columnDefinition = "TEXT")
    private String bootstrapToken;
```

And remove:
```java
    public String getBootstrapToken() { return bootstrapToken; }
    public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; }
```

- [ ] **Step 2: Update EnvironmentService.create()**

In `EnvironmentService.java`, remove the bootstrap token line from `create()` method. Remove line 44:

```java
        entity.setBootstrapToken(runtimeConfig.getBootstrapToken());
```

Also remove `RuntimeConfig` from the constructor and field if it's only used for bootstrap token. Check: `runtimeConfig` is also used nowhere else in this service — but actually it might be injected for tier limits. Check the imports — no, it's only used for `getBootstrapToken()`. However, keep the field for now if other code references it; just remove the `setBootstrapToken` call.

Actually, looking at the code, `runtimeConfig` is only used on line 44 for `getBootstrapToken()`. Remove it from constructor and field. Update the constructor:

Replace constructor (lines 23-30):
```java
    public EnvironmentService(EnvironmentRepository environmentRepository,
                              LicenseRepository licenseRepository,
                              AuditService auditService) {
        this.environmentRepository = environmentRepository;
        this.licenseRepository = licenseRepository;
        this.auditService = auditService;
    }
```

Remove the `runtimeConfig` field and import.

- [ ] **Step 3: Fix compilation — update tests and other references**

Search for `getBootstrapToken()` and `setBootstrapToken()` in the SaaS codebase. Update:
- `DeploymentService.java` line 145: `env.getBootstrapToken()` — this needs the API key now. For now, this will be addressed in a follow-up task. Comment out or use a placeholder.
- `BootstrapDataSeeder.java`: references bootstrap token — will be rewritten in Phase 3.
- Test files: update to remove `setBootstrapToken()` calls.

For each test that calls `env.setBootstrapToken("...")`, simply remove that line.

- [ ] **Step 4: Commit**

```bash
git add src/main/java/net/siegeln/cameleer/saas/environment/
git commit -m "feat: remove bootstrap_token from EnvironmentEntity — API keys managed separately"
```

---

### Task 12: Simplify LogtoManagementClient

**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java`

- [ ] **Step 1: Remove getUserRoles method**

In `LogtoManagementClient.java`, delete the `getUserRoles()` method (lines 78-99). Roles now come from JWT claims.

- [ ] **Step 2: Fix compilation — remove getUserRoles callers**

Search for `getUserRoles` in the codebase. The only caller was `TenantController` (already rewritten in Task 9) and `MeController` (already rewritten in Task 8). Verify no other callers exist.

- [ ] **Step 3: Commit**

```bash
git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
git commit -m "refactor: remove getUserRoles from LogtoManagementClient — roles come from JWT"
```

---

### Task 13: Update TestSecurityConfig

**Files:**
- Modify: `src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java`

- [ ] **Step 1: Update mock JwtDecoder to include org and role claims**

Replace `src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java` with:

```java
package net.siegeln.cameleer.saas;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;

import java.time.Instant;
import java.util.List;

@TestConfiguration
public class TestSecurityConfig {

    @Bean
    public JwtDecoder jwtDecoder() {
        return token -> Jwt.withTokenValue(token)
                .header("alg", "ES384")
                .claim("sub", "test-user")
                .claim("iss", "https://test-issuer.example.com/oidc")
                .claim("organization_id", "test-org-id")
                .claim("roles", List.of("platform-admin"))
                .claim("organization_roles", List.of("admin"))
                .issuedAt(Instant.now())
                .expiresAt(Instant.now().plusSeconds(3600))
                .build();
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java
git commit -m "test: update TestSecurityConfig with org and role claims for Logto tokens"
```

---

### Task 14: Rewrite frontend auth

**Files:**
- Modify: `ui/src/auth/useAuth.ts`
- Modify: `ui/src/hooks/usePermissions.ts`

- [ ] **Step 1: Rewrite useAuth.ts**

Replace `ui/src/auth/useAuth.ts` with:

```typescript
import { useLogto } from '@logto/react';
import { useCallback } from 'react';
import { useOrgStore } from './useOrganization';

export function useAuth() {
  const { isAuthenticated, isLoading, signOut, signIn } = useLogto();
  const { currentTenantId, isPlatformAdmin } = useOrgStore();

  const logout = useCallback(() => {
    signOut(window.location.origin + '/login');
  }, [signOut]);

  return {
    isAuthenticated,
    isLoading,
    tenantId: currentTenantId,
    isPlatformAdmin,
    logout,
    signIn,
  };
}
```

- [ ] **Step 2: Rewrite usePermissions.ts**

Replace `ui/src/hooks/usePermissions.ts` with:

```typescript
import { useOrgStore } from '../auth/useOrganization';

const ROLE_PERMISSIONS: Record<string, string[]> = {
  'admin': [
    'tenant:manage', 'billing:manage', 'team:manage', 'apps:manage',
    'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug',
    'settings:manage',
  ],
  'member': ['apps:deploy', 'observe:read', 'observe:debug'],
};

export function usePermissions() {
  const { currentOrgRoles } = useOrgStore();
  const roles = currentOrgRoles ?? [];

  const permissions = new Set<string>();
  for (const role of roles) {
    const perms = ROLE_PERMISSIONS[role];
    if (perms) perms.forEach((p) => permissions.add(p));
  }

  return {
    has: (permission: string) => permissions.has(permission),
    canManageApps: permissions.has('apps:manage'),
    canDeploy: permissions.has('apps:deploy'),
    canManageTenant: permissions.has('tenant:manage'),
    canViewObservability: permissions.has('observe:read'),
    roles,
  };
}
```

Note: This requires adding `currentOrgRoles` to the org store. Update `ui/src/auth/useOrganization.ts` to include it:

Add to the `OrgState` interface:
```typescript
currentOrgRoles: string[] | null;
setCurrentOrgRoles: (roles: string[] | null) => void;
```

Add to the store create:
```typescript
currentOrgRoles: null,
setCurrentOrgRoles: (roles) => set({ currentOrgRoles: roles }),
```

Then update `OrgResolver.tsx` to set org roles from the `/api/me` response (the backend would need to return `orgRoles` — or extract from the token claims on the frontend side). For now, the org roles can be hardcoded from the OrgResolver after calling `/api/me`.

- [ ] **Step 3: Commit**

```bash
git add ui/src/auth/useAuth.ts ui/src/hooks/usePermissions.ts ui/src/auth/useOrganization.ts
git commit -m "feat: rewrite frontend auth — roles from org store, Logto org role names"
```

---

## Phase 3: Infrastructure Updates

### Task 15: Update docker-compose.yml

**Files:**
- Modify: `docker-compose.yml`

- [ ] **Step 1: Remove ForwardAuth labels from cameleer-saas service**

In `docker-compose.yml`, remove these two labels from `cameleer-saas` (lines 122-124):

```yaml
      - traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
      - traefik.http.routers.forwardauth.service=forwardauth
      - traefik.http.services.forwardauth.loadbalancer.server.port=8080
```

- [ ] **Step 2: Remove ForwardAuth middleware from cameleer-server**

In `docker-compose.yml`, remove the forward-auth middleware labels from `cameleer-server` (lines 158-159):

```yaml
      - traefik.http.routers.observe.middlewares=forward-auth
      - traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
```

And change line 163 from:
```yaml
      - traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
```
to:
```yaml
      - traefik.http.routers.dashboard.middlewares=dashboard-strip
```

- [ ] **Step 3: Remove keys volume mount from cameleer-saas**

Remove line 99:
```yaml
      - ./keys:/etc/cameleer/keys:ro
```

- [ ] **Step 4: Remove dead env vars, add OIDC env vars**

In `cameleer-saas` environment, remove:
```yaml
      CAMELEER_JWT_PRIVATE_KEY_PATH: ${CAMELEER_JWT_PRIVATE_KEY_PATH:-}
      CAMELEER_JWT_PUBLIC_KEY_PATH: ${CAMELEER_JWT_PUBLIC_KEY_PATH:-}
      CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
```

In `cameleer-server` environment, add:
```yaml
      CAMELEER_OIDC_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
      CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
```

- [ ] **Step 5: Commit**

```bash
git add docker-compose.yml
git commit -m "infra: remove ForwardAuth, keys mount, add OIDC env vars for server"
```

---

### Task 16: Update bootstrap script

**Files:**
- Modify: `docker/logto-bootstrap.sh`

- [ ] **Step 1: Add OIDC env vars to bootstrap output**

In `docker/logto-bootstrap.sh`, add to the bootstrap JSON output (around line 431):

After `"tenantAdminUser"`, add:
```json
  "oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
  "oidcAudience": "$API_RESOURCE_INDICATOR"
```

- [ ] **Step 2: Remove direct psql reads for existing app secrets**

The script reads Logto's `applications` table directly via `psql` for M2M and Traditional app secrets when apps already exist (lines 155-156, 193-194). Replace with reading from the bootstrap JSON file if it exists:

At the top of the script (after variable declarations), add:
```bash
# Read cached secrets from previous run
if [ -f "$BOOTSTRAP_FILE" ]; then
  CACHED_M2M_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
  CACHED_TRAD_SECRET=$(jq -r '.tradAppSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
fi
```

Then replace the `psql` fallbacks with:
```bash
M2M_SECRET="${CACHED_M2M_SECRET:-}"
TRAD_SECRET="${CACHED_TRAD_SECRET:-}"
```

- [ ] **Step 3: Commit**

```bash
git add docker/logto-bootstrap.sh
git commit -m "infra: add OIDC config to bootstrap output, stop reading Logto DB for secrets"
```

---

## Self-Review Checklist

| Spec Requirement | Task |
|-----------------|------|
| Delete custom JWT stack (JwtService, filter, config, entities) | Task 5 |
| Delete ForwardAuthController | Task 5 |
| Delete PasswordEncoder bean | Task 7 (SecurityConfig rewrite) |
| Delete old migrations V001-V003 | Task 5 + Task 6 |
| Delete Ed25519 key config from application.yml | Task 7 |
| Rewrite SecurityConfig (single chain, OAuth2 RS) | Task 7 |
| Add JwtAuthenticationConverter for Logto roles | Task 7 |
| Rewrite MeController (JWT claims) | Task 8 |
| Rewrite TenantController (@PreAuthorize) | Task 9 |
| Add ApiKeyEntity + migration | Task 6 + Task 10 |
| Add ApiKeyService | Task 10 |
| Update EnvironmentEntity (remove bootstrap_token) | Task 11 |
| Simplify LogtoManagementClient | Task 12 |
| Update TestSecurityConfig | Task 13 |
| Rewrite frontend useAuth.ts | Task 14 |
| Rewrite frontend usePermissions.ts | Task 14 |
| Remove Traefik ForwardAuth | Task 15 |
| Remove keys mount from docker-compose | Task 15 |
| Add OIDC env vars to server | Task 15 |
| Update bootstrap script | Task 16 |
| Server: add oauth2-resource-server dep | Task 1 |
| Server: add SecurityProperties fields | Task 1 |
| Server: conditional OIDC JwtDecoder | Task 2 |
| Server: JwtAuthenticationFilter OIDC fallback | Task 3 |
| Server: wire decoder into SecurityConfig | Task 4 |
| Agent: no changes | N/A (verified) |
