Files
cameleer-server/docs/superpowers/specs/2026-04-05-logto-oidc-resource-server-design.md
hsiegeln ac680b7f3f
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m7s
CI / docker (push) Successful in 1m33s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m51s
SonarQube / sonarqube (push) Successful in 3m28s
refactor: prefix all third-party service names with cameleer-
Rename all Docker/K8s service names, DNS hostnames, secrets, volumes,
and manifest files to use the cameleer- prefix, making it clear which
software package each container belongs to.

Services renamed:
- postgres → cameleer-postgres
- clickhouse → cameleer-clickhouse
- logto → cameleer-logto
- logto-postgresql → cameleer-logto-postgresql
- traefik (service) → cameleer-traefik
- postgres-external → cameleer-postgres-external

Secrets renamed:
- postgres-credentials → cameleer-postgres-credentials
- clickhouse-credentials → cameleer-clickhouse-credentials
- logto-credentials → cameleer-logto-credentials

Volumes renamed:
- pgdata → cameleer-pgdata
- chdata → cameleer-chdata
- certs → cameleer-certs
- bootstrapdata → cameleer-bootstrapdata

K8s manifests renamed:
- deploy/postgres.yaml → deploy/cameleer-postgres.yaml
- deploy/clickhouse.yaml → deploy/cameleer-clickhouse.yaml
- deploy/logto.yaml → deploy/cameleer-logto.yaml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:51:08 +02:00

9.7 KiB

Replace Authentik with Logto + Add OIDC Resource Server Support

Context

Cameleer3 Server uses Authentik as its OIDC provider for external identity federation. The SaaS platform (cameleer-saas) has adopted Logto as its identity provider. To align the stack:

  1. Replace Authentik with Logto — self-hosted Logto in the K8s cluster, replacing the Authentik deployment
  2. Add OIDC resource server support — the server must accept Logto access tokens (asymmetric JWT, ES384) in addition to its own internal HMAC JWTs, so the SaaS platform can call server APIs using M2M tokens

The server currently has comprehensive OIDC support for the authorization code flow (UI users log in via external provider, exchange code for internal JWT). The new capability is orthogonal: resource server mode where the server directly validates and accepts external access tokens as Bearer tokens.

Design Decisions

  • M2M authorization uses OAuth2 scope-based role mapping (not client ID allowlists or user-claim detection). Logto API Resources define permissions (scopes). The server maps token scopes to its RBAC roles: admin scope -> ADMIN, operator -> OPERATOR, viewer -> VIEWER.
  • OIDC decoder created inline in SecurityConfig.filterChain() with a blank check on the issuer URI. No conditional bean registration — avoids @ConditionalOnProperty issues with empty-string defaults.
  • JWKS URI discovered from the OIDC well-known endpoint (not hardcoded). Logto's JWKS is at issuer/oidc/jwks.
  • at+jwt type handling: Custom type verifier that accepts any JWT type, matching the cameleer-saas workaround for RFC 9068 tokens.
  • Zero breaking changes: When CAMELEER_OIDC_ISSUER_URI is not set, the server behaves identically to today.

Part 1: Infrastructure — Replace Authentik with Logto

Delete

  • deploy/authentik.yaml (288 lines — PostgreSQL, Redis, Authentik server, Authentik worker)

Create: deploy/logto.yaml

Self-hosted Logto deployment:

  • Logto PostgreSQL StatefulSet — dedicated database for identity data (isolated from app data)
  • Logto server container (ghcr.io/logto-io/logto) — ports 3001 (API/OIDC) and 3002 (admin console)
  • K8s Services — NodePort for external access (matching Authentik's pattern)
  • Credentials from logto-credentials secret

CI/CD Changes (.gitea/workflows/ci.yml)

  • Replace authentik-credentials secret -> logto-credentials (LOGTO_PG_USER, LOGTO_PG_PASSWORD, LOGTO_ENDPOINT)
  • Replace kubectl apply -f deploy/authentik.yaml -> deploy/logto.yaml
  • Replace rollout wait for authentik-server -> logto
  • Remove AUTHENTIK_PG_USER, AUTHENTIK_PG_PASSWORD, AUTHENTIK_SECRET_KEY secret refs
  • Add OIDC resource server env vars to cameleer-auth secret or server deployment: CAMELEER_OIDC_ISSUER_URI, CAMELEER_OIDC_AUDIENCE

Part 2: Server — OIDC Resource Server Support

Change 1: Add dependency

File: cameleer3-server-app/pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Change 2: Add OIDC properties

File: cameleer3-server-app/src/main/resources/application.yml

security:
  # ... existing properties unchanged ...
  oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
  oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}

File: SecurityProperties.java

Add fields:

private String oidcIssuerUri;    // Logto issuer URI for M2M token validation
private String oidcAudience;     // Expected audience (API resource indicator)
// + getters/setters

Change 3: Build OIDC decoder inline in SecurityConfig

File: SecurityConfig.java

Build the OIDC JwtDecoder inline in the filterChain() method. When the issuer URI is blank/null, no decoder is created and the server behaves as before.

The decoder:

  1. Discovers JWKS URI from the OIDC well-known endpoint
  2. Builds a NimbusJwtDecoder with a custom type verifier (accepts at+jwt per RFC 9068)
  3. Validates issuer and optionally audience
private org.springframework.security.oauth2.jwt.JwtDecoder buildOidcDecoder(
        SecurityProperties properties) {
    // Build decoder with at+jwt type workaround (RFC 9068)
    // Discover JWKS URI from well-known endpoint, not hardcoded
    var jwkSource = JWKSourceBuilder.create(new URL(jwksUri)).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);

    // 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;
}

Change 4: Modify JwtAuthenticationFilter for OIDC fallback

File: JwtAuthenticationFilter.java

Current: extracts Bearer token, validates with JwtService (HMAC), sets auth context.

New: try HMAC first. If fails AND OIDC decoder is configured, try validating as Logto token. Map scopes to roles.

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);
        List<String> roles = extractRolesFromOidcToken(jwt);
        List<GrantedAuthority> authorities = roles.stream()
            .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
            .collect(Collectors.toList());
        var auth = new UsernamePasswordAuthenticationToken(
            "oidc:" + jwt.getSubject(), 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) {
    // Scope-based role mapping (OAuth2 standard)
    List<String> scopes = jwt.getClaimAsStringList("scope");
    if (scopes == null) {
        String scopeStr = jwt.getClaimAsString("scope");
        scopes = scopeStr != null ? List.of(scopeStr.split(" ")) : List.of();
    }
    if (scopes.contains("admin")) return List.of("ADMIN");
    if (scopes.contains("operator")) return List.of("OPERATOR");
    if (scopes.contains("viewer")) return List.of("VIEWER");
    return List.of("VIEWER"); // safe default
}

Part 3: OidcConfig Defaults + Documentation

OidcConfig defaults for Logto

File: OidcConfig.java

Update disabled() factory: rolesClaim from realm_access.roles to roles (Logto convention).

File: OidcConfigAdminController.java

Update PUT handler default: rolesClaim from realm_access.roles to roles.

Documentation updates

HOWTO.md: Replace "Authentik Setup" section with "Logto Setup" — provisioning, OIDC config values, API resource creation, M2M app setup, scope configuration.

CLAUDE.md:

  • Replace "Authentik" with "Logto" in shared infra description
  • Add OIDC resource server note

SERVER-CAPABILITIES.md: Add section documenting dual-path JWT validation (HMAC internal + OIDC external) and scope-to-role mapping.


New Environment Variables

Variable Purpose Required
CAMELEER_OIDC_ISSUER_URI Logto issuer URI (e.g., http://cameleer-logto:3001/oidc) No — when blank, no OIDC resource server
CAMELEER_OIDC_AUDIENCE Expected audience / API resource indicator No — when blank, audience not validated

Files Changed

File Action
deploy/authentik.yaml Delete
deploy/logto.yaml Create
.gitea/workflows/ci.yml Modify (Authentik -> Logto)
cameleer3-server-app/pom.xml Modify (add dependency)
application.yml Modify (add OIDC properties)
SecurityProperties.java Modify (add fields)
SecurityConfig.java Modify (build decoder, pass to filter)
JwtAuthenticationFilter.java Modify (add OIDC fallback)
OidcConfig.java Modify (default rolesClaim)
OidcConfigAdminController.java Modify (default rolesClaim)
HOWTO.md Modify (Authentik -> Logto docs)
CLAUDE.md Modify (Authentik -> Logto refs)
SERVER-CAPABILITIES.md Modify (add OIDC resource server)

Verification

  1. No OIDC configured: Start server without CAMELEER_OIDC_ISSUER_URI -> behaves identically to today (internal HMAC only)
  2. M2M token accepted: Configure issuer + audience, send Logto M2M token with admin scope -> ADMIN access
  3. Scope mapping: M2M token with viewer scope -> VIEWER access only
  4. Invalid token rejected: Random JWT -> 401
  5. Wrong audience rejected: Valid Logto token for different API resource -> 401
  6. Internal tokens still work: Agent registration, heartbeat, UI login -> unchanged
  7. OIDC login flow unchanged: Code exchange via admin-configured OIDC -> still works
  8. Logto deployment healthy: kubectl get pods shows Logto running, admin console accessible
  9. CI deploys Logto: Push to main -> Logto deployed instead of Authentik