2026-04-05 12:26:47 +02:00
# 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.
2026-04-15 15:28:44 +02:00
**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.
2026-04-05 12:26:47 +02:00
2026-04-15 15:28:44 +02:00
**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.
2026-04-05 12:26:47 +02:00
**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:**
2026-04-15 15:28:44 +02:00
- cameleer-server: `C:\Users\Hendrik\Documents\projects\cameleer-server` (Phase 1)
2026-04-05 12:26:47 +02:00
- cameleer-saas: `C:\Users\Hendrik\Documents\projects\cameleer-saas` (Phases 2-3)
2026-04-15 15:28:44 +02:00
- cameleer (agent): NO CHANGES
2026-04-05 12:26:47 +02:00
---
2026-04-15 15:28:44 +02:00
## Phase 1: cameleer-server — OIDC Resource Server Support
2026-04-05 12:26:47 +02:00
2026-04-15 15:28:44 +02:00
All Phase 1 work is in `C:\Users\Hendrik\Documents\projects\cameleer-server` .
2026-04-05 12:26:47 +02:00
### Task 1: Add OAuth2 Resource Server dependency and config properties
**Files:**
2026-04-15 15:28:44 +02:00
- 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`
2026-04-05 12:26:47 +02:00
- [ ] **Step 1: Add dependency to pom.xml **
2026-04-15 15:28:44 +02:00
In `cameleer-server-app/pom.xml` , add after the `spring-boot-starter-security` dependency (around line 88):
2026-04-05 12:26:47 +02:00
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
```
- [ ] **Step 2: Add OIDC properties to application.yml **
2026-04-15 15:28:44 +02:00
In `cameleer-server-app/src/main/resources/application.yml` , add two new properties under the `security:` block (after line 52):
2026-04-05 12:26:47 +02:00
```yaml
oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
```
- [ ] **Step 3: Add fields to SecurityProperties.java **
2026-04-15 15:28:44 +02:00
In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java` , add after the `jwtSecret` field (line 19):
2026-04-05 12:26:47 +02:00
```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 **
2026-04-15 15:28:44 +02:00
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw compile -pl cameleer-server-app -q`
2026-04-05 12:26:47 +02:00
Expected: BUILD SUCCESS
- [ ] **Step 5: Commit **
```bash
2026-04-15 15:28:44 +02:00
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
2026-04-05 12:26:47 +02:00
git commit -m "feat: add oauth2-resource-server dependency and OIDC config properties"
```
---
### Task 2: Add conditional OIDC JwtDecoder bean
**Files:**
2026-04-15 15:28:44 +02:00
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`
2026-04-05 12:26:47 +02:00
- [ ] **Step 1: Write the failing test **
2026-04-15 15:28:44 +02:00
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java` :
2026-04-05 12:26:47 +02:00
```java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.app.security;
2026-04-05 12:26:47 +02:00
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 **
2026-04-15 15:28:44 +02:00
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
2026-04-05 12:26:47 +02:00
Expected: FAIL — method `oidcJwtDecoder` does not exist
- [ ] **Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig **
2026-04-15 15:28:44 +02:00
In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java` , add these imports at the top:
2026-04-05 12:26:47 +02:00
```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 **
2026-04-15 15:28:44 +02:00
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
2026-04-05 12:26:47 +02:00
Expected: PASS
- [ ] **Step 6: Commit **
```bash
2026-04-15 15:28:44 +02:00
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
2026-04-05 12:26:47 +02:00
git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token validation"
```
---
### Task 3: Update JwtAuthenticationFilter with OIDC fallback
**Files:**
2026-04-15 15:28:44 +02:00
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java`
2026-04-05 12:26:47 +02:00
- [ ] **Step 1: Write the failing test **
2026-04-15 15:28:44 +02:00
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java` :
2026-04-05 12:26:47 +02:00
```java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.app.security;
2026-04-05 12:26:47 +02:00
2026-04-15 15:28:44 +02:00
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.InvalidTokenException;
import com.cameleer.server.core.security.JwtService;
2026-04-05 12:26:47 +02:00
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 **
2026-04-15 15:28:44 +02:00
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
2026-04-05 12:26:47 +02:00
Expected: FAIL — constructor doesn't accept 3 args
- [ ] **Step 3: Update JwtAuthenticationFilter **
2026-04-15 15:28:44 +02:00
Replace `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` with:
2026-04-05 12:26:47 +02:00
```java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.app.security;
2026-04-05 12:26:47 +02:00
2026-04-15 15:28:44 +02:00
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.JwtService;
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
2026-04-05 12:26:47 +02:00
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 **
2026-04-15 15:28:44 +02:00
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
2026-04-05 12:26:47 +02:00
Expected: PASS (all 4 tests)
- [ ] **Step 5: Commit **
```bash
2026-04-15 15:28:44 +02:00
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
2026-04-05 12:26:47 +02:00
git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
```
---
### Task 4: Wire OIDC decoder into SecurityConfig
**Files:**
2026-04-15 15:28:44 +02:00
- 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`
2026-04-05 12:26:47 +02:00
- [ ] **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 **
2026-04-15 15:28:44 +02:00
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -q`
2026-04-05 12:26:47 +02:00
Expected: All existing tests PASS (no OIDC env vars set, decoder is null, filter behaves as before)
- [ ] **Step 4: Commit **
```bash
2026-04-15 15:28:44 +02:00
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
2026-04-05 12:26:47 +02:00
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
```
2026-04-15 15:28:44 +02:00
- [ ] **Step 2: Remove ForwardAuth middleware from cameleer-server **
2026-04-05 12:26:47 +02:00
2026-04-15 15:28:44 +02:00
In `docker-compose.yml` , remove the forward-auth middleware labels from `cameleer-server` (lines 158-159):
2026-04-05 12:26:47 +02:00
```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}
```
2026-04-15 15:28:44 +02:00
In `cameleer-server` environment, add:
2026-04-05 12:26:47 +02:00
```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) |