# 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
org.springframework.boot
spring-boot-starter-oauth2-resource-server
```
- [ ] **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.
*
* 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/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:
*
* - 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/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 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