refactor: architecture cleanup — OIDC dedup, PKCE, K8s hardening
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m6s
CI / docker (push) Successful in 59s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Failing after 2m59s

- Extract OidcProviderHelper for shared discovery + JWK source construction
- Add SystemRole.normalizeScope() to centralize role normalization
- Merge duplicate claim extraction in OidcTokenExchanger
- Add PKCE (S256) to OIDC authorization flow (frontend + backend)
- Add SecurityContext (runAsNonRoot) to all K8s deployments
- Fix postgres probe to use $POSTGRES_USER instead of hardcoded username
- Remove default credentials from Dockerfile
- Extract sanitize_branch() to shared .gitea/sanitize-branch.sh
- Fix sidebar to use /exchanges/ paths directly, remove legacy redirects
- Centralize basePath computation in router.tsx via config module

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-06 21:57:29 +02:00
parent 07ff576eb6
commit c502a42f17
19 changed files with 191 additions and 169 deletions

View File

@@ -1,6 +1,7 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
import jakarta.servlet.FilterChain;
@@ -106,25 +107,22 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
/**
* Maps OAuth2 scopes to server RBAC roles.
* Accepts both prefixed ({@code server:admin}) and bare ({@code admin}) scope names,
* case-insensitive. Scopes are defined on the Logto API Resource for this server.
* case-insensitive. Uses {@link SystemRole#normalizeScope} for consistent normalization.
*/
private List<String> extractRolesFromScopes(Jwt jwt) {
String scopeStr = jwt.getClaimAsString("scope");
if (scopeStr == null || scopeStr.isBlank()) {
return List.of("VIEWER");
}
List<String> scopes = List.of(scopeStr.split(" "));
if (hasScope(scopes, "admin")) return List.of("ADMIN");
if (hasScope(scopes, "operator")) return List.of("OPERATOR");
if (hasScope(scopes, "viewer")) return List.of("VIEWER");
for (String scope : scopeStr.split(" ")) {
String normalized = SystemRole.normalizeScope(scope);
if ("ADMIN".equals(normalized)) return List.of("ADMIN");
if ("OPERATOR".equals(normalized)) return List.of("OPERATOR");
if ("VIEWER".equals(normalized)) return List.of("VIEWER");
}
return List.of("VIEWER");
}
private boolean hasScope(List<String> scopes, String role) {
return scopes.stream().anyMatch(s ->
s.equalsIgnoreCase(role) || s.equalsIgnoreCase("server:" + role));
}
private List<GrantedAuthority> toAuthorities(List<String> roles) {
return roles.stream()
.map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))

View File

@@ -127,7 +127,7 @@ public class OidcAuthController {
try {
OidcTokenExchanger.OidcUserInfo oidcUser =
tokenExchanger.exchange(request.code(), request.redirectUri());
tokenExchanger.exchange(request.code(), request.redirectUri(), request.codeVerifier());
String userId = "user:oidc:" + oidcUser.subject();
String issuerHost = URI.create(config.get().issuerUri()).getHost();
@@ -177,11 +177,7 @@ public class OidcAuthController {
// Resolve desired role IDs from OIDC scopes
Set<UUID> desired = new HashSet<>();
for (String roleName : roleNames) {
String normalized = roleName.toUpperCase();
if (normalized.startsWith("SERVER:")) {
normalized = normalized.substring("SERVER:".length());
}
UUID roleId = SystemRole.BY_NAME.get(normalized);
UUID roleId = SystemRole.BY_NAME.get(SystemRole.normalizeScope(roleName));
if (roleId != null) {
desired.add(roleId);
}
@@ -207,5 +203,5 @@ public class OidcAuthController {
}
}
public record CallbackRequest(String code, String redirectUri) {}
public record CallbackRequest(String code, String redirectUri, String codeVerifier) {}
}

View File

@@ -0,0 +1,59 @@
package com.cameleer3.server.app.security;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.DefaultResourceRetriever;
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
import net.minidev.json.JSONObject;
import net.minidev.json.parser.JSONParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.util.Set;
/**
* Shared helpers for OIDC provider discovery and JWK source construction.
* Used by both {@link SecurityConfig} (resource server decoder) and
* {@link OidcTokenExchanger} (login token exchange).
*/
final class OidcProviderHelper {
private static final Logger log = LoggerFactory.getLogger(OidcProviderHelper.class);
static final Set<JWSAlgorithm> SUPPORTED_ALGORITHMS =
Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
private OidcProviderHelper() {}
/**
* Fetches OIDC provider metadata from the well-known discovery endpoint.
*/
static OIDCProviderMetadata fetchMetadata(String issuerUri, boolean tlsSkipVerify) throws Exception {
String discoveryUrl = issuerUri.endsWith("/")
? issuerUri + ".well-known/openid-configuration"
: issuerUri + "/.well-known/openid-configuration";
URL url = new URI(discoveryUrl).toURL();
try (InputStream in = InsecureTlsHelper.openStream(url, tlsSkipVerify)) {
JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(in);
return OIDCProviderMetadata.parse(json);
}
}
/**
* Builds a JWK source for the given JWKS URI, optionally skipping TLS verification.
*/
static JWKSource<SecurityContext> buildJwkSource(URL jwksUri, boolean tlsSkipVerify) throws Exception {
if (tlsSkipVerify) {
var retriever = new DefaultResourceRetriever(5000, 5000, 0, true,
InsecureTlsHelper.socketFactory());
return new RemoteJWKSet<>(jwksUri, retriever);
}
return JWKSourceBuilder.create(jwksUri).build();
}
}

View File

@@ -2,10 +2,7 @@ package com.cameleer3.server.app.security;
import com.cameleer3.server.core.security.OidcConfig;
import com.cameleer3.server.core.security.OidcConfigRepository;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
@@ -15,28 +12,21 @@ import com.nimbusds.oauth2.sdk.AuthorizationCode;
import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
import com.nimbusds.oauth2.sdk.TokenRequest;
import com.nimbusds.oauth2.sdk.TokenResponse;
import com.nimbusds.oauth2.sdk.pkce.CodeVerifier;
import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
import com.nimbusds.oauth2.sdk.auth.Secret;
import com.nimbusds.oauth2.sdk.id.ClientID;
import com.nimbusds.oauth2.sdk.id.Issuer;
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
import net.minidev.json.JSONObject;
import net.minidev.json.parser.JSONParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.util.DefaultResourceRetriever;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Exchanges OIDC authorization codes for validated user information.
@@ -68,7 +58,7 @@ public class OidcTokenExchanger {
/**
* Exchanges an authorization code for validated user info.
*/
public OidcUserInfo exchange(String code, String redirectUri) throws Exception {
public OidcUserInfo exchange(String code, String redirectUri, String codeVerifier) throws Exception {
OidcConfig config = getConfig();
OIDCProviderMetadata metadata = getProviderMetadata(config.issuerUri());
@@ -78,10 +68,14 @@ public class OidcTokenExchanger {
new Secret(config.clientSecret())
);
AuthorizationCodeGrant grant = codeVerifier != null && !codeVerifier.isBlank()
? new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri), new CodeVerifier(codeVerifier))
: new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri));
TokenRequest tokenRequest = new TokenRequest(
metadata.getTokenEndpointURI(),
clientAuth,
new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri))
grant
);
var httpRequest = tokenRequest.toHTTPRequest();
@@ -163,7 +157,7 @@ public class OidcTokenExchanger {
}
@SuppressWarnings("unchecked")
private String extractStringClaim(JWTClaimsSet claims, String claimPath) {
private Object extractClaim(JWTClaimsSet claims, String claimPath) {
if (claimPath == null || claimPath.isBlank()) {
return null;
}
@@ -173,30 +167,22 @@ public class OidcTokenExchanger {
for (int i = 1; i < parts.length && current instanceof Map; i++) {
current = ((Map<String, Object>) current).get(parts[i]);
}
return current instanceof String ? (String) current : null;
return current;
} catch (Exception e) {
log.debug("Could not extract string from claim path '{}': {}", claimPath, e.getMessage());
log.debug("Could not extract claim '{}': {}", claimPath, e.getMessage());
return null;
}
}
@SuppressWarnings("unchecked")
private String extractStringClaim(JWTClaimsSet claims, String claimPath) {
Object value = extractClaim(claims, claimPath);
return value instanceof String s ? s : null;
}
private List<String> extractRoles(JWTClaimsSet claims, String claimPath) {
try {
String[] parts = claimPath.split("\\.");
Object current = claims.getClaim(parts[0]);
for (int i = 1; i < parts.length && current instanceof Map; i++) {
current = ((Map<String, Object>) current).get(parts[i]);
}
if (current instanceof List<?>) {
return ((List<?>) current).stream()
.map(Object::toString)
.toList();
}
} catch (Exception e) {
log.debug("Could not extract roles from claim path '{}': {}", claimPath, e.getMessage());
Object value = extractClaim(claims, claimPath);
if (value instanceof List<?> list) {
return list.stream().map(Object::toString).toList();
}
return Collections.emptyList();
}
@@ -205,15 +191,8 @@ public class OidcTokenExchanger {
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
synchronized (this) {
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
String discoveryPath = issuerUri.endsWith("/")
? issuerUri + ".well-known/openid-configuration"
: issuerUri + "/.well-known/openid-configuration";
URL discoveryUrl = new URI(discoveryPath).toURL();
try (InputStream in = InsecureTlsHelper.openStream(discoveryUrl, securityProperties.isOidcTlsSkipVerify())) {
JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE)
.parse(in);
providerMetadata = OIDCProviderMetadata.parse(json);
}
providerMetadata = OidcProviderHelper.fetchMetadata(
issuerUri, securityProperties.isOidcTlsSkipVerify());
cachedIssuerUri = issuerUri;
jwtProcessor = null; // Reset processor when issuer changes
log.info("OIDC provider metadata loaded from {}", issuerUri);
@@ -229,18 +208,10 @@ public class OidcTokenExchanger {
if (jwtProcessor == null) {
OIDCProviderMetadata metadata = getProviderMetadata(issuerUri);
URL jwksUrl = metadata.getJWKSetURI().toURL();
JWKSource<SecurityContext> jwkSource;
if (securityProperties.isOidcTlsSkipVerify()) {
var retriever = new DefaultResourceRetriever(5000, 5000, 0, true,
InsecureTlsHelper.socketFactory());
jwkSource = new RemoteJWKSet<>(jwksUrl, retriever);
} else {
jwkSource = JWKSourceBuilder.create(jwksUrl).build();
}
Set<JWSAlgorithm> expectedAlgs = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
JWSKeySelector<SecurityContext> keySelector =
new JWSVerificationKeySelector<>(expectedAlgs, jwkSource);
JWKSource<SecurityContext> jwkSource = OidcProviderHelper.buildJwkSource(
jwksUrl, securityProperties.isOidcTlsSkipVerify());
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
OidcProviderHelper.SUPPORTED_ALGORITHMS, jwkSource);
ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
processor.setJWSKeySelector(keySelector);

View File

@@ -2,17 +2,11 @@ package com.cameleer3.server.app.security;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.security.JwtService;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.DefaultResourceRetriever;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
import net.minidev.json.JSONObject;
import net.minidev.json.parser.JSONParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
@@ -38,12 +32,10 @@ import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
/**
* Spring Security configuration for JWT-based stateless authentication with RBAC.
@@ -161,37 +153,21 @@ public class SecurityConfig {
String issuerUri = properties.getOidcIssuerUri();
// Resolve JWKS URI: use explicit config if set, otherwise discover from OIDC metadata.
// Explicit URI is needed when the public issuer URL isn't reachable from inside
// containers (e.g., https://domain.com/oidc) but the internal URL is (http://logto:3001/oidc/jwks).
URL jwksUri;
String jwkSetUri = properties.getOidcJwkSetUri();
if (jwkSetUri != null && !jwkSetUri.isBlank()) {
jwksUri = new URI(jwkSetUri).toURL();
log.info("Using explicit JWKS URI: {}", jwksUri);
} else {
String discoveryUrl = issuerUri.endsWith("/")
? issuerUri + ".well-known/openid-configuration"
: issuerUri + "/.well-known/openid-configuration";
URL url = new URI(discoveryUrl).toURL();
OIDCProviderMetadata metadata;
try (InputStream in = InsecureTlsHelper.openStream(url, properties.isOidcTlsSkipVerify())) {
JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(in);
metadata = OIDCProviderMetadata.parse(json);
}
OIDCProviderMetadata metadata = OidcProviderHelper.fetchMetadata(
issuerUri, properties.isOidcTlsSkipVerify());
jwksUri = metadata.getJWKSetURI().toURL();
}
// Build decoder supporting ES384 (Logto default) and ES256, RS256
JWKSource<SecurityContext> jwkSource;
if (properties.isOidcTlsSkipVerify()) {
var retriever = new DefaultResourceRetriever(5000, 5000, 0, true,
InsecureTlsHelper.socketFactory());
jwkSource = new RemoteJWKSet<>(jwksUri, retriever);
} else {
jwkSource = JWKSourceBuilder.create(jwksUri).build();
}
Set<JWSAlgorithm> algorithms = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
var keySelector = new JWSVerificationKeySelector<SecurityContext>(algorithms, jwkSource);
JWKSource<SecurityContext> jwkSource = OidcProviderHelper.buildJwkSource(
jwksUri, properties.isOidcTlsSkipVerify());
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
OidcProviderHelper.SUPPORTED_ALGORITHMS, jwkSource);
var processor = new DefaultJWTProcessor<SecurityContext>();
processor.setJWSKeySelector(keySelector);
// Accept any JWT type — Logto uses "at+jwt" (RFC 9068)