# Replace Authentik with Logto + Add OIDC Resource Server Support

## Context

Cameleer 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:** `cameleer-server-app/pom.xml`

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

### Change 2: Add OIDC properties
**File:** `cameleer-server-app/src/main/resources/application.yml`

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

**File:** `SecurityProperties.java`

Add fields:
```java
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

```java
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.

```java
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) |
| `cameleer-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
