docs: add Logto OIDC resource server spec and implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1068
docs/superpowers/plans/2026-04-05-logto-oidc-resource-server.md
Normal file
1068
docs/superpowers/plans/2026-04-05-logto-oidc-resource-server.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
|||||||
|
# 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`
|
||||||
|
|
||||||
|
```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`
|
||||||
|
|
||||||
|
```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://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
|
||||||
Reference in New Issue
Block a user