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.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
|
||||||
|
import com.nimbusds.jose.util.DefaultResourceRetriever;
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
@@ -48,13 +51,16 @@ public class OidcTokenExchanger {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(OidcTokenExchanger.class);
|
private static final Logger log = LoggerFactory.getLogger(OidcTokenExchanger.class);
|
||||||
|
|
||||||
private final OidcConfigRepository configRepository;
|
private final OidcConfigRepository configRepository;
|
||||||
|
private final boolean tlsSkipVerify;
|
||||||
|
|
||||||
private volatile String cachedIssuerUri;
|
private volatile String cachedIssuerUri;
|
||||||
private volatile OIDCProviderMetadata providerMetadata;
|
private volatile OIDCProviderMetadata providerMetadata;
|
||||||
private volatile ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
|
private volatile ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
|
||||||
|
|
||||||
public OidcTokenExchanger(OidcConfigRepository configRepository) {
|
public OidcTokenExchanger(OidcConfigRepository configRepository,
|
||||||
|
SecurityProperties securityProperties) {
|
||||||
this.configRepository = configRepository;
|
this.configRepository = configRepository;
|
||||||
|
this.tlsSkipVerify = securityProperties.isOidcTlsSkipVerify();
|
||||||
}
|
}
|
||||||
|
|
||||||
public record OidcUserInfo(String subject, String email, String name, List<String> roles, String idToken) {}
|
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))
|
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()) {
|
if (!tokenResponse.indicatesSuccess()) {
|
||||||
String error = tokenResponse.toErrorResponse().getErrorObject().getDescription();
|
String error = tokenResponse.toErrorResponse().getErrorObject().getDescription();
|
||||||
@@ -191,7 +202,7 @@ public class OidcTokenExchanger {
|
|||||||
// .well-known/openid-configuration automatically, the user provides
|
// .well-known/openid-configuration automatically, the user provides
|
||||||
// the complete URL.
|
// the complete URL.
|
||||||
URL discoveryUrl = new URI(issuerUri).toURL();
|
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)
|
JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE)
|
||||||
.parse(in);
|
.parse(in);
|
||||||
providerMetadata = OIDCProviderMetadata.parse(json);
|
providerMetadata = OIDCProviderMetadata.parse(json);
|
||||||
@@ -210,9 +221,15 @@ public class OidcTokenExchanger {
|
|||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (jwtProcessor == null) {
|
if (jwtProcessor == null) {
|
||||||
OIDCProviderMetadata metadata = getProviderMetadata(issuerUri);
|
OIDCProviderMetadata metadata = getProviderMetadata(issuerUri);
|
||||||
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder
|
URL jwksUrl = metadata.getJWKSetURI().toURL();
|
||||||
.create(metadata.getJWKSetURI().toURL())
|
JWKSource<SecurityContext> jwkSource;
|
||||||
.build();
|
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);
|
Set<JWSAlgorithm> expectedAlgs = Set.of(JWSAlgorithm.RS256, JWSAlgorithm.ES256);
|
||||||
JWSKeySelector<SecurityContext> keySelector =
|
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.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
import com.nimbusds.jose.JWSAlgorithm;
|
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.JWKSourceBuilder;
|
||||||
|
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
|
||||||
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
||||||
import com.nimbusds.jose.proc.SecurityContext;
|
import com.nimbusds.jose.proc.SecurityContext;
|
||||||
|
import com.nimbusds.jose.util.DefaultResourceRetriever;
|
||||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||||
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
|
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
|
||||||
import net.minidev.json.JSONObject;
|
import net.minidev.json.JSONObject;
|
||||||
@@ -169,7 +172,7 @@ public class SecurityConfig {
|
|||||||
: issuerUri + "/.well-known/openid-configuration";
|
: issuerUri + "/.well-known/openid-configuration";
|
||||||
URL url = new URI(discoveryUrl).toURL();
|
URL url = new URI(discoveryUrl).toURL();
|
||||||
OIDCProviderMetadata metadata;
|
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);
|
JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(in);
|
||||||
metadata = OIDCProviderMetadata.parse(json);
|
metadata = OIDCProviderMetadata.parse(json);
|
||||||
}
|
}
|
||||||
@@ -177,7 +180,14 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build decoder supporting ES384 (Logto default) and ES256, RS256
|
// 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);
|
Set<JWSAlgorithm> algorithms = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
|
||||||
var keySelector = new JWSVerificationKeySelector<SecurityContext>(algorithms, jwkSource);
|
var keySelector = new JWSVerificationKeySelector<SecurityContext>(algorithms, jwkSource);
|
||||||
var processor = new DefaultJWTProcessor<SecurityContext>();
|
var processor = new DefaultJWTProcessor<SecurityContext>();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class SecurityProperties {
|
|||||||
private String oidcIssuerUri;
|
private String oidcIssuerUri;
|
||||||
private String oidcJwkSetUri;
|
private String oidcJwkSetUri;
|
||||||
private String oidcAudience;
|
private String oidcAudience;
|
||||||
|
private boolean oidcTlsSkipVerify;
|
||||||
|
|
||||||
public long getAccessTokenExpiryMs() { return accessTokenExpiryMs; }
|
public long getAccessTokenExpiryMs() { return accessTokenExpiryMs; }
|
||||||
public void setAccessTokenExpiryMs(long accessTokenExpiryMs) { this.accessTokenExpiryMs = 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 void setOidcJwkSetUri(String oidcJwkSetUri) { this.oidcJwkSetUri = oidcJwkSetUri; }
|
||||||
public String getOidcAudience() { return oidcAudience; }
|
public String getOidcAudience() { return oidcAudience; }
|
||||||
public void setOidcAudience(String oidcAudience) { this.oidcAudience = 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-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
|
||||||
oidc-jwk-set-uri: ${CAMELEER_OIDC_JWK_SET_URI:}
|
oidc-jwk-set-uri: ${CAMELEER_OIDC_JWK_SET_URI:}
|
||||||
oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
|
oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
|
||||||
|
oidc-tls-skip-verify: ${CAMELEER_OIDC_TLS_SKIP_VERIFY:false}
|
||||||
|
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
|
|||||||
Reference in New Issue
Block a user