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
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.
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,
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,
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 */ });
Database migrations V001 (users table), V002 (roles/permissions tables), V003 (default role seed) are deleted entirely — greenfield, no production data. Replace with clean migrations containing only the tables actually needed.
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 {
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)
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.
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`.
The `bootstrap_token` column on `EnvironmentEntity` is removed. API keys are managed exclusively through the `api_keys` table. The plaintext is returned once at creation time and injected into server/agent containers.
**`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.
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.
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.
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.
This is a new development — no production data exists. All database schemas, migrations, and code are written fresh without backward-compatibility constraints.
- **Remove** migrations V001 (users), V002 (roles/permissions), V003 (default roles) entirely. These tables are not needed — users and roles live in Logto.
- **Replace** with a single clean migration that creates only the tables needed: `tenants`, `environments`, `api_keys`, `licenses`, `apps`, `deployments`, `audit_log`.
- The `bootstrap_token` column on `environments` is renamed to `api_key_plaintext` or removed in favor of the `api_keys` table exclusively.