16 tasks across 3 phases: server OIDC support, SaaS auth rewrite, infrastructure updates. TDD, complete code, greenfield migrations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
65 KiB
Auth Overhaul Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the incoherent three-system auth in cameleer-saas with Logto-centric architecture, and add OIDC resource server support to cameleer3-server for M2M.
Architecture: Logto is the single identity provider for all humans. Spring OAuth2 Resource Server validates Logto JWTs in both the SaaS platform and cameleer3-server. Agents authenticate with per-environment API keys exchanged for server-issued JWTs. Ed25519 command signing is unchanged. Zero trust: every service validates tokens independently via JWKS.
Tech Stack: Spring Boot 3.4, Spring Security OAuth2 Resource Server, Nimbus JOSE+JWT, Logto, React + @logto/react, Zustand, PostgreSQL, Flyway
Spec: docs/superpowers/specs/2026-04-05-auth-overhaul-design.md
Repos:
- cameleer3-server:
C:\Users\Hendrik\Documents\projects\cameleer3-server(Phase 1) - cameleer-saas:
C:\Users\Hendrik\Documents\projects\cameleer-saas(Phases 2-3) - cameleer3 (agent): NO CHANGES
Phase 1: cameleer3-server — OIDC Resource Server Support
All Phase 1 work is in C:\Users\Hendrik\Documents\projects\cameleer3-server.
Task 1: Add OAuth2 Resource Server dependency and config properties
Files:
-
Modify:
cameleer3-server-app/pom.xml -
Modify:
cameleer3-server-app/src/main/resources/application.yml -
Modify:
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java -
Step 1: Add dependency to pom.xml
In cameleer3-server-app/pom.xml, add after the spring-boot-starter-security dependency (around line 88):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
- Step 2: Add OIDC properties to application.yml
In cameleer3-server-app/src/main/resources/application.yml, add two new properties under the security: block (after line 52):
oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
- Step 3: Add fields to SecurityProperties.java
In cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java, add after the jwtSecret field (line 19):
private String oidcIssuerUri;
private String oidcAudience;
public String getOidcIssuerUri() { return oidcIssuerUri; }
public void setOidcIssuerUri(String oidcIssuerUri) { this.oidcIssuerUri = oidcIssuerUri; }
public String getOidcAudience() { return oidcAudience; }
public void setOidcAudience(String oidcAudience) { this.oidcAudience = oidcAudience; }
- Step 4: Verify build compiles
Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw compile -pl cameleer3-server-app -q
Expected: BUILD SUCCESS
- Step 5: Commit
git add cameleer3-server-app/pom.xml cameleer3-server-app/src/main/resources/application.yml cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
git commit -m "feat: add oauth2-resource-server dependency and OIDC config properties"
Task 2: Add conditional OIDC JwtDecoder bean
Files:
-
Modify:
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java -
Step 1: Write the failing test
Create cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcJwtDecoderBeanTest.java:
package com.cameleer3.server.app.security;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import static org.assertj.core.api.Assertions.assertThat;
class OidcJwtDecoderBeanTest {
@Test
void shouldNotCreateDecoderWhenIssuerUriBlank() {
var properties = new SecurityProperties();
properties.setBootstrapToken("test-token");
properties.setOidcIssuerUri("");
var config = new SecurityBeanConfig();
JwtDecoder decoder = config.oidcJwtDecoder(properties);
assertThat(decoder).isNull();
}
@Test
void shouldNotCreateDecoderWhenIssuerUriNull() {
var properties = new SecurityProperties();
properties.setBootstrapToken("test-token");
properties.setOidcIssuerUri(null);
var config = new SecurityBeanConfig();
JwtDecoder decoder = config.oidcJwtDecoder(properties);
assertThat(decoder).isNull();
}
}
- Step 2: Run test to verify it fails
Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=OidcJwtDecoderBeanTest -q
Expected: FAIL — method oidcJwtDecoder does not exist
- Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig
In cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java, add these imports at the top:
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:
/**
* 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:
@Bean
public JwtDecoder oidcJwtDecoder(SecurityProperties properties) {
// body is the method above
}
Actually, rename the existing method to createOidcJwtDecoder (private) and add the @Bean method that calls it:
Replace the method added in step 3 — make it a @Bean directly. The method signature stays the same, just add @Bean annotation. Spring will call it; if properties are blank, it returns null, and @Autowired(required = false) in SecurityConfig will receive null.
Note: Spring won't register a bean that returns null from a @Bean method — it throws. So instead, we should NOT use @Bean for this. Keep it as a factory method called from SecurityConfig. Remove the @Bean annotation and keep the method public.
Update the test to match: the test calls config.oidcJwtDecoder(properties) directly, which returns null when not configured. This is correct.
- Step 5: Run test to verify it passes
Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=OidcJwtDecoderBeanTest -q
Expected: PASS
- Step 6: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcJwtDecoderBeanTest.java
git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token validation"
Task 3: Update JwtAuthenticationFilter with OIDC fallback
Files:
-
Modify:
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java -
Step 1: Write the failing test
Create cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtAuthenticationFilterOidcTest.java:
package com.cameleer3.server.app.security;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.security.InvalidTokenException;
import com.cameleer3.server.core.security.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class JwtAuthenticationFilterOidcTest {
private JwtService jwtService;
private AgentRegistryService registryService;
private JwtDecoder oidcDecoder;
private JwtAuthenticationFilter filter;
private FilterChain chain;
@BeforeEach
void setUp() {
SecurityContextHolder.clearContext();
jwtService = mock(JwtService.class);
registryService = mock(AgentRegistryService.class);
oidcDecoder = mock(JwtDecoder.class);
filter = new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder);
chain = mock(FilterChain.class);
}
@Test
void shouldFallBackToOidcWhenHmacFails() throws ServletException, IOException {
var request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer oidc-token");
var response = new MockHttpServletResponse();
when(jwtService.validateAccessToken("oidc-token"))
.thenThrow(new InvalidTokenException("bad sig"));
Jwt jwt = Jwt.withTokenValue("oidc-token")
.header("alg", "ES384")
.claim("sub", "user-123")
.claim("client_id", "m2m-app-id")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(3600))
.build();
when(oidcDecoder.decode("oidc-token")).thenReturn(jwt);
filter.doFilterInternal(request, response, chain);
var auth = SecurityContextHolder.getContext().getAuthentication();
assertThat(auth).isNotNull();
assertThat(auth.getName()).isEqualTo("oidc:user-123");
verify(chain).doFilter(request, response);
}
@Test
void shouldGrantAdminForM2mToken() throws ServletException, IOException {
var request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer m2m-token");
var response = new MockHttpServletResponse();
when(jwtService.validateAccessToken("m2m-token"))
.thenThrow(new InvalidTokenException("bad sig"));
// M2M token: client_id == sub
Jwt jwt = Jwt.withTokenValue("m2m-token")
.header("alg", "ES384")
.claim("sub", "m2m-app-id")
.claim("client_id", "m2m-app-id")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(3600))
.build();
when(oidcDecoder.decode("m2m-token")).thenReturn(jwt);
filter.doFilterInternal(request, response, chain);
var auth = SecurityContextHolder.getContext().getAuthentication();
assertThat(auth).isNotNull();
assertThat(auth.getAuthorities()).anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
}
@Test
void shouldSkipOidcWhenDecoderIsNull() throws ServletException, IOException {
filter = new JwtAuthenticationFilter(jwtService, registryService, null);
var request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer bad-token");
var response = new MockHttpServletResponse();
when(jwtService.validateAccessToken("bad-token"))
.thenThrow(new InvalidTokenException("bad"));
filter.doFilterInternal(request, response, chain);
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
verify(chain).doFilter(request, response);
}
@Test
void shouldPreferHmacOverOidc() throws ServletException, IOException {
var request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer hmac-token");
var response = new MockHttpServletResponse();
when(jwtService.validateAccessToken("hmac-token"))
.thenReturn(new JwtService.JwtValidationResult(
"agent-1", "my-app", "prod", List.of("AGENT")));
filter.doFilterInternal(request, response, chain);
var auth = SecurityContextHolder.getContext().getAuthentication();
assertThat(auth.getName()).isEqualTo("agent-1");
// OIDC decoder should never be called
verifyNoInteractions(oidcDecoder);
}
}
- Step 2: Run test to verify it fails
Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=JwtAuthenticationFilterOidcTest -q
Expected: FAIL — constructor doesn't accept 3 args
- Step 3: Update JwtAuthenticationFilter
Replace cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java with:
package com.cameleer3.server.app.security;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
/**
* JWT authentication filter that supports two token types:
* <ol>
* <li>Internal HMAC-SHA256 tokens (agents, local users) — validated by {@link JwtService}</li>
* <li>OIDC tokens from Logto (SaaS M2M, OIDC users) — validated by {@link JwtDecoder} via JWKS</li>
* </ol>
* Internal tokens are tried first. OIDC is a fallback when configured.
* <p>
* Not annotated {@code @Component} — constructed explicitly in {@link SecurityConfig}.
*/
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private static final String BEARER_PREFIX = "Bearer ";
public static final String JWT_RESULT_ATTR = "cameleer.jwt.result";
private final JwtService jwtService;
private final AgentRegistryService agentRegistryService;
private final JwtDecoder oidcDecoder;
public JwtAuthenticationFilter(JwtService jwtService,
AgentRegistryService agentRegistryService,
JwtDecoder oidcDecoder) {
this.jwtService = jwtService;
this.agentRegistryService = agentRegistryService;
this.oidcDecoder = oidcDecoder;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String token = extractToken(request);
if (token != null) {
if (tryInternalToken(token, request)) {
chain.doFilter(request, response);
return;
}
if (oidcDecoder != null) {
tryOidcToken(token, request);
}
}
chain.doFilter(request, response);
}
private boolean tryInternalToken(String token, HttpServletRequest request) {
try {
JwtValidationResult result = jwtService.validateAccessToken(token);
String subject = result.subject();
List<String> roles = result.roles();
if (!subject.startsWith("user:") && roles.isEmpty()) {
roles = List.of("AGENT");
}
List<GrantedAuthority> authorities = toAuthorities(roles);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(subject, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
request.setAttribute(JWT_RESULT_ATTR, result);
return true;
} catch (Exception e) {
log.debug("Internal JWT validation failed: {}", e.getMessage());
return false;
}
}
private void tryOidcToken(String token, HttpServletRequest request) {
try {
Jwt jwt = oidcDecoder.decode(token);
String subject = jwt.getSubject();
List<String> roles = extractRolesFromOidcToken(jwt);
List<GrantedAuthority> authorities = toAuthorities(roles);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken("oidc:" + subject, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
log.debug("OIDC token validation failed: {}", e.getMessage());
}
}
private List<String> extractRolesFromOidcToken(Jwt jwt) {
String sub = jwt.getSubject();
Object clientId = jwt.getClaim("client_id");
if (clientId != null && clientId.toString().equals(sub)) {
return List.of("ADMIN");
}
return List.of("VIEWER");
}
private List<GrantedAuthority> toAuthorities(List<String> roles) {
return roles.stream()
.map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))
.toList();
}
private String extractToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
return authHeader.substring(BEARER_PREFIX.length());
}
return request.getParameter("token");
}
}
- Step 4: Run tests
Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=JwtAuthenticationFilterOidcTest -q
Expected: PASS (all 4 tests)
- Step 5: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtAuthenticationFilterOidcTest.java
git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
Task 4: Wire OIDC decoder into SecurityConfig
Files:
-
Modify:
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java -
Modify:
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java -
Step 1: Add OIDC decoder bean creation to SecurityBeanConfig
In SecurityBeanConfig.java, add this bean method:
@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:
public SecurityFilterChain filterChain(HttpSecurity http,
JwtService jwtService,
AgentRegistryService registryService,
CorsConfigurationSource corsConfigurationSource)
to:
public SecurityFilterChain filterChain(HttpSecurity http,
JwtService jwtService,
AgentRegistryService registryService,
CorsConfigurationSource corsConfigurationSource,
SecurityProperties securityProperties,
SecurityBeanConfig securityBeanConfig)
Then update the filter construction line from:
.addFilterBefore(
new JwtAuthenticationFilter(jwtService, registryService),
UsernamePasswordAuthenticationFilter.class
);
to:
.addFilterBefore(
new JwtAuthenticationFilter(jwtService, registryService,
securityBeanConfig.oidcJwtDecoder(securityProperties)),
UsernamePasswordAuthenticationFilter.class
);
Add import:
import org.springframework.security.oauth2.jwt.JwtDecoder;
- Step 3: Run existing tests
Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -q
Expected: All existing tests PASS (no OIDC env vars set, decoder is null, filter behaves as before)
- Step 4: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
git commit -m "feat: wire optional OIDC JwtDecoder into security filter chain"
Phase 2: cameleer-saas — Backend + Frontend Rewrite
All Phase 2 work is in C:\Users\Hendrik\Documents\projects\cameleer-saas.
Task 5: Delete dead auth files
Files:
-
Delete:
src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java -
Delete:
src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java -
Delete:
src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java -
Delete:
src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java -
Delete:
src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java -
Delete:
src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java -
Delete:
src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java -
Delete:
src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java -
Delete:
src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java -
Delete:
src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java -
Delete:
src/main/resources/db/migration/V001__create_users_table.sql -
Delete:
src/main/resources/db/migration/V002__create_roles_and_permissions.sql -
Delete:
src/main/resources/db/migration/V003__seed_default_roles.sql -
Step 1: Delete all dead files
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
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
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:
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:
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:
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:
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:
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:
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:
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
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:
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):
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):
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
- Step 3: Commit
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:
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
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:
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
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:
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:
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:
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:
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
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:
@Column(name = "bootstrap_token", nullable = false, columnDefinition = "TEXT")
private String bootstrapToken;
And remove:
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:
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):
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.javaline 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
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
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:
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
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:
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:
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:
currentOrgRoles: string[] | null;
setCurrentOrgRoles: (roles: string[] | null) => void;
Add to the store create:
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
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):
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
- traefik.http.routers.forwardauth.service=forwardauth
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
- Step 2: Remove ForwardAuth middleware from cameleer3-server
In docker-compose.yml, remove the forward-auth middleware labels from cameleer3-server (lines 158-159):
- 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:
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
to:
- traefik.http.routers.dashboard.middlewares=dashboard-strip
- Step 3: Remove keys volume mount from cameleer-saas
Remove line 99:
- ./keys:/etc/cameleer/keys:ro
- Step 4: Remove dead env vars, add OIDC env vars
In cameleer-saas environment, remove:
CAMELEER_JWT_PRIVATE_KEY_PATH: ${CAMELEER_JWT_PRIVATE_KEY_PATH:-}
CAMELEER_JWT_PUBLIC_KEY_PATH: ${CAMELEER_JWT_PUBLIC_KEY_PATH:-}
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
In cameleer3-server environment, add:
CAMELEER_OIDC_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
- Step 5: Commit
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:
"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:
# 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:
M2M_SECRET="${CACHED_M2M_SECRET:-}"
TRAD_SECRET="${CACHED_TRAD_SECRET:-}"
- Step 3: Commit
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) |