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