Files
cameleer-saas/docs/superpowers/specs/2026-04-05-auth-overhaul-design.md
hsiegeln 63c194dab7
Some checks failed
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
chore: rename cameleer3 to cameleer
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>
2026-04-15 15:28:44 +02:00

32 KiB

Authentication & Authorization Overhaul

Date: 2026-04-05 Status: Draft Scope: cameleer-saas (large), cameleer-server (small), cameleer 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 cameleer-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 cameleer-server HS256 (symmetric) Issuing server only Agents (after registration)
API key (opaque) SaaS platform N/A (hashed at rest) cameleer-server (bootstrap validator) Agent initial registration
Ed25519 signature cameleer-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 → cameleer-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 → cameleer-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 → cameleer-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

cameleer (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.

cameleer-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: cameleer-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: cameleer-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) are deleted entirely — greenfield, no production data. Replace with clean migrations containing only the tables actually needed.

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 cameleer-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 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.

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 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:

# Add to the cameleer-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:

  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.


Greenfield Approach

This is a new development — no production data exists. All database schemas, migrations, and code are written fresh without backward-compatibility constraints.

Database

  • 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.

Implementation Order

  1. Phase 1: Update cameleer-server (add OIDC resource server support). Deploy.
  2. Phase 2: Rewrite cameleer-saas backend (clean security config, API key management, Logto-only auth). 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.