diff --git a/README.md b/README.md index 18dfdb1b..5df28ca8 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,14 @@ install dependencies. ### Installation -Install the SDK by running one of the following commands: - -```bash -composer require zitadel/client +Add the SDK dependency to your `pom.xml`: + +```xml + + io.github.zitadel + client + 4.1.0 + ``` ## Authentication Methods @@ -193,6 +197,64 @@ Choose the authentication method that best suits your needs based on your environment and security requirements. For more details, please refer to the [Zitadel documentation on authenticating service users](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users). +## Advanced Configuration + +The SDK provides a `TransportOptions` object that allows you to customise +the underlying HTTP transport used for both OpenID discovery and API calls. + +### Disabling TLS Verification + +In development or testing environments with self-signed certificates, you can +disable TLS verification entirely: + +```java +TransportOptions options = new TransportOptions( + Map.of(), null, true, null); + +Zitadel zitadel = Zitadel.withClientCredentials( + "https://your-instance.zitadel.cloud", "client-id", "client-secret", options); +``` + +### Using a Custom CA Certificate + +If your Zitadel instance uses a certificate signed by a private CA, you can +provide the path to the CA certificate in PEM format: + +```java +TransportOptions options = new TransportOptions( + Map.of(), "/path/to/ca.pem", false, null); + +Zitadel zitadel = Zitadel.withClientCredentials( + "https://your-instance.zitadel.cloud", "client-id", "client-secret", options); +``` + +### Custom Default Headers + +You can attach default headers to every outgoing request. This is useful for +custom routing or tracing headers: + +```java +TransportOptions options = new TransportOptions( + Map.of("X-Custom-Header", "my-value"), null, false, null); + +Zitadel zitadel = Zitadel.withClientCredentials( + "https://your-instance.zitadel.cloud", "client-id", "client-secret", options); +``` + +### Proxy Configuration + +If your environment requires routing traffic through an HTTP proxy, you can +specify the proxy URL. To authenticate with the proxy, embed the credentials +directly in the URL: + +```java +TransportOptions options = new TransportOptions( + Map.of(), null, false, "http://user:pass@proxy:8080"); + +Zitadel zitadel = Zitadel.withClientCredentials( + "https://your-instance.zitadel.cloud", "client-id", "client-secret", options); +``` + ## Design and Dependencies This SDK is designed to be lean and efficient, focusing on providing a diff --git a/pom.xml b/pom.xml index 00e212e2..d05228fe 100644 --- a/pom.xml +++ b/pom.xml @@ -324,6 +324,7 @@ **/api/*.java **/model/*.java + diff --git a/src/main/java/com/zitadel/ApiClient.java b/src/main/java/com/zitadel/ApiClient.java index 663eacc2..c427a65f 100644 --- a/src/main/java/com/zitadel/ApiClient.java +++ b/src/main/java/com/zitadel/ApiClient.java @@ -15,20 +15,27 @@ import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.core5.http.*; import org.apache.hc.core5.http.io.entity.ByteArrayEntity; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.FileEntity; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; +import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.http.message.BasicNameValuePair; import org.openapitools.jackson.nullable.JsonNullableModule; import javax.annotation.Nullable; +import javax.net.ssl.SSLContext; import java.io.File; import java.io.IOException; +import java.security.GeneralSecurityException; import java.lang.reflect.Type; import java.net.URLEncoder; import java.nio.charset.Charset; @@ -45,7 +52,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -@SuppressFBWarnings("THROWS_METHOD_THROWS_RUNTIMEEXCEPTION") +@SuppressFBWarnings({"THROWS_METHOD_THROWS_RUNTIMEEXCEPTION", "CT_CONSTRUCTOR_THROW"}) public class ApiClient { public static final String USER_AGENT = @@ -91,6 +98,59 @@ public ApiClient(Authenticator authenticator) { this(authenticator, HttpClients.custom().setUserAgent(USER_AGENT).build()); } + /** + * @param authenticator the authenticator to use for API requests. + * @param transportOptions Optional transport options for TLS, proxy, and headers. + */ + public ApiClient(Authenticator authenticator, TransportOptions transportOptions) { + this(authenticator, buildHttpClient(transportOptions != null ? transportOptions : TransportOptions.defaults())); + } + + private static CloseableHttpClient buildHttpClient(TransportOptions transportOptions) { + try { + HttpClientBuilder builder = HttpClients.custom().setUserAgent(USER_AGENT); + + SSLContext sslContext = transportOptions.buildSSLContext(); + if (sslContext != null) { + SSLConnectionSocketFactoryBuilder sslSocketFactoryBuilder = SSLConnectionSocketFactoryBuilder.create() + .setSslContext(sslContext); + if (transportOptions.isInsecure()) { + sslSocketFactoryBuilder.setHostnameVerifier(NoopHostnameVerifier.INSTANCE); + } + builder.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslSocketFactoryBuilder.build()) + .build()); + } + + List
defaultHeaders = new ArrayList<>(); + + if (transportOptions.getProxyUrl() != null) { + java.net.URL proxyParsed = new java.net.URL(transportOptions.getProxyUrl()); + String proxyScheme = proxyParsed.getProtocol(); + String proxyHost = proxyParsed.getHost(); + int proxyPort = proxyParsed.getPort() != -1 ? proxyParsed.getPort() : proxyParsed.getDefaultPort(); + builder.setProxy(new HttpHost(proxyScheme, proxyHost, proxyPort)); + if (proxyParsed.getUserInfo() != null) { + String encoded = Base64.getEncoder() + .encodeToString(proxyParsed.getUserInfo().getBytes(StandardCharsets.UTF_8)); + defaultHeaders.add(new BasicHeader("Proxy-Authorization", "Basic " + encoded)); + } + } + + for (Map.Entry entry : transportOptions.getDefaultHeaders().entrySet()) { + defaultHeaders.add(new BasicHeader(entry.getKey(), entry.getValue())); + } + + if (!defaultHeaders.isEmpty()) { + builder.setDefaultHeaders(defaultHeaders); + } + + return builder.build(); + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException("Failed to build HTTP client with transport options: " + e.getMessage(), e); + } + } + public static DateFormat buildDefaultDateFormat() { return new RFC3339DateFormat(); } diff --git a/src/main/java/com/zitadel/TransportOptions.java b/src/main/java/com/zitadel/TransportOptions.java new file mode 100644 index 00000000..d4cd4860 --- /dev/null +++ b/src/main/java/com/zitadel/TransportOptions.java @@ -0,0 +1,173 @@ +package com.zitadel; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import javax.annotation.Nullable; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Immutable transport options for configuring HTTP connections. + */ +public class TransportOptions { + + private final Map defaultHeaders; + @Nullable + private final String caCertPath; + private final boolean insecure; + @Nullable + private final String proxyUrl; + @Nullable + private volatile SSLContext cachedSSLContext; + private volatile boolean sslContextBuilt; + + /** + * @param defaultHeaders Default HTTP headers sent to the origin server with every request. + * @param caCertPath Path to a custom CA certificate file for TLS verification, or {@code null}. + * @param insecure Whether to disable TLS certificate verification. + * @param proxyUrl Proxy URL for HTTP connections, or {@code null}. + */ + public TransportOptions(Map defaultHeaders, @Nullable String caCertPath, boolean insecure, @Nullable String proxyUrl) { + this.defaultHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(defaultHeaders)); + this.caCertPath = caCertPath; + this.insecure = insecure; + this.proxyUrl = proxyUrl; + } + + /** + * Returns a {@code TransportOptions} instance with all default values. + * + * @return the default transport options. + */ + public static TransportOptions defaults() { + return new TransportOptions(Collections.emptyMap(), null, false, null); + } + + /** + * Returns the default HTTP headers sent to the origin server with every request. + * + * @return an unmodifiable map of header names to values. + */ + public Map 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