getDefaultHeaders() {
+ return defaultHeaders;
+ }
+
+ /**
+ * Returns the path to a custom CA certificate file used for TLS verification.
+ *
+ * @return the CA cert file path, or {@code null} if not set.
+ */
+ @Nullable
+ public String getCaCertPath() {
+ return caCertPath;
+ }
+
+ /**
+ * Returns whether insecure (trust-all) TLS mode is enabled.
+ *
+ * @return {@code true} if insecure mode is enabled; {@code false} otherwise.
+ */
+ public boolean isInsecure() {
+ return insecure;
+ }
+
+ /**
+ * Returns the proxy URL used for HTTP connections.
+ *
+ * @return the proxy URL, or {@code null} if not set.
+ */
+ @Nullable
+ public String getProxyUrl() {
+ return proxyUrl;
+ }
+
+ /**
+ * Builds an {@link SSLContext} from these transport options.
+ *
+ * If insecure mode is enabled, returns a trust-all SSLContext.
+ * If a custom CA cert path is set, returns an SSLContext that trusts
+ * both the custom CA and the system default CAs.
+ * Otherwise returns {@code null} (use JVM defaults).
+ *
+ * @return the configured SSLContext, or {@code null} if no custom SSL is needed.
+ * @throws GeneralSecurityException if SSL context creation fails.
+ * @throws IOException if the CA cert file cannot be read.
+ */
+ @Nullable
+ public SSLContext buildSSLContext() throws GeneralSecurityException, IOException {
+ if (sslContextBuilt) {
+ return cachedSSLContext;
+ }
+ synchronized (this) {
+ if (sslContextBuilt) {
+ return cachedSSLContext;
+ }
+ cachedSSLContext = buildSSLContextInternal();
+ sslContextBuilt = true;
+ return cachedSSLContext;
+ }
+ }
+
+ @Nullable
+ @SuppressFBWarnings("PATH_TRAVERSAL_IN")
+ private SSLContext buildSSLContextInternal() throws GeneralSecurityException, IOException {
+ if (insecure) {
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, new TrustManager[]{new InsecureTrustManager()}, null);
+ return sslContext;
+ } else if (caCertPath != null) {
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ java.security.cert.Certificate caCert;
+ try (FileInputStream fis = new FileInputStream(caCertPath)) {
+ caCert = cf.generateCertificate(fis);
+ }
+ KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
+ ks.load(null, null);
+ ks.setCertificateEntry("custom-ca", caCert);
+ TrustManagerFactory defaultTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ defaultTmf.init((KeyStore) null);
+ int certIndex = 0;
+ for (TrustManager tm : defaultTmf.getTrustManagers()) {
+ if (tm instanceof X509TrustManager) {
+ for (X509Certificate cert : ((X509TrustManager) tm).getAcceptedIssuers()) {
+ ks.setCertificateEntry("default-" + certIndex++, cert);
+ }
+ }
+ }
+ TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init(ks);
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, tmf.getTrustManagers(), null);
+ return sslContext;
+ }
+ return null;
+ }
+
+ @SuppressFBWarnings("WEAK_TRUST_MANAGER")
+ private static final class InsecureTrustManager implements X509TrustManager {
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) {
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) {
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ }
+
+}
diff --git a/src/main/java/com/zitadel/Zitadel.java b/src/main/java/com/zitadel/Zitadel.java
index 15a3c255..541895f6 100644
--- a/src/main/java/com/zitadel/Zitadel.java
+++ b/src/main/java/com/zitadel/Zitadel.java
@@ -227,10 +227,64 @@ public Zitadel(Authenticator authenticator) {
this(
authenticator,
apiClient -> {
- // Dummy lambda that does nothing, can add default behavior here if needed
});
}
+ /**
+ * @param authenticator the authenticator to use for API requests.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ */
+ public Zitadel(Authenticator authenticator, TransportOptions transportOptions) {
+ this(
+ authenticator,
+ transportOptions,
+ apiClient -> {
+ });
+ }
+
+ /**
+ * @param authenticator the authenticator to use for API requests.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ * @param mutateApiClient a consumer to customize the API client after creation.
+ */
+ public Zitadel(Authenticator authenticator, TransportOptions transportOptions, Consumer mutateApiClient) {
+ this.apiClient = new ApiClient(authenticator, transportOptions);
+
+ if (mutateApiClient != null) {
+ mutateApiClient.accept(this.apiClient);
+ }
+
+ this.betaProjects = new BetaProjectServiceApi(apiClient);
+ this.betaApps = new BetaAppServiceApi(apiClient);
+ this.betaOidc = new BetaOidcServiceApi(apiClient);
+ this.betaUsers = new BetaUserServiceApi(apiClient);
+ this.betaOrganizations = new BetaOrganizationServiceApi(apiClient);
+ this.betaSettings = new BetaSettingsServiceApi(apiClient);
+ this.betaPermissions = new BetaInternalPermissionServiceApi(apiClient);
+ this.betaAuthorizations = new BetaAuthorizationServiceApi(apiClient);
+ this.betaSessions = new BetaSessionServiceApi(apiClient);
+ this.betaInstance = new BetaInstanceServiceApi(apiClient);
+ this.betaTelemetry = new BetaTelemetryServiceApi(apiClient);
+ this.betaFeatures = new BetaFeatureServiceApi(apiClient);
+ this.betaWebkeys = new BetaWebKeyServiceApi(apiClient);
+ this.betaActions = new BetaActionServiceApi(apiClient);
+ this.actions = new ActionServiceApi(apiClient);
+ this.applications = new ApplicationServiceApi(apiClient);
+ this.authorizations = new AuthorizationServiceApi(apiClient);
+ this.users = new UserServiceApi(apiClient);
+ this.sessions = new SessionServiceApi(apiClient);
+ this.oidc = new OidcServiceApi(apiClient);
+ this.features = new FeatureServiceApi(apiClient);
+ this.idps = new IdentityProviderServiceApi(apiClient);
+ this.instances = new InstanceServiceApi(apiClient);
+ this.internalPermissions = new InternalPermissionServiceApi(apiClient);
+ this.organizations = new OrganizationServiceApi(apiClient);
+ this.projects = new ProjectServiceApi(apiClient);
+ this.settings = new SettingsServiceApi(apiClient);
+ this.saml = new SamlServiceApi(apiClient);
+ this.webkeys = new WebKeyServiceApi(apiClient);
+ }
+
public Zitadel(Authenticator authenticator, Consumer mutateApiClient) {
this.apiClient = new ApiClient(authenticator);
@@ -283,6 +337,21 @@ public static Zitadel withAccessToken(String host, String accessToken) {
return new Zitadel(new PersonalAccessTokenAuthenticator(host, accessToken));
}
+ /**
+ * Initialize the SDK with a Personal Access Token (PAT).
+ *
+ * @param host API URL.
+ * @param accessToken Personal Access Token for Bearer authentication.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ * @return Configured Zitadel client instance.
+ * @see PAT
+ * Guide
+ */
+ public static Zitadel withAccessToken(String host, String accessToken, TransportOptions transportOptions) {
+ return new Zitadel(new PersonalAccessTokenAuthenticator(host, accessToken), transportOptions);
+ }
+
/**
* Initialize the SDK using OAuth2 Client Credentials flow.
*
@@ -299,6 +368,24 @@ public static Zitadel withClientCredentials(String host, String clientId, String
ClientCredentialsAuthenticator.builder(host, clientId, clientSecret).build());
}
+ /**
+ * Initialize the SDK using OAuth2 Client Credentials flow.
+ *
+ * @param host API URL.
+ * @param clientId OAuth2 client identifier.
+ * @param clientSecret OAuth2 client secret.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ * @return Configured Zitadel client instance with token auto-refresh.
+ * @see Client
+ * Credentials Guide
+ */
+ public static Zitadel withClientCredentials(String host, String clientId, String clientSecret, TransportOptions transportOptions) {
+ return new Zitadel(
+ ClientCredentialsAuthenticator.builder(host, clientId, clientSecret, transportOptions).build(),
+ transportOptions);
+ }
+
/**
* Initialize the SDK via Private Key JWT assertion.
*
@@ -331,6 +418,40 @@ public static Zitadel withPrivateKey(String host, java.io.InputStream inputStrea
return new Zitadel(WebTokenAuthenticator.fromJson(host, inputStream));
}
+ /**
+ * Initialize the SDK via Private Key JWT assertion.
+ *
+ * @param host API URL.
+ * @param keyFile Path to service account JSON or PEM key file.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ * @return Configured Zitadel client instance using JWT assertion.
+ * @see Private
+ * Key JWT Guide
+ */
+ @SuppressFBWarnings({"PATH_TRAVERSAL_IN", "THROWS_METHOD_THROWS_RUNTIMEEXCEPTION"})
+ public static Zitadel withPrivateKey(String host, String keyFile, TransportOptions transportOptions) {
+ try (java.io.FileInputStream fis = new java.io.FileInputStream(keyFile)) {
+ return withPrivateKey(host, fis, transportOptions);
+ } catch (IOException e) {
+ throw new RuntimeException(
+ "Unable to read key file at " + keyFile + ": " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Initialize the SDK via Private Key JWT assertion.
+ *
+ * @param host API URL.
+ * @param inputStream Input stream containing service account JSON or PEM key data.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ * @return Configured Zitadel client instance using JWT assertion.
+ * @see Private
+ * Key JWT Guide
+ */
+ public static Zitadel withPrivateKey(String host, java.io.InputStream inputStream, TransportOptions transportOptions) {
+ return new Zitadel(WebTokenAuthenticator.fromJson(host, inputStream, transportOptions), transportOptions);
+ }
+
/**
* Gets the underlying API client. Recommended over direct field access for testability.
*
diff --git a/src/main/java/com/zitadel/auth/ClientCredentialsAuthenticator.java b/src/main/java/com/zitadel/auth/ClientCredentialsAuthenticator.java
index 627708d3..1226ba28 100644
--- a/src/main/java/com/zitadel/auth/ClientCredentialsAuthenticator.java
+++ b/src/main/java/com/zitadel/auth/ClientCredentialsAuthenticator.java
@@ -6,6 +6,7 @@
import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
import com.nimbusds.oauth2.sdk.auth.Secret;
import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.zitadel.TransportOptions;
import com.zitadel.ZitadelException;
/**
@@ -25,14 +26,15 @@ public class ClientCredentialsAuthenticator extends OAuthAuthenticator {
/**
* Constructs a ClientCredentialsAuthenticator.
*
- * @param openId The base URL for the API endpoints.
- * @param clientId The OAuth2 client identifier.
- * @param clientSecret The OAuth2 client secret.
- * @param authScopes The scope for the token request.
+ * @param openId The base URL for the API endpoints.
+ * @param clientId The OAuth2 client identifier.
+ * @param clientSecret The OAuth2 client secret.
+ * @param authScopes The scope for the token request.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
*/
ClientCredentialsAuthenticator(
- OpenId openId, ClientID clientId, Secret clientSecret, Scope authScopes) {
- super(openId, authScopes);
+ OpenId openId, ClientID clientId, Secret clientSecret, Scope authScopes, TransportOptions transportOptions) {
+ super(openId, authScopes, transportOptions);
this.clientSecret = clientSecret;
this.clientId = clientId;
}
@@ -49,6 +51,19 @@ public static Builder builder(String host, String clientId, String clientSecret)
return new Builder(host, clientId, clientSecret);
}
+ /**
+ * Returns a new builder instance for ClientCredentialsAuthenticator with custom transport options.
+ *
+ * @param host The base URL for API endpoints.
+ * @param clientId The OAuth2 client identifier.
+ * @param clientSecret The OAuth2 client secret.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ * @return a new ClientCredentialsAuthenticatorBuilder instance.
+ */
+ public static Builder builder(String host, String clientId, String clientSecret, TransportOptions transportOptions) {
+ return new Builder(host, clientId, clientSecret, transportOptions);
+ }
+
/**
* Refreshes the access token using the client credentials grant.
*
@@ -86,13 +101,25 @@ public static class Builder extends OAuthAuthenticatorBuilder {
this.clientSecret = new Secret(clientSecret);
}
+ /**
+ * @param host The base URL for the API endpoints.
+ * @param clientId The OAuth2 client identifier.
+ * @param clientSecret The OAuth2 client secret.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ */
+ Builder(String host, String clientId, String clientSecret, TransportOptions transportOptions) {
+ super(host, transportOptions);
+ this.clientId = new ClientID(clientId);
+ this.clientSecret = new Secret(clientSecret);
+ }
+
/**
* Builds the ClientCredentialsAuthenticator.
*
* @return a new ClientCredentialsAuthenticator instance.
*/
public ClientCredentialsAuthenticator build() {
- return new ClientCredentialsAuthenticator(openId, clientId, clientSecret, authScopes);
+ return new ClientCredentialsAuthenticator(openId, clientId, clientSecret, authScopes, transportOptions);
}
}
}
diff --git a/src/main/java/com/zitadel/auth/OAuthAuthenticator.java b/src/main/java/com/zitadel/auth/OAuthAuthenticator.java
index 23abb151..3395aff6 100644
--- a/src/main/java/com/zitadel/auth/OAuthAuthenticator.java
+++ b/src/main/java/com/zitadel/auth/OAuthAuthenticator.java
@@ -4,12 +4,17 @@
import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
import com.nimbusds.oauth2.sdk.http.HTTPRequest;
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
+import com.zitadel.TransportOptions;
import com.zitadel.ZitadelException;
import javax.annotation.Nullable;
+import javax.net.ssl.SSLContext;
import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
import java.net.URI;
import java.net.URISyntaxException;
+import java.net.URL;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
@@ -30,6 +35,7 @@ public abstract class OAuthAuthenticator extends Authenticator {
protected final Scope scope;
private final OpenId openId;
+ private final TransportOptions transportOptions;
/**
* The OAuth token.
*/
@@ -43,10 +49,22 @@ public abstract class OAuthAuthenticator extends Authenticator {
* @param scope The scope for the token request.
*/
public OAuthAuthenticator(OpenId openId, Scope scope) {
+ this(openId, scope, null);
+ }
+
+ /**
+ * Constructs an OAuthAuthenticator.
+ *
+ * @param openId The URL of the OAuth2 token endpoint.
+ * @param scope The scope for the token request.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ */
+ public OAuthAuthenticator(OpenId openId, Scope scope, @Nullable TransportOptions transportOptions) {
super(openId.getHostEndpoint());
this.scope = new Scope(scope);
this.token = null;
this.openId = openId;
+ this.transportOptions = transportOptions != null ? transportOptions : TransportOptions.defaults();
}
public String getAuthToken() throws ZitadelException {
@@ -95,6 +113,31 @@ protected Token getToken(ClientAuthentication authentication) throws ZitadelExce
TokenRequest request =
new TokenRequest(tokenEndpoint, authentication, this.getGrant(), this.scope);
HTTPRequest httpRequest = request.toHTTPRequest();
+
+ if (transportOptions.getProxyUrl() != null) {
+ URL proxyParsed = new URL(transportOptions.getProxyUrl());
+ httpRequest.setProxy(new Proxy(Proxy.Type.HTTP,
+ new InetSocketAddress(proxyParsed.getHost(),
+ proxyParsed.getPort() != -1 ? proxyParsed.getPort() : proxyParsed.getDefaultPort())));
+ if (proxyParsed.getUserInfo() != null) {
+ String encoded = java.util.Base64.getEncoder()
+ .encodeToString(proxyParsed.getUserInfo().getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ httpRequest.setHeader("Proxy-Authorization", "Basic " + encoded);
+ }
+ }
+
+ SSLContext sslContext = transportOptions.buildSSLContext();
+ if (sslContext != null) {
+ httpRequest.setSSLSocketFactory(sslContext.getSocketFactory());
+ if (transportOptions.isInsecure()) {
+ httpRequest.setHostnameVerifier((h, s) -> true);
+ }
+ }
+
+ for (Map.Entry entry : transportOptions.getDefaultHeaders().entrySet()) {
+ httpRequest.setHeader(entry.getKey(), entry.getValue());
+ }
+
TokenResponse tokenResponse = TokenResponse.parse(httpRequest.send());
if (!tokenResponse.indicatesSuccess()) {
@@ -108,7 +151,8 @@ protected Token getToken(ClientAuthentication authentication) throws ZitadelExce
return new Token(
accessToken.getValue(), Instant.now().plusSeconds(accessToken.getLifetime()));
}
- } catch (RuntimeException | IOException | ParseException | URISyntaxException e) {
+ } catch (RuntimeException | IOException | ParseException | URISyntaxException
+ | java.security.GeneralSecurityException e) {
throw new ZitadelException("Failed to refresh token: " + e.getMessage(), e);
}
}
@@ -146,12 +190,22 @@ protected abstract static class OAuthAuthenticatorBuilder<
T extends OAuthAuthenticatorBuilder>> {
protected final OpenId openId;
+ protected TransportOptions transportOptions = TransportOptions.defaults();
protected Scope authScopes = Scope.parse("openid urn:zitadel:iam:org:project:id:zitadel:aud");
protected OAuthAuthenticatorBuilder(String host) {
this.openId = new OpenId(host);
}
+ /**
+ * @param host The base URL for the API endpoints.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ */
+ protected OAuthAuthenticatorBuilder(String host, TransportOptions transportOptions) {
+ this.transportOptions = transportOptions != null ? transportOptions : TransportOptions.defaults();
+ this.openId = new OpenId(host, this.transportOptions);
+ }
+
/**
* Overrides the default scopes.
*
@@ -163,5 +217,6 @@ public final T scopes(Set authScopes) {
this.authScopes = Scope.parse(authScopes);
return (T) this;
}
+
}
}
diff --git a/src/main/java/com/zitadel/auth/OpenId.java b/src/main/java/com/zitadel/auth/OpenId.java
index 90f16e5b..5e0261cb 100644
--- a/src/main/java/com/zitadel/auth/OpenId.java
+++ b/src/main/java/com/zitadel/auth/OpenId.java
@@ -2,29 +2,79 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.zitadel.TransportOptions;
import com.zitadel.utils.URLUtil;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLContext;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
import java.net.URL;
import java.nio.charset.StandardCharsets;
+import java.net.MalformedURLException;
+import java.security.GeneralSecurityException;
+import java.util.Map;
+@SuppressFBWarnings("CT_CONSTRUCTOR_THROW")
public class OpenId {
private final URL hostEndpoint;
private final URL tokenEndpoint;
- @SuppressFBWarnings({"URLCONNECTION_SSRF_FD", "CT_CONSTRUCTOR_THROW"})
+ /**
+ * @param hostname the hostname of the OpenID provider.
+ */
public OpenId(String hostname) {
+ this(hostname, TransportOptions.defaults());
+ }
+
+ /**
+ * @param hostname the hostname of the OpenID provider.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ */
+ @SuppressFBWarnings("URLCONNECTION_SSRF_FD")
+ public OpenId(String hostname, TransportOptions transportOptions) {
+ TransportOptions opts = transportOptions != null ? transportOptions : TransportOptions.defaults();
HttpURLConnection connection = null;
try {
this.hostEndpoint = URLUtil.buildHostname(hostname);
- connection = (HttpURLConnection) buildWellKnownUrl(hostname).openConnection();
+ URL wellKnownUrl = buildWellKnownUrl(hostname);
+ if (opts.getProxyUrl() != null) {
+ URL proxyParsed = new URL(opts.getProxyUrl());
+ String proxyHost = proxyParsed.getHost();
+ int proxyPort = proxyParsed.getPort() != -1 ? proxyParsed.getPort() : proxyParsed.getDefaultPort();
+ Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort));
+ connection = (HttpURLConnection) wellKnownUrl.openConnection(proxy);
+ if (proxyParsed.getUserInfo() != null) {
+ String encoded = java.util.Base64.getEncoder()
+ .encodeToString(proxyParsed.getUserInfo().getBytes(StandardCharsets.UTF_8));
+ connection.setRequestProperty("Proxy-Authorization", "Basic " + encoded);
+ }
+ } else {
+ connection = (HttpURLConnection) wellKnownUrl.openConnection();
+ }
connection.setRequestMethod("GET");
+ if (connection instanceof HttpsURLConnection) {
+ HttpsURLConnection httpsConn = (HttpsURLConnection) connection;
+ SSLContext sslContext = opts.buildSSLContext();
+ if (sslContext != null) {
+ httpsConn.setSSLSocketFactory(sslContext.getSocketFactory());
+ if (opts.isInsecure()) {
+ httpsConn.setHostnameVerifier((h, s) -> true);
+ }
+ }
+ }
+
+ for (Map.Entry entry : opts.getDefaultHeaders().entrySet()) {
+ connection.setRequestProperty(entry.getKey(), entry.getValue());
+ }
+
int status = connection.getResponseCode();
if (status != HttpURLConnection.HTTP_OK) {
throw new IOException("Failed to fetch OpenID configuration: HTTP " + status);
@@ -40,7 +90,7 @@ public OpenId(String hostname) {
JsonNode root = new ObjectMapper().readTree(responseBuilder.toString());
this.tokenEndpoint = new URL(root.path("token_endpoint").asText());
}
- } catch (IOException e) {
+ } catch (IOException | GeneralSecurityException e) {
throw new RuntimeException(e);
} finally {
if (connection != null) {
@@ -54,12 +104,12 @@ public OpenId(String hostname) {
private static URL buildWellKnownUrl(String hostname) {
try {
if (!hostname.startsWith("http://") && !hostname.startsWith("https://")) {
- hostname = "https://" + hostname; // default to https
+ hostname = "https://" + hostname;
}
URL base = new URL(hostname);
return new URL(base, "/.well-known/openid-configuration");
- } catch (Exception e) {
+ } catch (MalformedURLException e) {
throw new RuntimeException(e);
}
}
diff --git a/src/main/java/com/zitadel/auth/WebTokenAuthenticator.java b/src/main/java/com/zitadel/auth/WebTokenAuthenticator.java
index e7dc0c67..a6ef555d 100644
--- a/src/main/java/com/zitadel/auth/WebTokenAuthenticator.java
+++ b/src/main/java/com/zitadel/auth/WebTokenAuthenticator.java
@@ -16,6 +16,7 @@
import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
import com.nimbusds.oauth2.sdk.http.HTTPRequest;
import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.zitadel.TransportOptions;
import com.zitadel.ZitadelException;
import com.zitadel.utils.KeyUtil;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@@ -24,7 +25,9 @@
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
+import java.security.spec.InvalidKeySpecException;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
@@ -46,6 +49,17 @@ public class WebTokenAuthenticator extends OAuthAuthenticator {
private final Duration tokenLifetime;
private final JWSHeader jwsHeader;
+ /**
+ * @param openId the OpenID configuration.
+ * @param jwtIssuer the issuer claim for the JWT.
+ * @param jwtSubject the subject claim for the JWT.
+ * @param jwtAudience the audience claim for the JWT.
+ * @param keySigner the signer used to sign the JWT.
+ * @param tokenLifetime the lifetime of the token.
+ * @param jwsHeader the JWS header for the JWT.
+ * @param authScopes the scopes for the token request.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ */
WebTokenAuthenticator(
OpenId openId,
String jwtIssuer,
@@ -54,8 +68,9 @@ public class WebTokenAuthenticator extends OAuthAuthenticator {
JWSSigner keySigner,
Duration tokenLifetime,
JWSHeader jwsHeader,
- Scope authScopes) {
- super(openId, authScopes);
+ Scope authScopes,
+ TransportOptions transportOptions) {
+ super(openId, authScopes, transportOptions);
this.jwtIssuer = jwtIssuer;
this.jwtSubject = jwtSubject;
this.jwtAudience = jwtAudience;
@@ -95,6 +110,26 @@ public static WebTokenAuthenticator fromJson(String host, String jsonPath) {
}
}
+ /**
+ * Creates a {@code WebTokenAuthenticator} instance from a JSON configuration file
+ * with custom transport options.
+ *
+ * @param host the base URL for the API endpoints.
+ * @param jsonPath the file path to the JSON configuration file.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ * @return a new instance of {@code WebTokenAuthenticator}.
+ */
+ @SuppressWarnings("unused")
+ @SuppressFBWarnings("PATH_TRAVERSAL_IN")
+ public static WebTokenAuthenticator fromJson(String host, String jsonPath, TransportOptions transportOptions) {
+ try (FileInputStream fis = new FileInputStream(jsonPath)) {
+ return fromJson(host, fis, transportOptions);
+ } catch (IOException e) {
+ throw new RuntimeException(
+ "Unable to read JSON file at " + jsonPath + ": " + e.getMessage(), e);
+ }
+ }
+
/**
* Creates a {@code WebTokenAuthenticator} instance from a JSON configuration input stream.
*
@@ -140,7 +175,7 @@ public static WebTokenAuthenticator fromJson(String host, InputStream inputStrea
PrivateKey privateKey;
try {
privateKey = KeyUtil.getPrivateKeyFromString(keyString);
- } catch (Exception e) {
+ } catch (IOException | InvalidKeySpecException | NoSuchAlgorithmException e) {
throw new RuntimeException(
"Unable to convert key string to PrivateKey: " + e.getMessage(), e);
}
@@ -149,17 +184,72 @@ public static WebTokenAuthenticator fromJson(String host, InputStream inputStrea
}
/**
- * Returns a new builder instance for JWTAuthenticator.
+ * Creates a {@code WebTokenAuthenticator} instance from a JSON configuration input stream
+ * with custom transport options.
+ *
+ * @param host the base URL for the API endpoints.
+ * @param inputStream the input stream containing the JSON configuration.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ * @return a new instance of {@code WebTokenAuthenticator}.
+ */
+ public static WebTokenAuthenticator fromJson(String host, InputStream inputStream, TransportOptions transportOptions) {
+ ObjectMapper mapper = new ObjectMapper();
+ Map config;
+ try {
+ config = mapper.readValue(inputStream, new TypeReference<>() {
+ });
+ } catch (IOException e) {
+ throw new RuntimeException(
+ "Unable to read or parse JSON from input stream: " + e.getMessage(), e);
+ }
+
+ if (config == null || config.isEmpty()) {
+ throw new RuntimeException("Expected a JSON object in input stream");
+ }
+
+ String userId = (String) config.get("userId");
+ String keyString = (String) config.get("key");
+ String keyId = (String) config.get("keyId");
+ if (userId == null || keyString == null || keyId == null) {
+ throw new RuntimeException("Missing required keys 'userId', 'keyId' or 'key' in JSON.");
+ }
+
+ PrivateKey privateKey;
+ try {
+ privateKey = KeyUtil.getPrivateKeyFromString(keyString);
+ } catch (IOException | InvalidKeySpecException | NoSuchAlgorithmException e) {
+ throw new RuntimeException(
+ "Unable to convert key string to PrivateKey: " + e.getMessage(), e);
+ }
+
+ return WebTokenAuthenticator.builder(host, userId, privateKey, transportOptions).keyId(keyId).build();
+ }
+
+ /**
+ * Returns a new builder instance for WebTokenAuthenticator.
*
* @param host The base URL for API endpoints.
* @param userId Used as both the issuer and subject in the JWT.
* @param privateKey The private key used to sign the JWT.
- * @return a new JWTAuthenticatorBuilder instance.
+ * @return a new WebTokenAuthenticator.Builder instance.
*/
public static Builder builder(String host, String userId, PrivateKey privateKey) {
return new Builder(host, userId, userId, host, privateKey);
}
+ /**
+ * Returns a new builder instance for WebTokenAuthenticator with custom transport options.
+ *
+ * @param host The base URL for API endpoints.
+ * @param userId Used as both the issuer and subject in the JWT.
+ * @param privateKey The private key used to sign the JWT.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ * @return a new WebTokenAuthenticator.Builder instance.
+ */
+ public static Builder builder(String host, String userId, PrivateKey privateKey, TransportOptions transportOptions) {
+ return new Builder(host, userId, userId, host, privateKey, transportOptions);
+ }
+
/**
* Refreshes the access token using a JWT assertion.
*
@@ -208,7 +298,7 @@ public AuthorizationGrant getGrant() {
}
/**
- * Builder for JWTAuthenticator.
+ * Builder for WebTokenAuthenticator.
*/
public static class Builder extends OAuthAuthenticatorBuilder {
@@ -243,6 +333,28 @@ public static class Builder extends OAuthAuthenticatorBuilder {
this.keySigner = new RSASSASigner(privateKey);
}
+ /**
+ * @param host The base URL for the API endpoints.
+ * @param jwtIssuer The issuer claim for the JWT.
+ * @param jwtSubject The subject claim for the JWT.
+ * @param jwtAudience The audience claim for the JWT.
+ * @param privateKey The RSAPrivateKey used to sign the JWT.
+ * @param transportOptions Optional transport options for TLS, proxy, and headers.
+ */
+ Builder(
+ String host,
+ String jwtIssuer,
+ String jwtSubject,
+ String jwtAudience,
+ PrivateKey privateKey,
+ TransportOptions transportOptions) {
+ super(host, transportOptions);
+ this.jwtIssuer = jwtIssuer;
+ this.jwtSubject = jwtSubject;
+ this.jwtAudience = jwtAudience;
+ this.keySigner = new RSASSASigner(privateKey);
+ }
+
public Builder tokenLifetime(Duration tokenLifetime) {
this.tokenLifetime = tokenLifetime;
return this;
@@ -259,11 +371,11 @@ public Builder keyId(String keyId) {
}
/**
- * Builds the JWTAuthenticator.
+ * Builds the WebTokenAuthenticator.
*
* Prepares all required values for JWT-based authentication.
*
- * @return a new JWTAuthenticator instance.
+ * @return a new WebTokenAuthenticator instance.
*/
public WebTokenAuthenticator build() {
return new WebTokenAuthenticator(
@@ -274,7 +386,8 @@ public WebTokenAuthenticator build() {
keySigner,
tokenLifetime,
new JWSHeader.Builder(jwtAlgorithm).keyID(keyId).build(),
- authScopes);
+ authScopes,
+ transportOptions);
}
}
}
diff --git a/src/test/java/com/zitadel/TransportOptionsTest.java b/src/test/java/com/zitadel/TransportOptionsTest.java
new file mode 100644
index 00000000..91c83420
--- /dev/null
+++ b/src/test/java/com/zitadel/TransportOptionsTest.java
@@ -0,0 +1,57 @@
+package com.zitadel;
+
+import org.junit.jupiter.api.Test;
+
+import javax.net.ssl.SSLContext;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.util.Collections;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class TransportOptionsTest {
+
+ @Test
+ void testDefaultsReturnsNull() throws GeneralSecurityException, IOException {
+ TransportOptions options = TransportOptions.defaults();
+ assertNull(options.buildSSLContext());
+ }
+
+ @Test
+ void testInsecureReturnsSSLContext() throws GeneralSecurityException, IOException {
+ TransportOptions options = new TransportOptions(Map.of(), null, true, null);
+ SSLContext ctx = options.buildSSLContext();
+ assertNotNull(ctx);
+ }
+
+ @Test
+ void testCaCertPathReturnsSSLContext() throws Exception {
+ String caCertPath = Path.of(TransportOptionsTest.class.getClassLoader()
+ .getResource("ca.pem").toURI()).toString();
+ TransportOptions options = new TransportOptions(Map.of(), caCertPath, false, null);
+ SSLContext ctx = options.buildSSLContext();
+ assertNotNull(ctx);
+ }
+
+ @Test
+ void testInsecureTakesPrecedenceOverCaCert() throws GeneralSecurityException, IOException {
+ TransportOptions options = new TransportOptions(Map.of(), "/nonexistent/ca.pem", true, null);
+ SSLContext ctx = options.buildSSLContext();
+ assertNotNull(ctx);
+ }
+
+ @Test
+ void testDefaultsFactory() {
+ TransportOptions options = TransportOptions.defaults();
+
+ assertEquals(Collections.emptyMap(), options.getDefaultHeaders());
+ assertNull(options.getCaCertPath());
+ assertFalse(options.isInsecure());
+ assertNull(options.getProxyUrl());
+ }
+}
diff --git a/src/test/java/com/zitadel/ZitadelTest.java b/src/test/java/com/zitadel/ZitadelTest.java
index 32bf187c..27115800 100644
--- a/src/test/java/com/zitadel/ZitadelTest.java
+++ b/src/test/java/com/zitadel/ZitadelTest.java
@@ -3,35 +3,102 @@
import com.zitadel.auth.NoAuthAuthenticator;
import io.github.classgraph.ClassGraph;
import io.github.classgraph.ScanResult;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.Network;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.MountableFile;
+import javax.annotation.Nullable;
import java.lang.reflect.Field;
+import java.nio.file.Path;
import java.util.Arrays;
+import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
-/**
- * Test class to verify that all API service classes defined in the "com.zitadel.api" package are
- * properly registered as fields in the {@link Zitadel} class.
- *
- *
This test dynamically scans the "com.zitadel.api" package using ClassGraph (excluding any
- * classes whose names end with "Test" and only including those whose names end with "ServiceApi").
- * It then uses reflection on a {@link Zitadel} instance to collect the types of all fields that
- * belong to the "com.zitadel.api" package. The test asserts that the set of expected API service
- * classes exactly matches the set of actual service classes registered in {@code Zitadel}.
- *
- *
This ensures that all API services are correctly registered in the {@code Zitadel} class
- * without duplicates, which is critical for the correct functioning of the SDK.
- */
class ZitadelTest {
- /**
- * Verifies that all API service classes in "com.zitadel.api" are registered as fields in the
- * Zitadel class.
- */
+ private static String host;
+ private static int httpPort;
+ private static int httpsPort;
+ private static int proxyPort;
+ private static String caCertPath;
+ @Nullable
+ private static Network network;
+ @Nullable
+ private static GenericContainer> wireMockServer;
+ @Nullable
+ private static GenericContainer> proxyServer;
+
+ @SuppressWarnings({"resource", "HttpUrlsUsage"})
+ @BeforeAll
+ static void setUp() throws Exception {
+ caCertPath = Path.of(ZitadelTest.class.getClassLoader()
+ .getResource("ca.pem").toURI()).toString();
+
+ network = Network.newNetwork();
+
+ wireMockServer = new GenericContainer<>(DockerImageName.parse("wiremock/wiremock:3.12.1"))
+ .withNetwork(network)
+ .withNetworkAliases("wiremock")
+ .withExposedPorts(8080, 8443)
+ .withCopyFileToContainer(
+ MountableFile.forClasspathResource("keystore.p12"),
+ "/home/wiremock/keystore.p12"
+ )
+ .withCopyFileToContainer(
+ MountableFile.forClasspathResource("mappings/"),
+ "/home/wiremock/mappings/"
+ )
+ .withCommand(
+ "--https-port", "8443",
+ "--https-keystore", "/home/wiremock/keystore.p12",
+ "--keystore-password", "password",
+ "--keystore-type", "PKCS12",
+ "--global-response-templating"
+ )
+ .waitingFor(Wait.forHttp("/__admin/mappings").forPort(8080).forStatusCode(200));
+
+ wireMockServer.start();
+
+ proxyServer = new GenericContainer<>(DockerImageName.parse("ubuntu/squid:6.10-24.10_beta"))
+ .withNetwork(network)
+ .withExposedPorts(3128)
+ .withCopyFileToContainer(
+ MountableFile.forClasspathResource("squid.conf"),
+ "/etc/squid/squid.conf"
+ )
+ .waitingFor(Wait.forListeningPort());
+
+ proxyServer.start();
+
+ host = wireMockServer.getHost();
+ httpPort = wireMockServer.getMappedPort(8080);
+ httpsPort = wireMockServer.getMappedPort(8443);
+ proxyPort = proxyServer.getMappedPort(3128);
+ }
+
+ @AfterAll
+ static void tearDown() {
+ if (proxyServer != null) {
+ proxyServer.stop();
+ }
+ if (wireMockServer != null) {
+ wireMockServer.stop();
+ }
+ if (network != null) {
+ network.close();
+ }
+ }
+
@Test
@DisplayName("All API services must be registered in Zitadel")
void testServicesDynamic() {
@@ -55,4 +122,63 @@ void testServicesDynamic() {
expected, actual, "The registered API services in Zitadel do not match the expected set");
}
}
+
+ @Test
+ void testCustomCaCert() {
+ TransportOptions options = new TransportOptions(
+ Map.of(), caCertPath, false, null);
+
+ Zitadel zitadel = Zitadel.withClientCredentials(
+ "https://" + host + ":" + httpsPort, "dummy-client", "dummy-secret", options);
+
+ var response = zitadel.getSettings().getGeneralSettings();
+ assertEquals("https", response.getDefaultLanguage());
+ }
+
+ @Test
+ void testInsecureMode() {
+ TransportOptions options = new TransportOptions(
+ Map.of(), null, true, null);
+
+ Zitadel zitadel = Zitadel.withClientCredentials(
+ "https://" + host + ":" + httpsPort, "dummy-client", "dummy-secret", options);
+
+ var response = zitadel.getSettings().getGeneralSettings();
+ assertEquals("https", response.getDefaultLanguage());
+ }
+
+ @SuppressWarnings("HttpUrlsUsage")
+ @Test
+ void testDefaultHeaders() {
+ TransportOptions options = new TransportOptions(
+ Map.of("X-Custom-Header", "test-value"), null, false, null);
+
+ Zitadel zitadel = Zitadel.withClientCredentials(
+ "http://" + host + ":" + httpPort, "dummy-client", "dummy-secret", options);
+
+ var response = zitadel.getSettings().getGeneralSettings();
+ assertEquals("http", response.getDefaultLanguage());
+ assertEquals("test-value", response.getDefaultOrgId());
+ }
+
+ @SuppressWarnings("HttpUrlsUsage")
+ @Test
+ void testProxyUrl() {
+ TransportOptions options = new TransportOptions(
+ Map.of(), null, false, "http://" + host + ":" + proxyPort);
+
+ Zitadel zitadel = Zitadel.withAccessToken(
+ "http://wiremock:8080", "test-token", options);
+
+ var response = zitadel.getSettings().getGeneralSettings();
+ assertEquals("http", response.getDefaultLanguage());
+ }
+
+ @Test
+ void testNoCaCertFails() {
+ assertThrows(RuntimeException.class, () -> {
+ Zitadel.withClientCredentials(
+ "https://" + host + ":" + httpsPort, "dummy-client", "dummy-secret");
+ });
+ }
}
diff --git a/src/test/resources/ca.pem b/src/test/resources/ca.pem
new file mode 100644
index 00000000..7ef3bf71
--- /dev/null
+++ b/src/test/resources/ca.pem
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDOjCCAiKgAwIBAgIUYtCHt3J95fUpagYaFNw8M1/oV7kwDQYJKoZIhvcNAQEL
+BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDMwNDA1MTcwNVoYDzIxMjYw
+MjA4MDUxNzA1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCVY1jORnqyVB9tUgYYo9U3uYCVtCzWt3lGCoxDpxAb
+LlpNnqOxG33ugRbNTY/QBht37Q37PjBahMJxkRE7EPsqi2Bz2fsZMyB7pJgP5iTA
+0cILFyFzGpgUkXjmtsozKy0jAHpnzHGALjtzoKgp4SxCrWSp/MYtfMkBP9xbEpf1
+IYYQyyiISgic0/vO+nUEjyR/ULFP+nd48KjOHwWIHqwMY3nuzqScshAsyIZzSRT0
+ND2TLK1rxGoITqsOg2yTxRWwP0khvE08Y/59BGfWZq0svBCp2E3sIXg2Z3hlie7o
+n+3P0F00kQfrEvkTi/cHv2vuhJpnlHxmTgJBRwhWE2+xAgMBAAGjgYEwfzAdBgNV
+HQ4EFgQUPOzmGXHMRu3zIZqKLad8EkHkZvowHwYDVR0jBBgwFoAUPOzmGXHMRu3z
+IZqKLad8EkHkZvowDwYDVR0TAQH/BAUwAwEB/zAsBgNVHREEJTAjgglsb2NhbGhv
+c3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggEBAD/z
+IRzYSBp6qPrvVgIX5/mEwN6ylp1J1pTC8nPQRozg0X2SEnRxz1DGBa1l046QVew2
+3+LuGYWtVkTzEtiX7BN2jSshX8d8Ss73+psZOye6t8VcAmEeVVdnqU+EzVAhM1DP
+mUiNxJPHgK2cZkpV2BHB0Ccu7qVfaIFvTk2OdbGOsQ7+r2l562kUDzCFvBo/mskO
+xiIt3YMZrpyLJJzvgi+fIo351oqLvTKOHw30FelAPIHo/A2OgngsM31HvwxROYlr
+C5mET6wnOtjTQbKORADTGQ8D3sJCjQJ/AI34Q4C2q/PBljVL8JKoAPzwviYAuqdd
+NIIKpaYUzng24gw7+50=
+-----END CERTIFICATE-----
diff --git a/src/test/resources/keystore.p12 b/src/test/resources/keystore.p12
new file mode 100644
index 00000000..9f1c66b1
Binary files /dev/null and b/src/test/resources/keystore.p12 differ
diff --git a/src/test/resources/mappings/oidc-discovery.json b/src/test/resources/mappings/oidc-discovery.json
new file mode 100644
index 00000000..3e2930a3
--- /dev/null
+++ b/src/test/resources/mappings/oidc-discovery.json
@@ -0,0 +1,19 @@
+{
+ "request": {
+ "method": "GET",
+ "url": "/.well-known/openid-configuration"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "issuer": "{{request.baseUrl}}",
+ "token_endpoint": "{{request.baseUrl}}/oauth/v2/token",
+ "authorization_endpoint": "{{request.baseUrl}}/oauth/v2/authorize",
+ "userinfo_endpoint": "{{request.baseUrl}}/oidc/v1/userinfo",
+ "jwks_uri": "{{request.baseUrl}}/oauth/v2/keys"
+ }
+ }
+}
diff --git a/src/test/resources/mappings/settings.json b/src/test/resources/mappings/settings.json
new file mode 100644
index 00000000..524a367a
--- /dev/null
+++ b/src/test/resources/mappings/settings.json
@@ -0,0 +1,16 @@
+{
+ "request": {
+ "method": "POST",
+ "url": "/zitadel.settings.v2.SettingsService/GetGeneralSettings"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "defaultLanguage": "{{request.scheme}}",
+ "defaultOrgId": "{{request.headers.X-Custom-Header}}"
+ }
+ }
+}
diff --git a/src/test/resources/mappings/token.json b/src/test/resources/mappings/token.json
new file mode 100644
index 00000000..b4227d3f
--- /dev/null
+++ b/src/test/resources/mappings/token.json
@@ -0,0 +1,17 @@
+{
+ "request": {
+ "method": "POST",
+ "url": "/oauth/v2/token"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "access_token": "test-token-12345",
+ "token_type": "Bearer",
+ "expires_in": 3600
+ }
+ }
+}
diff --git a/src/test/resources/squid.conf b/src/test/resources/squid.conf
new file mode 100644
index 00000000..f3e60d4b
--- /dev/null
+++ b/src/test/resources/squid.conf
@@ -0,0 +1,3 @@
+http_port 3128
+acl all src all
+http_access allow all