feat: add CAMELEER_OIDC_TLS_SKIP_VERIFY to bypass cert verification for OIDC
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user