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>
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:
- Replace Authentik with Logto — self-hosted Logto in the K8s cluster, replacing the Authentik deployment
- 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:
adminscope -> 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@ConditionalOnPropertyissues with empty-string defaults. - JWKS URI discovered from the OIDC well-known endpoint (not hardcoded). Logto's JWKS is at
issuer/oidc/jwks. at+jwttype 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_URIis 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-credentialssecret
CI/CD Changes (.gitea/workflows/ci.yml)
- Replace
authentik-credentialssecret ->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_KEYsecret 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:
- Discovers JWKS URI from the OIDC well-known endpoint
- Builds a
NimbusJwtDecoderwith a custom type verifier (acceptsat+jwtper RFC 9068) - 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
- No OIDC configured: Start server without
CAMELEER_OIDC_ISSUER_URI-> behaves identically to today (internal HMAC only) - M2M token accepted: Configure issuer + audience, send Logto M2M token with
adminscope -> ADMIN access - Scope mapping: M2M token with
viewerscope -> VIEWER access only - Invalid token rejected: Random JWT -> 401
- Wrong audience rejected: Valid Logto token for different API resource -> 401
- Internal tokens still work: Agent registration, heartbeat, UI login -> unchanged
- OIDC login flow unchanged: Code exchange via admin-configured OIDC -> still works
- Logto deployment healthy:
kubectl get podsshows Logto running, admin console accessible - CI deploys Logto: Push to main -> Logto deployed instead of Authentik