feat: add CAMELEER_OIDC_TLS_SKIP_VERIFY to bypass cert verification for OIDC
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m7s
CI / docker (push) Successful in 43s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 36s

Self-signed CA certs on the OIDC provider (e.g. Logto behind a reverse
proxy) cause the login flow to fail because Java's truststore rejects
the connection. This adds an opt-in env var that creates a trust-all
SSLContext scoped to OIDC HTTP calls only (discovery, token exchange,
JWKS fetch) without affecting system-wide TLS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-06 00:26:40 +02:00
parent 7ebbc18b31
commit ca92b3ce7d
5 changed files with 110 additions and 8 deletions

View File

@@ -0,0 +1,71 @@
package com.cameleer3.server.app.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
/**
* Provides trust-all TLS helpers for OIDC connections when
* {@code CAMELEER_OIDC_TLS_SKIP_VERIFY=true}. Package-private — only used
* by {@link OidcTokenExchanger} and {@link SecurityConfig}.
*/
final class InsecureTlsHelper {
private static final Logger log = LoggerFactory.getLogger(InsecureTlsHelper.class);
private static volatile SSLSocketFactory cachedFactory;
private static final HostnameVerifier ACCEPT_ALL = (hostname, session) -> true;
private InsecureTlsHelper() {}
static SSLSocketFactory socketFactory() {
if (cachedFactory == null) {
synchronized (InsecureTlsHelper.class) {
if (cachedFactory == null) {
try {
TrustManager[] trustAll = {new X509TrustManager() {
@Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
@Override public void checkClientTrusted(X509Certificate[] certs, String authType) {}
@Override public void checkServerTrusted(X509Certificate[] certs, String authType) {}
}};
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, trustAll, new SecureRandom());
cachedFactory = ctx.getSocketFactory();
log.warn("OIDC TLS certificate verification disabled (skip-verify) — do not use in production");
} catch (Exception e) {
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
}
}
}
}
return cachedFactory;
}
static HostnameVerifier hostnameVerifier() {
return ACCEPT_ALL;
}
/**
* Opens an {@link InputStream} from the given URL, applying trust-all TLS
* if the connection is HTTPS and {@code skipVerify} is true.
*/
static InputStream openStream(URL url, boolean skipVerify) throws Exception {
URLConnection conn = url.openConnection();
if (skipVerify && conn instanceof HttpsURLConnection https) {
https.setSSLSocketFactory(socketFactory());
https.setHostnameVerifier(hostnameVerifier());
}
return conn.getInputStream();
}
}

View File

@@ -27,6 +27,9 @@ 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;
@@ -48,13 +51,16 @@ public class OidcTokenExchanger {
private static final Logger log = LoggerFactory.getLogger(OidcTokenExchanger.class);
private final OidcConfigRepository configRepository;
private final boolean tlsSkipVerify;
private volatile String cachedIssuerUri;
private volatile OIDCProviderMetadata providerMetadata;
private volatile ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
public OidcTokenExchanger(OidcConfigRepository configRepository) {
public OidcTokenExchanger(OidcConfigRepository configRepository,
SecurityProperties securityProperties) {
this.configRepository = configRepository;
this.tlsSkipVerify = securityProperties.isOidcTlsSkipVerify();
}
public record OidcUserInfo(String subject, String email, String name, List<String> roles, String idToken) {}
@@ -78,7 +84,12 @@ public class OidcTokenExchanger {
new AuthorizationCodeGrant(new AuthorizationCode(code), new URI(redirectUri))
);
TokenResponse tokenResponse = TokenResponse.parse(tokenRequest.toHTTPRequest().send());
var httpRequest = tokenRequest.toHTTPRequest();
if (tlsSkipVerify) {
httpRequest.setSSLSocketFactory(InsecureTlsHelper.socketFactory());
httpRequest.setHostnameVerifier(InsecureTlsHelper.hostnameVerifier());
}
TokenResponse tokenResponse = TokenResponse.parse(httpRequest.send());
if (!tokenResponse.indicatesSuccess()) {
String error = tokenResponse.toErrorResponse().getErrorObject().getDescription();
@@ -191,7 +202,7 @@ public class OidcTokenExchanger {
// .well-known/openid-configuration automatically, the user provides
// the complete URL.
URL discoveryUrl = new URI(issuerUri).toURL();
try (InputStream in = discoveryUrl.openStream()) {
try (InputStream in = InsecureTlsHelper.openStream(discoveryUrl, tlsSkipVerify)) {
JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE)
.parse(in);
providerMetadata = OIDCProviderMetadata.parse(json);
@@ -210,9 +221,15 @@ public class OidcTokenExchanger {
synchronized (this) {
if (jwtProcessor == null) {
OIDCProviderMetadata metadata = getProviderMetadata(issuerUri);
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder
.create(metadata.getJWKSetURI().toURL())
.build();
URL jwksUrl = metadata.getJWKSetURI().toURL();
JWKSource<SecurityContext> jwkSource;
if (tlsSkipVerify) {
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.RS256, JWSAlgorithm.ES256);
JWSKeySelector<SecurityContext> keySelector =

View File

@@ -3,9 +3,12 @@ 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;
@@ -169,7 +172,7 @@ public class SecurityConfig {
: issuerUri + "/.well-known/openid-configuration";
URL url = new URI(discoveryUrl).toURL();
OIDCProviderMetadata metadata;
try (InputStream in = url.openStream()) {
try (InputStream in = InsecureTlsHelper.openStream(url, properties.isOidcTlsSkipVerify())) {
JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(in);
metadata = OIDCProviderMetadata.parse(json);
}
@@ -177,7 +180,14 @@ public class SecurityConfig {
}
// Build decoder supporting ES384 (Logto default) and ES256, RS256
var jwkSource = JWKSourceBuilder.create(jwksUri).build();
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);
var processor = new DefaultJWTProcessor<SecurityContext>();

View File

@@ -20,6 +20,7 @@ public class SecurityProperties {
private String oidcIssuerUri;
private String oidcJwkSetUri;
private String oidcAudience;
private boolean oidcTlsSkipVerify;
public long getAccessTokenExpiryMs() { return accessTokenExpiryMs; }
public void setAccessTokenExpiryMs(long accessTokenExpiryMs) { this.accessTokenExpiryMs = accessTokenExpiryMs; }
@@ -43,4 +44,6 @@ public class SecurityProperties {
public void setOidcJwkSetUri(String oidcJwkSetUri) { this.oidcJwkSetUri = oidcJwkSetUri; }
public String getOidcAudience() { return oidcAudience; }
public void setOidcAudience(String oidcAudience) { this.oidcAudience = oidcAudience; }
public boolean isOidcTlsSkipVerify() { return oidcTlsSkipVerify; }
public void setOidcTlsSkipVerify(boolean oidcTlsSkipVerify) { this.oidcTlsSkipVerify = oidcTlsSkipVerify; }
}

View File

@@ -53,6 +53,7 @@ security:
oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
oidc-jwk-set-uri: ${CAMELEER_OIDC_JWK_SET_URI:}
oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
oidc-tls-skip-verify: ${CAMELEER_OIDC_TLS_SKIP_VERIFY:false}
springdoc: