# Auth Overhaul Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the incoherent three-system auth in cameleer-saas with Logto-centric architecture, and add OIDC resource server support to cameleer-server for M2M. **Architecture:** Logto is the single identity provider for all humans. Spring OAuth2 Resource Server validates Logto JWTs in both the SaaS platform and cameleer-server. Agents authenticate with per-environment API keys exchanged for server-issued JWTs. Ed25519 command signing is unchanged. Zero trust: every service validates tokens independently via JWKS. **Tech Stack:** Spring Boot 3.4, Spring Security OAuth2 Resource Server, Nimbus JOSE+JWT, Logto, React + @logto/react, Zustand, PostgreSQL, Flyway **Spec:** `docs/superpowers/specs/2026-04-05-auth-overhaul-design.md` **Repos:** - cameleer-server: `C:\Users\Hendrik\Documents\projects\cameleer-server` (Phase 1) - cameleer-saas: `C:\Users\Hendrik\Documents\projects\cameleer-saas` (Phases 2-3) - cameleer (agent): NO CHANGES --- ## Phase 1: cameleer-server — OIDC Resource Server Support All Phase 1 work is in `C:\Users\Hendrik\Documents\projects\cameleer-server`. ### Task 1: Add OAuth2 Resource Server dependency and config properties **Files:** - Modify: `cameleer-server-app/pom.xml` - Modify: `cameleer-server-app/src/main/resources/application.yml` - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java` - [ ] **Step 1: Add dependency to pom.xml** In `cameleer-server-app/pom.xml`, add after the `spring-boot-starter-security` dependency (around line 88): ```xml org.springframework.boot spring-boot-starter-oauth2-resource-server ``` - [ ] **Step 2: Add OIDC properties to application.yml** In `cameleer-server-app/src/main/resources/application.yml`, add two new properties under the `security:` block (after line 52): ```yaml oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:} oidc-audience: ${CAMELEER_OIDC_AUDIENCE:} ``` - [ ] **Step 3: Add fields to SecurityProperties.java** In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java`, add after the `jwtSecret` field (line 19): ```java private String oidcIssuerUri; private String oidcAudience; public String getOidcIssuerUri() { return oidcIssuerUri; } public void setOidcIssuerUri(String oidcIssuerUri) { this.oidcIssuerUri = oidcIssuerUri; } public String getOidcAudience() { return oidcAudience; } public void setOidcAudience(String oidcAudience) { this.oidcAudience = oidcAudience; } ``` - [ ] **Step 4: Verify build compiles** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw compile -pl cameleer-server-app -q` Expected: BUILD SUCCESS - [ ] **Step 5: Commit** ```bash git add cameleer-server-app/pom.xml cameleer-server-app/src/main/resources/application.yml cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java git commit -m "feat: add oauth2-resource-server dependency and OIDC config properties" ``` --- ### Task 2: Add conditional OIDC JwtDecoder bean **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java` - [ ] **Step 1: Write the failing test** Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java`: ```java package com.cameleer.server.app.security; import org.junit.jupiter.api.Test; import org.springframework.security.oauth2.jwt.JwtDecoder; import static org.assertj.core.api.Assertions.assertThat; class OidcJwtDecoderBeanTest { @Test void shouldNotCreateDecoderWhenIssuerUriBlank() { var properties = new SecurityProperties(); properties.setBootstrapToken("test-token"); properties.setOidcIssuerUri(""); var config = new SecurityBeanConfig(); JwtDecoder decoder = config.oidcJwtDecoder(properties); assertThat(decoder).isNull(); } @Test void shouldNotCreateDecoderWhenIssuerUriNull() { var properties = new SecurityProperties(); properties.setBootstrapToken("test-token"); properties.setOidcIssuerUri(null); var config = new SecurityBeanConfig(); JwtDecoder decoder = config.oidcJwtDecoder(properties); assertThat(decoder).isNull(); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q` Expected: FAIL — method `oidcJwtDecoder` does not exist - [ ] **Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig** In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`, add these imports at the top: ```java import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.source.JWKSourceBuilder; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.proc.DefaultJWTProcessor; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimValidator; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import java.net.URL; import java.util.List; ``` Add this method to the class: ```java /** * Creates an OIDC-aware JwtDecoder when {@code security.oidc-issuer-uri} is configured. * Returns {@code null} when not configured, so the filter skips OIDC validation. *

* Handles Logto's {@code typ: at+jwt} (RFC 9068) by disabling type verification. * Discovers JWKS URI from the issuer's well-known endpoint. */ public JwtDecoder oidcJwtDecoder(SecurityProperties properties) { String issuerUri = properties.getOidcIssuerUri(); if (issuerUri == null || issuerUri.isBlank()) { return null; } try { String jwksUri = issuerUri.replaceAll("/+$", "") + "/jwks"; var jwkSource = JWKSourceBuilder.create(new URL(jwksUri)).build(); var keySelector = new JWSVerificationKeySelector( JWSAlgorithm.ES384, jwkSource); var processor = new DefaultJWTProcessor(); processor.setJWSKeySelector(keySelector); // Accept both "JWT" and "at+jwt" token types (Logto uses at+jwt per RFC 9068) processor.setJWSTypeVerifier((type, context) -> { }); var decoder = new NimbusJwtDecoder(processor); OAuth2TokenValidator validator; String audience = properties.getOidcAudience(); if (audience != null && !audience.isBlank()) { validator = new DelegatingOAuth2TokenValidator<>( JwtValidators.createDefaultWithIssuer(issuerUri), new JwtClaimValidator>("aud", aud -> aud != null && aud.contains(audience))); } else { validator = JwtValidators.createDefaultWithIssuer(issuerUri); } decoder.setJwtValidator(validator); return decoder; } catch (Exception e) { throw new IllegalStateException("Failed to create OIDC JwtDecoder for " + issuerUri, e); } } ``` - [ ] **Step 4: Wire the bean with @Bean annotation** Now wrap the method call in a proper `@Bean` method. Add to `SecurityBeanConfig`: ```java @Bean public JwtDecoder oidcJwtDecoder(SecurityProperties properties) { // body is the method above } ``` Actually, rename the existing method to `createOidcJwtDecoder` (private) and add the `@Bean` method that calls it: Replace the method added in step 3 — make it a `@Bean` directly. The method signature stays the same, just add `@Bean` annotation. Spring will call it; if properties are blank, it returns `null`, and `@Autowired(required = false)` in SecurityConfig will receive `null`. Note: Spring won't register a bean that returns `null` from a `@Bean` method — it throws. So instead, we should NOT use `@Bean` for this. Keep it as a factory method called from `SecurityConfig`. Remove the `@Bean` annotation and keep the method public. Update the test to match: the test calls `config.oidcJwtDecoder(properties)` directly, which returns `null` when not configured. This is correct. - [ ] **Step 5: Run test to verify it passes** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q` Expected: PASS - [ ] **Step 6: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token validation" ``` --- ### Task 3: Update JwtAuthenticationFilter with OIDC fallback **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` - [ ] **Step 1: Write the failing test** Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java`: ```java package com.cameleer.server.app.security; import com.cameleer.server.core.agent.AgentRegistryService; import com.cameleer.server.core.security.InvalidTokenException; import com.cameleer.server.core.security.JwtService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import java.io.IOException; import java.time.Instant; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class JwtAuthenticationFilterOidcTest { private JwtService jwtService; private AgentRegistryService registryService; private JwtDecoder oidcDecoder; private JwtAuthenticationFilter filter; private FilterChain chain; @BeforeEach void setUp() { SecurityContextHolder.clearContext(); jwtService = mock(JwtService.class); registryService = mock(AgentRegistryService.class); oidcDecoder = mock(JwtDecoder.class); filter = new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder); chain = mock(FilterChain.class); } @Test void shouldFallBackToOidcWhenHmacFails() throws ServletException, IOException { var request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer oidc-token"); var response = new MockHttpServletResponse(); when(jwtService.validateAccessToken("oidc-token")) .thenThrow(new InvalidTokenException("bad sig")); Jwt jwt = Jwt.withTokenValue("oidc-token") .header("alg", "ES384") .claim("sub", "user-123") .claim("client_id", "m2m-app-id") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(3600)) .build(); when(oidcDecoder.decode("oidc-token")).thenReturn(jwt); filter.doFilterInternal(request, response, chain); var auth = SecurityContextHolder.getContext().getAuthentication(); assertThat(auth).isNotNull(); assertThat(auth.getName()).isEqualTo("oidc:user-123"); verify(chain).doFilter(request, response); } @Test void shouldGrantAdminForM2mToken() throws ServletException, IOException { var request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer m2m-token"); var response = new MockHttpServletResponse(); when(jwtService.validateAccessToken("m2m-token")) .thenThrow(new InvalidTokenException("bad sig")); // M2M token: client_id == sub Jwt jwt = Jwt.withTokenValue("m2m-token") .header("alg", "ES384") .claim("sub", "m2m-app-id") .claim("client_id", "m2m-app-id") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(3600)) .build(); when(oidcDecoder.decode("m2m-token")).thenReturn(jwt); filter.doFilterInternal(request, response, chain); var auth = SecurityContextHolder.getContext().getAuthentication(); assertThat(auth).isNotNull(); assertThat(auth.getAuthorities()).anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); } @Test void shouldSkipOidcWhenDecoderIsNull() throws ServletException, IOException { filter = new JwtAuthenticationFilter(jwtService, registryService, null); var request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer bad-token"); var response = new MockHttpServletResponse(); when(jwtService.validateAccessToken("bad-token")) .thenThrow(new InvalidTokenException("bad")); filter.doFilterInternal(request, response, chain); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); verify(chain).doFilter(request, response); } @Test void shouldPreferHmacOverOidc() throws ServletException, IOException { var request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer hmac-token"); var response = new MockHttpServletResponse(); when(jwtService.validateAccessToken("hmac-token")) .thenReturn(new JwtService.JwtValidationResult( "agent-1", "my-app", "prod", List.of("AGENT"))); filter.doFilterInternal(request, response, chain); var auth = SecurityContextHolder.getContext().getAuthentication(); assertThat(auth.getName()).isEqualTo("agent-1"); // OIDC decoder should never be called verifyNoInteractions(oidcDecoder); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q` Expected: FAIL — constructor doesn't accept 3 args - [ ] **Step 3: Update JwtAuthenticationFilter** Replace `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` with: ```java package com.cameleer.server.app.security; import com.cameleer.server.core.agent.AgentRegistryService; import com.cameleer.server.core.security.JwtService; import com.cameleer.server.core.security.JwtService.JwtValidationResult; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.List; /** * JWT authentication filter that supports two token types: *

    *
  1. Internal HMAC-SHA256 tokens (agents, local users) — validated by {@link JwtService}
  2. *
  3. OIDC tokens from Logto (SaaS M2M, OIDC users) — validated by {@link JwtDecoder} via JWKS
  4. *
* Internal tokens are tried first. OIDC is a fallback when configured. *

* Not annotated {@code @Component} — constructed explicitly in {@link SecurityConfig}. */ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class); private static final String BEARER_PREFIX = "Bearer "; public static final String JWT_RESULT_ATTR = "cameleer.jwt.result"; private final JwtService jwtService; private final AgentRegistryService agentRegistryService; private final JwtDecoder oidcDecoder; public JwtAuthenticationFilter(JwtService jwtService, AgentRegistryService agentRegistryService, JwtDecoder oidcDecoder) { this.jwtService = jwtService; this.agentRegistryService = agentRegistryService; this.oidcDecoder = oidcDecoder; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token = extractToken(request); if (token != null) { if (tryInternalToken(token, request)) { chain.doFilter(request, response); return; } if (oidcDecoder != null) { tryOidcToken(token, request); } } chain.doFilter(request, response); } private boolean tryInternalToken(String token, HttpServletRequest request) { try { JwtValidationResult result = jwtService.validateAccessToken(token); String subject = result.subject(); List roles = result.roles(); if (!subject.startsWith("user:") && roles.isEmpty()) { roles = List.of("AGENT"); } List authorities = toAuthorities(roles); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(subject, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); request.setAttribute(JWT_RESULT_ATTR, result); return true; } catch (Exception e) { log.debug("Internal JWT validation failed: {}", e.getMessage()); return false; } } private void tryOidcToken(String token, HttpServletRequest request) { try { Jwt jwt = oidcDecoder.decode(token); String subject = jwt.getSubject(); List roles = extractRolesFromOidcToken(jwt); List authorities = toAuthorities(roles); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken("oidc:" + subject, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); } catch (Exception e) { log.debug("OIDC token validation failed: {}", e.getMessage()); } } private List extractRolesFromOidcToken(Jwt jwt) { String sub = jwt.getSubject(); Object clientId = jwt.getClaim("client_id"); if (clientId != null && clientId.toString().equals(sub)) { return List.of("ADMIN"); } return List.of("VIEWER"); } private List toAuthorities(List roles) { return roles.stream() .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role)) .toList(); } private String extractToken(HttpServletRequest request) { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { return authHeader.substring(BEARER_PREFIX.length()); } return request.getParameter("token"); } } ``` - [ ] **Step 4: Run tests** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q` Expected: PASS (all 4 tests) - [ ] **Step 5: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter" ``` --- ### Task 4: Wire OIDC decoder into SecurityConfig **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java` - [ ] **Step 1: Add OIDC decoder bean creation to SecurityBeanConfig** In `SecurityBeanConfig.java`, add this bean method: ```java @Bean public JwtDecoder oidcJwtDecoder(SecurityProperties properties) { return createOidcJwtDecoder(properties); } ``` Wait — Spring does not allow `@Bean` methods to return `null`. Instead, make the decoder optional. Create a holder: Actually, the simplest approach: create the decoder in SecurityConfig directly, not as a bean. In `SecurityConfig.java`, inject `SecurityProperties` and call the factory method from `SecurityBeanConfig`. Better approach: keep the factory in `SecurityBeanConfig` as a plain method (not `@Bean`), and have `SecurityConfig` call it. In `SecurityBeanConfig.java`, make `oidcJwtDecoder` public but NOT a `@Bean` (keep it as written in Task 2 — no `@Bean` annotation). - [ ] **Step 2: Update SecurityConfig to accept and use optional decoder** In `SecurityConfig.java`, update the `filterChain` method signature to accept `SecurityBeanConfig`: Replace the `filterChain` method. Change the parameter list from: ```java public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService, AgentRegistryService registryService, CorsConfigurationSource corsConfigurationSource) ``` to: ```java public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService, AgentRegistryService registryService, CorsConfigurationSource corsConfigurationSource, SecurityProperties securityProperties, SecurityBeanConfig securityBeanConfig) ``` Then update the filter construction line from: ```java .addFilterBefore( new JwtAuthenticationFilter(jwtService, registryService), UsernamePasswordAuthenticationFilter.class ); ``` to: ```java .addFilterBefore( new JwtAuthenticationFilter(jwtService, registryService, securityBeanConfig.oidcJwtDecoder(securityProperties)), UsernamePasswordAuthenticationFilter.class ); ``` Add import: ```java import org.springframework.security.oauth2.jwt.JwtDecoder; ``` - [ ] **Step 3: Run existing tests** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -q` Expected: All existing tests PASS (no OIDC env vars set, decoder is null, filter behaves as before) - [ ] **Step 4: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java git commit -m "feat: wire optional OIDC JwtDecoder into security filter chain" ``` --- ## Phase 2: cameleer-saas — Backend + Frontend Rewrite All Phase 2 work is in `C:\Users\Hendrik\Documents\projects\cameleer-saas`. ### Task 5: Delete dead auth files **Files:** - Delete: `src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java` - Delete: `src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java` - Delete: `src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java` - Delete: `src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java` - Delete: `src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java` - Delete: `src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java` - Delete: `src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java` - Delete: `src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java` - Delete: `src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java` - Delete: `src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java` - Delete: `src/main/resources/db/migration/V001__create_users_table.sql` - Delete: `src/main/resources/db/migration/V002__create_roles_and_permissions.sql` - Delete: `src/main/resources/db/migration/V003__seed_default_roles.sql` - [ ] **Step 1: Delete all dead files** ```bash cd /c/Users/Hendrik/Documents/projects/cameleer-saas rm -f src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java rm -f src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java rm -f src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java rm -f src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java rm -f src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java rm -f src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java rm -f src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java rm -f src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java rm -f src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java rm -f src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java rm -f src/main/resources/db/migration/V001__create_users_table.sql rm -f src/main/resources/db/migration/V002__create_roles_and_permissions.sql rm -f src/main/resources/db/migration/V003__seed_default_roles.sql ``` - [ ] **Step 2: Commit** ```bash git add -A git commit -m "chore: delete dead auth code — users/roles/JWTs/ForwardAuth live in Logto now" ``` --- ### Task 6: Clean database migrations (greenfield) **Files:** - Create: `src/main/resources/db/migration/V001__create_tenants.sql` (contents from old V005) - Create: `src/main/resources/db/migration/V002__create_licenses.sql` (contents from old V006) - Create: `src/main/resources/db/migration/V003__create_environments.sql` (modified — no bootstrap_token) - Create: `src/main/resources/db/migration/V004__create_api_keys.sql` (new) - Create: `src/main/resources/db/migration/V005__create_apps.sql` (contents from old V008+V010) - Create: `src/main/resources/db/migration/V006__create_deployments.sql` (contents from old V009) - Create: `src/main/resources/db/migration/V007__create_audit_log.sql` (contents from old V004) - Delete: old V004–V010 files - [ ] **Step 1: Remove old migrations** ```bash cd /c/Users/Hendrik/Documents/projects/cameleer-saas rm -f src/main/resources/db/migration/V004__create_audit_log.sql rm -f src/main/resources/db/migration/V005__create_tenants.sql rm -f src/main/resources/db/migration/V006__create_licenses.sql rm -f src/main/resources/db/migration/V007__create_environments.sql rm -f src/main/resources/db/migration/V008__create_apps.sql rm -f src/main/resources/db/migration/V009__create_deployments.sql rm -f src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql ``` - [ ] **Step 2: Create V001__create_tenants.sql** Write `src/main/resources/db/migration/V001__create_tenants.sql`: ```sql CREATE TABLE tenants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, slug VARCHAR(100) NOT NULL UNIQUE, tier VARCHAR(20) NOT NULL DEFAULT 'LOW', status VARCHAR(20) NOT NULL DEFAULT 'PROVISIONING', logto_org_id VARCHAR(255), stripe_customer_id VARCHAR(255), stripe_subscription_id VARCHAR(255), settings JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_tenants_slug ON tenants (slug); CREATE INDEX idx_tenants_status ON tenants (status); CREATE INDEX idx_tenants_logto_org_id ON tenants (logto_org_id); ``` - [ ] **Step 3: Create V002__create_licenses.sql** Write `src/main/resources/db/migration/V002__create_licenses.sql`: ```sql CREATE TABLE licenses ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, tier VARCHAR(20) NOT NULL, features JSONB NOT NULL DEFAULT '{}', limits JSONB NOT NULL DEFAULT '{}', issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, token TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_licenses_tenant_id ON licenses (tenant_id); CREATE INDEX idx_licenses_expires_at ON licenses (expires_at); ``` - [ ] **Step 4: Create V003__create_environments.sql (no bootstrap_token)** Write `src/main/resources/db/migration/V003__create_environments.sql`: ```sql CREATE TABLE environments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, slug VARCHAR(100) NOT NULL, display_name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_id, slug) ); CREATE INDEX idx_environments_tenant_id ON environments(tenant_id); ``` - [ ] **Step 5: Create V004__create_api_keys.sql** Write `src/main/resources/db/migration/V004__create_api_keys.sql`: ```sql CREATE TABLE api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE, key_hash VARCHAR(64) NOT NULL, key_prefix VARCHAR(12) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ ); CREATE INDEX idx_api_keys_env ON api_keys(environment_id); CREATE INDEX idx_api_keys_hash ON api_keys(key_hash); ``` - [ ] **Step 6: Create V005__create_apps.sql (includes exposed_port)** Write `src/main/resources/db/migration/V005__create_apps.sql`: ```sql CREATE TABLE apps ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE, slug VARCHAR(100) NOT NULL, display_name VARCHAR(255) NOT NULL, jar_storage_path VARCHAR(500), jar_checksum VARCHAR(64), jar_original_filename VARCHAR(255), jar_size_bytes BIGINT, exposed_port INTEGER, current_deployment_id UUID, previous_deployment_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(environment_id, slug) ); CREATE INDEX idx_apps_environment_id ON apps(environment_id); ``` - [ ] **Step 7: Create V006__create_deployments.sql** Write `src/main/resources/db/migration/V006__create_deployments.sql`: ```sql CREATE TABLE deployments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, version INTEGER NOT NULL, image_ref VARCHAR(500) NOT NULL, desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING', observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING', orchestrator_metadata JSONB DEFAULT '{}', error_message TEXT, deployed_at TIMESTAMPTZ, stopped_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(app_id, version) ); CREATE INDEX idx_deployments_app_id ON deployments(app_id); ``` - [ ] **Step 8: Create V007__create_audit_log.sql** Write `src/main/resources/db/migration/V007__create_audit_log.sql`: ```sql CREATE TABLE audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), actor_id UUID, actor_email VARCHAR(255), tenant_id UUID, action VARCHAR(100) NOT NULL, resource VARCHAR(500), environment VARCHAR(50), source_ip VARCHAR(45), result VARCHAR(20) NOT NULL DEFAULT 'SUCCESS', metadata JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_audit_log_tenant ON audit_log (tenant_id, created_at DESC); CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC); CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC); ``` - [ ] **Step 9: Commit** ```bash git add -A git commit -m "chore: greenfield migrations — remove user/role tables, add api_keys, drop bootstrap_token" ``` --- ### Task 7: Rewrite SecurityConfig + JwtAuthenticationConverter **Files:** - Rewrite: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java` - Modify: `src/main/resources/application.yml` - [ ] **Step 1: Rewrite SecurityConfig.java** Replace the entire file `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java` with: ```java package net.siegeln.cameleer.saas.config; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.source.JWKSourceBuilder; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.proc.DefaultJWTProcessor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; import java.net.URL; import java.util.ArrayList; import java.util.List; @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { private final TenantResolutionFilter tenantResolutionFilter; public SecurityConfig(TenantResolutionFilter tenantResolutionFilter) { this.tenantResolutionFilter = tenantResolutionFilter; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health").permitAll() .requestMatchers("/api/config").permitAll() .requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license", "/admin/**").permitAll() .requestMatchers("/assets/**", "/favicon.ico").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))) .addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class); return http.build(); } @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { var converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(jwt -> { List authorities = new ArrayList<>(); // Global roles (e.g., platform-admin) var roles = jwt.getClaimAsStringList("roles"); if (roles != null) { roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r))); } // Org roles (e.g., admin, member) var orgRoles = jwt.getClaimAsStringList("organization_roles"); if (orgRoles != null) { orgRoles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_org_" + r))); } return authorities; }); return converter; } @Bean public JwtDecoder jwtDecoder( @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri, @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri) throws Exception { var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build(); var keySelector = new JWSVerificationKeySelector( JWSAlgorithm.ES384, jwkSource); var processor = new DefaultJWTProcessor(); processor.setJWSKeySelector(keySelector); processor.setJWSTypeVerifier((type, context) -> { /* accept JWT and at+jwt */ }); var decoder = new NimbusJwtDecoder(processor); if (issuerUri != null && !issuerUri.isEmpty()) { decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri)); } return decoder; } } ``` - [ ] **Step 2: Clean application.yml — remove dead JWT config** In `src/main/resources/application.yml`, remove the entire `cameleer.jwt` block (lines 32-35): ```yaml jwt: expiration: 86400 # 24 hours in seconds private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:} public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:} ``` Also remove `bootstrap-token` from the `runtime` block (line 52): ```yaml bootstrap-token: ${CAMELEER_AUTH_TOKEN:} ``` - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java src/main/resources/application.yml git commit -m "feat: rewrite SecurityConfig — single filter chain, Logto OAuth2 Resource Server" ``` --- ### Task 8: Rewrite MeController (JWT claims only) **Files:** - Rewrite: `src/main/java/net/siegeln/cameleer/saas/config/MeController.java` - [ ] **Step 1: Rewrite MeController** Replace `src/main/java/net/siegeln/cameleer/saas/config/MeController.java` with: ```java package net.siegeln.cameleer.saas.config; import net.siegeln.cameleer.saas.identity.LogtoManagementClient; import net.siegeln.cameleer.saas.tenant.TenantService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Map; @RestController public class MeController { private final TenantService tenantService; private final LogtoManagementClient logtoClient; public MeController(TenantService tenantService, LogtoManagementClient logtoClient) { this.tenantService = tenantService; this.logtoClient = logtoClient; } @GetMapping("/api/me") public ResponseEntity> 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 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> tenants = tenant != null ? List.of(Map.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> logtoOrgs = logtoClient.getUserOrganizations(userId); List> tenants = logtoOrgs.stream() .map(org -> tenantService.getByLogtoOrgId(org.get("id")) .map(t -> Map.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> listAll() { List tenants = tenantService.findAll().stream() .map(this::toResponse).toList(); return ResponseEntity.ok(tenants); } @PostMapping @PreAuthorize("hasRole('platform-admin')") public ResponseEntity 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 getById(@PathVariable UUID id) { return tenantService.getById(id) .map(entity -> ResponseEntity.ok(toResponse(entity))) .orElse(ResponseEntity.notFound().build()); } @GetMapping("/by-slug/{slug}") public ResponseEntity 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 { Optional findByKeyHashAndStatus(String keyHash, String status); List findByEnvironmentId(UUID environmentId); List 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 validate(String plaintext) { String hash = sha256Hex(plaintext); return repository.findByKeyHashAndStatus(hash, "ACTIVE"); } public GeneratedKey rotate(UUID environmentId) { // Mark existing active keys as ROTATED List 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 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 = { '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(); for (const role of roles) { const perms = ROLE_PERMISSIONS[role]; if (perms) perms.forEach((p) => permissions.add(p)); } return { has: (permission: string) => permissions.has(permission), canManageApps: permissions.has('apps:manage'), canDeploy: permissions.has('apps:deploy'), canManageTenant: permissions.has('tenant:manage'), canViewObservability: permissions.has('observe:read'), roles, }; } ``` Note: This requires adding `currentOrgRoles` to the org store. Update `ui/src/auth/useOrganization.ts` to include it: Add to the `OrgState` interface: ```typescript currentOrgRoles: string[] | null; setCurrentOrgRoles: (roles: string[] | null) => void; ``` Add to the store create: ```typescript currentOrgRoles: null, setCurrentOrgRoles: (roles) => set({ currentOrgRoles: roles }), ``` Then update `OrgResolver.tsx` to set org roles from the `/api/me` response (the backend would need to return `orgRoles` — or extract from the token claims on the frontend side). For now, the org roles can be hardcoded from the OrgResolver after calling `/api/me`. - [ ] **Step 3: Commit** ```bash git add ui/src/auth/useAuth.ts ui/src/hooks/usePermissions.ts ui/src/auth/useOrganization.ts git commit -m "feat: rewrite frontend auth — roles from org store, Logto org role names" ``` --- ## Phase 3: Infrastructure Updates ### Task 15: Update docker-compose.yml **Files:** - Modify: `docker-compose.yml` - [ ] **Step 1: Remove ForwardAuth labels from cameleer-saas service** In `docker-compose.yml`, remove these two labels from `cameleer-saas` (lines 122-124): ```yaml - traefik.http.routers.forwardauth.rule=Path(`/auth/verify`) - traefik.http.routers.forwardauth.service=forwardauth - traefik.http.services.forwardauth.loadbalancer.server.port=8080 ``` - [ ] **Step 2: Remove ForwardAuth middleware from cameleer-server** In `docker-compose.yml`, remove the forward-auth middleware labels from `cameleer-server` (lines 158-159): ```yaml - traefik.http.routers.observe.middlewares=forward-auth - traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify ``` And change line 163 from: ```yaml - traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip ``` to: ```yaml - traefik.http.routers.dashboard.middlewares=dashboard-strip ``` - [ ] **Step 3: Remove keys volume mount from cameleer-saas** Remove line 99: ```yaml - ./keys:/etc/cameleer/keys:ro ``` - [ ] **Step 4: Remove dead env vars, add OIDC env vars** In `cameleer-saas` environment, remove: ```yaml CAMELEER_JWT_PRIVATE_KEY_PATH: ${CAMELEER_JWT_PRIVATE_KEY_PATH:-} CAMELEER_JWT_PUBLIC_KEY_PATH: ${CAMELEER_JWT_PUBLIC_KEY_PATH:-} CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token} ``` In `cameleer-server` environment, add: ```yaml CAMELEER_OIDC_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc} CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local} ``` - [ ] **Step 5: Commit** ```bash git add docker-compose.yml git commit -m "infra: remove ForwardAuth, keys mount, add OIDC env vars for server" ``` --- ### Task 16: Update bootstrap script **Files:** - Modify: `docker/logto-bootstrap.sh` - [ ] **Step 1: Add OIDC env vars to bootstrap output** In `docker/logto-bootstrap.sh`, add to the bootstrap JSON output (around line 431): After `"tenantAdminUser"`, add: ```json "oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc", "oidcAudience": "$API_RESOURCE_INDICATOR" ``` - [ ] **Step 2: Remove direct psql reads for existing app secrets** The script reads Logto's `applications` table directly via `psql` for M2M and Traditional app secrets when apps already exist (lines 155-156, 193-194). Replace with reading from the bootstrap JSON file if it exists: At the top of the script (after variable declarations), add: ```bash # Read cached secrets from previous run if [ -f "$BOOTSTRAP_FILE" ]; then CACHED_M2M_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null) CACHED_TRAD_SECRET=$(jq -r '.tradAppSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null) fi ``` Then replace the `psql` fallbacks with: ```bash M2M_SECRET="${CACHED_M2M_SECRET:-}" TRAD_SECRET="${CACHED_TRAD_SECRET:-}" ``` - [ ] **Step 3: Commit** ```bash git add docker/logto-bootstrap.sh git commit -m "infra: add OIDC config to bootstrap output, stop reading Logto DB for secrets" ``` --- ## Self-Review Checklist | Spec Requirement | Task | |-----------------|------| | Delete custom JWT stack (JwtService, filter, config, entities) | Task 5 | | Delete ForwardAuthController | Task 5 | | Delete PasswordEncoder bean | Task 7 (SecurityConfig rewrite) | | Delete old migrations V001-V003 | Task 5 + Task 6 | | Delete Ed25519 key config from application.yml | Task 7 | | Rewrite SecurityConfig (single chain, OAuth2 RS) | Task 7 | | Add JwtAuthenticationConverter for Logto roles | Task 7 | | Rewrite MeController (JWT claims) | Task 8 | | Rewrite TenantController (@PreAuthorize) | Task 9 | | Add ApiKeyEntity + migration | Task 6 + Task 10 | | Add ApiKeyService | Task 10 | | Update EnvironmentEntity (remove bootstrap_token) | Task 11 | | Simplify LogtoManagementClient | Task 12 | | Update TestSecurityConfig | Task 13 | | Rewrite frontend useAuth.ts | Task 14 | | Rewrite frontend usePermissions.ts | Task 14 | | Remove Traefik ForwardAuth | Task 15 | | Remove keys mount from docker-compose | Task 15 | | Add OIDC env vars to server | Task 15 | | Update bootstrap script | Task 16 | | Server: add oauth2-resource-server dep | Task 1 | | Server: add SecurityProperties fields | Task 1 | | Server: conditional OIDC JwtDecoder | Task 2 | | Server: JwtAuthenticationFilter OIDC fallback | Task 3 | | Server: wire decoder into SecurityConfig | Task 4 | | Agent: no changes | N/A (verified) |