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>
32 KiB
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
- Logto is the single identity provider for all human users across all components.
- Zero trust — every service validates tokens independently via JWKS or its own signing key. No identity in HTTP headers. The JWT is the proof.
- No custom crypto — use standard libraries and protocols (OAuth2, OIDC, JWT). No hand-rolled JWT generation or validation.
- Server-per-tenant — each tenant gets their own cameleer3-server instance. The SaaS platform provisions and manages them.
- API keys for agents — per-environment opaque secrets, exchanged for server-issued JWTs via the existing bootstrap registration flow.
- 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:
- User authenticates with Logto (OIDC authorization code flow via
@logto/react) - Frontend obtains org-scoped access token via
getAccessToken(resource, orgId) - Backend validates via Logto's JWKS (Spring OAuth2 Resource Server)
organization_idclaim in JWT → resolves to internal tenant ID- Roles come from JWT claims (Logto org roles), not Management API calls
Human user → cameleer3-server dashboard:
- User authenticates with Logto (OIDC flow, server configured via existing admin API)
- Server exchanges auth code for ID token, validates via provider JWKS
- Server issues internal HMAC JWT with mapped roles
- Existing flow, no changes needed
SaaS platform → cameleer3-server API (M2M):
- SaaS platform obtains Logto M2M access token (
client_credentialsgrant) - Calls tenant server API with
Authorization: Bearer <logto-m2m-token> - Server validates via Logto JWKS (new capability — see server changes below)
- Server grants ADMIN role to valid M2M tokens
Agent → cameleer3-server:
- Agent reads
CAMELEER_API_KEYenv var (fallback:CAMELEER_AUTH_TOKENfor backward compat) - Calls
POST /api/v1/agents/registerwithAuthorization: Bearer <api-key> - Server validates via
BootstrapTokenValidator(constant-time comparison, unchanged) - Server issues internal HMAC JWT (access + refresh) + Ed25519 public key
- Agent uses JWT for all subsequent requests, refreshes on expiry
- Existing flow, no changes needed
Server → Agent (commands):
- Server signs command payload with Ed25519 private key
- Sends via SSE with signature field
- Agent verifies using server's public key (received at registration)
- Destructive commands require nonce (replay protection)
- 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:
<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::
security:
# ... existing properties unchanged ...
oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
File: SecurityProperties.java
Add two new fields:
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.
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:
@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:
@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:
// 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:
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:
@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:
@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:
@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:
@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
@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
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
@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:
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.
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:
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
OrgResolverinitial 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:
# 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:
# 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:
-
Global roles in access token: Configure Logto to include user roles in the access token's
rolesclaim. This may require a custom JWT script in Logto's admin console. -
Organization roles in access token: When a token is requested with an organization scope, Logto includes
organization_idandorganization_rolesin the token by default. -
API resource: The
https://api.cameleer.localresource 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
- New migration V011: Create
api_keystable. - New migration V012: (Optional) Drop
users,roles,permissions,user_roles,role_permissionstables. Or leave them unused — no runtime cost, avoids risk. - The
bootstrap_tokencolumn onenvironmentsstays for now. Its value becomes the API key plaintext (stored temporarily for injection into containers).
Backward Compatibility
- Agent: No breaking change.
CAMELEER_AUTH_TOKENcontinues to work. - Server: No breaking change when
CAMELEER_OIDC_ISSUER_URIis 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
- Phase 1: Update cameleer3-server (add OIDC resource server support). Deploy. Backward compatible.
- Phase 2: Update cameleer-saas backend (delete custom JWT stack, add API key management, rewrite security config). Deploy with frontend changes atomically.
- 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.