Rename Java packages from net.siegeln.cameleer3 to net.siegeln.cameleer, update all references in workflows, Docker configs, docs, and bootstrap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1810 lines
65 KiB
Markdown
1810 lines
65 KiB
Markdown
# Auth Overhaul Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the incoherent three-system auth in cameleer-saas with Logto-centric architecture, and add OIDC resource server support to cameleer-server for M2M.
|
||
|
||
**Architecture:** Logto is the single identity provider for all humans. Spring OAuth2 Resource Server validates Logto JWTs in both the SaaS platform and cameleer-server. Agents authenticate with per-environment API keys exchanged for server-issued JWTs. Ed25519 command signing is unchanged. Zero trust: every service validates tokens independently via JWKS.
|
||
|
||
**Tech Stack:** Spring Boot 3.4, Spring Security OAuth2 Resource Server, Nimbus JOSE+JWT, Logto, React + @logto/react, Zustand, PostgreSQL, Flyway
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-05-auth-overhaul-design.md`
|
||
|
||
**Repos:**
|
||
- cameleer-server: `C:\Users\Hendrik\Documents\projects\cameleer-server` (Phase 1)
|
||
- cameleer-saas: `C:\Users\Hendrik\Documents\projects\cameleer-saas` (Phases 2-3)
|
||
- cameleer (agent): NO CHANGES
|
||
|
||
---
|
||
|
||
## Phase 1: cameleer-server — OIDC Resource Server Support
|
||
|
||
All Phase 1 work is in `C:\Users\Hendrik\Documents\projects\cameleer-server`.
|
||
|
||
### Task 1: Add OAuth2 Resource Server dependency and config properties
|
||
|
||
**Files:**
|
||
- Modify: `cameleer-server-app/pom.xml`
|
||
- Modify: `cameleer-server-app/src/main/resources/application.yml`
|
||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java`
|
||
|
||
- [ ] **Step 1: Add dependency to pom.xml**
|
||
|
||
In `cameleer-server-app/pom.xml`, add after the `spring-boot-starter-security` dependency (around line 88):
|
||
|
||
```xml
|
||
<dependency>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||
</dependency>
|
||
```
|
||
|
||
- [ ] **Step 2: Add OIDC properties to application.yml**
|
||
|
||
In `cameleer-server-app/src/main/resources/application.yml`, add two new properties under the `security:` block (after line 52):
|
||
|
||
```yaml
|
||
oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
|
||
oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
|
||
```
|
||
|
||
- [ ] **Step 3: Add fields to SecurityProperties.java**
|
||
|
||
In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java`, add after the `jwtSecret` field (line 19):
|
||
|
||
```java
|
||
private String oidcIssuerUri;
|
||
private String oidcAudience;
|
||
|
||
public String getOidcIssuerUri() { return oidcIssuerUri; }
|
||
public void setOidcIssuerUri(String oidcIssuerUri) { this.oidcIssuerUri = oidcIssuerUri; }
|
||
public String getOidcAudience() { return oidcAudience; }
|
||
public void setOidcAudience(String oidcAudience) { this.oidcAudience = oidcAudience; }
|
||
```
|
||
|
||
- [ ] **Step 4: Verify build compiles**
|
||
|
||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw compile -pl cameleer-server-app -q`
|
||
Expected: BUILD SUCCESS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-app/pom.xml cameleer-server-app/src/main/resources/application.yml cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java
|
||
git commit -m "feat: add oauth2-resource-server dependency and OIDC config properties"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Add conditional OIDC JwtDecoder bean
|
||
|
||
**Files:**
|
||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java`:
|
||
|
||
```java
|
||
package com.cameleer.server.app.security;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class OidcJwtDecoderBeanTest {
|
||
|
||
@Test
|
||
void shouldNotCreateDecoderWhenIssuerUriBlank() {
|
||
var properties = new SecurityProperties();
|
||
properties.setBootstrapToken("test-token");
|
||
properties.setOidcIssuerUri("");
|
||
|
||
var config = new SecurityBeanConfig();
|
||
JwtDecoder decoder = config.oidcJwtDecoder(properties);
|
||
|
||
assertThat(decoder).isNull();
|
||
}
|
||
|
||
@Test
|
||
void shouldNotCreateDecoderWhenIssuerUriNull() {
|
||
var properties = new SecurityProperties();
|
||
properties.setBootstrapToken("test-token");
|
||
properties.setOidcIssuerUri(null);
|
||
|
||
var config = new SecurityBeanConfig();
|
||
JwtDecoder decoder = config.oidcJwtDecoder(properties);
|
||
|
||
assertThat(decoder).isNull();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
|
||
Expected: FAIL — method `oidcJwtDecoder` does not exist
|
||
|
||
- [ ] **Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig**
|
||
|
||
In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`, add these imports at the top:
|
||
|
||
```java
|
||
import com.nimbusds.jose.JWSAlgorithm;
|
||
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
|
||
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
||
import com.nimbusds.jose.proc.SecurityContext;
|
||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||
import org.springframework.security.oauth2.jwt.Jwt;
|
||
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
|
||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||
import java.net.URL;
|
||
import java.util.List;
|
||
```
|
||
|
||
Add this method to the class:
|
||
|
||
```java
|
||
/**
|
||
* Creates an OIDC-aware JwtDecoder when {@code security.oidc-issuer-uri} is configured.
|
||
* Returns {@code null} when not configured, so the filter skips OIDC validation.
|
||
* <p>
|
||
* Handles Logto's {@code typ: at+jwt} (RFC 9068) by disabling type verification.
|
||
* Discovers JWKS URI from the issuer's well-known endpoint.
|
||
*/
|
||
public JwtDecoder oidcJwtDecoder(SecurityProperties properties) {
|
||
String issuerUri = properties.getOidcIssuerUri();
|
||
if (issuerUri == null || issuerUri.isBlank()) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
String jwksUri = issuerUri.replaceAll("/+$", "") + "/jwks";
|
||
var jwkSource = JWKSourceBuilder.create(new URL(jwksUri)).build();
|
||
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
|
||
JWSAlgorithm.ES384, jwkSource);
|
||
|
||
var processor = new DefaultJWTProcessor<SecurityContext>();
|
||
processor.setJWSKeySelector(keySelector);
|
||
// Accept both "JWT" and "at+jwt" token types (Logto uses at+jwt per RFC 9068)
|
||
processor.setJWSTypeVerifier((type, context) -> { });
|
||
|
||
var decoder = new NimbusJwtDecoder(processor);
|
||
|
||
OAuth2TokenValidator<Jwt> validator;
|
||
String audience = properties.getOidcAudience();
|
||
if (audience != null && !audience.isBlank()) {
|
||
validator = new DelegatingOAuth2TokenValidator<>(
|
||
JwtValidators.createDefaultWithIssuer(issuerUri),
|
||
new JwtClaimValidator<List<String>>("aud",
|
||
aud -> aud != null && aud.contains(audience)));
|
||
} else {
|
||
validator = JwtValidators.createDefaultWithIssuer(issuerUri);
|
||
}
|
||
decoder.setJwtValidator(validator);
|
||
|
||
return decoder;
|
||
} catch (Exception e) {
|
||
throw new IllegalStateException("Failed to create OIDC JwtDecoder for " + issuerUri, e);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire the bean with @Bean annotation**
|
||
|
||
Now wrap the method call in a proper `@Bean` method. Add to `SecurityBeanConfig`:
|
||
|
||
```java
|
||
@Bean
|
||
public JwtDecoder oidcJwtDecoder(SecurityProperties properties) {
|
||
// body is the method above
|
||
}
|
||
```
|
||
|
||
Actually, rename the existing method to `createOidcJwtDecoder` (private) and add the `@Bean` method that calls it:
|
||
|
||
Replace the method added in step 3 — make it a `@Bean` directly. The method signature stays the same, just add `@Bean` annotation. Spring will call it; if properties are blank, it returns `null`, and `@Autowired(required = false)` in SecurityConfig will receive `null`.
|
||
|
||
Note: Spring won't register a bean that returns `null` from a `@Bean` method — it throws. So instead, we should NOT use `@Bean` for this. Keep it as a factory method called from `SecurityConfig`. Remove the `@Bean` annotation and keep the method public.
|
||
|
||
Update the test to match: the test calls `config.oidcJwtDecoder(properties)` directly, which returns `null` when not configured. This is correct.
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java
|
||
git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token validation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Update JwtAuthenticationFilter with OIDC fallback
|
||
|
||
**Files:**
|
||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java`:
|
||
|
||
```java
|
||
package com.cameleer.server.app.security;
|
||
|
||
import com.cameleer.server.core.agent.AgentRegistryService;
|
||
import com.cameleer.server.core.security.InvalidTokenException;
|
||
import com.cameleer.server.core.security.JwtService;
|
||
import jakarta.servlet.FilterChain;
|
||
import jakarta.servlet.ServletException;
|
||
import org.junit.jupiter.api.BeforeEach;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.springframework.mock.web.MockHttpServletRequest;
|
||
import org.springframework.mock.web.MockHttpServletResponse;
|
||
import org.springframework.security.core.context.SecurityContextHolder;
|
||
import org.springframework.security.oauth2.jwt.Jwt;
|
||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||
|
||
import java.io.IOException;
|
||
import java.time.Instant;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.mockito.Mockito.*;
|
||
|
||
class JwtAuthenticationFilterOidcTest {
|
||
|
||
private JwtService jwtService;
|
||
private AgentRegistryService registryService;
|
||
private JwtDecoder oidcDecoder;
|
||
private JwtAuthenticationFilter filter;
|
||
private FilterChain chain;
|
||
|
||
@BeforeEach
|
||
void setUp() {
|
||
SecurityContextHolder.clearContext();
|
||
jwtService = mock(JwtService.class);
|
||
registryService = mock(AgentRegistryService.class);
|
||
oidcDecoder = mock(JwtDecoder.class);
|
||
filter = new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder);
|
||
chain = mock(FilterChain.class);
|
||
}
|
||
|
||
@Test
|
||
void shouldFallBackToOidcWhenHmacFails() throws ServletException, IOException {
|
||
var request = new MockHttpServletRequest();
|
||
request.addHeader("Authorization", "Bearer oidc-token");
|
||
var response = new MockHttpServletResponse();
|
||
|
||
when(jwtService.validateAccessToken("oidc-token"))
|
||
.thenThrow(new InvalidTokenException("bad sig"));
|
||
|
||
Jwt jwt = Jwt.withTokenValue("oidc-token")
|
||
.header("alg", "ES384")
|
||
.claim("sub", "user-123")
|
||
.claim("client_id", "m2m-app-id")
|
||
.issuedAt(Instant.now())
|
||
.expiresAt(Instant.now().plusSeconds(3600))
|
||
.build();
|
||
when(oidcDecoder.decode("oidc-token")).thenReturn(jwt);
|
||
|
||
filter.doFilterInternal(request, response, chain);
|
||
|
||
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||
assertThat(auth).isNotNull();
|
||
assertThat(auth.getName()).isEqualTo("oidc:user-123");
|
||
verify(chain).doFilter(request, response);
|
||
}
|
||
|
||
@Test
|
||
void shouldGrantAdminForM2mToken() throws ServletException, IOException {
|
||
var request = new MockHttpServletRequest();
|
||
request.addHeader("Authorization", "Bearer m2m-token");
|
||
var response = new MockHttpServletResponse();
|
||
|
||
when(jwtService.validateAccessToken("m2m-token"))
|
||
.thenThrow(new InvalidTokenException("bad sig"));
|
||
|
||
// M2M token: client_id == sub
|
||
Jwt jwt = Jwt.withTokenValue("m2m-token")
|
||
.header("alg", "ES384")
|
||
.claim("sub", "m2m-app-id")
|
||
.claim("client_id", "m2m-app-id")
|
||
.issuedAt(Instant.now())
|
||
.expiresAt(Instant.now().plusSeconds(3600))
|
||
.build();
|
||
when(oidcDecoder.decode("m2m-token")).thenReturn(jwt);
|
||
|
||
filter.doFilterInternal(request, response, chain);
|
||
|
||
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||
assertThat(auth).isNotNull();
|
||
assertThat(auth.getAuthorities()).anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
|
||
}
|
||
|
||
@Test
|
||
void shouldSkipOidcWhenDecoderIsNull() throws ServletException, IOException {
|
||
filter = new JwtAuthenticationFilter(jwtService, registryService, null);
|
||
var request = new MockHttpServletRequest();
|
||
request.addHeader("Authorization", "Bearer bad-token");
|
||
var response = new MockHttpServletResponse();
|
||
|
||
when(jwtService.validateAccessToken("bad-token"))
|
||
.thenThrow(new InvalidTokenException("bad"));
|
||
|
||
filter.doFilterInternal(request, response, chain);
|
||
|
||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
||
verify(chain).doFilter(request, response);
|
||
}
|
||
|
||
@Test
|
||
void shouldPreferHmacOverOidc() throws ServletException, IOException {
|
||
var request = new MockHttpServletRequest();
|
||
request.addHeader("Authorization", "Bearer hmac-token");
|
||
var response = new MockHttpServletResponse();
|
||
|
||
when(jwtService.validateAccessToken("hmac-token"))
|
||
.thenReturn(new JwtService.JwtValidationResult(
|
||
"agent-1", "my-app", "prod", List.of("AGENT")));
|
||
|
||
filter.doFilterInternal(request, response, chain);
|
||
|
||
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||
assertThat(auth.getName()).isEqualTo("agent-1");
|
||
// OIDC decoder should never be called
|
||
verifyNoInteractions(oidcDecoder);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
|
||
Expected: FAIL — constructor doesn't accept 3 args
|
||
|
||
- [ ] **Step 3: Update JwtAuthenticationFilter**
|
||
|
||
Replace `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` with:
|
||
|
||
```java
|
||
package com.cameleer.server.app.security;
|
||
|
||
import com.cameleer.server.core.agent.AgentRegistryService;
|
||
import com.cameleer.server.core.security.JwtService;
|
||
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
|
||
import jakarta.servlet.FilterChain;
|
||
import jakarta.servlet.ServletException;
|
||
import jakarta.servlet.http.HttpServletRequest;
|
||
import jakarta.servlet.http.HttpServletResponse;
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||
import org.springframework.security.core.GrantedAuthority;
|
||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||
import org.springframework.security.core.context.SecurityContextHolder;
|
||
import org.springframework.security.oauth2.jwt.Jwt;
|
||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||
import org.springframework.web.filter.OncePerRequestFilter;
|
||
|
||
import java.io.IOException;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* JWT authentication filter that supports two token types:
|
||
* <ol>
|
||
* <li>Internal HMAC-SHA256 tokens (agents, local users) — validated by {@link JwtService}</li>
|
||
* <li>OIDC tokens from Logto (SaaS M2M, OIDC users) — validated by {@link JwtDecoder} via JWKS</li>
|
||
* </ol>
|
||
* Internal tokens are tried first. OIDC is a fallback when configured.
|
||
* <p>
|
||
* Not annotated {@code @Component} — constructed explicitly in {@link SecurityConfig}.
|
||
*/
|
||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
|
||
private static final String BEARER_PREFIX = "Bearer ";
|
||
public static final String JWT_RESULT_ATTR = "cameleer.jwt.result";
|
||
|
||
private final JwtService jwtService;
|
||
private final AgentRegistryService agentRegistryService;
|
||
private final JwtDecoder oidcDecoder;
|
||
|
||
public JwtAuthenticationFilter(JwtService jwtService,
|
||
AgentRegistryService agentRegistryService,
|
||
JwtDecoder oidcDecoder) {
|
||
this.jwtService = jwtService;
|
||
this.agentRegistryService = agentRegistryService;
|
||
this.oidcDecoder = oidcDecoder;
|
||
}
|
||
|
||
@Override
|
||
protected void doFilterInternal(HttpServletRequest request,
|
||
HttpServletResponse response,
|
||
FilterChain chain) throws ServletException, IOException {
|
||
String token = extractToken(request);
|
||
|
||
if (token != null) {
|
||
if (tryInternalToken(token, request)) {
|
||
chain.doFilter(request, response);
|
||
return;
|
||
}
|
||
if (oidcDecoder != null) {
|
||
tryOidcToken(token, request);
|
||
}
|
||
}
|
||
|
||
chain.doFilter(request, response);
|
||
}
|
||
|
||
private boolean tryInternalToken(String token, HttpServletRequest request) {
|
||
try {
|
||
JwtValidationResult result = jwtService.validateAccessToken(token);
|
||
String subject = result.subject();
|
||
|
||
List<String> roles = result.roles();
|
||
if (!subject.startsWith("user:") && roles.isEmpty()) {
|
||
roles = List.of("AGENT");
|
||
}
|
||
List<GrantedAuthority> authorities = toAuthorities(roles);
|
||
UsernamePasswordAuthenticationToken auth =
|
||
new UsernamePasswordAuthenticationToken(subject, null, authorities);
|
||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||
request.setAttribute(JWT_RESULT_ATTR, result);
|
||
return true;
|
||
} catch (Exception e) {
|
||
log.debug("Internal JWT validation failed: {}", e.getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private void tryOidcToken(String token, HttpServletRequest request) {
|
||
try {
|
||
Jwt jwt = oidcDecoder.decode(token);
|
||
String subject = jwt.getSubject();
|
||
List<String> roles = extractRolesFromOidcToken(jwt);
|
||
List<GrantedAuthority> authorities = toAuthorities(roles);
|
||
UsernamePasswordAuthenticationToken auth =
|
||
new UsernamePasswordAuthenticationToken("oidc:" + subject, null, authorities);
|
||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||
} catch (Exception e) {
|
||
log.debug("OIDC token validation failed: {}", e.getMessage());
|
||
}
|
||
}
|
||
|
||
private List<String> extractRolesFromOidcToken(Jwt jwt) {
|
||
String sub = jwt.getSubject();
|
||
Object clientId = jwt.getClaim("client_id");
|
||
if (clientId != null && clientId.toString().equals(sub)) {
|
||
return List.of("ADMIN");
|
||
}
|
||
return List.of("VIEWER");
|
||
}
|
||
|
||
private List<GrantedAuthority> toAuthorities(List<String> roles) {
|
||
return roles.stream()
|
||
.map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))
|
||
.toList();
|
||
}
|
||
|
||
private String extractToken(HttpServletRequest request) {
|
||
String authHeader = request.getHeader("Authorization");
|
||
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
|
||
return authHeader.substring(BEARER_PREFIX.length());
|
||
}
|
||
return request.getParameter("token");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
|
||
Expected: PASS (all 4 tests)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java
|
||
git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Wire OIDC decoder into SecurityConfig
|
||
|
||
**Files:**
|
||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java`
|
||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`
|
||
|
||
- [ ] **Step 1: Add OIDC decoder bean creation to SecurityBeanConfig**
|
||
|
||
In `SecurityBeanConfig.java`, add this bean method:
|
||
|
||
```java
|
||
@Bean
|
||
public JwtDecoder oidcJwtDecoder(SecurityProperties properties) {
|
||
return createOidcJwtDecoder(properties);
|
||
}
|
||
```
|
||
|
||
Wait — Spring does not allow `@Bean` methods to return `null`. Instead, make the decoder optional. Create a holder:
|
||
|
||
Actually, the simplest approach: create the decoder in SecurityConfig directly, not as a bean. In `SecurityConfig.java`, inject `SecurityProperties` and call the factory method from `SecurityBeanConfig`.
|
||
|
||
Better approach: keep the factory in `SecurityBeanConfig` as a plain method (not `@Bean`), and have `SecurityConfig` call it.
|
||
|
||
In `SecurityBeanConfig.java`, make `oidcJwtDecoder` public but NOT a `@Bean` (keep it as written in Task 2 — no `@Bean` annotation).
|
||
|
||
- [ ] **Step 2: Update SecurityConfig to accept and use optional decoder**
|
||
|
||
In `SecurityConfig.java`, update the `filterChain` method signature to accept `SecurityBeanConfig`:
|
||
|
||
Replace the `filterChain` method. Change the parameter list from:
|
||
|
||
```java
|
||
public SecurityFilterChain filterChain(HttpSecurity http,
|
||
JwtService jwtService,
|
||
AgentRegistryService registryService,
|
||
CorsConfigurationSource corsConfigurationSource)
|
||
```
|
||
|
||
to:
|
||
|
||
```java
|
||
public SecurityFilterChain filterChain(HttpSecurity http,
|
||
JwtService jwtService,
|
||
AgentRegistryService registryService,
|
||
CorsConfigurationSource corsConfigurationSource,
|
||
SecurityProperties securityProperties,
|
||
SecurityBeanConfig securityBeanConfig)
|
||
```
|
||
|
||
Then update the filter construction line from:
|
||
|
||
```java
|
||
.addFilterBefore(
|
||
new JwtAuthenticationFilter(jwtService, registryService),
|
||
UsernamePasswordAuthenticationFilter.class
|
||
);
|
||
```
|
||
|
||
to:
|
||
|
||
```java
|
||
.addFilterBefore(
|
||
new JwtAuthenticationFilter(jwtService, registryService,
|
||
securityBeanConfig.oidcJwtDecoder(securityProperties)),
|
||
UsernamePasswordAuthenticationFilter.class
|
||
);
|
||
```
|
||
|
||
Add import:
|
||
```java
|
||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||
```
|
||
|
||
- [ ] **Step 3: Run existing tests**
|
||
|
||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -q`
|
||
Expected: All existing tests PASS (no OIDC env vars set, decoder is null, filter behaves as before)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java
|
||
git commit -m "feat: wire optional OIDC JwtDecoder into security filter chain"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2: cameleer-saas — Backend + Frontend Rewrite
|
||
|
||
All Phase 2 work is in `C:\Users\Hendrik\Documents\projects\cameleer-saas`.
|
||
|
||
### Task 5: Delete dead auth files
|
||
|
||
**Files:**
|
||
- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java`
|
||
- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java`
|
||
- Delete: `src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java`
|
||
- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java`
|
||
- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java`
|
||
- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java`
|
||
- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java`
|
||
- Delete: `src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java`
|
||
- Delete: `src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java`
|
||
- Delete: `src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java`
|
||
- Delete: `src/main/resources/db/migration/V001__create_users_table.sql`
|
||
- Delete: `src/main/resources/db/migration/V002__create_roles_and_permissions.sql`
|
||
- Delete: `src/main/resources/db/migration/V003__seed_default_roles.sql`
|
||
|
||
- [ ] **Step 1: Delete all dead files**
|
||
|
||
```bash
|
||
cd /c/Users/Hendrik/Documents/projects/cameleer-saas
|
||
rm -f src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java
|
||
rm -f src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java
|
||
rm -f src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java
|
||
rm -f src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java
|
||
rm -f src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java
|
||
rm -f src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java
|
||
rm -f src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java
|
||
rm -f src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java
|
||
rm -f src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java
|
||
rm -f src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java
|
||
rm -f src/main/resources/db/migration/V001__create_users_table.sql
|
||
rm -f src/main/resources/db/migration/V002__create_roles_and_permissions.sql
|
||
rm -f src/main/resources/db/migration/V003__seed_default_roles.sql
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: delete dead auth code — users/roles/JWTs/ForwardAuth live in Logto now"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Clean database migrations (greenfield)
|
||
|
||
**Files:**
|
||
- Create: `src/main/resources/db/migration/V001__create_tenants.sql` (contents from old V005)
|
||
- Create: `src/main/resources/db/migration/V002__create_licenses.sql` (contents from old V006)
|
||
- Create: `src/main/resources/db/migration/V003__create_environments.sql` (modified — no bootstrap_token)
|
||
- Create: `src/main/resources/db/migration/V004__create_api_keys.sql` (new)
|
||
- Create: `src/main/resources/db/migration/V005__create_apps.sql` (contents from old V008+V010)
|
||
- Create: `src/main/resources/db/migration/V006__create_deployments.sql` (contents from old V009)
|
||
- Create: `src/main/resources/db/migration/V007__create_audit_log.sql` (contents from old V004)
|
||
- Delete: old V004–V010 files
|
||
|
||
- [ ] **Step 1: Remove old migrations**
|
||
|
||
```bash
|
||
cd /c/Users/Hendrik/Documents/projects/cameleer-saas
|
||
rm -f src/main/resources/db/migration/V004__create_audit_log.sql
|
||
rm -f src/main/resources/db/migration/V005__create_tenants.sql
|
||
rm -f src/main/resources/db/migration/V006__create_licenses.sql
|
||
rm -f src/main/resources/db/migration/V007__create_environments.sql
|
||
rm -f src/main/resources/db/migration/V008__create_apps.sql
|
||
rm -f src/main/resources/db/migration/V009__create_deployments.sql
|
||
rm -f src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql
|
||
```
|
||
|
||
- [ ] **Step 2: Create V001__create_tenants.sql**
|
||
|
||
Write `src/main/resources/db/migration/V001__create_tenants.sql`:
|
||
|
||
```sql
|
||
CREATE TABLE tenants (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
name VARCHAR(255) NOT NULL,
|
||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||
tier VARCHAR(20) NOT NULL DEFAULT 'LOW',
|
||
status VARCHAR(20) NOT NULL DEFAULT 'PROVISIONING',
|
||
logto_org_id VARCHAR(255),
|
||
stripe_customer_id VARCHAR(255),
|
||
stripe_subscription_id VARCHAR(255),
|
||
settings JSONB NOT NULL DEFAULT '{}',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_tenants_slug ON tenants (slug);
|
||
CREATE INDEX idx_tenants_status ON tenants (status);
|
||
CREATE INDEX idx_tenants_logto_org_id ON tenants (logto_org_id);
|
||
```
|
||
|
||
- [ ] **Step 3: Create V002__create_licenses.sql**
|
||
|
||
Write `src/main/resources/db/migration/V002__create_licenses.sql`:
|
||
|
||
```sql
|
||
CREATE TABLE licenses (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
tier VARCHAR(20) NOT NULL,
|
||
features JSONB NOT NULL DEFAULT '{}',
|
||
limits JSONB NOT NULL DEFAULT '{}',
|
||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
expires_at TIMESTAMPTZ NOT NULL,
|
||
revoked_at TIMESTAMPTZ,
|
||
token TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_licenses_tenant_id ON licenses (tenant_id);
|
||
CREATE INDEX idx_licenses_expires_at ON licenses (expires_at);
|
||
```
|
||
|
||
- [ ] **Step 4: Create V003__create_environments.sql (no bootstrap_token)**
|
||
|
||
Write `src/main/resources/db/migration/V003__create_environments.sql`:
|
||
|
||
```sql
|
||
CREATE TABLE environments (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
slug VARCHAR(100) NOT NULL,
|
||
display_name VARCHAR(255) NOT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
UNIQUE(tenant_id, slug)
|
||
);
|
||
|
||
CREATE INDEX idx_environments_tenant_id ON environments(tenant_id);
|
||
```
|
||
|
||
- [ ] **Step 5: Create V004__create_api_keys.sql**
|
||
|
||
Write `src/main/resources/db/migration/V004__create_api_keys.sql`:
|
||
|
||
```sql
|
||
CREATE TABLE api_keys (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
|
||
key_hash VARCHAR(64) NOT NULL,
|
||
key_prefix VARCHAR(12) NOT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
revoked_at TIMESTAMPTZ
|
||
);
|
||
|
||
CREATE INDEX idx_api_keys_env ON api_keys(environment_id);
|
||
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
|
||
```
|
||
|
||
- [ ] **Step 6: Create V005__create_apps.sql (includes exposed_port)**
|
||
|
||
Write `src/main/resources/db/migration/V005__create_apps.sql`:
|
||
|
||
```sql
|
||
CREATE TABLE apps (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
|
||
slug VARCHAR(100) NOT NULL,
|
||
display_name VARCHAR(255) NOT NULL,
|
||
jar_storage_path VARCHAR(500),
|
||
jar_checksum VARCHAR(64),
|
||
jar_original_filename VARCHAR(255),
|
||
jar_size_bytes BIGINT,
|
||
exposed_port INTEGER,
|
||
current_deployment_id UUID,
|
||
previous_deployment_id UUID,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
UNIQUE(environment_id, slug)
|
||
);
|
||
|
||
CREATE INDEX idx_apps_environment_id ON apps(environment_id);
|
||
```
|
||
|
||
- [ ] **Step 7: Create V006__create_deployments.sql**
|
||
|
||
Write `src/main/resources/db/migration/V006__create_deployments.sql`:
|
||
|
||
```sql
|
||
CREATE TABLE deployments (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||
version INTEGER NOT NULL,
|
||
image_ref VARCHAR(500) NOT NULL,
|
||
desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
|
||
observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING',
|
||
orchestrator_metadata JSONB DEFAULT '{}',
|
||
error_message TEXT,
|
||
deployed_at TIMESTAMPTZ,
|
||
stopped_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
UNIQUE(app_id, version)
|
||
);
|
||
|
||
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
|
||
```
|
||
|
||
- [ ] **Step 8: Create V007__create_audit_log.sql**
|
||
|
||
Write `src/main/resources/db/migration/V007__create_audit_log.sql`:
|
||
|
||
```sql
|
||
CREATE TABLE audit_log (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
actor_id UUID,
|
||
actor_email VARCHAR(255),
|
||
tenant_id UUID,
|
||
action VARCHAR(100) NOT NULL,
|
||
resource VARCHAR(500),
|
||
environment VARCHAR(50),
|
||
source_ip VARCHAR(45),
|
||
result VARCHAR(20) NOT NULL DEFAULT 'SUCCESS',
|
||
metadata JSONB,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_audit_log_tenant ON audit_log (tenant_id, created_at DESC);
|
||
CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
|
||
CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
|
||
```
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: greenfield migrations — remove user/role tables, add api_keys, drop bootstrap_token"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Rewrite SecurityConfig + JwtAuthenticationConverter
|
||
|
||
**Files:**
|
||
- Rewrite: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java`
|
||
- Modify: `src/main/resources/application.yml`
|
||
|
||
- [ ] **Step 1: Rewrite SecurityConfig.java**
|
||
|
||
Replace the entire file `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java` with:
|
||
|
||
```java
|
||
package net.siegeln.cameleer.saas.config;
|
||
|
||
import com.nimbusds.jose.JWSAlgorithm;
|
||
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
|
||
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
||
import com.nimbusds.jose.proc.SecurityContext;
|
||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.context.annotation.Bean;
|
||
import org.springframework.context.annotation.Configuration;
|
||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||
import org.springframework.security.core.GrantedAuthority;
|
||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||
import org.springframework.security.web.SecurityFilterChain;
|
||
|
||
import java.net.URL;
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
@Configuration
|
||
@EnableWebSecurity
|
||
@EnableMethodSecurity
|
||
public class SecurityConfig {
|
||
|
||
private final TenantResolutionFilter tenantResolutionFilter;
|
||
|
||
public SecurityConfig(TenantResolutionFilter tenantResolutionFilter) {
|
||
this.tenantResolutionFilter = tenantResolutionFilter;
|
||
}
|
||
|
||
@Bean
|
||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||
http
|
||
.csrf(csrf -> csrf.disable())
|
||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||
.authorizeHttpRequests(auth -> auth
|
||
.requestMatchers("/actuator/health").permitAll()
|
||
.requestMatchers("/api/config").permitAll()
|
||
.requestMatchers("/", "/index.html", "/login", "/callback",
|
||
"/environments/**", "/license", "/admin/**").permitAll()
|
||
.requestMatchers("/assets/**", "/favicon.ico").permitAll()
|
||
.anyRequest().authenticated()
|
||
)
|
||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
|
||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
|
||
.addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class);
|
||
|
||
return http.build();
|
||
}
|
||
|
||
@Bean
|
||
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||
var converter = new JwtAuthenticationConverter();
|
||
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
|
||
List<GrantedAuthority> authorities = new ArrayList<>();
|
||
|
||
// Global roles (e.g., platform-admin)
|
||
var roles = jwt.getClaimAsStringList("roles");
|
||
if (roles != null) {
|
||
roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
|
||
}
|
||
|
||
// Org roles (e.g., admin, member)
|
||
var orgRoles = jwt.getClaimAsStringList("organization_roles");
|
||
if (orgRoles != null) {
|
||
orgRoles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_org_" + r)));
|
||
}
|
||
|
||
return authorities;
|
||
});
|
||
return converter;
|
||
}
|
||
|
||
@Bean
|
||
public JwtDecoder jwtDecoder(
|
||
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
|
||
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri) throws Exception {
|
||
var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build();
|
||
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
|
||
JWSAlgorithm.ES384, jwkSource);
|
||
|
||
var processor = new DefaultJWTProcessor<SecurityContext>();
|
||
processor.setJWSKeySelector(keySelector);
|
||
processor.setJWSTypeVerifier((type, context) -> { /* accept JWT and at+jwt */ });
|
||
|
||
var decoder = new NimbusJwtDecoder(processor);
|
||
if (issuerUri != null && !issuerUri.isEmpty()) {
|
||
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri));
|
||
}
|
||
return decoder;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Clean application.yml — remove dead JWT config**
|
||
|
||
In `src/main/resources/application.yml`, remove the entire `cameleer.jwt` block (lines 32-35):
|
||
|
||
```yaml
|
||
jwt:
|
||
expiration: 86400 # 24 hours in seconds
|
||
private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
|
||
public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}
|
||
```
|
||
|
||
Also remove `bootstrap-token` from the `runtime` block (line 52):
|
||
|
||
```yaml
|
||
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java src/main/resources/application.yml
|
||
git commit -m "feat: rewrite SecurityConfig — single filter chain, Logto OAuth2 Resource Server"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Rewrite MeController (JWT claims only)
|
||
|
||
**Files:**
|
||
- Rewrite: `src/main/java/net/siegeln/cameleer/saas/config/MeController.java`
|
||
|
||
- [ ] **Step 1: Rewrite MeController**
|
||
|
||
Replace `src/main/java/net/siegeln/cameleer/saas/config/MeController.java` with:
|
||
|
||
```java
|
||
package net.siegeln.cameleer.saas.config;
|
||
|
||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||
import net.siegeln.cameleer.saas.tenant.TenantService;
|
||
import org.springframework.http.ResponseEntity;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.security.oauth2.jwt.Jwt;
|
||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||
import org.springframework.web.bind.annotation.GetMapping;
|
||
import org.springframework.web.bind.annotation.RestController;
|
||
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
|
||
@RestController
|
||
public class MeController {
|
||
|
||
private final TenantService tenantService;
|
||
private final LogtoManagementClient logtoClient;
|
||
|
||
public MeController(TenantService tenantService, LogtoManagementClient logtoClient) {
|
||
this.tenantService = tenantService;
|
||
this.logtoClient = logtoClient;
|
||
}
|
||
|
||
@GetMapping("/api/me")
|
||
public ResponseEntity<Map<String, Object>> me(Authentication authentication) {
|
||
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
|
||
return ResponseEntity.status(401).build();
|
||
}
|
||
|
||
Jwt jwt = jwtAuth.getToken();
|
||
String userId = jwt.getSubject();
|
||
|
||
// Read org from JWT claims (Logto includes organization_id in org-scoped tokens)
|
||
String orgId = jwt.getClaimAsString("organization_id");
|
||
|
||
// Check platform admin via global roles in token
|
||
List<String> globalRoles = jwt.getClaimAsStringList("roles");
|
||
boolean isPlatformAdmin = globalRoles != null && globalRoles.contains("platform-admin");
|
||
|
||
// If org-scoped token, resolve single tenant
|
||
if (orgId != null) {
|
||
var tenant = tenantService.getByLogtoOrgId(orgId).orElse(null);
|
||
List<Map<String, Object>> tenants = tenant != null
|
||
? List.of(Map.<String, Object>of(
|
||
"id", tenant.getId().toString(),
|
||
"name", tenant.getName(),
|
||
"slug", tenant.getSlug(),
|
||
"logtoOrgId", tenant.getLogtoOrgId()))
|
||
: List.of();
|
||
|
||
return ResponseEntity.ok(Map.of(
|
||
"userId", userId,
|
||
"isPlatformAdmin", isPlatformAdmin,
|
||
"tenants", tenants));
|
||
}
|
||
|
||
// Non-org-scoped token: enumerate orgs via Management API (cold-start only)
|
||
List<Map<String, String>> logtoOrgs = logtoClient.getUserOrganizations(userId);
|
||
List<Map<String, Object>> tenants = logtoOrgs.stream()
|
||
.map(org -> tenantService.getByLogtoOrgId(org.get("id"))
|
||
.map(t -> Map.<String, Object>of(
|
||
"id", t.getId().toString(),
|
||
"name", t.getName(),
|
||
"slug", t.getSlug(),
|
||
"logtoOrgId", t.getLogtoOrgId()))
|
||
.orElse(null))
|
||
.filter(t -> t != null)
|
||
.toList();
|
||
|
||
return ResponseEntity.ok(Map.of(
|
||
"userId", userId,
|
||
"isPlatformAdmin", isPlatformAdmin,
|
||
"tenants", tenants));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/net/siegeln/cameleer/saas/config/MeController.java
|
||
git commit -m "feat: rewrite MeController — read from JWT claims, Management API only for cold start"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Rewrite TenantController authorization
|
||
|
||
**Files:**
|
||
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java`
|
||
|
||
- [ ] **Step 1: Rewrite TenantController**
|
||
|
||
Replace `src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java` with:
|
||
|
||
```java
|
||
package net.siegeln.cameleer.saas.tenant;
|
||
|
||
import jakarta.validation.Valid;
|
||
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
|
||
import org.springframework.http.HttpStatus;
|
||
import org.springframework.http.ResponseEntity;
|
||
import org.springframework.security.access.prepost.PreAuthorize;
|
||
import org.springframework.security.core.Authentication;
|
||
import org.springframework.web.bind.annotation.GetMapping;
|
||
import org.springframework.web.bind.annotation.PathVariable;
|
||
import org.springframework.web.bind.annotation.PostMapping;
|
||
import org.springframework.web.bind.annotation.RequestBody;
|
||
import org.springframework.web.bind.annotation.RequestMapping;
|
||
import org.springframework.web.bind.annotation.RestController;
|
||
|
||
import java.util.List;
|
||
import java.util.UUID;
|
||
|
||
@RestController
|
||
@RequestMapping("/api/tenants")
|
||
public class TenantController {
|
||
|
||
private final TenantService tenantService;
|
||
|
||
public TenantController(TenantService tenantService) {
|
||
this.tenantService = tenantService;
|
||
}
|
||
|
||
@GetMapping
|
||
@PreAuthorize("hasRole('platform-admin')")
|
||
public ResponseEntity<List<TenantResponse>> listAll() {
|
||
List<TenantResponse> tenants = tenantService.findAll().stream()
|
||
.map(this::toResponse).toList();
|
||
return ResponseEntity.ok(tenants);
|
||
}
|
||
|
||
@PostMapping
|
||
@PreAuthorize("hasRole('platform-admin')")
|
||
public ResponseEntity<TenantResponse> create(@Valid @RequestBody CreateTenantRequest request,
|
||
Authentication authentication) {
|
||
try {
|
||
String sub = authentication.getName();
|
||
UUID actorId;
|
||
try {
|
||
actorId = UUID.fromString(sub);
|
||
} catch (IllegalArgumentException e) {
|
||
actorId = UUID.nameUUIDFromBytes(sub.getBytes());
|
||
}
|
||
|
||
var entity = tenantService.create(request, actorId);
|
||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
|
||
} catch (IllegalArgumentException e) {
|
||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||
}
|
||
}
|
||
|
||
@GetMapping("/{id}")
|
||
public ResponseEntity<TenantResponse> getById(@PathVariable UUID id) {
|
||
return tenantService.getById(id)
|
||
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
||
.orElse(ResponseEntity.notFound().build());
|
||
}
|
||
|
||
@GetMapping("/by-slug/{slug}")
|
||
public ResponseEntity<TenantResponse> getBySlug(@PathVariable String slug) {
|
||
return tenantService.getBySlug(slug)
|
||
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
||
.orElse(ResponseEntity.notFound().build());
|
||
}
|
||
|
||
private TenantResponse toResponse(TenantEntity entity) {
|
||
return new TenantResponse(
|
||
entity.getId(),
|
||
entity.getName(),
|
||
entity.getSlug(),
|
||
entity.getTier().name(),
|
||
entity.getStatus().name(),
|
||
entity.getCreatedAt(),
|
||
entity.getUpdatedAt()
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java
|
||
git commit -m "feat: replace manual Logto role check with @PreAuthorize in TenantController"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Add ApiKeyEntity + repository + service
|
||
|
||
**Files:**
|
||
- Create: `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java`
|
||
- Create: `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java`
|
||
- Create: `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java`
|
||
- Create: `src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java`:
|
||
|
||
```java
|
||
package net.siegeln.cameleer.saas.apikey;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class ApiKeyServiceTest {
|
||
|
||
@Test
|
||
void generatedKeyShouldHaveCmkPrefix() {
|
||
var service = new ApiKeyService(null);
|
||
var key = service.generate();
|
||
|
||
assertThat(key.plaintext()).startsWith("cmk_");
|
||
assertThat(key.prefix()).hasSize(12);
|
||
assertThat(key.keyHash()).hasSize(64); // SHA-256 hex
|
||
}
|
||
|
||
@Test
|
||
void generatedKeyHashShouldBeConsistent() {
|
||
var service = new ApiKeyService(null);
|
||
var key = service.generate();
|
||
|
||
String rehash = ApiKeyService.sha256Hex(key.plaintext());
|
||
assertThat(rehash).isEqualTo(key.keyHash());
|
||
}
|
||
|
||
@Test
|
||
void twoGeneratedKeysShouldDiffer() {
|
||
var service = new ApiKeyService(null);
|
||
var key1 = service.generate();
|
||
var key2 = service.generate();
|
||
|
||
assertThat(key1.plaintext()).isNotEqualTo(key2.plaintext());
|
||
assertThat(key1.keyHash()).isNotEqualTo(key2.keyHash());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw test -Dtest=ApiKeyServiceTest -q`
|
||
Expected: FAIL — class not found
|
||
|
||
- [ ] **Step 3: Create ApiKeyEntity**
|
||
|
||
Create `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java`:
|
||
|
||
```java
|
||
package net.siegeln.cameleer.saas.apikey;
|
||
|
||
import jakarta.persistence.*;
|
||
import java.time.Instant;
|
||
import java.util.UUID;
|
||
|
||
@Entity
|
||
@Table(name = "api_keys")
|
||
public class ApiKeyEntity {
|
||
|
||
@Id
|
||
@GeneratedValue(strategy = GenerationType.UUID)
|
||
private UUID id;
|
||
|
||
@Column(name = "environment_id", nullable = false)
|
||
private UUID environmentId;
|
||
|
||
@Column(name = "key_hash", nullable = false, length = 64)
|
||
private String keyHash;
|
||
|
||
@Column(name = "key_prefix", nullable = false, length = 12)
|
||
private String keyPrefix;
|
||
|
||
@Column(name = "status", nullable = false, length = 20)
|
||
private String status = "ACTIVE";
|
||
|
||
@Column(name = "created_at", nullable = false)
|
||
private Instant createdAt;
|
||
|
||
@Column(name = "revoked_at")
|
||
private Instant revokedAt;
|
||
|
||
@PrePersist
|
||
protected void onCreate() {
|
||
if (createdAt == null) createdAt = Instant.now();
|
||
}
|
||
|
||
public UUID getId() { return id; }
|
||
public void setId(UUID id) { this.id = id; }
|
||
public UUID getEnvironmentId() { return environmentId; }
|
||
public void setEnvironmentId(UUID environmentId) { this.environmentId = environmentId; }
|
||
public String getKeyHash() { return keyHash; }
|
||
public void setKeyHash(String keyHash) { this.keyHash = keyHash; }
|
||
public String getKeyPrefix() { return keyPrefix; }
|
||
public void setKeyPrefix(String keyPrefix) { this.keyPrefix = keyPrefix; }
|
||
public String getStatus() { return status; }
|
||
public void setStatus(String status) { this.status = status; }
|
||
public Instant getCreatedAt() { return createdAt; }
|
||
public Instant getRevokedAt() { return revokedAt; }
|
||
public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create ApiKeyRepository**
|
||
|
||
Create `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java`:
|
||
|
||
```java
|
||
package net.siegeln.cameleer.saas.apikey;
|
||
|
||
import org.springframework.data.jpa.repository.JpaRepository;
|
||
import java.util.List;
|
||
import java.util.Optional;
|
||
import java.util.UUID;
|
||
|
||
public interface ApiKeyRepository extends JpaRepository<ApiKeyEntity, UUID> {
|
||
Optional<ApiKeyEntity> findByKeyHashAndStatus(String keyHash, String status);
|
||
List<ApiKeyEntity> findByEnvironmentId(UUID environmentId);
|
||
List<ApiKeyEntity> findByEnvironmentIdAndStatus(UUID environmentId, String status);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create ApiKeyService**
|
||
|
||
Create `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java`:
|
||
|
||
```java
|
||
package net.siegeln.cameleer.saas.apikey;
|
||
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.security.MessageDigest;
|
||
import java.security.NoSuchAlgorithmException;
|
||
import java.security.SecureRandom;
|
||
import java.time.Instant;
|
||
import java.util.Base64;
|
||
import java.util.List;
|
||
import java.util.Optional;
|
||
import java.util.UUID;
|
||
|
||
@Service
|
||
public class ApiKeyService {
|
||
|
||
public record GeneratedKey(String plaintext, String keyHash, String prefix) {}
|
||
|
||
private final ApiKeyRepository repository;
|
||
|
||
public ApiKeyService(ApiKeyRepository repository) {
|
||
this.repository = repository;
|
||
}
|
||
|
||
public GeneratedKey generate() {
|
||
byte[] bytes = new byte[32];
|
||
new SecureRandom().nextBytes(bytes);
|
||
String plaintext = "cmk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||
String hash = sha256Hex(plaintext);
|
||
String prefix = plaintext.substring(0, 12);
|
||
return new GeneratedKey(plaintext, hash, prefix);
|
||
}
|
||
|
||
public ApiKeyEntity createForEnvironment(UUID environmentId) {
|
||
var key = generate();
|
||
var entity = new ApiKeyEntity();
|
||
entity.setEnvironmentId(environmentId);
|
||
entity.setKeyHash(key.keyHash());
|
||
entity.setKeyPrefix(key.prefix());
|
||
return repository.save(entity);
|
||
}
|
||
|
||
public GeneratedKey createForEnvironmentReturningPlaintext(UUID environmentId) {
|
||
var key = generate();
|
||
var entity = new ApiKeyEntity();
|
||
entity.setEnvironmentId(environmentId);
|
||
entity.setKeyHash(key.keyHash());
|
||
entity.setKeyPrefix(key.prefix());
|
||
repository.save(entity);
|
||
return key;
|
||
}
|
||
|
||
public Optional<ApiKeyEntity> validate(String plaintext) {
|
||
String hash = sha256Hex(plaintext);
|
||
return repository.findByKeyHashAndStatus(hash, "ACTIVE");
|
||
}
|
||
|
||
public GeneratedKey rotate(UUID environmentId) {
|
||
// Mark existing active keys as ROTATED
|
||
List<ApiKeyEntity> active = repository.findByEnvironmentIdAndStatus(environmentId, "ACTIVE");
|
||
for (var k : active) {
|
||
k.setStatus("ROTATED");
|
||
}
|
||
repository.saveAll(active);
|
||
|
||
return createForEnvironmentReturningPlaintext(environmentId);
|
||
}
|
||
|
||
public void revoke(UUID keyId) {
|
||
repository.findById(keyId).ifPresent(k -> {
|
||
k.setStatus("REVOKED");
|
||
k.setRevokedAt(Instant.now());
|
||
repository.save(k);
|
||
});
|
||
}
|
||
|
||
public List<ApiKeyEntity> listByEnvironment(UUID environmentId) {
|
||
return repository.findByEnvironmentId(environmentId);
|
||
}
|
||
|
||
public static String sha256Hex(String input) {
|
||
try {
|
||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||
StringBuilder hex = new StringBuilder(64);
|
||
for (byte b : hash) {
|
||
hex.append(String.format("%02x", b));
|
||
}
|
||
return hex.toString();
|
||
} catch (NoSuchAlgorithmException e) {
|
||
throw new RuntimeException("SHA-256 not available", e);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests**
|
||
|
||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw test -Dtest=ApiKeyServiceTest -q`
|
||
Expected: PASS (all 3 tests)
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/net/siegeln/cameleer/saas/apikey/ src/test/java/net/siegeln/cameleer/saas/apikey/
|
||
git commit -m "feat: add API key entity, repository, and service with SHA-256 hashing"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Update EnvironmentEntity and EnvironmentService
|
||
|
||
**Files:**
|
||
- Modify: `src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentEntity.java`
|
||
- Modify: `src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentService.java`
|
||
|
||
- [ ] **Step 1: Remove bootstrap_token from EnvironmentEntity**
|
||
|
||
In `EnvironmentEntity.java`, remove the `bootstrapToken` field (lines 24-25) and its getter/setter (lines 56-57):
|
||
|
||
Remove:
|
||
```java
|
||
@Column(name = "bootstrap_token", nullable = false, columnDefinition = "TEXT")
|
||
private String bootstrapToken;
|
||
```
|
||
|
||
And remove:
|
||
```java
|
||
public String getBootstrapToken() { return bootstrapToken; }
|
||
public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; }
|
||
```
|
||
|
||
- [ ] **Step 2: Update EnvironmentService.create()**
|
||
|
||
In `EnvironmentService.java`, remove the bootstrap token line from `create()` method. Remove line 44:
|
||
|
||
```java
|
||
entity.setBootstrapToken(runtimeConfig.getBootstrapToken());
|
||
```
|
||
|
||
Also remove `RuntimeConfig` from the constructor and field if it's only used for bootstrap token. Check: `runtimeConfig` is also used nowhere else in this service — but actually it might be injected for tier limits. Check the imports — no, it's only used for `getBootstrapToken()`. However, keep the field for now if other code references it; just remove the `setBootstrapToken` call.
|
||
|
||
Actually, looking at the code, `runtimeConfig` is only used on line 44 for `getBootstrapToken()`. Remove it from constructor and field. Update the constructor:
|
||
|
||
Replace constructor (lines 23-30):
|
||
```java
|
||
public EnvironmentService(EnvironmentRepository environmentRepository,
|
||
LicenseRepository licenseRepository,
|
||
AuditService auditService) {
|
||
this.environmentRepository = environmentRepository;
|
||
this.licenseRepository = licenseRepository;
|
||
this.auditService = auditService;
|
||
}
|
||
```
|
||
|
||
Remove the `runtimeConfig` field and import.
|
||
|
||
- [ ] **Step 3: Fix compilation — update tests and other references**
|
||
|
||
Search for `getBootstrapToken()` and `setBootstrapToken()` in the SaaS codebase. Update:
|
||
- `DeploymentService.java` line 145: `env.getBootstrapToken()` — this needs the API key now. For now, this will be addressed in a follow-up task. Comment out or use a placeholder.
|
||
- `BootstrapDataSeeder.java`: references bootstrap token — will be rewritten in Phase 3.
|
||
- Test files: update to remove `setBootstrapToken()` calls.
|
||
|
||
For each test that calls `env.setBootstrapToken("...")`, simply remove that line.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/net/siegeln/cameleer/saas/environment/
|
||
git commit -m "feat: remove bootstrap_token from EnvironmentEntity — API keys managed separately"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Simplify LogtoManagementClient
|
||
|
||
**Files:**
|
||
- Modify: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java`
|
||
|
||
- [ ] **Step 1: Remove getUserRoles method**
|
||
|
||
In `LogtoManagementClient.java`, delete the `getUserRoles()` method (lines 78-99). Roles now come from JWT claims.
|
||
|
||
- [ ] **Step 2: Fix compilation — remove getUserRoles callers**
|
||
|
||
Search for `getUserRoles` in the codebase. The only caller was `TenantController` (already rewritten in Task 9) and `MeController` (already rewritten in Task 8). Verify no other callers exist.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
|
||
git commit -m "refactor: remove getUserRoles from LogtoManagementClient — roles come from JWT"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Update TestSecurityConfig
|
||
|
||
**Files:**
|
||
- Modify: `src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java`
|
||
|
||
- [ ] **Step 1: Update mock JwtDecoder to include org and role claims**
|
||
|
||
Replace `src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java` with:
|
||
|
||
```java
|
||
package net.siegeln.cameleer.saas;
|
||
|
||
import org.springframework.boot.test.context.TestConfiguration;
|
||
import org.springframework.context.annotation.Bean;
|
||
import org.springframework.security.oauth2.jwt.Jwt;
|
||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||
|
||
import java.time.Instant;
|
||
import java.util.List;
|
||
|
||
@TestConfiguration
|
||
public class TestSecurityConfig {
|
||
|
||
@Bean
|
||
public JwtDecoder jwtDecoder() {
|
||
return token -> Jwt.withTokenValue(token)
|
||
.header("alg", "ES384")
|
||
.claim("sub", "test-user")
|
||
.claim("iss", "https://test-issuer.example.com/oidc")
|
||
.claim("organization_id", "test-org-id")
|
||
.claim("roles", List.of("platform-admin"))
|
||
.claim("organization_roles", List.of("admin"))
|
||
.issuedAt(Instant.now())
|
||
.expiresAt(Instant.now().plusSeconds(3600))
|
||
.build();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java
|
||
git commit -m "test: update TestSecurityConfig with org and role claims for Logto tokens"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Rewrite frontend auth
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/auth/useAuth.ts`
|
||
- Modify: `ui/src/hooks/usePermissions.ts`
|
||
|
||
- [ ] **Step 1: Rewrite useAuth.ts**
|
||
|
||
Replace `ui/src/auth/useAuth.ts` with:
|
||
|
||
```typescript
|
||
import { useLogto } from '@logto/react';
|
||
import { useCallback } from 'react';
|
||
import { useOrgStore } from './useOrganization';
|
||
|
||
export function useAuth() {
|
||
const { isAuthenticated, isLoading, signOut, signIn } = useLogto();
|
||
const { currentTenantId, isPlatformAdmin } = useOrgStore();
|
||
|
||
const logout = useCallback(() => {
|
||
signOut(window.location.origin + '/login');
|
||
}, [signOut]);
|
||
|
||
return {
|
||
isAuthenticated,
|
||
isLoading,
|
||
tenantId: currentTenantId,
|
||
isPlatformAdmin,
|
||
logout,
|
||
signIn,
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite usePermissions.ts**
|
||
|
||
Replace `ui/src/hooks/usePermissions.ts` with:
|
||
|
||
```typescript
|
||
import { useOrgStore } from '../auth/useOrganization';
|
||
|
||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||
'admin': [
|
||
'tenant:manage', 'billing:manage', 'team:manage', 'apps:manage',
|
||
'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug',
|
||
'settings:manage',
|
||
],
|
||
'member': ['apps:deploy', 'observe:read', 'observe:debug'],
|
||
};
|
||
|
||
export function usePermissions() {
|
||
const { currentOrgRoles } = useOrgStore();
|
||
const roles = currentOrgRoles ?? [];
|
||
|
||
const permissions = new Set<string>();
|
||
for (const role of roles) {
|
||
const perms = ROLE_PERMISSIONS[role];
|
||
if (perms) perms.forEach((p) => permissions.add(p));
|
||
}
|
||
|
||
return {
|
||
has: (permission: string) => permissions.has(permission),
|
||
canManageApps: permissions.has('apps:manage'),
|
||
canDeploy: permissions.has('apps:deploy'),
|
||
canManageTenant: permissions.has('tenant:manage'),
|
||
canViewObservability: permissions.has('observe:read'),
|
||
roles,
|
||
};
|
||
}
|
||
```
|
||
|
||
Note: This requires adding `currentOrgRoles` to the org store. Update `ui/src/auth/useOrganization.ts` to include it:
|
||
|
||
Add to the `OrgState` interface:
|
||
```typescript
|
||
currentOrgRoles: string[] | null;
|
||
setCurrentOrgRoles: (roles: string[] | null) => void;
|
||
```
|
||
|
||
Add to the store create:
|
||
```typescript
|
||
currentOrgRoles: null,
|
||
setCurrentOrgRoles: (roles) => set({ currentOrgRoles: roles }),
|
||
```
|
||
|
||
Then update `OrgResolver.tsx` to set org roles from the `/api/me` response (the backend would need to return `orgRoles` — or extract from the token claims on the frontend side). For now, the org roles can be hardcoded from the OrgResolver after calling `/api/me`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add ui/src/auth/useAuth.ts ui/src/hooks/usePermissions.ts ui/src/auth/useOrganization.ts
|
||
git commit -m "feat: rewrite frontend auth — roles from org store, Logto org role names"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3: Infrastructure Updates
|
||
|
||
### Task 15: Update docker-compose.yml
|
||
|
||
**Files:**
|
||
- Modify: `docker-compose.yml`
|
||
|
||
- [ ] **Step 1: Remove ForwardAuth labels from cameleer-saas service**
|
||
|
||
In `docker-compose.yml`, remove these two labels from `cameleer-saas` (lines 122-124):
|
||
|
||
```yaml
|
||
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
|
||
- traefik.http.routers.forwardauth.service=forwardauth
|
||
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
|
||
```
|
||
|
||
- [ ] **Step 2: Remove ForwardAuth middleware from cameleer-server**
|
||
|
||
In `docker-compose.yml`, remove the forward-auth middleware labels from `cameleer-server` (lines 158-159):
|
||
|
||
```yaml
|
||
- traefik.http.routers.observe.middlewares=forward-auth
|
||
- traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
|
||
```
|
||
|
||
And change line 163 from:
|
||
```yaml
|
||
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
|
||
```
|
||
to:
|
||
```yaml
|
||
- traefik.http.routers.dashboard.middlewares=dashboard-strip
|
||
```
|
||
|
||
- [ ] **Step 3: Remove keys volume mount from cameleer-saas**
|
||
|
||
Remove line 99:
|
||
```yaml
|
||
- ./keys:/etc/cameleer/keys:ro
|
||
```
|
||
|
||
- [ ] **Step 4: Remove dead env vars, add OIDC env vars**
|
||
|
||
In `cameleer-saas` environment, remove:
|
||
```yaml
|
||
CAMELEER_JWT_PRIVATE_KEY_PATH: ${CAMELEER_JWT_PRIVATE_KEY_PATH:-}
|
||
CAMELEER_JWT_PUBLIC_KEY_PATH: ${CAMELEER_JWT_PUBLIC_KEY_PATH:-}
|
||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||
```
|
||
|
||
In `cameleer-server` environment, add:
|
||
```yaml
|
||
CAMELEER_OIDC_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
|
||
CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add docker-compose.yml
|
||
git commit -m "infra: remove ForwardAuth, keys mount, add OIDC env vars for server"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: Update bootstrap script
|
||
|
||
**Files:**
|
||
- Modify: `docker/logto-bootstrap.sh`
|
||
|
||
- [ ] **Step 1: Add OIDC env vars to bootstrap output**
|
||
|
||
In `docker/logto-bootstrap.sh`, add to the bootstrap JSON output (around line 431):
|
||
|
||
After `"tenantAdminUser"`, add:
|
||
```json
|
||
"oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
|
||
"oidcAudience": "$API_RESOURCE_INDICATOR"
|
||
```
|
||
|
||
- [ ] **Step 2: Remove direct psql reads for existing app secrets**
|
||
|
||
The script reads Logto's `applications` table directly via `psql` for M2M and Traditional app secrets when apps already exist (lines 155-156, 193-194). Replace with reading from the bootstrap JSON file if it exists:
|
||
|
||
At the top of the script (after variable declarations), add:
|
||
```bash
|
||
# Read cached secrets from previous run
|
||
if [ -f "$BOOTSTRAP_FILE" ]; then
|
||
CACHED_M2M_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
|
||
CACHED_TRAD_SECRET=$(jq -r '.tradAppSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
|
||
fi
|
||
```
|
||
|
||
Then replace the `psql` fallbacks with:
|
||
```bash
|
||
M2M_SECRET="${CACHED_M2M_SECRET:-}"
|
||
TRAD_SECRET="${CACHED_TRAD_SECRET:-}"
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add docker/logto-bootstrap.sh
|
||
git commit -m "infra: add OIDC config to bootstrap output, stop reading Logto DB for secrets"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Checklist
|
||
|
||
| Spec Requirement | Task |
|
||
|-----------------|------|
|
||
| Delete custom JWT stack (JwtService, filter, config, entities) | Task 5 |
|
||
| Delete ForwardAuthController | Task 5 |
|
||
| Delete PasswordEncoder bean | Task 7 (SecurityConfig rewrite) |
|
||
| Delete old migrations V001-V003 | Task 5 + Task 6 |
|
||
| Delete Ed25519 key config from application.yml | Task 7 |
|
||
| Rewrite SecurityConfig (single chain, OAuth2 RS) | Task 7 |
|
||
| Add JwtAuthenticationConverter for Logto roles | Task 7 |
|
||
| Rewrite MeController (JWT claims) | Task 8 |
|
||
| Rewrite TenantController (@PreAuthorize) | Task 9 |
|
||
| Add ApiKeyEntity + migration | Task 6 + Task 10 |
|
||
| Add ApiKeyService | Task 10 |
|
||
| Update EnvironmentEntity (remove bootstrap_token) | Task 11 |
|
||
| Simplify LogtoManagementClient | Task 12 |
|
||
| Update TestSecurityConfig | Task 13 |
|
||
| Rewrite frontend useAuth.ts | Task 14 |
|
||
| Rewrite frontend usePermissions.ts | Task 14 |
|
||
| Remove Traefik ForwardAuth | Task 15 |
|
||
| Remove keys mount from docker-compose | Task 15 |
|
||
| Add OIDC env vars to server | Task 15 |
|
||
| Update bootstrap script | Task 16 |
|
||
| Server: add oauth2-resource-server dep | Task 1 |
|
||
| Server: add SecurityProperties fields | Task 1 |
|
||
| Server: conditional OIDC JwtDecoder | Task 2 |
|
||
| Server: JwtAuthenticationFilter OIDC fallback | Task 3 |
|
||
| Server: wire decoder into SecurityConfig | Task 4 |
|
||
| Agent: no changes | N/A (verified) |
|