docs: add auth overhaul design spec
Comprehensive design for replacing the incoherent three-system auth with Logto-centric architecture: OAuth2 Resource Server for humans, API keys for agents, zero trust (no header identity), server-per-tenant. Covers cameleer-saas (large), cameleer3-server (small), agent (none). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
759
docs/superpowers/specs/2026-04-05-auth-overhaul-design.md
Normal file
759
docs/superpowers/specs/2026-04-05-auth-overhaul-design.md
Normal file
@@ -0,0 +1,759 @@
|
|||||||
|
# Authentication & Authorization Overhaul
|
||||||
|
|
||||||
|
**Date:** 2026-04-05
|
||||||
|
**Status:** Draft
|
||||||
|
**Scope:** cameleer-saas (large), cameleer3-server (small), cameleer3 agent (none)
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current cameleer-saas authentication implementation has three overlapping identity systems that don't compose: Logto OIDC tokens, a hand-rolled Ed25519 JWT stack, and vestigial local user/role/permission tables. The `ForwardAuthController` validates custom JWTs but users carry Logto tokens. The `machineTokenFilter` is wired into both filter chains. Agent auth appears broken (bootstrap token is a plain string but the filter expects an Ed25519 JWT). Authorization calls Logto's Management API at request time instead of reading JWT claims. Frontend permissions are hardcoded with role names that don't match Logto.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. **Logto is the single identity provider** for all human users across all components.
|
||||||
|
2. **Zero trust** — every service validates tokens independently via JWKS or its own signing key. No identity in HTTP headers. The JWT is the proof.
|
||||||
|
3. **No custom crypto** — use standard libraries and protocols (OAuth2, OIDC, JWT). No hand-rolled JWT generation or validation.
|
||||||
|
4. **Server-per-tenant** — each tenant gets their own cameleer3-server instance. The SaaS platform provisions and manages them.
|
||||||
|
5. **API keys for agents** — per-environment opaque secrets, exchanged for server-issued JWTs via the existing bootstrap registration flow.
|
||||||
|
6. **Self-hosted compatible** — same stack, single Logto org, single tenant. No special code paths.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ Logto │ ── OIDC Provider (all humans)
|
||||||
|
│ (self-host) │ ── JWKS endpoint for token validation
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
┌─────────────────┼─────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────────┐ ┌──────────────┐ ┌───────────────┐
|
||||||
|
│ cameleer-saas │ │ c3-server │ │ c3-server │
|
||||||
|
│ (SaaS API) │ │ (tenant A) │ │ (tenant B) │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ Validates: │ │ Validates: │ │ Validates: │
|
||||||
|
│ - Logto JWT │ │ - Own HMAC │ │ - Own HMAC │
|
||||||
|
│ (users) │ │ JWT(agents)│ │ JWT(agents) │
|
||||||
|
│ - Logto M2M │ │ - Logto JWT │ │ - Logto JWT │
|
||||||
|
│ (↔ servers) │ │ (M2M+OIDC) │ │ (M2M+OIDC) │
|
||||||
|
└────────────────┘ └──────────────┘ └───────────────┘
|
||||||
|
▲
|
||||||
|
│ API key → register → JWT
|
||||||
|
┌──────┴───────┐
|
||||||
|
│ Agent │
|
||||||
|
│ (per-env) │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token types and who validates what
|
||||||
|
|
||||||
|
| Token | Issuer | Algorithm | Validator | Used by |
|
||||||
|
|-------|--------|-----------|-----------|---------|
|
||||||
|
| Logto user JWT | Logto | ES384 (asymmetric) | Any service via JWKS | SaaS UI users, server dashboard users |
|
||||||
|
| Logto M2M JWT | Logto | ES384 (asymmetric) | Any service via JWKS | SaaS platform → server API calls |
|
||||||
|
| Server internal JWT | cameleer3-server | HS256 (symmetric) | Issuing server only | Agents (after registration) |
|
||||||
|
| API key (opaque) | SaaS platform | N/A (hashed at rest) | cameleer3-server (bootstrap validator) | Agent initial registration |
|
||||||
|
| Ed25519 signature | cameleer3-server | EdDSA | Agent | Server → agent command integrity |
|
||||||
|
|
||||||
|
### Authentication flows
|
||||||
|
|
||||||
|
**Human user → SaaS Platform:**
|
||||||
|
1. User authenticates with Logto (OIDC authorization code flow via `@logto/react`)
|
||||||
|
2. Frontend obtains org-scoped access token via `getAccessToken(resource, orgId)`
|
||||||
|
3. Backend validates via Logto's JWKS (Spring OAuth2 Resource Server)
|
||||||
|
4. `organization_id` claim in JWT → resolves to internal tenant ID
|
||||||
|
5. Roles come from JWT claims (Logto org roles), not Management API calls
|
||||||
|
|
||||||
|
**Human user → cameleer3-server dashboard:**
|
||||||
|
1. User authenticates with Logto (OIDC flow, server configured via existing admin API)
|
||||||
|
2. Server exchanges auth code for ID token, validates via provider JWKS
|
||||||
|
3. Server issues internal HMAC JWT with mapped roles
|
||||||
|
4. Existing flow, no changes needed
|
||||||
|
|
||||||
|
**SaaS platform → cameleer3-server API (M2M):**
|
||||||
|
1. SaaS platform obtains Logto M2M access token (`client_credentials` grant)
|
||||||
|
2. Calls tenant server API with `Authorization: Bearer <logto-m2m-token>`
|
||||||
|
3. Server validates via Logto JWKS (new capability — see server changes below)
|
||||||
|
4. Server grants ADMIN role to valid M2M tokens
|
||||||
|
|
||||||
|
**Agent → cameleer3-server:**
|
||||||
|
1. Agent reads `CAMELEER_API_KEY` env var (fallback: `CAMELEER_AUTH_TOKEN` for backward compat)
|
||||||
|
2. Calls `POST /api/v1/agents/register` with `Authorization: Bearer <api-key>`
|
||||||
|
3. Server validates via `BootstrapTokenValidator` (constant-time comparison, unchanged)
|
||||||
|
4. Server issues internal HMAC JWT (access + refresh) + Ed25519 public key
|
||||||
|
5. Agent uses JWT for all subsequent requests, refreshes on expiry
|
||||||
|
6. Existing flow, no changes needed
|
||||||
|
|
||||||
|
**Server → Agent (commands):**
|
||||||
|
1. Server signs command payload with Ed25519 private key
|
||||||
|
2. Sends via SSE with signature field
|
||||||
|
3. Agent verifies using server's public key (received at registration)
|
||||||
|
4. Destructive commands require nonce (replay protection)
|
||||||
|
5. Existing flow, no changes needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Changes
|
||||||
|
|
||||||
|
### cameleer3 (agent) — NO CHANGES
|
||||||
|
|
||||||
|
The agent's authentication flow is correct as designed:
|
||||||
|
- Reads API key from environment variable
|
||||||
|
- Exchanges for JWT via registration endpoint
|
||||||
|
- Uses JWT for all requests, auto-refreshes, re-registers on failure
|
||||||
|
- Verifies Ed25519 signatures on server commands
|
||||||
|
|
||||||
|
The only optional change is renaming `CAMELEER_AUTH_TOKEN` to `CAMELEER_API_KEY` for clarity, with backward-compatible fallback. This is cosmetic and can be done at any time.
|
||||||
|
|
||||||
|
### cameleer3-server — SMALL CHANGES
|
||||||
|
|
||||||
|
The server needs one new capability: accepting Logto access tokens (asymmetric JWT) in addition to its own internal HMAC JWTs. This enables the SaaS platform to call server APIs using M2M tokens.
|
||||||
|
|
||||||
|
#### Change 1: Add `spring-boot-starter-oauth2-resource-server` dependency
|
||||||
|
|
||||||
|
**File:** `cameleer3-server-app/pom.xml`
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Change 2: Add OIDC resource server properties
|
||||||
|
|
||||||
|
**File:** `cameleer3-server-app/src/main/resources/application.yml`
|
||||||
|
|
||||||
|
Add under `security:`:
|
||||||
|
```yaml
|
||||||
|
security:
|
||||||
|
# ... existing properties unchanged ...
|
||||||
|
oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
|
||||||
|
oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `SecurityProperties.java`
|
||||||
|
|
||||||
|
Add two new fields:
|
||||||
|
```java
|
||||||
|
private String oidcIssuerUri; // Logto issuer URI for M2M token validation
|
||||||
|
private String oidcAudience; // Expected audience (API resource indicator)
|
||||||
|
// + getters/setters
|
||||||
|
```
|
||||||
|
|
||||||
|
These are optional — when blank, the server behaves exactly as before (no OIDC resource server). When set, the server accepts Logto tokens in addition to internal tokens.
|
||||||
|
|
||||||
|
#### Change 3: Modify `JwtAuthenticationFilter` to try Logto validation as fallback
|
||||||
|
|
||||||
|
**File:** `JwtAuthenticationFilter.java`
|
||||||
|
|
||||||
|
Current behavior: extracts Bearer token, validates with `JwtService` (HMAC), sets auth context.
|
||||||
|
|
||||||
|
New behavior: extracts Bearer token, tries `JwtService` (HMAC) first. If HMAC validation fails AND an OIDC JwtDecoder is configured, try validating as a Logto token via JWKS. If that succeeds, extract claims and set auth context with appropriate roles.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final AgentRegistryService agentRegistryService;
|
||||||
|
private final org.springframework.security.oauth2.jwt.JwtDecoder oidcDecoder; // nullable
|
||||||
|
|
||||||
|
public JwtAuthenticationFilter(JwtService jwtService,
|
||||||
|
AgentRegistryService agentRegistryService,
|
||||||
|
org.springframework.security.oauth2.jwt.JwtDecoder oidcDecoder) {
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.agentRegistryService = agentRegistryService;
|
||||||
|
this.oidcDecoder = oidcDecoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(...) {
|
||||||
|
String token = extractToken(request);
|
||||||
|
if (token != null) {
|
||||||
|
// Try internal HMAC token first (agents, local users)
|
||||||
|
if (tryInternalToken(token, request)) {
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fall back to OIDC token (SaaS M2M, OIDC users)
|
||||||
|
if (oidcDecoder != null) {
|
||||||
|
tryOidcToken(token, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean tryInternalToken(String token, HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||||
|
// ... existing auth setup (unchanged) ...
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void tryOidcToken(String token, HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
var jwt = oidcDecoder.decode(token);
|
||||||
|
String subject = jwt.getSubject();
|
||||||
|
// M2M tokens: grant ADMIN role (SaaS platform managing this server)
|
||||||
|
// OIDC user tokens: map roles from claims
|
||||||
|
List<String> roles = extractRolesFromOidcToken(jwt);
|
||||||
|
List<GrantedAuthority> authorities = toAuthorities(roles);
|
||||||
|
var 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(org.springframework.security.oauth2.jwt.Jwt jwt) {
|
||||||
|
// M2M tokens (no sub or sub matches client_id) get ADMIN
|
||||||
|
// User tokens get roles from configured claim path
|
||||||
|
String sub = jwt.getSubject();
|
||||||
|
Object clientId = jwt.getClaim("client_id");
|
||||||
|
if (clientId != null && clientId.toString().equals(sub)) {
|
||||||
|
// M2M token — grant admin access
|
||||||
|
return List.of("ADMIN");
|
||||||
|
}
|
||||||
|
// User OIDC token — read roles from claim (reuse OidcConfig.rolesClaim)
|
||||||
|
return List.of("VIEWER"); // safe default, can be enhanced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Change 4: Create OIDC JwtDecoder bean (conditional)
|
||||||
|
|
||||||
|
**File:** `SecurityBeanConfig.java`
|
||||||
|
|
||||||
|
Add a conditional bean that creates a Spring `JwtDecoder` when OIDC issuer is configured:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "security.oidc-issuer-uri", matchIfMissing = false)
|
||||||
|
public org.springframework.security.oauth2.jwt.JwtDecoder oidcJwtDecoder(
|
||||||
|
SecurityProperties properties) {
|
||||||
|
NimbusJwtDecoder decoder = NimbusJwtDecoder
|
||||||
|
.withIssuerLocation(properties.getOidcIssuerUri())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Logto uses typ "at+jwt" — accept both "JWT" and "at+jwt"
|
||||||
|
// (same workaround as cameleer-saas SecurityConfig)
|
||||||
|
|
||||||
|
// Validate issuer + audience
|
||||||
|
OAuth2TokenValidator<Jwt> validators;
|
||||||
|
if (properties.getOidcAudience() != null && !properties.getOidcAudience().isBlank()) {
|
||||||
|
validators = new DelegatingOAuth2TokenValidator<>(
|
||||||
|
JwtValidators.createDefaultWithIssuer(properties.getOidcIssuerUri()),
|
||||||
|
new JwtClaimValidator<List<String>>("aud",
|
||||||
|
aud -> aud != null && aud.contains(properties.getOidcAudience()))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
validators = JwtValidators.createDefaultWithIssuer(properties.getOidcIssuerUri());
|
||||||
|
}
|
||||||
|
decoder.setJwtValidator(validators);
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When the env var `CAMELEER_OIDC_ISSUER_URI` is not set, no OIDC decoder bean is created, the `JwtAuthenticationFilter` constructor receives `null`, and the server behaves exactly as before. Zero impact on self-hosted customers who don't use the SaaS platform.
|
||||||
|
|
||||||
|
#### Change 5: Wire the optional decoder into `SecurityConfig`
|
||||||
|
|
||||||
|
**File:** `SecurityConfig.java`
|
||||||
|
|
||||||
|
Update the filter chain to pass the optional OIDC decoder:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http,
|
||||||
|
JwtService jwtService,
|
||||||
|
AgentRegistryService registryService,
|
||||||
|
CorsConfigurationSource corsConfigurationSource,
|
||||||
|
@Autowired(required = false)
|
||||||
|
org.springframework.security.oauth2.jwt.JwtDecoder oidcDecoder) throws Exception {
|
||||||
|
// ... existing config unchanged ...
|
||||||
|
.addFilterBefore(
|
||||||
|
new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder),
|
||||||
|
UsernamePasswordAuthenticationFilter.class
|
||||||
|
);
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Change 6: Accepted algorithm for Logto tokens
|
||||||
|
|
||||||
|
Logto issues tokens with `typ: at+jwt` (RFC 9068) and signs with ES384. The `NimbusJwtDecoder` created via `withIssuerLocation()` auto-discovers the JWKS and supported algorithms from the OIDC discovery document. The same `at+jwt` type workaround used in cameleer-saas is needed here.
|
||||||
|
|
||||||
|
Build the decoder manually instead of using `withIssuerLocation()` to control the JWT processor type verifier:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// In SecurityBeanConfig, replace withIssuerLocation with:
|
||||||
|
var jwkSetUri = properties.getOidcIssuerUri() + "/jwks"; // or discover from .well-known
|
||||||
|
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, ctx) -> { /* accept any type */ });
|
||||||
|
var decoder = new NimbusJwtDecoder(processor);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Summary of server file changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `pom.xml` | Add `spring-boot-starter-oauth2-resource-server` |
|
||||||
|
| `application.yml` | Add `security.oidc-issuer-uri` and `security.oidc-audience` |
|
||||||
|
| `SecurityProperties.java` | Add `oidcIssuerUri` and `oidcAudience` fields |
|
||||||
|
| `SecurityBeanConfig.java` | Add conditional `JwtDecoder` bean |
|
||||||
|
| `SecurityConfig.java` | Pass optional `JwtDecoder` to filter constructor |
|
||||||
|
| `JwtAuthenticationFilter.java` | Add OIDC fallback path (try HMAC first, then JWKS) |
|
||||||
|
|
||||||
|
All changes are additive. No existing behavior is modified. When `CAMELEER_OIDC_ISSUER_URI` is not set, the server is identical to today.
|
||||||
|
|
||||||
|
#### Docker / provisioning
|
||||||
|
|
||||||
|
When the SaaS platform provisions a tenant server, it sets:
|
||||||
|
```
|
||||||
|
CAMELEER_OIDC_ISSUER_URI=http://logto:3001/oidc
|
||||||
|
CAMELEER_OIDC_AUDIENCE=https://api.cameleer.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Self-hosted customers who don't use the SaaS platform leave these blank — the server works exactly as before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### cameleer-saas — LARGE CHANGES
|
||||||
|
|
||||||
|
#### DELETE: Custom JWT stack
|
||||||
|
|
||||||
|
These files are removed entirely:
|
||||||
|
|
||||||
|
| File | Reason |
|
||||||
|
|------|--------|
|
||||||
|
| `src/main/java/.../auth/JwtService.java` | Hand-rolled Ed25519 JWT. Replaced by Spring OAuth2 Resource Server. |
|
||||||
|
| `src/main/java/.../auth/JwtAuthenticationFilter.java` | Custom filter. Replaced by Spring's `BearerTokenAuthenticationFilter` + API key filter. |
|
||||||
|
| `src/main/java/.../config/JwtConfig.java` | Ed25519 key loading. SaaS platform does not sign tokens. |
|
||||||
|
| `src/main/java/.../auth/UserEntity.java` | Users live in Logto, not local DB. |
|
||||||
|
| `src/main/java/.../auth/UserRepository.java` | Unused. |
|
||||||
|
| `src/main/java/.../auth/RoleEntity.java` | Roles live in Logto, not local DB. |
|
||||||
|
| `src/main/java/.../auth/RoleRepository.java` | Unused. |
|
||||||
|
| `src/main/java/.../auth/PermissionEntity.java` | Unused. |
|
||||||
|
| `src/main/java/.../config/ForwardAuthController.java` | Identity-in-headers pattern violates zero trust. |
|
||||||
|
|
||||||
|
Remove the `PasswordEncoder` bean from `SecurityConfig.java`.
|
||||||
|
|
||||||
|
Database migrations V001 (users table), V002 (roles/permissions tables), V003 (default role seed) should be replaced with a single migration that drops these tables if they contain no production data, or left as-is and simply unused if data migration is a concern.
|
||||||
|
|
||||||
|
#### DELETE: Ed25519 key configuration
|
||||||
|
|
||||||
|
Remove from `application.yml`:
|
||||||
|
```yaml
|
||||||
|
cameleer:
|
||||||
|
jwt:
|
||||||
|
expiration: 86400
|
||||||
|
private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
|
||||||
|
public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the `keys/` directory mount from `docker-compose.yml`. The SaaS platform does not sign anything — Ed25519 signing lives in cameleer3-server only.
|
||||||
|
|
||||||
|
#### REWRITE: `SecurityConfig.java`
|
||||||
|
|
||||||
|
Replace the current two-filter-chain setup with a single clean chain:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@EnableMethodSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final TenantResolutionFilter tenantResolutionFilter;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.sessionManagement(s -> s.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 -> {}))
|
||||||
|
.addFilterAfter(tenantResolutionFilter,
|
||||||
|
BearerTokenAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
// Same Logto at+jwt workaround as current code, minus the custom JWT filter
|
||||||
|
var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build();
|
||||||
|
var keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.ES384, jwkSource);
|
||||||
|
var processor = new DefaultJWTProcessor<SecurityContext>();
|
||||||
|
processor.setJWSKeySelector(keySelector);
|
||||||
|
processor.setJWSTypeVerifier((type, ctx) -> { /* accept any type */ });
|
||||||
|
|
||||||
|
var decoder = new NimbusJwtDecoder(processor);
|
||||||
|
if (issuerUri != null && !issuerUri.isEmpty()) {
|
||||||
|
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri));
|
||||||
|
}
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No more `machineTokenFilter`. No more `PasswordEncoder`. No more dual filter chains. Agent traffic does not reach the SaaS platform — it goes directly to the tenant's server.
|
||||||
|
|
||||||
|
#### REWRITE: `MeController.java`
|
||||||
|
|
||||||
|
Stop calling Logto Management API on every request. Read everything from the JWT:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@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 membership from JWT claims (Logto includes this when
|
||||||
|
// org-scoped token is requested with UserScope.Organizations)
|
||||||
|
String orgId = jwt.getClaimAsString("organization_id");
|
||||||
|
List<String> orgRoles = jwt.getClaimAsStringList("organization_roles");
|
||||||
|
|
||||||
|
// Check platform admin via Logto global roles in token
|
||||||
|
// (Logto custom JWT feature puts roles in access token)
|
||||||
|
List<String> globalRoles = jwt.getClaimAsStringList("roles");
|
||||||
|
boolean isPlatformAdmin = globalRoles != null
|
||||||
|
&& globalRoles.contains("platform-admin");
|
||||||
|
|
||||||
|
// Resolve tenant from org
|
||||||
|
var tenant = orgId != null
|
||||||
|
? tenantService.getByLogtoOrgId(orgId).orElse(null) : null;
|
||||||
|
|
||||||
|
List<Map<String, Object>> 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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: If the user has multiple orgs, the frontend requests a separate token per org. The `/api/me` endpoint returns the tenant for the org in the current token. The frontend's `OrgResolver` can call `/api/me` once with a non-org-scoped token to get the list, then switch to org-scoped tokens.
|
||||||
|
|
||||||
|
For multi-org enumeration (the `OrgResolver` initial load), `LogtoManagementClient` is still needed — but only on this one cold-start path, not on every request. This is acceptable. Over time, Logto's organization token claims will make this unnecessary.
|
||||||
|
|
||||||
|
#### REWRITE: `TenantController.java` authorization
|
||||||
|
|
||||||
|
Replace manual role-checking via Management API with `@PreAuthorize`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_platform-admin') or hasRole('platform-admin')")
|
||||||
|
public ResponseEntity<List<TenantResponse>> listAll() {
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
tenantService.findAll().stream().map(this::toResponse).toList());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires configuring a `JwtAuthenticationConverter` that maps Logto's role claims to Spring Security authorities. Add to `SecurityConfig`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then wire it: `.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))`.
|
||||||
|
|
||||||
|
#### REWRITE: `TenantResolutionFilter.java`
|
||||||
|
|
||||||
|
Keep the concept, minor cleanup. The current code is correct — extracts `organization_id` from JWT, resolves to internal tenant. No functional change needed, just remove the import of the deleted `JwtAuthenticationFilter`.
|
||||||
|
|
||||||
|
#### NEW: API key management
|
||||||
|
|
||||||
|
**New entity: `ApiKeyEntity`**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@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; // SHA-256 hex
|
||||||
|
|
||||||
|
@Column(name = "key_prefix", nullable = false, length = 8)
|
||||||
|
private String keyPrefix; // First 8 chars, for identification
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
private String status = "ACTIVE"; // ACTIVE, ROTATED, REVOKED
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(name = "revoked_at")
|
||||||
|
private Instant revokedAt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New migration: `V011__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(8) 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);
|
||||||
|
```
|
||||||
|
|
||||||
|
**New service: `ApiKeyService`**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class ApiKeyService {
|
||||||
|
|
||||||
|
public record GeneratedKey(String plaintext, String keyHash, String prefix) {}
|
||||||
|
|
||||||
|
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 Optional<ApiKeyEntity> validate(String plaintext) {
|
||||||
|
String hash = sha256Hex(plaintext);
|
||||||
|
return repository.findByKeyHashAndStatus(hash, "ACTIVE");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiKeyEntity rotate(UUID environmentId) {
|
||||||
|
// Mark existing keys as ROTATED (still valid during grace period)
|
||||||
|
// Create new key
|
||||||
|
// Return new key entity
|
||||||
|
}
|
||||||
|
|
||||||
|
public void revoke(UUID keyId) {
|
||||||
|
// Mark as REVOKED, set revokedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `cmk_` prefix (cameleer key) makes API keys visually identifiable and greppable in logs/configs.
|
||||||
|
|
||||||
|
**Updated `EnvironmentService.create()`:**
|
||||||
|
|
||||||
|
When creating an environment, auto-generate an API key:
|
||||||
|
```java
|
||||||
|
var key = apiKeyService.generate();
|
||||||
|
// Store hash in api_keys table
|
||||||
|
// Return plaintext to caller (shown once, never stored in plaintext)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `bootstrap_token` column on `EnvironmentEntity` becomes the plaintext API key that's injected into the server and agent containers. In a future migration, rename this column to `api_key_ref` or similar. For now, keep the column and populate it with the generated key.
|
||||||
|
|
||||||
|
#### REWRITE: Frontend auth
|
||||||
|
|
||||||
|
**`useAuth.ts`** — Read roles from access token, not ID token:
|
||||||
|
|
||||||
|
The current code reads `claims?.roles` from `getIdTokenClaims()`. Logto puts roles in access tokens, not ID tokens. The fix: roles come from the `/api/me` endpoint (which reads from the JWT on the backend) and are stored in the org store, not extracted client-side from token claims.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useAuth() {
|
||||||
|
const { isAuthenticated, isLoading, signOut, signIn } = useLogto();
|
||||||
|
const { currentTenantId, isPlatformAdmin, organizations } = useOrgStore();
|
||||||
|
|
||||||
|
// Roles come from the org store (populated by OrgResolver from /api/me)
|
||||||
|
// Not from token claims
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
signOut(window.location.origin + '/login');
|
||||||
|
}, [signOut]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
tenantId: currentTenantId,
|
||||||
|
isPlatformAdmin,
|
||||||
|
logout,
|
||||||
|
signIn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`usePermissions.ts`** — Map Logto org roles to permissions:
|
||||||
|
|
||||||
|
Replace hardcoded `OWNER/ADMIN/DEVELOPER/VIEWER` with Logto org role names:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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'],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Role names must match the Logto organization roles created by the bootstrap script (`admin`, `member`). Additional roles (e.g., `viewer`, `operator`) can be added to Logto and mapped here.
|
||||||
|
|
||||||
|
**`OrgResolver.tsx`** — Keep as-is. It calls `/api/me` and populates the org store. The backend now reads from JWT claims instead of calling the Management API, so this is faster.
|
||||||
|
|
||||||
|
**`ProtectedRoute.tsx`** — Keep as-is.
|
||||||
|
|
||||||
|
**`main.tsx` (TokenSync)** — Keep as-is. Already correctly requests org-scoped tokens.
|
||||||
|
|
||||||
|
#### REWRITE: `LogtoManagementClient.java`
|
||||||
|
|
||||||
|
Keep this service but reduce its usage. It's still needed for:
|
||||||
|
- Creating organizations when a new tenant is provisioned
|
||||||
|
- Adding users to organizations
|
||||||
|
- Deleting organizations
|
||||||
|
- Enumerating user organizations (for `OrgResolver` initial load — until Logto puts full org list in token claims)
|
||||||
|
|
||||||
|
Remove `getUserRoles()` — roles come from JWT claims now.
|
||||||
|
|
||||||
|
#### REWRITE: `PublicConfigController.java`
|
||||||
|
|
||||||
|
Keep as-is. Serves frontend configuration. No auth changes needed.
|
||||||
|
|
||||||
|
#### REWRITE: Bootstrap script (`docker/logto-bootstrap.sh`)
|
||||||
|
|
||||||
|
Update to set `CAMELEER_OIDC_ISSUER_URI` and `CAMELEER_OIDC_AUDIENCE` on the tenant server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add to the cameleer3-server environment in docker-compose or bootstrap output:
|
||||||
|
CAMELEER_OIDC_ISSUER_URI=http://logto:3001/oidc
|
||||||
|
CAMELEER_OIDC_AUDIENCE=https://api.cameleer.local
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap script should also stop reading Logto's internal database for secrets. Instead, create the M2M app via Management API and capture the returned `secret` from the API response (which it already does for new apps — the `psql` fallback is only for retrieving secrets of existing apps). For idempotency, store the M2M secret in the bootstrap JSON file and re-read it on subsequent runs.
|
||||||
|
|
||||||
|
#### REMOVE: Traefik ForwardAuth middleware
|
||||||
|
|
||||||
|
Remove from `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
# DELETE these labels:
|
||||||
|
traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
|
||||||
|
traefik.http.services.forwardauth.loadbalancer.server.port=8080
|
||||||
|
traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Each service validates tokens independently. No proxy-mediated trust.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logto Configuration Requirements
|
||||||
|
|
||||||
|
For the JWT claims to contain the information needed (roles, org_id, org_roles), Logto must be configured to include custom claims in access tokens. This is done via Logto's **Custom JWT** feature:
|
||||||
|
|
||||||
|
1. **Global roles in access token**: Configure Logto to include user roles in the access token's `roles` claim. This may require a custom JWT script in Logto's admin console.
|
||||||
|
|
||||||
|
2. **Organization roles in access token**: When a token is requested with an organization scope, Logto includes `organization_id` and `organization_roles` in the token by default.
|
||||||
|
|
||||||
|
3. **API resource**: The `https://api.cameleer.local` resource must be created in Logto and configured to accept organization tokens.
|
||||||
|
|
||||||
|
The bootstrap script already creates the API resource and roles. Verify that the Logto custom JWT configuration includes roles in the access token payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
1. **New migration V011**: Create `api_keys` table.
|
||||||
|
2. **New migration V012**: (Optional) Drop `users`, `roles`, `permissions`, `user_roles`, `role_permissions` tables. Or leave them unused — no runtime cost, avoids risk.
|
||||||
|
3. The `bootstrap_token` column on `environments` stays for now. Its value becomes the API key plaintext (stored temporarily for injection into containers).
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
|
||||||
|
- **Agent**: No breaking change. `CAMELEER_AUTH_TOKEN` continues to work.
|
||||||
|
- **Server**: No breaking change when `CAMELEER_OIDC_ISSUER_URI` is unset.
|
||||||
|
- **SaaS frontend**: Breaking change — org role names change. Deployed atomically with backend.
|
||||||
|
- **Self-hosted**: No impact. They don't set `CAMELEER_OIDC_ISSUER_URI`, server behaves as before.
|
||||||
|
|
||||||
|
### Rollout Order
|
||||||
|
|
||||||
|
1. **Phase 1**: Update cameleer3-server (add OIDC resource server support). Deploy. Backward compatible.
|
||||||
|
2. **Phase 2**: Update cameleer-saas backend (delete custom JWT stack, add API key management, rewrite security config). Deploy with frontend changes atomically.
|
||||||
|
3. **Phase 3**: Update bootstrap script (set OIDC env vars on server, stop reading Logto DB directly).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Properties
|
||||||
|
|
||||||
|
| Property | Status |
|
||||||
|
|----------|--------|
|
||||||
|
| All human auth via Logto OIDC | Yes |
|
||||||
|
| Zero trust (JWT validated independently by each service) | Yes |
|
||||||
|
| No identity in HTTP headers | Yes (ForwardAuth deleted) |
|
||||||
|
| Server-per-tenant isolation | Yes |
|
||||||
|
| API keys hashed at rest (SHA-256) | Yes |
|
||||||
|
| API key rotation with grace period | Yes |
|
||||||
|
| Short-lived agent JWTs (1h access, 7d refresh) | Yes (server default) |
|
||||||
|
| Ed25519 command signing (integrity) | Unchanged |
|
||||||
|
| Nonce protection for destructive commands | Unchanged |
|
||||||
|
| No custom crypto | Yes (all standard: OIDC, JWKS, HMAC-SHA256, Ed25519 via JCA) |
|
||||||
|
| Self-hosted compatibility | Yes (OIDC properties optional) |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None — all design decisions resolved during brainstorming.
|
||||||
Reference in New Issue
Block a user