refactor: architecture cleanup — OIDC dedup, PKCE, K8s hardening
- 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:
@@ -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))
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user