From b8b69fd0f0c8d8eac6cc012f7b1101c7aa9f8d98 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 13:43:41 +1100 Subject: [PATCH 01/42] Add TransportOptions for configuring TLS, proxy, and default headers Introduce a TransportOptions class that encapsulates transport-level configuration (default_headers, ca_cert_path, insecure, proxy_url). Factory methods now accept an optional transport_options parameter alongside the existing individual parameters for backward compatibility. Internal auth plumbing (OpenId, builders) refactored to use TransportOptions throughout. --- README.md | 86 ++++++++- lib/zitadel/client/api_client.rb | 5 +- lib/zitadel/client/auth/authenticator.rb | 5 +- .../auth/client_credentials_authenticator.rb | 9 +- lib/zitadel/client/auth/open_id.rb | 24 ++- .../client/auth/web_token_authenticator.rb | 18 +- lib/zitadel/client/configuration.rb | 20 ++ lib/zitadel/client/transport_options.rb | 22 +++ lib/zitadel/client/zitadel.rb | 53 +++++- sig/lib.rbs | 31 ++- test/zitadel/client/transport_options_test.rb | 177 ++++++++++++++++++ 11 files changed, 415 insertions(+), 35 deletions(-) create mode 100644 lib/zitadel/client/transport_options.rb create mode 100644 test/zitadel/client/transport_options_test.rb diff --git a/README.md b/README.md index 90f21121..07d7323c 100644 --- a/README.md +++ b/README.md @@ -198,18 +198,100 @@ and debugging purposes. You can enable debug logging by setting the `debug` flag to `true` when initializing the `Zitadel` client, like this: ```ruby -zitadel = zitadel.Zitadel("your-zitadel-base-url", 'your-valid-token', lambda config: config.debug = True) +zitadel = Zitadel::Client::Zitadel.with_access_token( + 'your-zitadel-base-url', + 'your-valid-token', + debug: true +) ``` When enabled, the SDK will log additional information, such as HTTP request and response details, which can be useful for identifying issues in the integration or troubleshooting unexpected behavior. +## Advanced Configuration + +The SDK supports several advanced configuration options that can be passed +to any of the factory methods (`with_client_credentials`, `with_private_key`, +or `with_access_token`). + +### Disabling TLS Verification + +To disable TLS certificate verification (not recommended for production), +pass `insecure: true`: + +```ruby +client = Zitadel::Client::Zitadel.with_client_credentials( + "https://example.us1.zitadel.cloud", + "client-id", "client-secret", + insecure: true +) +``` + +### Using a Custom CA Certificate + +To use a custom CA certificate for TLS verification, pass the path to the +certificate file via `ca_cert_path`: + +```ruby +client = Zitadel::Client::Zitadel.with_client_credentials( + "https://example.us1.zitadel.cloud", + "client-id", "client-secret", + ca_cert_path: '/path/to/ca.pem' +) +``` + +### Custom Default Headers + +To include additional headers in every HTTP request, pass a hash via +`default_headers`: + +```ruby +client = Zitadel::Client::Zitadel.with_client_credentials( + "https://example.us1.zitadel.cloud", + "client-id", "client-secret", + default_headers: { 'Proxy-Authorization' => 'Basic ...' } +) +``` + +### Proxy Configuration + +To route all HTTP requests through a proxy, pass the proxy URL via +`proxy_url`: + +```ruby +client = Zitadel::Client::Zitadel.with_client_credentials( + "https://example.us1.zitadel.cloud", + "client-id", "client-secret", + proxy_url: 'http://proxy:8080' +) +``` + +### Using TransportOptions + +All transport settings can be combined into a single `TransportOptions` object: + +```ruby +options = Zitadel::Client::TransportOptions.new( + insecure: true, + ca_cert_path: '/path/to/ca.pem', + default_headers: { 'Proxy-Authorization' => 'Basic dXNlcjpwYXNz' }, + proxy_url: 'http://proxy:8080' +) + +zitadel = Zitadel::Client::Zitadel.with_client_credentials( + 'https://my-instance.zitadel.cloud', + 'client-id', + 'client-secret', + transport_options: options +) +``` + ## Design and Dependencies This SDK is designed to be lean and efficient, focusing on providing a streamlined way to interact with the Zitadel API. It relies on the commonly used -urllib3 HTTP transport for making requests, which ensures that +Faraday HTTP library for making requests, which ensures that the SDK integrates well with other libraries and provides flexibility in terms of request handling and error management. diff --git a/lib/zitadel/client/api_client.rb b/lib/zitadel/client/api_client.rb index 5a75e25f..43715e4e 100644 --- a/lib/zitadel/client/api_client.rb +++ b/lib/zitadel/client/api_client.rb @@ -48,7 +48,7 @@ def initialize(config = Configuration.new) @default_headers = { 'Content-Type' => 'application/json', 'User-Agent' => config.user_agent - } + }.merge(config.default_headers || {}) end # noinspection RubyClassVariableUsageInspection,RbsMissingTypeSignature @@ -114,6 +114,9 @@ def build_request(http_method, path, opts = {}) # set custom cert, if provided req_opts[:cainfo] = @config.ssl_ca_cert if @config.ssl_ca_cert + # set proxy, if provided + req_opts[:proxy] = @config.proxy_url if @config.proxy_url + if %i[post patch put delete].include?(http_method) req_body = build_request_body(header_params, form_params, opts[:body]) req_opts.update body: req_body diff --git a/lib/zitadel/client/auth/authenticator.rb b/lib/zitadel/client/auth/authenticator.rb index e1a816f9..5fd8162b 100644 --- a/lib/zitadel/client/auth/authenticator.rb +++ b/lib/zitadel/client/auth/authenticator.rb @@ -57,8 +57,9 @@ class OAuthAuthenticatorBuilder # # @param host [String] the base URL for the OAuth provider. # - def initialize(host) - @open_id = OpenId.new(host) + def initialize(host, transport_options: nil) + transport_options ||= TransportOptions.defaults + @open_id = OpenId.new(host, transport_options: transport_options) @auth_scopes = Set.new(%w[openid urn:zitadel:iam:org:project:id:zitadel:aud]) end diff --git a/lib/zitadel/client/auth/client_credentials_authenticator.rb b/lib/zitadel/client/auth/client_credentials_authenticator.rb index b726a5c2..9c34ea56 100644 --- a/lib/zitadel/client/auth/client_credentials_authenticator.rb +++ b/lib/zitadel/client/auth/client_credentials_authenticator.rb @@ -25,8 +25,9 @@ def initialize(open_id, client_id, client_secret, auth_scopes) # @param client_id [String] The OAuth client identifier. # @param client_secret [String] The OAuth client secret. # @return [ClientCredentialsAuthenticatorBuilder] A builder instance. - def self.builder(host, client_id, client_secret) - ClientCredentialsAuthenticatorBuilder.new(host, client_id, client_secret) + def self.builder(host, client_id, client_secret, transport_options: nil) + ClientCredentialsAuthenticatorBuilder.new(host, client_id, client_secret, + transport_options: transport_options) end protected @@ -45,9 +46,9 @@ class ClientCredentialsAuthenticatorBuilder < OAuthAuthenticatorBuilder # @param host [String] The OAuth provider's base URL. # @param client_id [String] The OAuth client identifier. # @param client_secret [String] The OAuth client secret. - def initialize(host, client_id, client_secret) + def initialize(host, client_id, client_secret, transport_options: nil) # noinspection RubyArgCount - super(host) + super(host, transport_options: transport_options) @client_id = client_id @client_secret = client_secret end diff --git a/lib/zitadel/client/auth/open_id.rb b/lib/zitadel/client/auth/open_id.rb index 7283e7f8..2f78d697 100644 --- a/lib/zitadel/client/auth/open_id.rb +++ b/lib/zitadel/client/auth/open_id.rb @@ -3,6 +3,7 @@ require 'json' require 'uri' require 'net/http' +require 'openssl' module Zitadel module Client @@ -23,13 +24,31 @@ class OpenId # @raise [RuntimeError] if the OpenID configuration cannot be fetched or the token_endpoint is missing. # # noinspection HttpUrlsUsage - def initialize(hostname) + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def initialize(hostname, transport_options: nil) + transport_options ||= TransportOptions.defaults hostname = "https://#{hostname}" unless hostname.start_with?('http://', 'https://') @host_endpoint = hostname well_known_url = self.class.build_well_known_url(hostname) uri = URI.parse(well_known_url) - response = Net::HTTP.get_response(uri) + http = if transport_options.proxy_url + proxy_uri = URI.parse(transport_options.proxy_url) + Net::HTTP.new(uri.host.to_s, uri.port, proxy_uri.host, proxy_uri.port) + else + Net::HTTP.new(uri.host.to_s, uri.port) + end + http.use_ssl = (uri.scheme == 'https') + if transport_options.insecure + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + elsif transport_options.ca_cert_path + http.ca_file = transport_options.ca_cert_path + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + http.verify_hostname = false + end + request = Net::HTTP::Get.new(uri) + transport_options.default_headers.each { |k, v| request[k] = v } + response = http.request(request) raise "Failed to fetch OpenID configuration: HTTP #{response.code}" unless response.code.to_i == 200 config = JSON.parse(response.body) @@ -38,6 +57,7 @@ def initialize(hostname) @token_endpoint = token_endpoint end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity ## # Builds the well-known OpenID configuration URL for the given hostname. diff --git a/lib/zitadel/client/auth/web_token_authenticator.rb b/lib/zitadel/client/auth/web_token_authenticator.rb index e0c6c718..88a51e99 100644 --- a/lib/zitadel/client/auth/web_token_authenticator.rb +++ b/lib/zitadel/client/auth/web_token_authenticator.rb @@ -64,7 +64,8 @@ def initialize(open_id, auth_scopes, jwt_issuer, jwt_subject, jwt_audience, priv # @param json_path [String] File path to the JSON configuration file. # @return [WebTokenAuthenticator] A new instance of WebTokenAuthenticator. # @raise [RuntimeError] If the file cannot be read, the JSON is invalid, or required keys are missing. - def self.from_json(host, json_path) + # rubocop:disable Metrics/MethodLength + def self.from_json(host, json_path, transport_options: nil) config = JSON.parse(File.read(json_path)) rescue Errno::ENOENT => e raise "Unable to read JSON file at #{json_path}: #{e.message}" @@ -76,8 +77,10 @@ def self.from_json(host, json_path) user_id, private_key, key_id = config.values_at('userId', 'key', 'keyId') raise "Missing required keys 'userId', 'keyId' or 'key'" unless user_id && key_id && private_key - WebTokenAuthenticator.builder(host, user_id, private_key).key_identifier(key_id).build + WebTokenAuthenticator.builder(host, user_id, private_key, transport_options: transport_options) + .key_identifier(key_id).build end + # rubocop:enable Metrics/MethodLength # Returns a builder for constructing a WebTokenAuthenticator. # @@ -85,8 +88,9 @@ def self.from_json(host, json_path) # @param user_id [String] The user identifier (used as both the issuer and subject). # @param private_key [String] The private key used to sign the JWT. # @return [WebTokenAuthenticatorBuilder] A builder instance. - def self.builder(host, user_id, private_key) - WebTokenAuthenticatorBuilder.new(host, user_id, user_id, host, private_key) + def self.builder(host, user_id, private_key, transport_options: nil) + WebTokenAuthenticatorBuilder.new(host, user_id, user_id, host, private_key, + transport_options: transport_options) end protected @@ -130,15 +134,17 @@ class WebTokenAuthenticatorBuilder < OAuthAuthenticatorBuilder # @param jwt_subject [String] The subject claim for the JWT. # @param jwt_audience [String] The audience claim for the JWT. # @param private_key [String] The PEM-formatted private key used for signing the JWT. - def initialize(host, jwt_issuer, jwt_subject, jwt_audience, private_key) + # rubocop:disable Metrics/ParameterLists + def initialize(host, jwt_issuer, jwt_subject, jwt_audience, private_key, transport_options: nil) # noinspection RubyArgCount - super(host) + super(host, transport_options: transport_options) @jwt_issuer = jwt_issuer @jwt_subject = jwt_subject @jwt_audience = jwt_audience @private_key = private_key @jwt_lifetime = 3600 end + # rubocop:enable Metrics/ParameterLists # Sets the JWT token lifetime in seconds. # diff --git a/lib/zitadel/client/configuration.rb b/lib/zitadel/client/configuration.rb index 76abc57d..8fc93f94 100644 --- a/lib/zitadel/client/configuration.rb +++ b/lib/zitadel/client/configuration.rb @@ -149,6 +149,24 @@ class Configuration # @return [String, nil] attr_accessor :user_agent + ## + # Additional headers to include in every HTTP request. + # + # These headers are merged with the default headers set by the API client. + # Defaults to an empty hash. + # + # @return [Hash{String => String}] + attr_accessor :default_headers + + ## + # Proxy URL to use for HTTP requests. + # + # When set, all HTTP requests will be routed through the specified proxy. + # The URL should include the scheme, host, and port (e.g., "http://proxy:8080"). + # + # @return [String, nil] + attr_accessor :proxy_url + # rubocop:disable Metrics/MethodLength def initialize(authenticator = Auth::NoAuthAuthenticator.new) @authenticator = authenticator @@ -157,6 +175,8 @@ def initialize(authenticator = Auth::NoAuthAuthenticator.new) @verify_ssl_host = true @cert_file = nil @key_file = nil + @default_headers = {} + @proxy_url = nil @timeout = 0 @params_encoding = nil @debugging = false diff --git a/lib/zitadel/client/transport_options.rb b/lib/zitadel/client/transport_options.rb new file mode 100644 index 00000000..cde22fd2 --- /dev/null +++ b/lib/zitadel/client/transport_options.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Zitadel + module Client + # Immutable transport options for configuring HTTP connections. + class TransportOptions + attr_reader :default_headers, :ca_cert_path, :insecure, :proxy_url + + def initialize(default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil) + @default_headers = default_headers.freeze + @ca_cert_path = ca_cert_path.freeze + @insecure = insecure + @proxy_url = proxy_url.freeze + freeze + end + + def self.defaults + new + end + end + end +end diff --git a/lib/zitadel/client/zitadel.rb b/lib/zitadel/client/zitadel.rb index 27a8613e..72afb88c 100644 --- a/lib/zitadel/client/zitadel.rb +++ b/lib/zitadel/client/zitadel.rb @@ -7,7 +7,7 @@ module Client # Initializes and configures the SDK with the provided authentication strategy. # Sets up service APIs for interacting with various Zitadel features. # noinspection RubyTooManyInstanceVariablesInspection - class Zitadel + class Zitadel # rubocop:disable Metrics/ClassLength attr_reader :features, :idps, :instances, @@ -92,9 +92,21 @@ class << self # @param access_token [String] Personal Access Token for Bearer authentication. # @return [Zitadel] SDK client configured with PAT authentication. # @see https://zitadel.com/docs/guides/integrate/service-users/personal-access-token - def with_access_token(host, access_token) - new(Auth::PersonalAccessTokenAuthenticator.new(host, access_token)) + # rubocop:disable Metrics/ParameterLists + def with_access_token(host, access_token, default_headers: {}, ca_cert_path: nil, insecure: false, + proxy_url: nil, transport_options: nil) + resolved = transport_options || TransportOptions.new(default_headers: default_headers, + ca_cert_path: ca_cert_path, + insecure: insecure, + proxy_url: proxy_url) + new(Auth::PersonalAccessTokenAuthenticator.new(host, access_token)) do |config| + config.default_headers = resolved.default_headers + config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path + config.verify_ssl = !resolved.insecure if resolved.insecure + config.proxy_url = resolved.proxy_url if resolved.proxy_url + end end + # rubocop:enable Metrics/ParameterLists # Initialize the SDK using OAuth2 Client Credentials flow. # @@ -103,13 +115,25 @@ def with_access_token(host, access_token) # @param client_secret [String] OAuth2 client secret. # @return [Zitadel] SDK client with automatic token acquisition & refresh. # @see https://zitadel.com/docs/guides/integrate/service-users/client-credentials - def with_client_credentials(host, client_id, client_secret) + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize + def with_client_credentials(host, client_id, client_secret, default_headers: {}, ca_cert_path: nil, + insecure: false, proxy_url: nil, transport_options: nil) + resolved = transport_options || TransportOptions.new(default_headers: default_headers, + ca_cert_path: ca_cert_path, + insecure: insecure, + proxy_url: proxy_url) new( Auth::ClientCredentialsAuthenticator - .builder(host, client_id, client_secret) + .builder(host, client_id, client_secret, transport_options: resolved) .build - ) + ) do |config| + config.default_headers = resolved.default_headers + config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path + config.verify_ssl = !resolved.insecure if resolved.insecure + config.proxy_url = resolved.proxy_url if resolved.proxy_url + end end + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize # Initialize the SDK via Private Key JWT assertion. # @@ -117,9 +141,22 @@ def with_client_credentials(host, client_id, client_secret) # @param key_file [String] Path to service account JSON/PEM key file. # @return [Zitadel] SDK client using JWT assertion for secure, secret-less auth. # @see https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt - def with_private_key(host, key_file) - new(Auth::WebTokenAuthenticator.from_json(host, key_file)) + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength + def with_private_key(host, key_file, default_headers: {}, ca_cert_path: nil, insecure: false, + proxy_url: nil, transport_options: nil) + resolved = transport_options || TransportOptions.new(default_headers: default_headers, + ca_cert_path: ca_cert_path, + insecure: insecure, + proxy_url: proxy_url) + new(Auth::WebTokenAuthenticator.from_json(host, key_file, + transport_options: resolved)) do |config| + config.default_headers = resolved.default_headers + config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path + config.verify_ssl = !resolved.insecure if resolved.insecure + config.proxy_url = resolved.proxy_url if resolved.proxy_url + end end + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength # @!endgroup end diff --git a/sig/lib.rbs b/sig/lib.rbs index 1a77650f..7d2d1cc0 100644 --- a/sig/lib.rbs +++ b/sig/lib.rbs @@ -132,6 +132,8 @@ module Zitadel attr_accessor cert_file: String? attr_accessor key_file: String? attr_accessor params_encoding: Symbol? + attr_accessor default_headers: Hash[String, String] + attr_accessor proxy_url: String? def self.default: () -> Configuration @@ -202,6 +204,15 @@ module Zitadel def initialize: (Integer, Hash[String, Array[String]], String | Typhoeus::Response) -> void end + class TransportOptions + attr_reader default_headers: Hash[String, String] + attr_reader ca_cert_path: String? + attr_reader insecure: bool + attr_reader proxy_url: String? + def initialize: (?default_headers: Hash[String, String], ?ca_cert_path: String?, ?insecure: bool, ?proxy_url: String?) -> void + def self.defaults: -> TransportOptions + end + module Auth class Authenticator @@ -221,13 +232,13 @@ module Zitadel def get_grant: (OAuth2::Client, String) -> OAuth2::AccessToken - def self.builder: (String, String, String) -> ClientCredentialsAuthenticatorBuilder + def self.builder: (String, String, String, ?transport_options: TransportOptions?) -> ClientCredentialsAuthenticatorBuilder class ClientCredentialsAuthenticatorBuilder < OAuthAuthenticatorBuilder @client_id: String @client_secret: String - def initialize: (String, String, String) -> void + def initialize: (String, String, String, ?transport_options: TransportOptions?) -> void def build: -> ClientCredentialsAuthenticator end @@ -248,7 +259,7 @@ module Zitadel attr_reader open_id: OpenId attr_reader auth_scopes: Set[String] - def initialize: (String) -> void + def initialize: (String, ?transport_options: TransportOptions?) -> void def scopes: (*String) -> self end @@ -278,7 +289,7 @@ module Zitadel attr_accessor host_endpoint: String attr_accessor token_endpoint: String - def initialize: (String) -> void + def initialize: (String, ?transport_options: TransportOptions?) -> void def self.build_well_known_url: (String) -> String end @@ -300,9 +311,9 @@ module Zitadel @key_id: String? @private_key: OpenSSL::PKey::RSA - def self.builder: (String, String, String) -> WebTokenAuthenticatorBuilder + def self.builder: (String, String, String, ?transport_options: TransportOptions?) -> WebTokenAuthenticatorBuilder - def self.from_json: (String, String) -> WebTokenAuthenticator + def self.from_json: (String, String, ?transport_options: TransportOptions?) -> WebTokenAuthenticator def get_grant: (OAuth2::Client, String) -> OAuth2::AccessToken @@ -316,7 +327,7 @@ module Zitadel @key_id: String @private_key: String - def initialize: (String, String, String, String, String) -> void + def initialize: (String, String, String, String, String, ?transport_options: TransportOptions?) -> void def build: -> WebTokenAuthenticator @@ -329,11 +340,11 @@ module Zitadel class Zitadel - def self.with_access_token: (String, String) -> Zitadel + def self.with_access_token: (String, String, ?default_headers: Hash[String, String], ?ca_cert_path: String?, ?insecure: bool, ?proxy_url: String?, ?transport_options: TransportOptions?) -> Zitadel - def self.with_client_credentials: (String, String, String) -> Zitadel + def self.with_client_credentials: (String, String, String, ?default_headers: Hash[String, String], ?ca_cert_path: String?, ?insecure: bool, ?proxy_url: String?, ?transport_options: TransportOptions?) -> Zitadel - def self.with_private_key: (String, String) -> Zitadel + def self.with_private_key: (String, String, ?default_headers: Hash[String, String], ?ca_cert_path: String?, ?insecure: bool, ?proxy_url: String?, ?transport_options: TransportOptions?) -> Zitadel attr_reader configuration: Configuration attr_reader features: Api::FeatureServiceApi diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb new file mode 100644 index 00000000..23afec74 --- /dev/null +++ b/test/zitadel/client/transport_options_test.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +# Test suite for transport options (default_headers, ca_cert_path, insecure). +# +# Uses a WireMock Docker container with HTTPS support to verify that the SDK +# correctly handles custom CA certificates, insecure TLS mode, and custom +# default headers when initializing via `with_client_credentials`. + +# noinspection RubyResolve +require 'test_helper' +require 'minitest/autorun' +require 'minitest/hooks/test' +require 'testcontainers' +require 'net/http' +require 'json' +require 'socket' +require 'openssl' +require 'tempfile' + +module Zitadel + module Client + class TransportOptionsTest < Minitest::Test # rubocop:disable Metrics/ClassLength + # noinspection RbsMissingTypeSignature + include Minitest::Hooks + + # rubocop:disable Metrics/MethodLength + def before_all + super + + @wiremock = Testcontainers::DockerContainer.new('wiremock/wiremock:3.3.1') + .with_command('--https-port', '8443', '--global-response-templating') + .with_exposed_ports(8080, 8443) + .start + @wiremock.wait_for_http(container_port: 8080, path: '/__admin/mappings', status: 200) + + @host = @wiremock.host + @http_port = @wiremock.mapped_port(8080) + @https_port = @wiremock.mapped_port(8443) + + register_wiremock_stubs + extract_wiremock_certificate + end + # rubocop:enable Metrics/MethodLength + + def after_all + @cert_tempfile&.close! + @wiremock&.stop + super + end + + def test_custom_ca_cert + zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( + "https://#{@host}:#{@https_port}", + 'dummy-client', 'dummy-secret', + ca_cert_path: @ca_cert_path + ) + + refute_nil zitadel + end + + def test_insecure_mode + zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( + "https://#{@host}:#{@https_port}", + 'dummy-client', 'dummy-secret', + insecure: true + ) + + refute_nil zitadel + end + + # rubocop:disable Metrics/MethodLength + def test_default_headers + # Use HTTP to avoid TLS concerns + zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( + "http://#{@host}:#{@http_port}", + 'dummy-client', 'dummy-secret', + default_headers: { 'X-Custom-Header' => 'test-value' } + ) + + refute_nil zitadel + + # Verify via WireMock request journal + # noinspection HttpUrlsUsage + journal_uri = URI("http://#{@host}:#{@http_port}/__admin/requests") + journal = JSON.parse(Net::HTTP.get(journal_uri)) + + found_header = journal['requests'].any? do |req| + req.dig('request', 'headers', 'X-Custom-Header') + end + + assert found_header, 'Custom header should be present in WireMock request journal' + end + # rubocop:enable Metrics/MethodLength + + def test_proxy_url + zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( + "http://#{@host}:#{@http_port}", + 'dummy-client', 'dummy-secret', + proxy_url: "http://#{@host}:#{@http_port}" + ) + + refute_nil zitadel + end + + def test_no_ca_cert_fails + assert_raises(Exception) do + ::Zitadel::Client::Zitadel.with_client_credentials( + "https://#{@host}:#{@https_port}", + 'dummy-client', 'dummy-secret' + ) + end + end + + def test_transport_options_object + opts = TransportOptions.new(insecure: true) + zitadel = Zitadel.with_client_credentials( + "https://#{@host}:#{@https_port}", + 'dummy-client', 'dummy-secret', + transport_options: opts + ) + + assert_instance_of Zitadel, zitadel + end + + private + + # rubocop:disable Metrics/MethodLength + def register_wiremock_stubs + # noinspection HttpUrlsUsage + uri = URI("http://#{@host}:#{@http_port}/__admin/mappings") + + # Stub 1 - OpenID Configuration + Net::HTTP.post(uri, { + request: { method: 'GET', url: '/.well-known/openid-configuration' }, + response: { + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: '{"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"}' + } + }.to_json, 'Content-Type' => 'application/json') + + # Stub 2 - Token endpoint + Net::HTTP.post(uri, { + 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 } + } + }.to_json, 'Content-Type' => 'application/json') + end + # rubocop:enable Metrics/MethodLength + + # rubocop:disable Metrics/MethodLength + def extract_wiremock_certificate + tcp = TCPSocket.new(@host, @https_port) + ctx = OpenSSL::SSL::SSLContext.new + ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE + ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx) + ssl.connect + pem = ssl.peer_cert.to_pem + ssl.close + tcp.close + + @cert_tempfile = Tempfile.new(['wiremock-ca-', '.pem']) + @cert_tempfile.write(pem) + @cert_tempfile.close + @ca_cert_path = @cert_tempfile.path + end + # rubocop:enable Metrics/MethodLength + end + end +end From 4e456f45c5eb504d1b3a312501b3f9648604f3b5 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 14:11:25 +1100 Subject: [PATCH 02/42] Fix hostname verification for custom CA certificates --- lib/zitadel/client/auth/open_id.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/zitadel/client/auth/open_id.rb b/lib/zitadel/client/auth/open_id.rb index 2f78d697..85024c4f 100644 --- a/lib/zitadel/client/auth/open_id.rb +++ b/lib/zitadel/client/auth/open_id.rb @@ -44,7 +44,6 @@ def initialize(hostname, transport_options: nil) elsif transport_options.ca_cert_path http.ca_file = transport_options.ca_cert_path http.verify_mode = OpenSSL::SSL::VERIFY_PEER - http.verify_hostname = false end request = Net::HTTP::Get.new(uri) transport_options.default_headers.each { |k, v| request[k] = v } From d591effbd65a4ab1c6d0d826c58bb968a09eff15 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 14:22:38 +1100 Subject: [PATCH 03/42] Merge custom CA with default trust store instead of replacing it When a custom CA certificate is configured, the previous implementation set http.ca_file which replaced the system trust store entirely. Now an OpenSSL::X509::Store is used with set_default_paths to load system CAs first, then add_file to include the custom CA alongside them. --- lib/zitadel/client/auth/open_id.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/zitadel/client/auth/open_id.rb b/lib/zitadel/client/auth/open_id.rb index 85024c4f..32ea8a73 100644 --- a/lib/zitadel/client/auth/open_id.rb +++ b/lib/zitadel/client/auth/open_id.rb @@ -42,7 +42,10 @@ def initialize(hostname, transport_options: nil) if transport_options.insecure http.verify_mode = OpenSSL::SSL::VERIFY_NONE elsif transport_options.ca_cert_path - http.ca_file = transport_options.ca_cert_path + store = OpenSSL::X509::Store.new + store.set_default_paths + store.add_file(transport_options.ca_cert_path) + http.cert_store = store http.verify_mode = OpenSSL::SSL::VERIFY_PEER end request = Net::HTTP::Get.new(uri) From 3f807bad2ce596a1d3531753ed94baec41df013c Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 14:25:51 +1100 Subject: [PATCH 04/42] Fix contradictory README example showing insecure with ca_cert_path --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 07d7323c..fd317ec8 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,6 @@ All transport settings can be combined into a single `TransportOptions` object: ```ruby options = Zitadel::Client::TransportOptions.new( - insecure: true, ca_cert_path: '/path/to/ca.pem', default_headers: { 'Proxy-Authorization' => 'Basic dXNlcjpwYXNz' }, proxy_url: 'http://proxy:8080' From 316b1998f85c61b96adc0bd5d0654972ae4bafef Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 14:27:04 +1100 Subject: [PATCH 05/42] Fix SSL socket resource leak in transport options test --- test/zitadel/client/transport_options_test.rb | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 23afec74..7229a3a5 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -158,13 +158,16 @@ def register_wiremock_stubs # rubocop:disable Metrics/MethodLength def extract_wiremock_certificate tcp = TCPSocket.new(@host, @https_port) - ctx = OpenSSL::SSL::SSLContext.new - ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE - ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx) - ssl.connect - pem = ssl.peer_cert.to_pem - ssl.close - tcp.close + begin + ctx = OpenSSL::SSL::SSLContext.new + ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE + ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx) + ssl.connect + pem = ssl.peer_cert.to_pem + ensure + ssl&.close + tcp.close + end @cert_tempfile = Tempfile.new(['wiremock-ca-', '.pem']) @cert_tempfile.write(pem) From 666391b0ad6bf690655bf262c9fb085dc1cbd0e9 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 15:14:28 +1100 Subject: [PATCH 06/42] Apply transport options to OAuth token exchange requests --- lib/zitadel/client/auth/authenticator.rb | 1 + .../auth/client_credentials_authenticator.rb | 28 ++++++++++++++++--- .../client/auth/o_auth_authenticator.rb | 3 +- .../client/auth/web_token_authenticator.rb | 24 +++++++++++++--- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/lib/zitadel/client/auth/authenticator.rb b/lib/zitadel/client/auth/authenticator.rb index 5fd8162b..382b8ed5 100644 --- a/lib/zitadel/client/auth/authenticator.rb +++ b/lib/zitadel/client/auth/authenticator.rb @@ -59,6 +59,7 @@ class OAuthAuthenticatorBuilder # def initialize(host, transport_options: nil) transport_options ||= TransportOptions.defaults + @transport_options = transport_options @open_id = OpenId.new(host, transport_options: transport_options) @auth_scopes = Set.new(%w[openid urn:zitadel:iam:org:project:id:zitadel:aud]) end diff --git a/lib/zitadel/client/auth/client_credentials_authenticator.rb b/lib/zitadel/client/auth/client_credentials_authenticator.rb index 9c34ea56..c2873b13 100644 --- a/lib/zitadel/client/auth/client_credentials_authenticator.rb +++ b/lib/zitadel/client/auth/client_credentials_authenticator.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'openssl' + module Zitadel module Client module Auth @@ -11,13 +13,30 @@ class ClientCredentialsAuthenticator < Auth::OAuthAuthenticator # @param client_id [String] The OAuth client identifier. # @param client_secret [String] The OAuth client secret. # @param auth_scopes [Set] The scope(s) for the token request. - def initialize(open_id, client_id, client_secret, auth_scopes) + # rubocop:disable Metrics/MethodLength + def initialize(open_id, client_id, client_secret, auth_scopes, transport_options: nil) + transport_options ||= TransportOptions.defaults + + conn_opts = {} + if transport_options.insecure + conn_opts[:ssl] = { verify: false } + elsif transport_options.ca_cert_path + store = OpenSSL::X509::Store.new + store.set_default_paths + store.add_file(transport_options.ca_cert_path) + conn_opts[:ssl] = { cert_store: store, verify: true } + end + conn_opts[:proxy] = transport_options.proxy_url if transport_options.proxy_url + conn_opts[:headers] = transport_options.default_headers if transport_options.default_headers.any? + # noinspection RubyArgCount super(open_id, auth_scopes, OAuth2::Client.new(client_id, client_secret, { site: open_id.host_endpoint, - token_url: open_id.token_endpoint - })) + token_url: open_id.token_endpoint, + connection_opts: conn_opts + }), transport_options: transport_options) end + # rubocop:enable Metrics/MethodLength # Returns a new builder for constructing a ClientCredentialsAuthenticator. # @@ -57,7 +76,8 @@ def initialize(host, client_id, client_secret, transport_options: nil) # # @return [ClientCredentialsAuthenticator] A configured instance. def build - ClientCredentialsAuthenticator.new(open_id, @client_id, @client_secret, auth_scopes) + ClientCredentialsAuthenticator.new(open_id, @client_id, @client_secret, auth_scopes, + transport_options: @transport_options) end end end diff --git a/lib/zitadel/client/auth/o_auth_authenticator.rb b/lib/zitadel/client/auth/o_auth_authenticator.rb index c6efd107..2c8acdc9 100644 --- a/lib/zitadel/client/auth/o_auth_authenticator.rb +++ b/lib/zitadel/client/auth/o_auth_authenticator.rb @@ -27,9 +27,10 @@ class OAuthAuthenticator < Authenticator # @param open_id [OpenId] An object that must implement `get_host_endpoint` and `get_token_endpoint`. # @param auth_session [OAuth2Session] The OAuth2Session instance used for token requests. # - def initialize(open_id, auth_scopes, auth_session) + def initialize(open_id, auth_scopes, auth_session, transport_options: nil) super(open_id.host_endpoint) @open_id = open_id + @transport_options = transport_options || TransportOptions.defaults @token = nil @auth_session = auth_session @auth_scopes = auth_scopes.to_a.join(' ') diff --git a/lib/zitadel/client/auth/web_token_authenticator.rb b/lib/zitadel/client/auth/web_token_authenticator.rb index 88a51e99..fdb7e6a4 100644 --- a/lib/zitadel/client/auth/web_token_authenticator.rb +++ b/lib/zitadel/client/auth/web_token_authenticator.rb @@ -27,12 +27,27 @@ class WebTokenAuthenticator < Auth::OAuthAuthenticator # @param key_id [String, nil] Optional key identifier for the JWT header (default: nil). # rubocop:disable Metrics/ParameterLists,Metrics/MethodLength def initialize(open_id, auth_scopes, jwt_issuer, jwt_subject, jwt_audience, private_key, - jwt_lifetime: 3600, jwt_algorithm: 'RS256', key_id: nil) + jwt_lifetime: 3600, jwt_algorithm: 'RS256', key_id: nil, transport_options: nil) + transport_options ||= TransportOptions.defaults + + conn_opts = {} + if transport_options.insecure + conn_opts[:ssl] = { verify: false } + elsif transport_options.ca_cert_path + store = OpenSSL::X509::Store.new + store.set_default_paths + store.add_file(transport_options.ca_cert_path) + conn_opts[:ssl] = { cert_store: store, verify: true } + end + conn_opts[:proxy] = transport_options.proxy_url if transport_options.proxy_url + conn_opts[:headers] = transport_options.default_headers if transport_options.default_headers.any? + # noinspection RubyArgCount,RubyMismatchedArgumentType super(open_id, auth_scopes, OAuth2::Client.new('zitadel', 'zitadel', { site: open_id.host_endpoint, - token_url: open_id.token_endpoint - })) + token_url: open_id.token_endpoint, + connection_opts: conn_opts + }), transport_options: transport_options) @jwt_issuer = jwt_issuer @jwt_subject = jwt_subject @jwt_audience = jwt_audience @@ -165,7 +180,8 @@ def key_identifier(key_id) # @return [WebTokenAuthenticator] A configured instance. def build WebTokenAuthenticator.new(open_id, auth_scopes, @jwt_issuer, @jwt_subject, @jwt_audience, - @private_key, jwt_lifetime: @jwt_lifetime, key_id: @key_id) + @private_key, jwt_lifetime: @jwt_lifetime, key_id: @key_id, + transport_options: @transport_options) end end end From ab4a73ef8780144b4259eb3fe38cbf545ec16afe Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 18:57:07 +1100 Subject: [PATCH 07/42] Fix custom CA cert test and apply transport options to token exchange Use a pre-generated keystore with proper SANs (localhost, 127.0.0.1, ::1) for WireMock HTTPS instead of extracting certs at runtime. This fixes the hostname mismatch error on systems where localhost resolves to IPv6. Also threads transport options through to OAuth token exchange requests so that custom CA, insecure mode, proxy, and default headers apply end-to-end. Updates RBS type signatures for transport_options parameters. --- sig/lib.rbs | 7 +-- sig/lib/oauth2/oauth2.rbs | 2 +- test/fixtures/ca.pem | 20 +++++++++ test/fixtures/keystore.p12 | Bin 0 -> 2644 bytes test/zitadel/client/transport_options_test.rb | 40 ++++++------------ 5 files changed, 38 insertions(+), 31 deletions(-) create mode 100644 test/fixtures/ca.pem create mode 100644 test/fixtures/keystore.p12 diff --git a/sig/lib.rbs b/sig/lib.rbs index 7d2d1cc0..a5f43dd5 100644 --- a/sig/lib.rbs +++ b/sig/lib.rbs @@ -228,7 +228,7 @@ module Zitadel class ClientCredentialsAuthenticator < OAuthAuthenticator - def initialize: (OpenId, String, String, Set[String]) -> void + def initialize: (OpenId, String, String, Set[String], ?transport_options: TransportOptions?) -> void def get_grant: (OAuth2::Client, String) -> OAuth2::AccessToken @@ -270,8 +270,9 @@ module Zitadel @open_id: OpenId @token: OAuth2::AccessToken @auth_scopes: String + @transport_options: TransportOptions - def initialize: (OpenId, Set[String], OAuth2::Client) -> void + def initialize: (OpenId, Set[String], OAuth2::Client, ?transport_options: TransportOptions?) -> void def auth_token: () -> String @@ -317,7 +318,7 @@ module Zitadel def get_grant: (OAuth2::Client, String) -> OAuth2::AccessToken - def initialize: (OpenId, Set[String], String, String, String, (String | OpenSSL::PKey::PKey), ?jwt_lifetime: Integer, ?jwt_algorithm: String, ?key_id: String?) -> void + def initialize: (OpenId, Set[String], String, String, String, (String | OpenSSL::PKey::PKey), ?jwt_lifetime: Integer, ?jwt_algorithm: String, ?key_id: String?, ?transport_options: TransportOptions?) -> void class WebTokenAuthenticatorBuilder < OAuthAuthenticatorBuilder @jwt_audience: String diff --git a/sig/lib/oauth2/oauth2.rbs b/sig/lib/oauth2/oauth2.rbs index bea06178..13f7833c 100644 --- a/sig/lib/oauth2/oauth2.rbs +++ b/sig/lib/oauth2/oauth2.rbs @@ -85,7 +85,7 @@ module OAuth2 end class Client - def initialize: (String | nil, String | nil, Hash[Symbol, String]) -> void + def initialize: (String | nil, String | nil, Hash[Symbol, untyped]) -> void def fetch_token: (url: String, **untyped) -> AccessToken diff --git a/test/fixtures/ca.pem b/test/fixtures/ca.pem new file mode 100644 index 00000000..7ef3bf71 --- /dev/null +++ b/test/fixtures/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/test/fixtures/keystore.p12 b/test/fixtures/keystore.p12 new file mode 100644 index 0000000000000000000000000000000000000000..9f1c66b18939d226680b40eadf982ab4f99daea2 GIT binary patch literal 2644 zcmai$c{CIX8^&jqL1m3Wb}`l&Ta&eDm|@I_YaJD`lx^&5p(!GnvS!PgwVJsSB8?$r zD|^T`uCjNthHsRwd%o|~pZAaVyytnI_nh~?-+7P}E-MfajHGaJaKMxjunD_Ifk%Kv z6xc3=0^34TU|*3Gi1F`;qlg00{AsC!fPkM%@pl45+W!*Jqeyck;Q&)T! z3Q#BKUD!Jwx)FsbBUTYpL3lO7`ME6rkL@kJ$+RtG?FFUVN|4qscM!REr>QdBCZ}e0 z{Dt@|dBX!MLhDej<?IydZm8qsXVrDC|;5oUjq?+RvDZ_NJk*bW63)j#2k8_Pjqw|NbSXE=;)PkV;6bjhCEOKEg-6uB$9wR7lAH%_?F z>aT@S=p0>(;YCbyD{sjxT;YsItP)+)To` zfaZ1tMov63DRv{>iBI|Oi(a&hx{$rt)pq{8>*DXLPa;yl<7xD_0DYqlz<6c)HM&wa z6QwV|4~hy|oJfmX8U9#8(*Sxr_)jzAi}%+$?YeFQvyS-r{H?fqNI6af{ke^(ihGHy z^zP@p%Pn1(s|IF$G_m5XLLM8p;w5zy&QNO~ryFi}68)?stj>mvTDZA-AcFVrxVzdV zXP2nKhUx$J;--<7EN*1{=|cu1LG~mN!t)N{ip97NwYB#z-ae1lOo%Vl4g}_ zx`55U{WiVPMyV!BtBm!t`OU#bP8~8_%DEaZ-R)sn;JFLMHeTUdtJ{8!DKurW<=U4O z0iw&qa66g5eF5qaU6>ItoXfwo;nisCel$PAwRoo+uNBS{SMPgW`|gCMjY@(tm+IBM zQH4uXhZ&8sQlZC~-Zqz`m~TruEw`e_PFwj)?3J7`_$r|vx4T{Lof}=|&c_NUC9Sf1 z$XF@0p=F%yK4H4IZUF&66Tq?wr>{(FD8XL!F1;fkxwn6Mq*A4xW|1RsdzL{-=JUFn zZRI8Ee8k_JLDE8tFsnat*Sb~Rj!rHx!J8T5-UXH-up%b*tE|0qZ%Y-Yq>2rMvkgS$ zm+|&&4h2;3`9%%Ng2?@B0z^xPsXl?EK+Asd;2*$1vmg{`+E1JMbMA6*|DQcPTtLvz z5cShk{WqYn`G=YWJh`*}6)3hZ8t+BnK2)&NC#Elb@X>)#AOmKZN$B3o9ZbnX-<66# z*vxvoSYfn7+I!tJ$W-o2-1>aPa?uF=$93pUBL^^3%d+2Uqno^Gm)}XWe`9y~L@&sj z)t-4#pl-h7RlBPjf3Do^2Fuk`_Sj`%JKWR4tX9h((~T7_R+h4TeX!D-Ak7D>;rj%w zf|L~L=NDx9W!)XxCrXx5;G3bVs`7+){u9R&*-S#RV7~1IOrfk1ukoOu@%a)VOxl{7EVjXw!Ah0Fo;!gA7V(DJ<5gS}8Mh(%;&FCFwS6QNspe%UD z+MxJi-4D$dKU%k>g1a$zt-M{&+Rq*@7vhp=6IT&qr09c!GL3!~%1!|7MI+5fffbx1 zb;y{{YZyA$)TvRS+Ln0b$y9>sfIx+^aO0?%@jLnePvwz60(mjld9{s00lO_@%EG^SnSyBjBe`6n6 z)3-zV-Y=}T@!8~N-I}tb1Lt?vNyymbhXi?yx&Y1k{_*S&D&?Wz`=zpZ;wz4AKbl{H zID@k(S-V$oY)9U;S7*yn>Ko_Ke=@)fGjwl_=Dw_=^~@zAf}=r@%ZgR}I$mYXoW1!D>IVA`C8@np*O}j@XvgKM z$4{h2zG{yGN_#Q`B#?{OGwIQnpPx}uy~G$a96K>Qr2SUt&W$R9LTsuLM5de4%Rj`R z+I(>QtGi#I+dqddkVidxJxR|uBbH(pYsRO=Dm{3=Fd}L2X#;&@okwWN@907-XN?C? zA=xaUMsm!N_!{m<#!HcHGGmWLB21(;@HEo(F!sp}8DDc?aD6_F;YxTf!CgHee7WJp z3%$jyoo6wHa!&kY(_XUu6~<GT93fHyVA~-J*lW zW}MuMV-3HDmbN!i!*B>27e3Z`xWKE*9#zU+Wlnb1!##-<(R)b<@^Sy_u;~b`C!vDQ zs}w&jImN(BB$rLg{_pV`m>xA%0RLRDT!H4^<6$2GR!Qd(Wy~fB9kgL`z9)uflq#)E z_oTt0&aN_+FdwlE%OK($K!o;tR*zw7u4hg z9wzObJx1`=F4XyA=^A0raA@uL*s5MD>~fRIn38>FjSpfv;X25VINV%CMPAue&aSpq z<%E{dXg!m0iuuXl4gAr2Yi;x!1TDYoGmf=P=1hsFN|uoAf> literal 0 HcmV?d00001 diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 7229a3a5..42ae98a2 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -13,9 +13,8 @@ require 'testcontainers' require 'net/http' require 'json' -require 'socket' -require 'openssl' -require 'tempfile' + +FIXTURES_DIR = File.join(__dir__, '..', '..', 'fixtures') module Zitadel module Client @@ -27,8 +26,18 @@ class TransportOptionsTest < Minitest::Test # rubocop:disable Metrics/ClassLengt def before_all super + @ca_cert_path = File.join(FIXTURES_DIR, 'ca.pem') + keystore_path = File.join(FIXTURES_DIR, 'keystore.p12') + @wiremock = Testcontainers::DockerContainer.new('wiremock/wiremock:3.3.1') - .with_command('--https-port', '8443', '--global-response-templating') + .with_filesystem_binds("#{keystore_path}:/home/wiremock/keystore.p12:ro") + .with_command( + '--https-port', '8443', + '--https-keystore', '/home/wiremock/keystore.p12', + '--keystore-password', 'password', + '--keystore-type', 'PKCS12', + '--global-response-templating' + ) .with_exposed_ports(8080, 8443) .start @wiremock.wait_for_http(container_port: 8080, path: '/__admin/mappings', status: 200) @@ -38,12 +47,10 @@ def before_all @https_port = @wiremock.mapped_port(8443) register_wiremock_stubs - extract_wiremock_certificate end # rubocop:enable Metrics/MethodLength def after_all - @cert_tempfile&.close! @wiremock&.stop super end @@ -154,27 +161,6 @@ def register_wiremock_stubs }.to_json, 'Content-Type' => 'application/json') end # rubocop:enable Metrics/MethodLength - - # rubocop:disable Metrics/MethodLength - def extract_wiremock_certificate - tcp = TCPSocket.new(@host, @https_port) - begin - ctx = OpenSSL::SSL::SSLContext.new - ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE - ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx) - ssl.connect - pem = ssl.peer_cert.to_pem - ensure - ssl&.close - tcp.close - end - - @cert_tempfile = Tempfile.new(['wiremock-ca-', '.pem']) - @cert_tempfile.write(pem) - @cert_tempfile.close - @ca_cert_path = @cert_tempfile.path - end - # rubocop:enable Metrics/MethodLength end end end From 111c86156277c649d8d05088a294c3497df0abbb Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 20:21:28 +1100 Subject: [PATCH 08/42] Standardize transport options tests across SDKs Assert 201 on stub registration, fix verify_ssl_host for insecure mode, add proxy credential support, consistent with all other SDKs. --- .../auth/client_credentials_authenticator.rb | 6 +++--- lib/zitadel/client/auth/open_id.rb | 3 ++- .../client/auth/web_token_authenticator.rb | 6 +++--- lib/zitadel/client/zitadel.rb | 19 ++++++++++++++----- test/zitadel/client/transport_options_test.rb | 10 +++++++--- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/lib/zitadel/client/auth/client_credentials_authenticator.rb b/lib/zitadel/client/auth/client_credentials_authenticator.rb index c2873b13..f268cce4 100644 --- a/lib/zitadel/client/auth/client_credentials_authenticator.rb +++ b/lib/zitadel/client/auth/client_credentials_authenticator.rb @@ -13,7 +13,7 @@ class ClientCredentialsAuthenticator < Auth::OAuthAuthenticator # @param client_id [String] The OAuth client identifier. # @param client_secret [String] The OAuth client secret. # @param auth_scopes [Set] The scope(s) for the token request. - # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize def initialize(open_id, client_id, client_secret, auth_scopes, transport_options: nil) transport_options ||= TransportOptions.defaults @@ -36,7 +36,7 @@ def initialize(open_id, client_id, client_secret, auth_scopes, transport_options connection_opts: conn_opts }), transport_options: transport_options) end - # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize # Returns a new builder for constructing a ClientCredentialsAuthenticator. # @@ -77,7 +77,7 @@ def initialize(host, client_id, client_secret, transport_options: nil) # @return [ClientCredentialsAuthenticator] A configured instance. def build ClientCredentialsAuthenticator.new(open_id, @client_id, @client_secret, auth_scopes, - transport_options: @transport_options) + transport_options: @transport_options) end end end diff --git a/lib/zitadel/client/auth/open_id.rb b/lib/zitadel/client/auth/open_id.rb index 32ea8a73..78e0d936 100644 --- a/lib/zitadel/client/auth/open_id.rb +++ b/lib/zitadel/client/auth/open_id.rb @@ -34,7 +34,8 @@ def initialize(hostname, transport_options: nil) uri = URI.parse(well_known_url) http = if transport_options.proxy_url proxy_uri = URI.parse(transport_options.proxy_url) - Net::HTTP.new(uri.host.to_s, uri.port, proxy_uri.host, proxy_uri.port) + Net::HTTP.new(uri.host.to_s, uri.port, proxy_uri.host, proxy_uri.port, + proxy_uri.user, proxy_uri.password) else Net::HTTP.new(uri.host.to_s, uri.port) end diff --git a/lib/zitadel/client/auth/web_token_authenticator.rb b/lib/zitadel/client/auth/web_token_authenticator.rb index fdb7e6a4..8d57298a 100644 --- a/lib/zitadel/client/auth/web_token_authenticator.rb +++ b/lib/zitadel/client/auth/web_token_authenticator.rb @@ -25,7 +25,7 @@ class WebTokenAuthenticator < Auth::OAuthAuthenticator # @param jwt_lifetime [Integer] Lifetime of the JWT in seconds (default 3600 seconds). # @param jwt_algorithm [String] The JWT signing algorithm (default "RS256"). # @param key_id [String, nil] Optional key identifier for the JWT header (default: nil). - # rubocop:disable Metrics/ParameterLists,Metrics/MethodLength + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity def initialize(open_id, auth_scopes, jwt_issuer, jwt_subject, jwt_audience, private_key, jwt_lifetime: 3600, jwt_algorithm: 'RS256', key_id: nil, transport_options: nil) transport_options ||= TransportOptions.defaults @@ -62,7 +62,7 @@ def initialize(open_id, auth_scopes, jwt_issuer, jwt_subject, jwt_audience, priv end end - # rubocop:enable Metrics/ParameterLists,Metrics/MethodLength + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity # Creates a WebTokenAuthenticator instance from a JSON configuration file. # @@ -181,7 +181,7 @@ def key_identifier(key_id) def build WebTokenAuthenticator.new(open_id, auth_scopes, @jwt_issuer, @jwt_subject, @jwt_audience, @private_key, jwt_lifetime: @jwt_lifetime, key_id: @key_id, - transport_options: @transport_options) + transport_options: @transport_options) end end end diff --git a/lib/zitadel/client/zitadel.rb b/lib/zitadel/client/zitadel.rb index 72afb88c..4943f1bb 100644 --- a/lib/zitadel/client/zitadel.rb +++ b/lib/zitadel/client/zitadel.rb @@ -92,7 +92,7 @@ class << self # @param access_token [String] Personal Access Token for Bearer authentication. # @return [Zitadel] SDK client configured with PAT authentication. # @see https://zitadel.com/docs/guides/integrate/service-users/personal-access-token - # rubocop:disable Metrics/ParameterLists + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength def with_access_token(host, access_token, default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil, transport_options: nil) resolved = transport_options || TransportOptions.new(default_headers: default_headers, @@ -102,11 +102,14 @@ def with_access_token(host, access_token, default_headers: {}, ca_cert_path: nil new(Auth::PersonalAccessTokenAuthenticator.new(host, access_token)) do |config| config.default_headers = resolved.default_headers config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path - config.verify_ssl = !resolved.insecure if resolved.insecure + if resolved.insecure + config.verify_ssl = false + config.verify_ssl_host = false + end config.proxy_url = resolved.proxy_url if resolved.proxy_url end end - # rubocop:enable Metrics/ParameterLists + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength # Initialize the SDK using OAuth2 Client Credentials flow. # @@ -129,7 +132,10 @@ def with_client_credentials(host, client_id, client_secret, default_headers: {}, ) do |config| config.default_headers = resolved.default_headers config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path - config.verify_ssl = !resolved.insecure if resolved.insecure + if resolved.insecure + config.verify_ssl = false + config.verify_ssl_host = false + end config.proxy_url = resolved.proxy_url if resolved.proxy_url end end @@ -152,7 +158,10 @@ def with_private_key(host, key_file, default_headers: {}, ca_cert_path: nil, ins transport_options: resolved)) do |config| config.default_headers = resolved.default_headers config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path - config.verify_ssl = !resolved.insecure if resolved.insecure + if resolved.insecure + config.verify_ssl = false + config.verify_ssl_host = false + end config.proxy_url = resolved.proxy_url if resolved.proxy_url end end diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 42ae98a2..716dd900 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -110,7 +110,7 @@ def test_proxy_url end def test_no_ca_cert_fails - assert_raises(Exception) do + assert_raises(StandardError) do ::Zitadel::Client::Zitadel.with_client_credentials( "https://#{@host}:#{@https_port}", 'dummy-client', 'dummy-secret' @@ -137,7 +137,7 @@ def register_wiremock_stubs uri = URI("http://#{@host}:#{@http_port}/__admin/mappings") # Stub 1 - OpenID Configuration - Net::HTTP.post(uri, { + response = Net::HTTP.post(uri, { request: { method: 'GET', url: '/.well-known/openid-configuration' }, response: { status: 200, @@ -150,8 +150,10 @@ def register_wiremock_stubs } }.to_json, 'Content-Type' => 'application/json') + assert_equal '201', response.code + # Stub 2 - Token endpoint - Net::HTTP.post(uri, { + response = Net::HTTP.post(uri, { request: { method: 'POST', url: '/oauth/v2/token' }, response: { status: 200, @@ -159,6 +161,8 @@ def register_wiremock_stubs jsonBody: { access_token: 'test-token-12345', token_type: 'Bearer', expires_in: 3600 } } }.to_json, 'Content-Type' => 'application/json') + + assert_equal '201', response.code end # rubocop:enable Metrics/MethodLength end From c8d3c3666973b87bcfba1aa85877bc3dab2ee7b2 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 20:27:41 +1100 Subject: [PATCH 09/42] Copy frozen default_headers before assigning to mutable config Frozen hashes from TransportOptions would raise FrozenError if config or Faraday later tries to mutate the headers hash. --- .../auth/client_credentials_authenticator.rb | 2 +- lib/zitadel/client/auth/web_token_authenticator.rb | 2 +- lib/zitadel/client/zitadel.rb | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/zitadel/client/auth/client_credentials_authenticator.rb b/lib/zitadel/client/auth/client_credentials_authenticator.rb index f268cce4..69850906 100644 --- a/lib/zitadel/client/auth/client_credentials_authenticator.rb +++ b/lib/zitadel/client/auth/client_credentials_authenticator.rb @@ -27,7 +27,7 @@ def initialize(open_id, client_id, client_secret, auth_scopes, transport_options conn_opts[:ssl] = { cert_store: store, verify: true } end conn_opts[:proxy] = transport_options.proxy_url if transport_options.proxy_url - conn_opts[:headers] = transport_options.default_headers if transport_options.default_headers.any? + conn_opts[:headers] = transport_options.default_headers.dup if transport_options.default_headers.any? # noinspection RubyArgCount super(open_id, auth_scopes, OAuth2::Client.new(client_id, client_secret, { diff --git a/lib/zitadel/client/auth/web_token_authenticator.rb b/lib/zitadel/client/auth/web_token_authenticator.rb index 8d57298a..4d9db1a5 100644 --- a/lib/zitadel/client/auth/web_token_authenticator.rb +++ b/lib/zitadel/client/auth/web_token_authenticator.rb @@ -40,7 +40,7 @@ def initialize(open_id, auth_scopes, jwt_issuer, jwt_subject, jwt_audience, priv conn_opts[:ssl] = { cert_store: store, verify: true } end conn_opts[:proxy] = transport_options.proxy_url if transport_options.proxy_url - conn_opts[:headers] = transport_options.default_headers if transport_options.default_headers.any? + conn_opts[:headers] = transport_options.default_headers.dup if transport_options.default_headers.any? # noinspection RubyArgCount,RubyMismatchedArgumentType super(open_id, auth_scopes, OAuth2::Client.new('zitadel', 'zitadel', { diff --git a/lib/zitadel/client/zitadel.rb b/lib/zitadel/client/zitadel.rb index 4943f1bb..2538a04d 100644 --- a/lib/zitadel/client/zitadel.rb +++ b/lib/zitadel/client/zitadel.rb @@ -92,7 +92,7 @@ class << self # @param access_token [String] Personal Access Token for Bearer authentication. # @return [Zitadel] SDK client configured with PAT authentication. # @see https://zitadel.com/docs/guides/integrate/service-users/personal-access-token - # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize def with_access_token(host, access_token, default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil, transport_options: nil) resolved = transport_options || TransportOptions.new(default_headers: default_headers, @@ -100,7 +100,7 @@ def with_access_token(host, access_token, default_headers: {}, ca_cert_path: nil insecure: insecure, proxy_url: proxy_url) new(Auth::PersonalAccessTokenAuthenticator.new(host, access_token)) do |config| - config.default_headers = resolved.default_headers + config.default_headers = resolved.default_headers.dup config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path if resolved.insecure config.verify_ssl = false @@ -109,7 +109,7 @@ def with_access_token(host, access_token, default_headers: {}, ca_cert_path: nil config.proxy_url = resolved.proxy_url if resolved.proxy_url end end - # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize # Initialize the SDK using OAuth2 Client Credentials flow. # @@ -130,7 +130,7 @@ def with_client_credentials(host, client_id, client_secret, default_headers: {}, .builder(host, client_id, client_secret, transport_options: resolved) .build ) do |config| - config.default_headers = resolved.default_headers + config.default_headers = resolved.default_headers.dup config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path if resolved.insecure config.verify_ssl = false @@ -147,7 +147,7 @@ def with_client_credentials(host, client_id, client_secret, default_headers: {}, # @param key_file [String] Path to service account JSON/PEM key file. # @return [Zitadel] SDK client using JWT assertion for secure, secret-less auth. # @see https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt - # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize def with_private_key(host, key_file, default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil, transport_options: nil) resolved = transport_options || TransportOptions.new(default_headers: default_headers, @@ -156,7 +156,7 @@ def with_private_key(host, key_file, default_headers: {}, ca_cert_path: nil, ins proxy_url: proxy_url) new(Auth::WebTokenAuthenticator.from_json(host, key_file, transport_options: resolved)) do |config| - config.default_headers = resolved.default_headers + config.default_headers = resolved.default_headers.dup config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path if resolved.insecure config.verify_ssl = false @@ -165,7 +165,7 @@ def with_private_key(host, key_file, default_headers: {}, ca_cert_path: nil, ins config.proxy_url = resolved.proxy_url if resolved.proxy_url end end - # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize # @!endgroup end From 7c8e778899fa0c79389464973dc7d9ecb245d4ec Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 20:57:06 +1100 Subject: [PATCH 10/42] Extract config mutation helper to eliminate duplication across factory methods --- lib/zitadel/client/zitadel.rb | 70 ++++++++++++++++------------------- sig/lib.rbs | 8 ++++ 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/lib/zitadel/client/zitadel.rb b/lib/zitadel/client/zitadel.rb index 2538a04d..b16325fa 100644 --- a/lib/zitadel/client/zitadel.rb +++ b/lib/zitadel/client/zitadel.rb @@ -92,24 +92,15 @@ class << self # @param access_token [String] Personal Access Token for Bearer authentication. # @return [Zitadel] SDK client configured with PAT authentication. # @see https://zitadel.com/docs/guides/integrate/service-users/personal-access-token - # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize + # rubocop:disable Metrics/ParameterLists def with_access_token(host, access_token, default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil, transport_options: nil) - resolved = transport_options || TransportOptions.new(default_headers: default_headers, - ca_cert_path: ca_cert_path, - insecure: insecure, - proxy_url: proxy_url) + resolved = resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) new(Auth::PersonalAccessTokenAuthenticator.new(host, access_token)) do |config| - config.default_headers = resolved.default_headers.dup - config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path - if resolved.insecure - config.verify_ssl = false - config.verify_ssl_host = false - end - config.proxy_url = resolved.proxy_url if resolved.proxy_url + apply_transport_options(config, resolved) end end - # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize + # rubocop:enable Metrics/ParameterLists # Initialize the SDK using OAuth2 Client Credentials flow. # @@ -118,28 +109,19 @@ def with_access_token(host, access_token, default_headers: {}, ca_cert_path: nil # @param client_secret [String] OAuth2 client secret. # @return [Zitadel] SDK client with automatic token acquisition & refresh. # @see https://zitadel.com/docs/guides/integrate/service-users/client-credentials - # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize + # rubocop:disable Metrics/ParameterLists def with_client_credentials(host, client_id, client_secret, default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil, transport_options: nil) - resolved = transport_options || TransportOptions.new(default_headers: default_headers, - ca_cert_path: ca_cert_path, - insecure: insecure, - proxy_url: proxy_url) + resolved = resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) new( Auth::ClientCredentialsAuthenticator .builder(host, client_id, client_secret, transport_options: resolved) .build ) do |config| - config.default_headers = resolved.default_headers.dup - config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path - if resolved.insecure - config.verify_ssl = false - config.verify_ssl_host = false - end - config.proxy_url = resolved.proxy_url if resolved.proxy_url + apply_transport_options(config, resolved) end end - # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize + # rubocop:enable Metrics/ParameterLists # Initialize the SDK via Private Key JWT assertion. # @@ -147,27 +129,37 @@ def with_client_credentials(host, client_id, client_secret, default_headers: {}, # @param key_file [String] Path to service account JSON/PEM key file. # @return [Zitadel] SDK client using JWT assertion for secure, secret-less auth. # @see https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt - # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize + # rubocop:disable Metrics/ParameterLists def with_private_key(host, key_file, default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil, transport_options: nil) - resolved = transport_options || TransportOptions.new(default_headers: default_headers, - ca_cert_path: ca_cert_path, - insecure: insecure, - proxy_url: proxy_url) + resolved = resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) new(Auth::WebTokenAuthenticator.from_json(host, key_file, transport_options: resolved)) do |config| - config.default_headers = resolved.default_headers.dup - config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path - if resolved.insecure - config.verify_ssl = false - config.verify_ssl_host = false - end - config.proxy_url = resolved.proxy_url if resolved.proxy_url + apply_transport_options(config, resolved) end end - # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize + # rubocop:enable Metrics/ParameterLists # @!endgroup + + private + + def resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) + transport_options || TransportOptions.new(default_headers: default_headers, + ca_cert_path: ca_cert_path, + insecure: insecure, + proxy_url: proxy_url) + end + + def apply_transport_options(config, resolved) + config.default_headers = resolved.default_headers.dup + config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path + if resolved.insecure + config.verify_ssl = false + config.verify_ssl_host = false + end + config.proxy_url = resolved.proxy_url if resolved.proxy_url + end end end end diff --git a/sig/lib.rbs b/sig/lib.rbs index a5f43dd5..ea36870a 100644 --- a/sig/lib.rbs +++ b/sig/lib.rbs @@ -347,6 +347,14 @@ module Zitadel def self.with_private_key: (String, String, ?default_headers: Hash[String, String], ?ca_cert_path: String?, ?insecure: bool, ?proxy_url: String?, ?transport_options: TransportOptions?) -> Zitadel + private + + def self.resolve_transport_options: (TransportOptions?, Hash[String, String], String?, bool, String?) -> TransportOptions + + def self.apply_transport_options: (Configuration, TransportOptions) -> void + + public + attr_reader configuration: Configuration attr_reader features: Api::FeatureServiceApi attr_reader idps: Api::IdentityProviderServiceApi From 15ee7319aa3fd6fd7321bf7747f28ea8f9c61ed5 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 21:14:51 +1100 Subject: [PATCH 11/42] Centralize connection opts building in TransportOptions Extract duplicated connection opts construction from authenticators into TransportOptions#to_connection_opts. --- .../auth/client_credentials_authenticator.rb | 12 +----------- .../client/auth/web_token_authenticator.rb | 12 +----------- lib/zitadel/client/transport_options.rb | 18 ++++++++++++++++++ sig/lib.rbs | 1 + 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/lib/zitadel/client/auth/client_credentials_authenticator.rb b/lib/zitadel/client/auth/client_credentials_authenticator.rb index 69850906..d6e9f458 100644 --- a/lib/zitadel/client/auth/client_credentials_authenticator.rb +++ b/lib/zitadel/client/auth/client_credentials_authenticator.rb @@ -17,17 +17,7 @@ class ClientCredentialsAuthenticator < Auth::OAuthAuthenticator def initialize(open_id, client_id, client_secret, auth_scopes, transport_options: nil) transport_options ||= TransportOptions.defaults - conn_opts = {} - if transport_options.insecure - conn_opts[:ssl] = { verify: false } - elsif transport_options.ca_cert_path - store = OpenSSL::X509::Store.new - store.set_default_paths - store.add_file(transport_options.ca_cert_path) - conn_opts[:ssl] = { cert_store: store, verify: true } - end - conn_opts[:proxy] = transport_options.proxy_url if transport_options.proxy_url - conn_opts[:headers] = transport_options.default_headers.dup if transport_options.default_headers.any? + conn_opts = transport_options.to_connection_opts # noinspection RubyArgCount super(open_id, auth_scopes, OAuth2::Client.new(client_id, client_secret, { diff --git a/lib/zitadel/client/auth/web_token_authenticator.rb b/lib/zitadel/client/auth/web_token_authenticator.rb index 4d9db1a5..e462f42c 100644 --- a/lib/zitadel/client/auth/web_token_authenticator.rb +++ b/lib/zitadel/client/auth/web_token_authenticator.rb @@ -30,17 +30,7 @@ def initialize(open_id, auth_scopes, jwt_issuer, jwt_subject, jwt_audience, priv jwt_lifetime: 3600, jwt_algorithm: 'RS256', key_id: nil, transport_options: nil) transport_options ||= TransportOptions.defaults - conn_opts = {} - if transport_options.insecure - conn_opts[:ssl] = { verify: false } - elsif transport_options.ca_cert_path - store = OpenSSL::X509::Store.new - store.set_default_paths - store.add_file(transport_options.ca_cert_path) - conn_opts[:ssl] = { cert_store: store, verify: true } - end - conn_opts[:proxy] = transport_options.proxy_url if transport_options.proxy_url - conn_opts[:headers] = transport_options.default_headers.dup if transport_options.default_headers.any? + conn_opts = transport_options.to_connection_opts # noinspection RubyArgCount,RubyMismatchedArgumentType super(open_id, auth_scopes, OAuth2::Client.new('zitadel', 'zitadel', { diff --git a/lib/zitadel/client/transport_options.rb b/lib/zitadel/client/transport_options.rb index cde22fd2..0bc22510 100644 --- a/lib/zitadel/client/transport_options.rb +++ b/lib/zitadel/client/transport_options.rb @@ -17,6 +17,24 @@ def initialize(default_headers: {}, ca_cert_path: nil, insecure: false, proxy_ur def self.defaults new end + + # Builds Faraday connection options from these transport options. + # + # @return [Hash] connection options for OAuth2::Client + def to_connection_opts + opts = {} + if insecure + opts[:ssl] = { verify: false } + elsif ca_cert_path + store = OpenSSL::X509::Store.new + store.set_default_paths + store.add_file(ca_cert_path) + opts[:ssl] = { cert_store: store, verify: true } + end + opts[:proxy] = proxy_url if proxy_url + opts[:headers] = default_headers.dup if default_headers.any? + opts + end end end end diff --git a/sig/lib.rbs b/sig/lib.rbs index ea36870a..93417749 100644 --- a/sig/lib.rbs +++ b/sig/lib.rbs @@ -211,6 +211,7 @@ module Zitadel attr_reader proxy_url: String? def initialize: (?default_headers: Hash[String, String], ?ca_cert_path: String?, ?insecure: bool, ?proxy_url: String?) -> void def self.defaults: -> TransportOptions + def to_connection_opts: -> Hash[Symbol, untyped] end module Auth From 0a0342954adc6b5e0a77c786d140df48821efdaf Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 22:05:19 +1100 Subject: [PATCH 12/42] Verify default headers on API calls via WireMock verification Add WireMock stub for settings endpoint and use WireMock's /__admin/requests/count API to assert custom headers are sent on actual API calls, not just during initialization. --- test/zitadel/client/transport_options_test.rb | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 716dd900..1d06affd 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -86,16 +86,19 @@ def test_default_headers refute_nil zitadel - # Verify via WireMock request journal - # noinspection HttpUrlsUsage - journal_uri = URI("http://#{@host}:#{@http_port}/__admin/requests") - journal = JSON.parse(Net::HTTP.get(journal_uri)) + # Make an actual API call to verify headers propagate to service requests + zitadel.settings.get_general_settings({}) - found_header = journal['requests'].any? do |req| - req.dig('request', 'headers', 'X-Custom-Header') - end + # Use WireMock's verification API to assert the header was sent on the API call + # noinspection HttpUrlsUsage + verify_uri = URI("http://#{@host}:#{@http_port}/__admin/requests/count") + verify_response = Net::HTTP.post(verify_uri, { + url: '/zitadel.settings.v2.SettingsService/GetGeneralSettings', + headers: { 'X-Custom-Header' => { 'equalTo' => 'test-value' } } + }.to_json, 'Content-Type' => 'application/json') - assert found_header, 'Custom header should be present in WireMock request journal' + count = JSON.parse(verify_response.body)['count'] + assert count >= 1, 'Custom header should be present on API call' end # rubocop:enable Metrics/MethodLength @@ -163,6 +166,18 @@ def register_wiremock_stubs }.to_json, 'Content-Type' => 'application/json') assert_equal '201', response.code + + # Stub 3 - Settings API endpoint (for verifying headers on API calls) + response = Net::HTTP.post(uri, { + request: { method: 'POST', url: '/zitadel.settings.v2.SettingsService/GetGeneralSettings' }, + response: { + status: 200, + headers: { 'Content-Type' => 'application/json' }, + jsonBody: {} + } + }.to_json, 'Content-Type' => 'application/json') + + assert_equal '201', response.code end # rubocop:enable Metrics/MethodLength end From 2d71b768f84e5408de69b38bad5a40721f821e98 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 22:12:27 +1100 Subject: [PATCH 13/42] Fix README: correct HTTP library name and debug example Change HTTP library reference from Faraday to Typhoeus and fix the debug example to use the configuration block instead of an unsupported debug: parameter on the factory method. --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fd317ec8..e374c280 100644 --- a/README.md +++ b/README.md @@ -194,15 +194,16 @@ environment and security requirements. For more details, please refer to the ### Debugging The SDK supports debug logging, which can be enabled for troubleshooting -and debugging purposes. You can enable debug logging by setting the `debug` -flag to `true` when initializing the `Zitadel` client, like this: +and debugging purposes. You can enable debug logging by setting `debugging` +to `true` via the configuration block when initializing the `Zitadel` client: ```ruby zitadel = Zitadel::Client::Zitadel.with_access_token( 'your-zitadel-base-url', - 'your-valid-token', - debug: true -) + 'your-valid-token' +) do |config| + config.debugging = true +end ``` When enabled, the SDK will log additional information, such as HTTP request @@ -290,7 +291,7 @@ zitadel = Zitadel::Client::Zitadel.with_client_credentials( This SDK is designed to be lean and efficient, focusing on providing a streamlined way to interact with the Zitadel API. It relies on the commonly used -Faraday HTTP library for making requests, which ensures that +Typhoeus HTTP library for making requests, which ensures that the SDK integrates well with other libraries and provides flexibility in terms of request handling and error management. From 12e91ee346f4367f1793fd41584ea17ee6241519 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 22:23:43 +1100 Subject: [PATCH 14/42] Fix rubocop offenses in transport options and tests --- lib/zitadel/client/auth/client_credentials_authenticator.rb | 2 -- lib/zitadel/client/auth/web_token_authenticator.rb | 4 ++-- lib/zitadel/client/transport_options.rb | 2 ++ test/zitadel/client/transport_options_test.rb | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/zitadel/client/auth/client_credentials_authenticator.rb b/lib/zitadel/client/auth/client_credentials_authenticator.rb index d6e9f458..abbb65f2 100644 --- a/lib/zitadel/client/auth/client_credentials_authenticator.rb +++ b/lib/zitadel/client/auth/client_credentials_authenticator.rb @@ -13,7 +13,6 @@ class ClientCredentialsAuthenticator < Auth::OAuthAuthenticator # @param client_id [String] The OAuth client identifier. # @param client_secret [String] The OAuth client secret. # @param auth_scopes [Set] The scope(s) for the token request. - # rubocop:disable Metrics/MethodLength, Metrics/AbcSize def initialize(open_id, client_id, client_secret, auth_scopes, transport_options: nil) transport_options ||= TransportOptions.defaults @@ -26,7 +25,6 @@ def initialize(open_id, client_id, client_secret, auth_scopes, transport_options connection_opts: conn_opts }), transport_options: transport_options) end - # rubocop:enable Metrics/MethodLength, Metrics/AbcSize # Returns a new builder for constructing a ClientCredentialsAuthenticator. # diff --git a/lib/zitadel/client/auth/web_token_authenticator.rb b/lib/zitadel/client/auth/web_token_authenticator.rb index e462f42c..75cf5a80 100644 --- a/lib/zitadel/client/auth/web_token_authenticator.rb +++ b/lib/zitadel/client/auth/web_token_authenticator.rb @@ -25,7 +25,7 @@ class WebTokenAuthenticator < Auth::OAuthAuthenticator # @param jwt_lifetime [Integer] Lifetime of the JWT in seconds (default 3600 seconds). # @param jwt_algorithm [String] The JWT signing algorithm (default "RS256"). # @param key_id [String, nil] Optional key identifier for the JWT header (default: nil). - # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength def initialize(open_id, auth_scopes, jwt_issuer, jwt_subject, jwt_audience, private_key, jwt_lifetime: 3600, jwt_algorithm: 'RS256', key_id: nil, transport_options: nil) transport_options ||= TransportOptions.defaults @@ -52,7 +52,7 @@ def initialize(open_id, auth_scopes, jwt_issuer, jwt_subject, jwt_audience, priv end end - # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength # Creates a WebTokenAuthenticator instance from a JSON configuration file. # diff --git a/lib/zitadel/client/transport_options.rb b/lib/zitadel/client/transport_options.rb index 0bc22510..e0e818be 100644 --- a/lib/zitadel/client/transport_options.rb +++ b/lib/zitadel/client/transport_options.rb @@ -21,6 +21,7 @@ def self.defaults # Builds Faraday connection options from these transport options. # # @return [Hash] connection options for OAuth2::Client + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def to_connection_opts opts = {} if insecure @@ -35,6 +36,7 @@ def to_connection_opts opts[:headers] = default_headers.dup if default_headers.any? opts end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength end end end diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 1d06affd..1500a30e 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -98,7 +98,8 @@ def test_default_headers }.to_json, 'Content-Type' => 'application/json') count = JSON.parse(verify_response.body)['count'] - assert count >= 1, 'Custom header should be present on API call' + + assert_operator count, :>=, 1, 'Custom header should be present on API call' end # rubocop:enable Metrics/MethodLength From 87f8dbc2877ed435cc4e784c59a1cd5b25499b4c Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 22:36:09 +1100 Subject: [PATCH 15/42] Remove individual transport params from factory methods Factory methods now only accept a TransportOptions object instead of individual default_headers, ca_cert_path, insecure, and proxy_url parameters. This matches the Java and Node SDKs. --- README.md | 73 ++++--------------- lib/zitadel/client/zitadel.rb | 33 +++------ sig/lib.rbs | 8 +- test/zitadel/client/transport_options_test.rb | 20 ++--- 4 files changed, 31 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index e374c280..509320f8 100644 --- a/README.md +++ b/README.md @@ -212,71 +212,17 @@ integration or troubleshooting unexpected behavior. ## Advanced Configuration -The SDK supports several advanced configuration options that can be passed -to any of the factory methods (`with_client_credentials`, `with_private_key`, -or `with_access_token`). - -### Disabling TLS Verification - -To disable TLS certificate verification (not recommended for production), -pass `insecure: true`: - -```ruby -client = Zitadel::Client::Zitadel.with_client_credentials( - "https://example.us1.zitadel.cloud", - "client-id", "client-secret", - insecure: true -) -``` - -### Using a Custom CA Certificate - -To use a custom CA certificate for TLS verification, pass the path to the -certificate file via `ca_cert_path`: - -```ruby -client = Zitadel::Client::Zitadel.with_client_credentials( - "https://example.us1.zitadel.cloud", - "client-id", "client-secret", - ca_cert_path: '/path/to/ca.pem' -) -``` - -### Custom Default Headers - -To include additional headers in every HTTP request, pass a hash via -`default_headers`: - -```ruby -client = Zitadel::Client::Zitadel.with_client_credentials( - "https://example.us1.zitadel.cloud", - "client-id", "client-secret", - default_headers: { 'Proxy-Authorization' => 'Basic ...' } -) -``` - -### Proxy Configuration - -To route all HTTP requests through a proxy, pass the proxy URL via -`proxy_url`: - -```ruby -client = Zitadel::Client::Zitadel.with_client_credentials( - "https://example.us1.zitadel.cloud", - "client-id", "client-secret", - proxy_url: 'http://proxy:8080' -) -``` - -### Using TransportOptions - -All transport settings can be combined into a single `TransportOptions` object: +All factory methods (`with_client_credentials`, `with_private_key`, +`with_access_token`) accept an optional `transport_options` parameter +for configuring TLS, proxies, and default headers via a `TransportOptions` +object. ```ruby options = Zitadel::Client::TransportOptions.new( ca_cert_path: '/path/to/ca.pem', default_headers: { 'Proxy-Authorization' => 'Basic dXNlcjpwYXNz' }, - proxy_url: 'http://proxy:8080' + proxy_url: 'http://proxy:8080', + insecure: false ) zitadel = Zitadel::Client::Zitadel.with_client_credentials( @@ -287,6 +233,13 @@ zitadel = Zitadel::Client::Zitadel.with_client_credentials( ) ``` +Available options: + +- `ca_cert_path` — path to a custom CA certificate for TLS verification +- `insecure` — disable TLS certificate verification (not recommended for production) +- `default_headers` — hash of headers to include in every HTTP request +- `proxy_url` — HTTP proxy URL for all requests + ## Design and Dependencies This SDK is designed to be lean and efficient, focusing on providing a diff --git a/lib/zitadel/client/zitadel.rb b/lib/zitadel/client/zitadel.rb index b16325fa..9908b4da 100644 --- a/lib/zitadel/client/zitadel.rb +++ b/lib/zitadel/client/zitadel.rb @@ -7,7 +7,7 @@ module Client # Initializes and configures the SDK with the provided authentication strategy. # Sets up service APIs for interacting with various Zitadel features. # noinspection RubyTooManyInstanceVariablesInspection - class Zitadel # rubocop:disable Metrics/ClassLength + class Zitadel attr_reader :features, :idps, :instances, @@ -90,29 +90,26 @@ class << self # # @param host [String] API URL (e.g. "https://api.zitadel.example.com"). # @param access_token [String] Personal Access Token for Bearer authentication. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, headers. # @return [Zitadel] SDK client configured with PAT authentication. # @see https://zitadel.com/docs/guides/integrate/service-users/personal-access-token - # rubocop:disable Metrics/ParameterLists - def with_access_token(host, access_token, default_headers: {}, ca_cert_path: nil, insecure: false, - proxy_url: nil, transport_options: nil) - resolved = resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) + def with_access_token(host, access_token, transport_options: nil) + resolved = transport_options || TransportOptions.defaults new(Auth::PersonalAccessTokenAuthenticator.new(host, access_token)) do |config| apply_transport_options(config, resolved) end end - # rubocop:enable Metrics/ParameterLists # Initialize the SDK using OAuth2 Client Credentials flow. # # @param host [String] API URL. # @param client_id [String] OAuth2 client identifier. # @param client_secret [String] OAuth2 client secret. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, headers. # @return [Zitadel] SDK client with automatic token acquisition & refresh. # @see https://zitadel.com/docs/guides/integrate/service-users/client-credentials - # rubocop:disable Metrics/ParameterLists - def with_client_credentials(host, client_id, client_secret, default_headers: {}, ca_cert_path: nil, - insecure: false, proxy_url: nil, transport_options: nil) - resolved = resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) + def with_client_credentials(host, client_id, client_secret, transport_options: nil) + resolved = transport_options || TransportOptions.defaults new( Auth::ClientCredentialsAuthenticator .builder(host, client_id, client_secret, transport_options: resolved) @@ -121,36 +118,26 @@ def with_client_credentials(host, client_id, client_secret, default_headers: {}, apply_transport_options(config, resolved) end end - # rubocop:enable Metrics/ParameterLists # Initialize the SDK via Private Key JWT assertion. # # @param host [String] API URL. # @param key_file [String] Path to service account JSON/PEM key file. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, headers. # @return [Zitadel] SDK client using JWT assertion for secure, secret-less auth. # @see https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt - # rubocop:disable Metrics/ParameterLists - def with_private_key(host, key_file, default_headers: {}, ca_cert_path: nil, insecure: false, - proxy_url: nil, transport_options: nil) - resolved = resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) + def with_private_key(host, key_file, transport_options: nil) + resolved = transport_options || TransportOptions.defaults new(Auth::WebTokenAuthenticator.from_json(host, key_file, transport_options: resolved)) do |config| apply_transport_options(config, resolved) end end - # rubocop:enable Metrics/ParameterLists # @!endgroup private - def resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) - transport_options || TransportOptions.new(default_headers: default_headers, - ca_cert_path: ca_cert_path, - insecure: insecure, - proxy_url: proxy_url) - end - def apply_transport_options(config, resolved) config.default_headers = resolved.default_headers.dup config.ssl_ca_cert = resolved.ca_cert_path if resolved.ca_cert_path diff --git a/sig/lib.rbs b/sig/lib.rbs index 93417749..879b4aaf 100644 --- a/sig/lib.rbs +++ b/sig/lib.rbs @@ -342,16 +342,14 @@ module Zitadel class Zitadel - def self.with_access_token: (String, String, ?default_headers: Hash[String, String], ?ca_cert_path: String?, ?insecure: bool, ?proxy_url: String?, ?transport_options: TransportOptions?) -> Zitadel + def self.with_access_token: (String, String, ?transport_options: TransportOptions?) -> Zitadel - def self.with_client_credentials: (String, String, String, ?default_headers: Hash[String, String], ?ca_cert_path: String?, ?insecure: bool, ?proxy_url: String?, ?transport_options: TransportOptions?) -> Zitadel + def self.with_client_credentials: (String, String, String, ?transport_options: TransportOptions?) -> Zitadel - def self.with_private_key: (String, String, ?default_headers: Hash[String, String], ?ca_cert_path: String?, ?insecure: bool, ?proxy_url: String?, ?transport_options: TransportOptions?) -> Zitadel + def self.with_private_key: (String, String, ?transport_options: TransportOptions?) -> Zitadel private - def self.resolve_transport_options: (TransportOptions?, Hash[String, String], String?, bool, String?) -> TransportOptions - def self.apply_transport_options: (Configuration, TransportOptions) -> void public diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 1500a30e..ab76a6c6 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -59,7 +59,7 @@ def test_custom_ca_cert zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( "https://#{@host}:#{@https_port}", 'dummy-client', 'dummy-secret', - ca_cert_path: @ca_cert_path + transport_options: TransportOptions.new(ca_cert_path: @ca_cert_path) ) refute_nil zitadel @@ -69,7 +69,7 @@ def test_insecure_mode zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( "https://#{@host}:#{@https_port}", 'dummy-client', 'dummy-secret', - insecure: true + transport_options: TransportOptions.new(insecure: true) ) refute_nil zitadel @@ -78,10 +78,11 @@ def test_insecure_mode # rubocop:disable Metrics/MethodLength def test_default_headers # Use HTTP to avoid TLS concerns + opts = TransportOptions.new(default_headers: { 'X-Custom-Header' => 'test-value' }) zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( "http://#{@host}:#{@http_port}", 'dummy-client', 'dummy-secret', - default_headers: { 'X-Custom-Header' => 'test-value' } + transport_options: opts ) refute_nil zitadel @@ -107,7 +108,7 @@ def test_proxy_url zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( "http://#{@host}:#{@http_port}", 'dummy-client', 'dummy-secret', - proxy_url: "http://#{@host}:#{@http_port}" + transport_options: TransportOptions.new(proxy_url: "http://#{@host}:#{@http_port}") ) refute_nil zitadel @@ -122,17 +123,6 @@ def test_no_ca_cert_fails end end - def test_transport_options_object - opts = TransportOptions.new(insecure: true) - zitadel = Zitadel.with_client_credentials( - "https://#{@host}:#{@https_port}", - 'dummy-client', 'dummy-secret', - transport_options: opts - ) - - assert_instance_of Zitadel, zitadel - end - private # rubocop:disable Metrics/MethodLength From 9ac8dd20bc1de08960cdc02c52804dbb5b284be3 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 23:03:28 +1100 Subject: [PATCH 16/42] Use withAccessToken for proxy test reliability WireMock cannot act as an HTTP proxy for OpenID discovery, so use withAccessToken which does not trigger discovery during construction. --- test/zitadel/client/transport_options_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index ab76a6c6..ea98df64 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -105,9 +105,9 @@ def test_default_headers # rubocop:enable Metrics/MethodLength def test_proxy_url - zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( + zitadel = ::Zitadel::Client::Zitadel.with_access_token( "http://#{@host}:#{@http_port}", - 'dummy-client', 'dummy-secret', + 'test-token', transport_options: TransportOptions.new(proxy_url: "http://#{@host}:#{@http_port}") ) From f8c59d10cd4bf0505f3794f74d7ff8b7a55650d6 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 23:47:38 +1100 Subject: [PATCH 17/42] Align README Advanced Configuration with canonical structure Use consistent subsection structure across all SDKs: intro paragraph, then separate sections for TLS, CA cert, headers, and proxy with identical explanatory text. --- README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 509320f8..65c787d2 100644 --- a/README.md +++ b/README.md @@ -212,33 +212,74 @@ integration or troubleshooting unexpected behavior. ## Advanced Configuration -All factory methods (`with_client_credentials`, `with_private_key`, -`with_access_token`) accept an optional `transport_options` parameter -for configuring TLS, proxies, and default headers via a `TransportOptions` -object. +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: + +```ruby +options = Zitadel::Client::TransportOptions.new(insecure: true) + +zitadel = Zitadel::Client::Zitadel.with_client_credentials( + 'https://your-instance.zitadel.cloud', + 'client-id', + 'client-secret', + transport_options: 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: + +```ruby +options = Zitadel::Client::TransportOptions.new(ca_cert_path: '/path/to/ca.pem') + +zitadel = Zitadel::Client::Zitadel.with_client_credentials( + 'https://your-instance.zitadel.cloud', + 'client-id', + 'client-secret', + transport_options: options +) +``` + +### Custom Default Headers + +You can attach default headers to every outgoing request. This is useful for +proxy authentication or custom routing headers: ```ruby options = Zitadel::Client::TransportOptions.new( - ca_cert_path: '/path/to/ca.pem', - default_headers: { 'Proxy-Authorization' => 'Basic dXNlcjpwYXNz' }, - proxy_url: 'http://proxy:8080', - insecure: false + default_headers: { 'Proxy-Authorization' => 'Basic dXNlcjpwYXNz' } ) zitadel = Zitadel::Client::Zitadel.with_client_credentials( - 'https://my-instance.zitadel.cloud', + 'https://your-instance.zitadel.cloud', 'client-id', 'client-secret', transport_options: options ) ``` -Available options: +### Proxy Configuration + +If your environment requires routing traffic through an HTTP proxy, you can +specify the proxy URL: -- `ca_cert_path` — path to a custom CA certificate for TLS verification -- `insecure` — disable TLS certificate verification (not recommended for production) -- `default_headers` — hash of headers to include in every HTTP request -- `proxy_url` — HTTP proxy URL for all requests +```ruby +options = Zitadel::Client::TransportOptions.new(proxy_url: 'http://proxy:8080') + +zitadel = Zitadel::Client::Zitadel.with_client_credentials( + 'https://your-instance.zitadel.cloud', + 'client-id', + 'client-secret', + transport_options: options +) +``` ## Design and Dependencies From 0ed69d0582715ac9814050f28072bf3114627acf Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 01:09:31 +1100 Subject: [PATCH 18/42] Add explicit require for openssl in transport_options --- lib/zitadel/client/transport_options.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/zitadel/client/transport_options.rb b/lib/zitadel/client/transport_options.rb index e0e818be..600364fe 100644 --- a/lib/zitadel/client/transport_options.rb +++ b/lib/zitadel/client/transport_options.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'openssl' + module Zitadel module Client # Immutable transport options for configuring HTTP connections. From 1218dc4c3e306a69afabca5099210f5ba764b663 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 01:20:59 +1100 Subject: [PATCH 19/42] Forward caller-provided block in factory methods --- lib/zitadel/client/zitadel.rb | 11 +++++++---- sig/lib.rbs | 6 +++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/zitadel/client/zitadel.rb b/lib/zitadel/client/zitadel.rb index 9908b4da..d13de6de 100644 --- a/lib/zitadel/client/zitadel.rb +++ b/lib/zitadel/client/zitadel.rb @@ -7,7 +7,7 @@ module Client # Initializes and configures the SDK with the provided authentication strategy. # Sets up service APIs for interacting with various Zitadel features. # noinspection RubyTooManyInstanceVariablesInspection - class Zitadel + class Zitadel # rubocop:disable Metrics/ClassLength attr_reader :features, :idps, :instances, @@ -93,10 +93,11 @@ class << self # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, headers. # @return [Zitadel] SDK client configured with PAT authentication. # @see https://zitadel.com/docs/guides/integrate/service-users/personal-access-token - def with_access_token(host, access_token, transport_options: nil) + def with_access_token(host, access_token, transport_options: nil, &block) resolved = transport_options || TransportOptions.defaults new(Auth::PersonalAccessTokenAuthenticator.new(host, access_token)) do |config| apply_transport_options(config, resolved) + block&.call(config) end end @@ -108,7 +109,7 @@ def with_access_token(host, access_token, transport_options: nil) # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, headers. # @return [Zitadel] SDK client with automatic token acquisition & refresh. # @see https://zitadel.com/docs/guides/integrate/service-users/client-credentials - def with_client_credentials(host, client_id, client_secret, transport_options: nil) + def with_client_credentials(host, client_id, client_secret, transport_options: nil, &block) resolved = transport_options || TransportOptions.defaults new( Auth::ClientCredentialsAuthenticator @@ -116,6 +117,7 @@ def with_client_credentials(host, client_id, client_secret, transport_options: n .build ) do |config| apply_transport_options(config, resolved) + block&.call(config) end end @@ -126,11 +128,12 @@ def with_client_credentials(host, client_id, client_secret, transport_options: n # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, headers. # @return [Zitadel] SDK client using JWT assertion for secure, secret-less auth. # @see https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt - def with_private_key(host, key_file, transport_options: nil) + def with_private_key(host, key_file, transport_options: nil, &block) resolved = transport_options || TransportOptions.defaults new(Auth::WebTokenAuthenticator.from_json(host, key_file, transport_options: resolved)) do |config| apply_transport_options(config, resolved) + block&.call(config) end end diff --git a/sig/lib.rbs b/sig/lib.rbs index 879b4aaf..5f340e08 100644 --- a/sig/lib.rbs +++ b/sig/lib.rbs @@ -342,11 +342,11 @@ module Zitadel class Zitadel - def self.with_access_token: (String, String, ?transport_options: TransportOptions?) -> Zitadel + def self.with_access_token: (String, String, ?transport_options: TransportOptions?) ?{ (Configuration) -> void } -> Zitadel - def self.with_client_credentials: (String, String, String, ?transport_options: TransportOptions?) -> Zitadel + def self.with_client_credentials: (String, String, String, ?transport_options: TransportOptions?) ?{ (Configuration) -> void } -> Zitadel - def self.with_private_key: (String, String, ?transport_options: TransportOptions?) -> Zitadel + def self.with_private_key: (String, String, ?transport_options: TransportOptions?) ?{ (Configuration) -> void } -> Zitadel private From 6a975d98f47fe31992df2fe4481211708686039c Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 01:54:44 +1100 Subject: [PATCH 20/42] Add real proxy container to transport options test --- test/fixtures/tinyproxy.conf | 6 ++++ test/zitadel/client/transport_options_test.rb | 32 +++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/tinyproxy.conf diff --git a/test/fixtures/tinyproxy.conf b/test/fixtures/tinyproxy.conf new file mode 100644 index 00000000..78732bef --- /dev/null +++ b/test/fixtures/tinyproxy.conf @@ -0,0 +1,6 @@ +Port 8888 +Listen 0.0.0.0 +Timeout 600 +MaxClients 100 +Allow 0.0.0.0/0 +DisableViaHeader Yes diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index ea98df64..c2e1b96e 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -11,6 +11,7 @@ require 'minitest/autorun' require 'minitest/hooks/test' require 'testcontainers' +require 'docker' require 'net/http' require 'json' @@ -28,6 +29,9 @@ def before_all @ca_cert_path = File.join(FIXTURES_DIR, 'ca.pem') keystore_path = File.join(FIXTURES_DIR, 'keystore.p12') + tinyproxy_conf = File.join(FIXTURES_DIR, 'tinyproxy.conf') + + @network = Docker::Network.create('zitadel-proxy-test') @wiremock = Testcontainers::DockerContainer.new('wiremock/wiremock:3.3.1') .with_filesystem_binds("#{keystore_path}:/home/wiremock/keystore.p12:ro") @@ -42,16 +46,38 @@ def before_all .start @wiremock.wait_for_http(container_port: 8080, path: '/__admin/mappings', status: 200) + # Connect WireMock to network with alias so the proxy can resolve it + wiremock_id = @wiremock.instance_variable_get(:@_id) + @network.connect(wiremock_id, {}, 'EndpointConfig' => { 'Aliases' => ['wiremock'] }) + + # Create proxy directly on the network so Docker DNS resolves 'wiremock' + Docker::Image.create('fromImage' => 'vimagick/tinyproxy') + @proxy_container = Docker::Container.create( + 'Image' => 'vimagick/tinyproxy', + 'ExposedPorts' => { '8888/tcp' => {} }, + 'HostConfig' => { + 'PortBindings' => { '8888/tcp' => [{ 'HostPort' => '' }] }, + 'Binds' => ["#{tinyproxy_conf}:/etc/tinyproxy/tinyproxy.conf:ro"], + 'NetworkMode' => 'zitadel-proxy-test' + } + ) + @proxy_container.start + @host = @wiremock.host @http_port = @wiremock.mapped_port(8080) @https_port = @wiremock.mapped_port(8443) + @proxy_container.refresh! + @proxy_port = @proxy_container.json['NetworkSettings']['Ports']['8888/tcp'].first['HostPort'].to_i register_wiremock_stubs end # rubocop:enable Metrics/MethodLength def after_all + @proxy_container&.stop + @proxy_container&.remove @wiremock&.stop + @network&.remove super end @@ -105,13 +131,15 @@ def test_default_headers # rubocop:enable Metrics/MethodLength def test_proxy_url + # Use Docker-internal hostname — only resolvable through the proxy's network zitadel = ::Zitadel::Client::Zitadel.with_access_token( - "http://#{@host}:#{@http_port}", + 'http://wiremock:8080', 'test-token', - transport_options: TransportOptions.new(proxy_url: "http://#{@host}:#{@http_port}") + transport_options: TransportOptions.new(proxy_url: "http://#{@host}:#{@proxy_port}") ) refute_nil zitadel + zitadel.settings.get_general_settings({}) end def test_no_ca_cert_fails From 7fa50cd640148b18d16853d52b778acf367bcbda Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 11:45:46 +1100 Subject: [PATCH 21/42] chore: align docs and remove inline comments --- lib/zitadel/client/transport_options.rb | 2 +- test/zitadel/client/transport_options_test.rb | 19 ------------------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/lib/zitadel/client/transport_options.rb b/lib/zitadel/client/transport_options.rb index 600364fe..737c0b9e 100644 --- a/lib/zitadel/client/transport_options.rb +++ b/lib/zitadel/client/transport_options.rb @@ -9,7 +9,7 @@ class TransportOptions attr_reader :default_headers, :ca_cert_path, :insecure, :proxy_url def initialize(default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil) - @default_headers = default_headers.freeze + @default_headers = default_headers.dup.freeze @ca_cert_path = ca_cert_path.freeze @insecure = insecure @proxy_url = proxy_url.freeze diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index c2e1b96e..cf6d3a3e 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -1,12 +1,5 @@ # frozen_string_literal: true -# Test suite for transport options (default_headers, ca_cert_path, insecure). -# -# Uses a WireMock Docker container with HTTPS support to verify that the SDK -# correctly handles custom CA certificates, insecure TLS mode, and custom -# default headers when initializing via `with_client_credentials`. - -# noinspection RubyResolve require 'test_helper' require 'minitest/autorun' require 'minitest/hooks/test' @@ -20,7 +13,6 @@ module Zitadel module Client class TransportOptionsTest < Minitest::Test # rubocop:disable Metrics/ClassLength - # noinspection RbsMissingTypeSignature include Minitest::Hooks # rubocop:disable Metrics/MethodLength @@ -46,11 +38,9 @@ def before_all .start @wiremock.wait_for_http(container_port: 8080, path: '/__admin/mappings', status: 200) - # Connect WireMock to network with alias so the proxy can resolve it wiremock_id = @wiremock.instance_variable_get(:@_id) @network.connect(wiremock_id, {}, 'EndpointConfig' => { 'Aliases' => ['wiremock'] }) - # Create proxy directly on the network so Docker DNS resolves 'wiremock' Docker::Image.create('fromImage' => 'vimagick/tinyproxy') @proxy_container = Docker::Container.create( 'Image' => 'vimagick/tinyproxy', @@ -103,7 +93,6 @@ def test_insecure_mode # rubocop:disable Metrics/MethodLength def test_default_headers - # Use HTTP to avoid TLS concerns opts = TransportOptions.new(default_headers: { 'X-Custom-Header' => 'test-value' }) zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( "http://#{@host}:#{@http_port}", @@ -113,11 +102,8 @@ def test_default_headers refute_nil zitadel - # Make an actual API call to verify headers propagate to service requests zitadel.settings.get_general_settings({}) - # Use WireMock's verification API to assert the header was sent on the API call - # noinspection HttpUrlsUsage verify_uri = URI("http://#{@host}:#{@http_port}/__admin/requests/count") verify_response = Net::HTTP.post(verify_uri, { url: '/zitadel.settings.v2.SettingsService/GetGeneralSettings', @@ -131,7 +117,6 @@ def test_default_headers # rubocop:enable Metrics/MethodLength def test_proxy_url - # Use Docker-internal hostname — only resolvable through the proxy's network zitadel = ::Zitadel::Client::Zitadel.with_access_token( 'http://wiremock:8080', 'test-token', @@ -155,10 +140,8 @@ def test_no_ca_cert_fails # rubocop:disable Metrics/MethodLength def register_wiremock_stubs - # noinspection HttpUrlsUsage uri = URI("http://#{@host}:#{@http_port}/__admin/mappings") - # Stub 1 - OpenID Configuration response = Net::HTTP.post(uri, { request: { method: 'GET', url: '/.well-known/openid-configuration' }, response: { @@ -174,7 +157,6 @@ def register_wiremock_stubs assert_equal '201', response.code - # Stub 2 - Token endpoint response = Net::HTTP.post(uri, { request: { method: 'POST', url: '/oauth/v2/token' }, response: { @@ -186,7 +168,6 @@ def register_wiremock_stubs assert_equal '201', response.code - # Stub 3 - Settings API endpoint (for verifying headers on API calls) response = Net::HTTP.post(uri, { request: { method: 'POST', url: '/zitadel.settings.v2.SettingsService/GetGeneralSettings' }, response: { From e50662a77e54bf50a69bbce8817130a6b413eeaa Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 09:51:26 +0530 Subject: [PATCH 22/42] Update lib/zitadel/client/transport_options.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/zitadel/client/transport_options.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/zitadel/client/transport_options.rb b/lib/zitadel/client/transport_options.rb index 737c0b9e..dcef255b 100644 --- a/lib/zitadel/client/transport_options.rb +++ b/lib/zitadel/client/transport_options.rb @@ -10,9 +10,9 @@ class TransportOptions def initialize(default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil) @default_headers = default_headers.dup.freeze - @ca_cert_path = ca_cert_path.freeze + @ca_cert_path = ca_cert_path&.dup&.freeze @insecure = insecure - @proxy_url = proxy_url.freeze + @proxy_url = proxy_url&.dup&.freeze freeze end From d62e2804f3db975a2967345db4942e1b1ab24fa8 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 15:34:15 +1100 Subject: [PATCH 23/42] replace tinyproxy with ubuntu/squid:6.10-24.10_beta --- test/fixtures/squid.conf | 3 +++ test/fixtures/tinyproxy.conf | 6 ------ test/zitadel/client/transport_options_test.rb | 14 +++++++------- 3 files changed, 10 insertions(+), 13 deletions(-) create mode 100644 test/fixtures/squid.conf delete mode 100644 test/fixtures/tinyproxy.conf diff --git a/test/fixtures/squid.conf b/test/fixtures/squid.conf new file mode 100644 index 00000000..f3e60d4b --- /dev/null +++ b/test/fixtures/squid.conf @@ -0,0 +1,3 @@ +http_port 3128 +acl all src all +http_access allow all diff --git a/test/fixtures/tinyproxy.conf b/test/fixtures/tinyproxy.conf deleted file mode 100644 index 78732bef..00000000 --- a/test/fixtures/tinyproxy.conf +++ /dev/null @@ -1,6 +0,0 @@ -Port 8888 -Listen 0.0.0.0 -Timeout 600 -MaxClients 100 -Allow 0.0.0.0/0 -DisableViaHeader Yes diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index cf6d3a3e..db4c9df1 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -21,7 +21,7 @@ def before_all @ca_cert_path = File.join(FIXTURES_DIR, 'ca.pem') keystore_path = File.join(FIXTURES_DIR, 'keystore.p12') - tinyproxy_conf = File.join(FIXTURES_DIR, 'tinyproxy.conf') + squid_conf = File.join(FIXTURES_DIR, 'squid.conf') @network = Docker::Network.create('zitadel-proxy-test') @@ -41,13 +41,13 @@ def before_all wiremock_id = @wiremock.instance_variable_get(:@_id) @network.connect(wiremock_id, {}, 'EndpointConfig' => { 'Aliases' => ['wiremock'] }) - Docker::Image.create('fromImage' => 'vimagick/tinyproxy') + Docker::Image.create('fromImage' => 'ubuntu/squid:6.10-24.10_beta') @proxy_container = Docker::Container.create( - 'Image' => 'vimagick/tinyproxy', - 'ExposedPorts' => { '8888/tcp' => {} }, + 'Image' => 'ubuntu/squid:6.10-24.10_beta', + 'ExposedPorts' => { '3128/tcp' => {} }, 'HostConfig' => { - 'PortBindings' => { '8888/tcp' => [{ 'HostPort' => '' }] }, - 'Binds' => ["#{tinyproxy_conf}:/etc/tinyproxy/tinyproxy.conf:ro"], + 'PortBindings' => { '3128/tcp' => [{ 'HostPort' => '' }] }, + 'Binds' => ["#{squid_conf}:/etc/squid/squid.conf:ro"], 'NetworkMode' => 'zitadel-proxy-test' } ) @@ -57,7 +57,7 @@ def before_all @http_port = @wiremock.mapped_port(8080) @https_port = @wiremock.mapped_port(8443) @proxy_container.refresh! - @proxy_port = @proxy_container.json['NetworkSettings']['Ports']['8888/tcp'].first['HostPort'].to_i + @proxy_port = @proxy_container.json['NetworkSettings']['Ports']['3128/tcp'].first['HostPort'].to_i register_wiremock_stubs end From 8183a189dadb6ffaea4b6b758ea4a66730ed198e Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 15:47:25 +1100 Subject: [PATCH 24/42] use unique network name to avoid collisions --- test/zitadel/client/transport_options_test.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index db4c9df1..d4e0968b 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -7,6 +7,7 @@ require 'docker' require 'net/http' require 'json' +require 'securerandom' FIXTURES_DIR = File.join(__dir__, '..', '..', 'fixtures') @@ -23,7 +24,8 @@ def before_all keystore_path = File.join(FIXTURES_DIR, 'keystore.p12') squid_conf = File.join(FIXTURES_DIR, 'squid.conf') - @network = Docker::Network.create('zitadel-proxy-test') + @network_name = "zitadel-test-#{SecureRandom.hex(4)}" + @network = Docker::Network.create(@network_name) @wiremock = Testcontainers::DockerContainer.new('wiremock/wiremock:3.3.1') .with_filesystem_binds("#{keystore_path}:/home/wiremock/keystore.p12:ro") @@ -48,7 +50,7 @@ def before_all 'HostConfig' => { 'PortBindings' => { '3128/tcp' => [{ 'HostPort' => '' }] }, 'Binds' => ["#{squid_conf}:/etc/squid/squid.conf:ro"], - 'NetworkMode' => 'zitadel-proxy-test' + 'NetworkMode' => @network_name } ) @proxy_container.start From 858ec8d5549df262e81892b5b282bfb5f825120a Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 16:05:42 +1100 Subject: [PATCH 25/42] use public _id accessor instead of instance_variable_get --- test/zitadel/client/transport_options_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index d4e0968b..078d2158 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -40,7 +40,7 @@ def before_all .start @wiremock.wait_for_http(container_port: 8080, path: '/__admin/mappings', status: 200) - wiremock_id = @wiremock.instance_variable_get(:@_id) + wiremock_id = @wiremock._id @network.connect(wiremock_id, {}, 'EndpointConfig' => { 'Aliases' => ['wiremock'] }) Docker::Image.create('fromImage' => 'ubuntu/squid:6.10-24.10_beta') From fb8d48e4a59df47cb82a9a10008661f8bfd6a664 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 16:50:13 +1100 Subject: [PATCH 26/42] docs: fix proxy auth docs to use URL credentials instead of default headers --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 65c787d2..4f7a64d5 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,11 @@ zitadel = Zitadel::Client::Zitadel.with_client_credentials( ### Custom Default Headers You can attach default headers to every outgoing request. This is useful for -proxy authentication or custom routing headers: +custom routing or tracing headers: ```ruby options = Zitadel::Client::TransportOptions.new( - default_headers: { 'Proxy-Authorization' => 'Basic dXNlcjpwYXNz' } + default_headers: { 'X-Custom-Header' => 'my-value' } ) zitadel = Zitadel::Client::Zitadel.with_client_credentials( @@ -268,10 +268,11 @@ zitadel = Zitadel::Client::Zitadel.with_client_credentials( ### Proxy Configuration If your environment requires routing traffic through an HTTP proxy, you can -specify the proxy URL: +specify the proxy URL. To authenticate with the proxy, embed the credentials +directly in the URL: ```ruby -options = Zitadel::Client::TransportOptions.new(proxy_url: 'http://proxy:8080') +options = Zitadel::Client::TransportOptions.new(proxy_url: 'http://user:pass@proxy:8080') zitadel = Zitadel::Client::Zitadel.with_client_credentials( 'https://your-instance.zitadel.cloud', From 83a4188f8e29b209c14dcd914244968e8323ca45 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 21:38:23 +1100 Subject: [PATCH 27/42] fix: add proxy container wait strategy to prevent flaky tests --- test/zitadel/client/transport_options_test.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 078d2158..e0791de9 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -8,6 +8,7 @@ require 'net/http' require 'json' require 'securerandom' +require 'socket' FIXTURES_DIR = File.join(__dir__, '..', '..', 'fixtures') @@ -54,6 +55,7 @@ def before_all } ) @proxy_container.start + wait_for_port(@proxy_container, 3128) @host = @wiremock.host @http_port = @wiremock.mapped_port(8080) @@ -140,6 +142,22 @@ def test_no_ca_cert_fails private + def wait_for_port(container, port, timeout: 30) + container.refresh! + host_port = container.json['NetworkSettings']['Ports']["#{port}/tcp"].first['HostPort'].to_i + host = container.json['NetworkSettings']['Ports']["#{port}/tcp"].first['HostIP'] + host = '127.0.0.1' if host == '0.0.0.0' # rubocop:disable Style/NumericLiteralPrefix + deadline = Time.now + timeout + loop do + TCPSocket.new(host, host_port).close + return + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH + raise "Timed out waiting for port #{port}" if Time.now > deadline + + sleep 0.1 + end + end + # rubocop:disable Metrics/MethodLength def register_wiremock_stubs uri = URI("http://#{@host}:#{@http_port}/__admin/mappings") From 0813387206892e1aaee5b7d164833a6f4b7f1996 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 22:08:32 +1100 Subject: [PATCH 28/42] fix: remove unused openssl require from client_credentials_authenticator --- lib/zitadel/client/auth/client_credentials_authenticator.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/zitadel/client/auth/client_credentials_authenticator.rb b/lib/zitadel/client/auth/client_credentials_authenticator.rb index abbb65f2..e0154c28 100644 --- a/lib/zitadel/client/auth/client_credentials_authenticator.rb +++ b/lib/zitadel/client/auth/client_credentials_authenticator.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'openssl' - module Zitadel module Client module Auth From fc5b2382034fd4f53fae5d4856284befd04eb5db Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 22:20:01 +1100 Subject: [PATCH 29/42] fix: resolve rubocop warnings in wait_for_port --- test/zitadel/client/transport_options_test.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index e0791de9..dafbf1da 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -142,14 +142,13 @@ def test_no_ca_cert_fails private - def wait_for_port(container, port, timeout: 30) + def wait_for_port(container, port, timeout: 30) # rubocop:disable Metrics/MethodLength container.refresh! - host_port = container.json['NetworkSettings']['Ports']["#{port}/tcp"].first['HostPort'].to_i - host = container.json['NetworkSettings']['Ports']["#{port}/tcp"].first['HostIP'] - host = '127.0.0.1' if host == '0.0.0.0' # rubocop:disable Style/NumericLiteralPrefix + binding = container.json['NetworkSettings']['Ports']["#{port}/tcp"].first + host = binding['HostIP'] == '0.0.0.0' ? '127.0.0.1' : binding['HostIP'] deadline = Time.now + timeout loop do - TCPSocket.new(host, host_port).close + TCPSocket.new(host, binding['HostPort'].to_i).close return rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH raise "Timed out waiting for port #{port}" if Time.now > deadline From 5402efb26fb1169759988bf7314344c2bd38dce2 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 09:19:44 +1100 Subject: [PATCH 30/42] Add missing @param transport_options YARD tags --- lib/zitadel/client/auth/authenticator.rb | 1 + .../auth/client_credentials_authenticator.rb | 3 +++ .../client/auth/o_auth_authenticator.rb | 4 ++- lib/zitadel/client/auth/open_id.rb | 1 + .../client/auth/web_token_authenticator.rb | 4 +++ lib/zitadel/client/transport_options.rb | 25 ++++++++++++++++++- 6 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/zitadel/client/auth/authenticator.rb b/lib/zitadel/client/auth/authenticator.rb index 382b8ed5..7135e43d 100644 --- a/lib/zitadel/client/auth/authenticator.rb +++ b/lib/zitadel/client/auth/authenticator.rb @@ -56,6 +56,7 @@ class OAuthAuthenticatorBuilder # Initializes the OAuthAuthenticatorBuilder with a given host. # # @param host [String] the base URL for the OAuth provider. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # def initialize(host, transport_options: nil) transport_options ||= TransportOptions.defaults diff --git a/lib/zitadel/client/auth/client_credentials_authenticator.rb b/lib/zitadel/client/auth/client_credentials_authenticator.rb index e0154c28..22d3b0e0 100644 --- a/lib/zitadel/client/auth/client_credentials_authenticator.rb +++ b/lib/zitadel/client/auth/client_credentials_authenticator.rb @@ -11,6 +11,7 @@ class ClientCredentialsAuthenticator < Auth::OAuthAuthenticator # @param client_id [String] The OAuth client identifier. # @param client_secret [String] The OAuth client secret. # @param auth_scopes [Set] The scope(s) for the token request. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. def initialize(open_id, client_id, client_secret, auth_scopes, transport_options: nil) transport_options ||= TransportOptions.defaults @@ -29,6 +30,7 @@ def initialize(open_id, client_id, client_secret, auth_scopes, transport_options # @param host [String] The OAuth provider's base URL. # @param client_id [String] The OAuth client identifier. # @param client_secret [String] The OAuth client secret. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # @return [ClientCredentialsAuthenticatorBuilder] A builder instance. def self.builder(host, client_id, client_secret, transport_options: nil) ClientCredentialsAuthenticatorBuilder.new(host, client_id, client_secret, @@ -51,6 +53,7 @@ class ClientCredentialsAuthenticatorBuilder < OAuthAuthenticatorBuilder # @param host [String] The OAuth provider's base URL. # @param client_id [String] The OAuth client identifier. # @param client_secret [String] The OAuth client secret. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. def initialize(host, client_id, client_secret, transport_options: nil) # noinspection RubyArgCount super(host, transport_options: transport_options) diff --git a/lib/zitadel/client/auth/o_auth_authenticator.rb b/lib/zitadel/client/auth/o_auth_authenticator.rb index 2c8acdc9..093c317f 100644 --- a/lib/zitadel/client/auth/o_auth_authenticator.rb +++ b/lib/zitadel/client/auth/o_auth_authenticator.rb @@ -25,7 +25,9 @@ class OAuthAuthenticator < Authenticator # Constructs an OAuthAuthenticator. # # @param open_id [OpenId] An object that must implement `get_host_endpoint` and `get_token_endpoint`. - # @param auth_session [OAuth2Session] The OAuth2Session instance used for token requests. + # @param auth_scopes [Set] The scope(s) for the token request. + # @param auth_session [OAuth2::Client] The OAuth2 client instance used for token requests. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # def initialize(open_id, auth_scopes, auth_session, transport_options: nil) super(open_id.host_endpoint) diff --git a/lib/zitadel/client/auth/open_id.rb b/lib/zitadel/client/auth/open_id.rb index 78e0d936..b3000880 100644 --- a/lib/zitadel/client/auth/open_id.rb +++ b/lib/zitadel/client/auth/open_id.rb @@ -21,6 +21,7 @@ class OpenId # Initializes a new OpenId instance. # # @param hostname [String] the hostname for the OpenID provider. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # @raise [RuntimeError] if the OpenID configuration cannot be fetched or the token_endpoint is missing. # # noinspection HttpUrlsUsage diff --git a/lib/zitadel/client/auth/web_token_authenticator.rb b/lib/zitadel/client/auth/web_token_authenticator.rb index 75cf5a80..8f5d5972 100644 --- a/lib/zitadel/client/auth/web_token_authenticator.rb +++ b/lib/zitadel/client/auth/web_token_authenticator.rb @@ -25,6 +25,7 @@ class WebTokenAuthenticator < Auth::OAuthAuthenticator # @param jwt_lifetime [Integer] Lifetime of the JWT in seconds (default 3600 seconds). # @param jwt_algorithm [String] The JWT signing algorithm (default "RS256"). # @param key_id [String, nil] Optional key identifier for the JWT header (default: nil). + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength def initialize(open_id, auth_scopes, jwt_issuer, jwt_subject, jwt_audience, private_key, jwt_lifetime: 3600, jwt_algorithm: 'RS256', key_id: nil, transport_options: nil) @@ -67,6 +68,7 @@ def initialize(open_id, auth_scopes, jwt_issuer, jwt_subject, jwt_audience, priv # # @param host [String] Base URL for the API endpoints. # @param json_path [String] File path to the JSON configuration file. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # @return [WebTokenAuthenticator] A new instance of WebTokenAuthenticator. # @raise [RuntimeError] If the file cannot be read, the JSON is invalid, or required keys are missing. # rubocop:disable Metrics/MethodLength @@ -92,6 +94,7 @@ def self.from_json(host, json_path, transport_options: nil) # @param host [String] The base URL for the OAuth provider. # @param user_id [String] The user identifier (used as both the issuer and subject). # @param private_key [String] The private key used to sign the JWT. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # @return [WebTokenAuthenticatorBuilder] A builder instance. def self.builder(host, user_id, private_key, transport_options: nil) WebTokenAuthenticatorBuilder.new(host, user_id, user_id, host, private_key, @@ -139,6 +142,7 @@ class WebTokenAuthenticatorBuilder < OAuthAuthenticatorBuilder # @param jwt_subject [String] The subject claim for the JWT. # @param jwt_audience [String] The audience claim for the JWT. # @param private_key [String] The PEM-formatted private key used for signing the JWT. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # rubocop:disable Metrics/ParameterLists def initialize(host, jwt_issuer, jwt_subject, jwt_audience, private_key, transport_options: nil) # noinspection RubyArgCount diff --git a/lib/zitadel/client/transport_options.rb b/lib/zitadel/client/transport_options.rb index dcef255b..87e787a7 100644 --- a/lib/zitadel/client/transport_options.rb +++ b/lib/zitadel/client/transport_options.rb @@ -5,9 +5,29 @@ module Zitadel module Client # Immutable transport options for configuring HTTP connections. + # + # Holds TLS, proxy, and default-header settings that are threaded through + # every authenticator and OpenID discovery call. class TransportOptions - attr_reader :default_headers, :ca_cert_path, :insecure, :proxy_url + # @return [Hash{String => String}] frozen default headers sent with every request. + attr_reader :default_headers + # @return [String, nil] path to a PEM-encoded CA certificate bundle. + attr_reader :ca_cert_path + + # @return [Boolean] when true, TLS certificate verification is disabled. + attr_reader :insecure + + # @return [String, nil] HTTP proxy URL (e.g. "http://proxy:8080"). + attr_reader :proxy_url + + # Creates a new TransportOptions instance. + # + # @param default_headers [Hash{String => String}] headers to include in every request. + # @param ca_cert_path [String, nil] path to a custom CA certificate file. + # @param insecure [Boolean] whether to skip TLS verification. + # @param proxy_url [String, nil] HTTP proxy URL. + # @return [TransportOptions] an immutable transport options instance. def initialize(default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil) @default_headers = default_headers.dup.freeze @ca_cert_path = ca_cert_path&.dup&.freeze @@ -16,6 +36,9 @@ def initialize(default_headers: {}, ca_cert_path: nil, insecure: false, proxy_ur freeze end + # Returns a TransportOptions instance with all default values. + # + # @return [TransportOptions] a default transport options instance. def self.defaults new end From 3ffee8adf8c74984573777e466628bafafced576 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 09:24:00 +1100 Subject: [PATCH 31/42] Standardize @param transport_options descriptions --- lib/zitadel/client/zitadel.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/zitadel/client/zitadel.rb b/lib/zitadel/client/zitadel.rb index d13de6de..ee00241c 100644 --- a/lib/zitadel/client/zitadel.rb +++ b/lib/zitadel/client/zitadel.rb @@ -90,7 +90,7 @@ class << self # # @param host [String] API URL (e.g. "https://api.zitadel.example.com"). # @param access_token [String] Personal Access Token for Bearer authentication. - # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, headers. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # @return [Zitadel] SDK client configured with PAT authentication. # @see https://zitadel.com/docs/guides/integrate/service-users/personal-access-token def with_access_token(host, access_token, transport_options: nil, &block) @@ -106,7 +106,7 @@ def with_access_token(host, access_token, transport_options: nil, &block) # @param host [String] API URL. # @param client_id [String] OAuth2 client identifier. # @param client_secret [String] OAuth2 client secret. - # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, headers. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # @return [Zitadel] SDK client with automatic token acquisition & refresh. # @see https://zitadel.com/docs/guides/integrate/service-users/client-credentials def with_client_credentials(host, client_id, client_secret, transport_options: nil, &block) @@ -125,7 +125,7 @@ def with_client_credentials(host, client_id, client_secret, transport_options: n # # @param host [String] API URL. # @param key_file [String] Path to service account JSON/PEM key file. - # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, headers. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. # @return [Zitadel] SDK client using JWT assertion for secure, secret-less auth. # @see https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt def with_private_key(host, key_file, transport_options: nil, &block) From 0c783aba25e3aaa62ad40511e693c80e1f81f941 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 09:53:58 +1100 Subject: [PATCH 32/42] Standardize factory method return descriptions\n\nAlign @return text on with_access_token, with_client_credentials,\nand with_private_key to match the other SDK implementations. --- lib/zitadel/client/zitadel.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/zitadel/client/zitadel.rb b/lib/zitadel/client/zitadel.rb index ee00241c..3fb670e5 100644 --- a/lib/zitadel/client/zitadel.rb +++ b/lib/zitadel/client/zitadel.rb @@ -91,7 +91,7 @@ class << self # @param host [String] API URL (e.g. "https://api.zitadel.example.com"). # @param access_token [String] Personal Access Token for Bearer authentication. # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. - # @return [Zitadel] SDK client configured with PAT authentication. + # @return [Zitadel] Configured Zitadel client instance. # @see https://zitadel.com/docs/guides/integrate/service-users/personal-access-token def with_access_token(host, access_token, transport_options: nil, &block) resolved = transport_options || TransportOptions.defaults @@ -107,7 +107,7 @@ def with_access_token(host, access_token, transport_options: nil, &block) # @param client_id [String] OAuth2 client identifier. # @param client_secret [String] OAuth2 client secret. # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. - # @return [Zitadel] SDK client with automatic token acquisition & refresh. + # @return [Zitadel] Configured Zitadel client instance with token auto-refresh. # @see https://zitadel.com/docs/guides/integrate/service-users/client-credentials def with_client_credentials(host, client_id, client_secret, transport_options: nil, &block) resolved = transport_options || TransportOptions.defaults @@ -126,7 +126,7 @@ def with_client_credentials(host, client_id, client_secret, transport_options: n # @param host [String] API URL. # @param key_file [String] Path to service account JSON/PEM key file. # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. - # @return [Zitadel] SDK client using JWT assertion for secure, secret-less auth. + # @return [Zitadel] Configured Zitadel client instance using JWT assertion. # @see https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt def with_private_key(host, key_file, transport_options: nil, &block) resolved = transport_options || TransportOptions.defaults From 20cac97422d8073f2ae353e01c6b6dcc642ac6ac Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 12:41:29 +1100 Subject: [PATCH 33/42] Standardize WireMock version to 3.12.1 Align transport options test with the version used in other tests. --- test/zitadel/client/transport_options_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index dafbf1da..22331c5a 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -28,7 +28,7 @@ def before_all @network_name = "zitadel-test-#{SecureRandom.hex(4)}" @network = Docker::Network.create(@network_name) - @wiremock = Testcontainers::DockerContainer.new('wiremock/wiremock:3.3.1') + @wiremock = Testcontainers::DockerContainer.new('wiremock/wiremock:3.12.1') .with_filesystem_binds("#{keystore_path}:/home/wiremock/keystore.p12:ro") .with_command( '--https-port', '8443', From ccae94f0c83d8b791d7402b141cf973ca70f153e Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 07:25:58 +0530 Subject: [PATCH 34/42] Update test/zitadel/client/transport_options_test.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/zitadel/client/transport_options_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 22331c5a..68aa68fa 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -71,6 +71,7 @@ def after_all @proxy_container&.stop @proxy_container&.remove @wiremock&.stop + @wiremock&.remove @network&.remove super end From 5cdec2b5cbaed70aa4ff9e1babf0b3f4e90a7d98 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 13:03:54 +1100 Subject: [PATCH 35/42] Clarify default_headers are sent to the origin server Align description with the other SDKs. --- lib/zitadel/client/transport_options.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/zitadel/client/transport_options.rb b/lib/zitadel/client/transport_options.rb index 87e787a7..2cb648ee 100644 --- a/lib/zitadel/client/transport_options.rb +++ b/lib/zitadel/client/transport_options.rb @@ -9,7 +9,7 @@ module Client # Holds TLS, proxy, and default-header settings that are threaded through # every authenticator and OpenID discovery call. class TransportOptions - # @return [Hash{String => String}] frozen default headers sent with every request. + # @return [Hash{String => String}] frozen default headers sent to the origin server with every request. attr_reader :default_headers # @return [String, nil] path to a PEM-encoded CA certificate bundle. @@ -23,7 +23,7 @@ class TransportOptions # Creates a new TransportOptions instance. # - # @param default_headers [Hash{String => String}] headers to include in every request. + # @param default_headers [Hash{String => String}] headers sent to the origin server with every request. # @param ca_cert_path [String, nil] path to a custom CA certificate file. # @param insecure [Boolean] whether to skip TLS verification. # @param proxy_url [String, nil] HTTP proxy URL. From d13a05e6cd32667d79705d3a570792603356429b Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 13:27:44 +1100 Subject: [PATCH 36/42] Replace hand-written wait_for_port with testcontainers built-in Use Testcontainers::DockerContainer and wait_for_tcp_port for the proxy container instead of raw Docker API and custom port-waiting logic. --- test/zitadel/client/transport_options_test.rb | 41 ++++--------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 68aa68fa..83942f4e 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -8,7 +8,6 @@ require 'net/http' require 'json' require 'securerandom' -require 'socket' FIXTURES_DIR = File.join(__dir__, '..', '..', 'fixtures') @@ -44,32 +43,25 @@ def before_all wiremock_id = @wiremock._id @network.connect(wiremock_id, {}, 'EndpointConfig' => { 'Aliases' => ['wiremock'] }) - Docker::Image.create('fromImage' => 'ubuntu/squid:6.10-24.10_beta') - @proxy_container = Docker::Container.create( - 'Image' => 'ubuntu/squid:6.10-24.10_beta', - 'ExposedPorts' => { '3128/tcp' => {} }, - 'HostConfig' => { - 'PortBindings' => { '3128/tcp' => [{ 'HostPort' => '' }] }, - 'Binds' => ["#{squid_conf}:/etc/squid/squid.conf:ro"], - 'NetworkMode' => @network_name - } - ) - @proxy_container.start - wait_for_port(@proxy_container, 3128) + @proxy = Testcontainers::DockerContainer.new('ubuntu/squid:6.10-24.10_beta') + .with_filesystem_binds("#{squid_conf}:/etc/squid/squid.conf:ro") + .with_exposed_ports(3128) + .start + @proxy.wait_for_tcp_port(3128) + @network.connect(@proxy._id) @host = @wiremock.host @http_port = @wiremock.mapped_port(8080) @https_port = @wiremock.mapped_port(8443) - @proxy_container.refresh! - @proxy_port = @proxy_container.json['NetworkSettings']['Ports']['3128/tcp'].first['HostPort'].to_i + @proxy_port = @proxy.mapped_port(3128) register_wiremock_stubs end # rubocop:enable Metrics/MethodLength def after_all - @proxy_container&.stop - @proxy_container&.remove + @proxy&.stop + @proxy&.remove @wiremock&.stop @wiremock&.remove @network&.remove @@ -143,21 +135,6 @@ def test_no_ca_cert_fails private - def wait_for_port(container, port, timeout: 30) # rubocop:disable Metrics/MethodLength - container.refresh! - binding = container.json['NetworkSettings']['Ports']["#{port}/tcp"].first - host = binding['HostIP'] == '0.0.0.0' ? '127.0.0.1' : binding['HostIP'] - deadline = Time.now + timeout - loop do - TCPSocket.new(host, binding['HostPort'].to_i).close - return - rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH - raise "Timed out waiting for port #{port}" if Time.now > deadline - - sleep 0.1 - end - end - # rubocop:disable Metrics/MethodLength def register_wiremock_stubs uri = URI("http://#{@host}:#{@http_port}/__admin/mappings") From 9c35d42db860790509e9217e8f9ff0d419f3d769 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 16:19:23 +1100 Subject: [PATCH 37/42] Replace programmatic WireMock stubs with static JSON mapping files\n\nWireMock auto-loads JSON files from /home/wiremock/mappings/ at startup,\nso there is no need to register stubs via the admin API in test setup. --- fixtures/mappings/oidc-discovery.json | 19 +++++++ fixtures/mappings/settings.json | 13 +++++ fixtures/mappings/token.json | 17 ++++++ test/zitadel/client/transport_options_test.rb | 52 +++---------------- 4 files changed, 55 insertions(+), 46 deletions(-) create mode 100644 fixtures/mappings/oidc-discovery.json create mode 100644 fixtures/mappings/settings.json create mode 100644 fixtures/mappings/token.json diff --git a/fixtures/mappings/oidc-discovery.json b/fixtures/mappings/oidc-discovery.json new file mode 100644 index 00000000..3e2930a3 --- /dev/null +++ b/fixtures/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/fixtures/mappings/settings.json b/fixtures/mappings/settings.json new file mode 100644 index 00000000..b20c0103 --- /dev/null +++ b/fixtures/mappings/settings.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "POST", + "url": "/zitadel.settings.v2.SettingsService/GetGeneralSettings" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": {} + } +} diff --git a/fixtures/mappings/token.json b/fixtures/mappings/token.json new file mode 100644 index 00000000..b4227d3f --- /dev/null +++ b/fixtures/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/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 83942f4e..342625e9 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -27,8 +27,13 @@ def before_all @network_name = "zitadel-test-#{SecureRandom.hex(4)}" @network = Docker::Network.create(@network_name) + mappings_dir = File.join(FIXTURES_DIR, 'mappings') + @wiremock = Testcontainers::DockerContainer.new('wiremock/wiremock:3.12.1') - .with_filesystem_binds("#{keystore_path}:/home/wiremock/keystore.p12:ro") + .with_filesystem_binds( + "#{keystore_path}:/home/wiremock/keystore.p12:ro", + "#{mappings_dir}:/home/wiremock/mappings:ro" + ) .with_command( '--https-port', '8443', '--https-keystore', '/home/wiremock/keystore.p12', @@ -55,7 +60,6 @@ def before_all @https_port = @wiremock.mapped_port(8443) @proxy_port = @proxy.mapped_port(3128) - register_wiremock_stubs end # rubocop:enable Metrics/MethodLength @@ -133,50 +137,6 @@ def test_no_ca_cert_fails end end - private - - # rubocop:disable Metrics/MethodLength - def register_wiremock_stubs - uri = URI("http://#{@host}:#{@http_port}/__admin/mappings") - - response = Net::HTTP.post(uri, { - request: { method: 'GET', url: '/.well-known/openid-configuration' }, - response: { - status: 200, - headers: { 'Content-Type' => 'application/json' }, - body: '{"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"}' - } - }.to_json, 'Content-Type' => 'application/json') - - assert_equal '201', response.code - - response = Net::HTTP.post(uri, { - 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 } - } - }.to_json, 'Content-Type' => 'application/json') - - assert_equal '201', response.code - - response = Net::HTTP.post(uri, { - request: { method: 'POST', url: '/zitadel.settings.v2.SettingsService/GetGeneralSettings' }, - response: { - status: 200, - headers: { 'Content-Type' => 'application/json' }, - jsonBody: {} - } - }.to_json, 'Content-Type' => 'application/json') - - assert_equal '201', response.code - end - # rubocop:enable Metrics/MethodLength end end end From 465054fdb3d9da0d4d304c6cac5c5167b33a79e1 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 18:59:46 +1100 Subject: [PATCH 38/42] Fix mapping files path and filesystem_binds argument count\n\nMove mappings from fixtures/ to test/fixtures/ to match FIXTURES_DIR,\nand chain with_filesystem_binds calls (method accepts a single argument). --- .../fixtures}/mappings/oidc-discovery.json | 0 {fixtures => test/fixtures}/mappings/settings.json | 0 {fixtures => test/fixtures}/mappings/token.json | 0 test/zitadel/client/transport_options_test.rb | 10 +++------- 4 files changed, 3 insertions(+), 7 deletions(-) rename {fixtures => test/fixtures}/mappings/oidc-discovery.json (100%) rename {fixtures => test/fixtures}/mappings/settings.json (100%) rename {fixtures => test/fixtures}/mappings/token.json (100%) diff --git a/fixtures/mappings/oidc-discovery.json b/test/fixtures/mappings/oidc-discovery.json similarity index 100% rename from fixtures/mappings/oidc-discovery.json rename to test/fixtures/mappings/oidc-discovery.json diff --git a/fixtures/mappings/settings.json b/test/fixtures/mappings/settings.json similarity index 100% rename from fixtures/mappings/settings.json rename to test/fixtures/mappings/settings.json diff --git a/fixtures/mappings/token.json b/test/fixtures/mappings/token.json similarity index 100% rename from fixtures/mappings/token.json rename to test/fixtures/mappings/token.json diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 342625e9..b3633f20 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -13,7 +13,7 @@ module Zitadel module Client - class TransportOptionsTest < Minitest::Test # rubocop:disable Metrics/ClassLength + class TransportOptionsTest < Minitest::Test include Minitest::Hooks # rubocop:disable Metrics/MethodLength @@ -30,10 +30,8 @@ def before_all mappings_dir = File.join(FIXTURES_DIR, 'mappings') @wiremock = Testcontainers::DockerContainer.new('wiremock/wiremock:3.12.1') - .with_filesystem_binds( - "#{keystore_path}:/home/wiremock/keystore.p12:ro", - "#{mappings_dir}:/home/wiremock/mappings:ro" - ) + .with_filesystem_binds("#{keystore_path}:/home/wiremock/keystore.p12:ro") + .with_filesystem_binds("#{mappings_dir}:/home/wiremock/mappings:ro") .with_command( '--https-port', '8443', '--https-keystore', '/home/wiremock/keystore.p12', @@ -59,7 +57,6 @@ def before_all @http_port = @wiremock.mapped_port(8080) @https_port = @wiremock.mapped_port(8443) @proxy_port = @proxy.mapped_port(3128) - end # rubocop:enable Metrics/MethodLength @@ -136,7 +133,6 @@ def test_no_ca_cert_fails ) end end - end end end From d4cf5832c9372d925017a1e5db42b73f18025d1e Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 19:43:03 +1100 Subject: [PATCH 39/42] Standardize TransportOptions docstrings for cross-SDK consistency --- lib/zitadel/client/transport_options.rb | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/zitadel/client/transport_options.rb b/lib/zitadel/client/transport_options.rb index 2cb648ee..144a7597 100644 --- a/lib/zitadel/client/transport_options.rb +++ b/lib/zitadel/client/transport_options.rb @@ -5,28 +5,25 @@ module Zitadel module Client # Immutable transport options for configuring HTTP connections. - # - # Holds TLS, proxy, and default-header settings that are threaded through - # every authenticator and OpenID discovery call. class TransportOptions - # @return [Hash{String => String}] frozen default headers sent to the origin server with every request. + # @return [Hash{String => String}] Default HTTP headers sent to the origin server with every request. attr_reader :default_headers - # @return [String, nil] path to a PEM-encoded CA certificate bundle. + # @return [String, nil] Path to a custom CA certificate file for TLS verification. attr_reader :ca_cert_path - # @return [Boolean] when true, TLS certificate verification is disabled. + # @return [Boolean] Whether to disable TLS certificate verification. attr_reader :insecure - # @return [String, nil] HTTP proxy URL (e.g. "http://proxy:8080"). + # @return [String, nil] Proxy URL for HTTP connections. attr_reader :proxy_url # Creates a new TransportOptions instance. # - # @param default_headers [Hash{String => String}] headers sent to the origin server with every request. - # @param ca_cert_path [String, nil] path to a custom CA certificate file. - # @param insecure [Boolean] whether to skip TLS verification. - # @param proxy_url [String, nil] HTTP proxy URL. + # @param default_headers [Hash{String => String}] Default HTTP headers sent to the origin server with every request. + # @param ca_cert_path [String, nil] Path to a custom CA certificate file for TLS verification. + # @param insecure [Boolean] Whether to disable TLS certificate verification. + # @param proxy_url [String, nil] Proxy URL for HTTP connections. # @return [TransportOptions] an immutable transport options instance. def initialize(default_headers: {}, ca_cert_path: nil, insecure: false, proxy_url: nil) @default_headers = default_headers.dup.freeze From e65caaeaef9258cbf3e0bd3c7286ffeb6b00210f Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 23:52:55 +1100 Subject: [PATCH 40/42] Restructure tests: split TransportOptions unit tests from Zitadel integration tests --- test/fixtures/mappings/settings.json | 5 +- test/zitadel/client/transport_options_test.rb | 148 +++++------------- test/zitadel/client/zitadel_test.rb | 129 +++++++++++++-- 3 files changed, 156 insertions(+), 126 deletions(-) diff --git a/test/fixtures/mappings/settings.json b/test/fixtures/mappings/settings.json index b20c0103..524a367a 100644 --- a/test/fixtures/mappings/settings.json +++ b/test/fixtures/mappings/settings.json @@ -8,6 +8,9 @@ "headers": { "Content-Type": "application/json" }, - "jsonBody": {} + "jsonBody": { + "defaultLanguage": "{{request.scheme}}", + "defaultOrgId": "{{request.headers.X-Custom-Header}}" + } } } diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index b3633f20..6f5c8961 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -2,136 +2,60 @@ require 'test_helper' require 'minitest/autorun' -require 'minitest/hooks/test' -require 'testcontainers' -require 'docker' -require 'net/http' -require 'json' -require 'securerandom' - -FIXTURES_DIR = File.join(__dir__, '..', '..', 'fixtures') module Zitadel module Client class TransportOptionsTest < Minitest::Test - include Minitest::Hooks - - # rubocop:disable Metrics/MethodLength - def before_all - super - - @ca_cert_path = File.join(FIXTURES_DIR, 'ca.pem') - keystore_path = File.join(FIXTURES_DIR, 'keystore.p12') - squid_conf = File.join(FIXTURES_DIR, 'squid.conf') - - @network_name = "zitadel-test-#{SecureRandom.hex(4)}" - @network = Docker::Network.create(@network_name) - - mappings_dir = File.join(FIXTURES_DIR, 'mappings') - - @wiremock = Testcontainers::DockerContainer.new('wiremock/wiremock:3.12.1') - .with_filesystem_binds("#{keystore_path}:/home/wiremock/keystore.p12:ro") - .with_filesystem_binds("#{mappings_dir}:/home/wiremock/mappings:ro") - .with_command( - '--https-port', '8443', - '--https-keystore', '/home/wiremock/keystore.p12', - '--keystore-password', 'password', - '--keystore-type', 'PKCS12', - '--global-response-templating' - ) - .with_exposed_ports(8080, 8443) - .start - @wiremock.wait_for_http(container_port: 8080, path: '/__admin/mappings', status: 200) - - wiremock_id = @wiremock._id - @network.connect(wiremock_id, {}, 'EndpointConfig' => { 'Aliases' => ['wiremock'] }) - - @proxy = Testcontainers::DockerContainer.new('ubuntu/squid:6.10-24.10_beta') - .with_filesystem_binds("#{squid_conf}:/etc/squid/squid.conf:ro") - .with_exposed_ports(3128) - .start - @proxy.wait_for_tcp_port(3128) - @network.connect(@proxy._id) + FIXTURES_DIR = File.join(__dir__, '..', '..', 'fixtures') - @host = @wiremock.host - @http_port = @wiremock.mapped_port(8080) - @https_port = @wiremock.mapped_port(8443) - @proxy_port = @proxy.mapped_port(3128) + def test_defaults_returns_empty + assert_equal({}, TransportOptions.defaults.to_connection_opts) end - # rubocop:enable Metrics/MethodLength - def after_all - @proxy&.stop - @proxy&.remove - @wiremock&.stop - @wiremock&.remove - @network&.remove - super + def test_insecure_sets_ssl_verify + opts = TransportOptions.new(insecure: true) + result = opts.to_connection_opts + assert_equal({ verify: false }, result[:ssl]) end - def test_custom_ca_cert - zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( - "https://#{@host}:#{@https_port}", - 'dummy-client', 'dummy-secret', - transport_options: TransportOptions.new(ca_cert_path: @ca_cert_path) - ) - - refute_nil zitadel + def test_ca_cert_path_sets_cert_store + ca_path = File.join(FIXTURES_DIR, 'ca.pem') + opts = TransportOptions.new(ca_cert_path: ca_path) + result = opts.to_connection_opts + assert_instance_of OpenSSL::X509::Store, result[:ssl][:cert_store] + assert_equal true, result[:ssl][:verify] end - def test_insecure_mode - zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( - "https://#{@host}:#{@https_port}", - 'dummy-client', 'dummy-secret', - transport_options: TransportOptions.new(insecure: true) - ) - - refute_nil zitadel + def test_proxy_url_sets_proxy + opts = TransportOptions.new(proxy_url: 'http://proxy:3128') + result = opts.to_connection_opts + assert_equal 'http://proxy:3128', result[:proxy] end - # rubocop:disable Metrics/MethodLength - def test_default_headers - opts = TransportOptions.new(default_headers: { 'X-Custom-Header' => 'test-value' }) - zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( - "http://#{@host}:#{@http_port}", - 'dummy-client', 'dummy-secret', - transport_options: opts - ) - - refute_nil zitadel - - zitadel.settings.get_general_settings({}) - - verify_uri = URI("http://#{@host}:#{@http_port}/__admin/requests/count") - verify_response = Net::HTTP.post(verify_uri, { - url: '/zitadel.settings.v2.SettingsService/GetGeneralSettings', - headers: { 'X-Custom-Header' => { 'equalTo' => 'test-value' } } - }.to_json, 'Content-Type' => 'application/json') - - count = JSON.parse(verify_response.body)['count'] - - assert_operator count, :>=, 1, 'Custom header should be present on API call' + def test_default_headers_sets_headers + opts = TransportOptions.new(default_headers: { 'X-Custom' => 'value' }) + result = opts.to_connection_opts + assert_equal({ 'X-Custom' => 'value' }, result[:headers]) end - # rubocop:enable Metrics/MethodLength - def test_proxy_url - zitadel = ::Zitadel::Client::Zitadel.with_access_token( - 'http://wiremock:8080', - 'test-token', - transport_options: TransportOptions.new(proxy_url: "http://#{@host}:#{@proxy_port}") - ) + def test_insecure_takes_precedence_over_ca_cert + ca_path = File.join(FIXTURES_DIR, 'ca.pem') + opts = TransportOptions.new(insecure: true, ca_cert_path: ca_path) + result = opts.to_connection_opts + assert_equal({ verify: false }, result[:ssl]) + end - refute_nil zitadel - zitadel.settings.get_general_settings({}) + def test_frozen + opts = TransportOptions.defaults + assert_predicate opts, :frozen? end - def test_no_ca_cert_fails - assert_raises(StandardError) do - ::Zitadel::Client::Zitadel.with_client_credentials( - "https://#{@host}:#{@https_port}", - 'dummy-client', 'dummy-secret' - ) - end + def test_defaults_factory + opts = TransportOptions.defaults + assert_equal({}, opts.default_headers) + assert_nil opts.ca_cert_path + assert_equal false, opts.insecure + assert_nil opts.proxy_url end end end diff --git a/test/zitadel/client/zitadel_test.rb b/test/zitadel/client/zitadel_test.rb index b2a0ccd9..4145fe98 100644 --- a/test/zitadel/client/zitadel_test.rb +++ b/test/zitadel/client/zitadel_test.rb @@ -1,32 +1,80 @@ # frozen_string_literal: true -# noinspection RubyResolve require 'test_helper' require 'minitest/autorun' +require 'minitest/hooks/test' +require 'testcontainers' +require 'docker' +require 'securerandom' + +FIXTURES_DIR = File.join(__dir__, '..', '..', 'fixtures') module Zitadel module Client - # This test ensures that the Zitadel SDK class exposes a reader method - # for every service API class defined in the Zitadel::Client namespace - # that ends with "ServiceApi". - # - # It dynamically collects all service classes and compares them with - # the classes returned by each public method defined directly on the - # Zitadel instance. - # noinspection RbsMissingTypeSignature class ZitadelTest < Minitest::Test - # noinspection RubyArgCount + include Minitest::Hooks + + # rubocop:disable Metrics/MethodLength + def before_all + super + + @ca_cert_path = File.join(FIXTURES_DIR, 'ca.pem') + keystore_path = File.join(FIXTURES_DIR, 'keystore.p12') + squid_conf = File.join(FIXTURES_DIR, 'squid.conf') + + @network_name = "zitadel-test-#{SecureRandom.hex(4)}" + @network = Docker::Network.create(@network_name) + + mappings_dir = File.join(FIXTURES_DIR, 'mappings') + + @wiremock = Testcontainers::DockerContainer.new('wiremock/wiremock:3.12.1') + .with_filesystem_binds("#{keystore_path}:/home/wiremock/keystore.p12:ro") + .with_filesystem_binds("#{mappings_dir}:/home/wiremock/mappings:ro") + .with_command( + '--https-port', '8443', + '--https-keystore', '/home/wiremock/keystore.p12', + '--keystore-password', 'password', + '--keystore-type', 'PKCS12', + '--global-response-templating' + ) + .with_exposed_ports(8080, 8443) + .start + @wiremock.wait_for_http(container_port: 8080, path: '/__admin/mappings', status: 200) + + wiremock_id = @wiremock._id + @network.connect(wiremock_id, {}, 'EndpointConfig' => { 'Aliases' => ['wiremock'] }) + + @proxy = Testcontainers::DockerContainer.new('ubuntu/squid:6.10-24.10_beta') + .with_filesystem_binds("#{squid_conf}:/etc/squid/squid.conf:ro") + .with_exposed_ports(3128) + .start + @proxy.wait_for_tcp_port(3128) + @network.connect(@proxy._id) + + @host = @wiremock.host + @http_port = @wiremock.mapped_port(8080) + @https_port = @wiremock.mapped_port(8443) + @proxy_port = @proxy.mapped_port(3128) + end + # rubocop:enable Metrics/MethodLength + + def after_all + @proxy&.stop + @proxy&.remove + @wiremock&.stop + @wiremock&.remove + @network&.remove + super + end + def test_zitadel_exposes_all_service_apis - # Collect all classes under Zitadel::Client that end with "ServiceApi" expected = Api.constants .map { |const| Api.const_get(const) } .select { |klass| klass.is_a?(Class) && klass.name.end_with?('ServiceApi') } .to_set - # Instantiate the Zitadel SDK with a dummy authenticator zitadel = Zitadel.new(Auth::NoAuthAuthenticator.new) - # Collect all instance method return types that are service API classes actual = Zitadel.instance_methods(false) .map { |meth| zitadel.public_send(meth).class } .select { |klass| klass.name.end_with?('ServiceApi') } @@ -34,6 +82,61 @@ def test_zitadel_exposes_all_service_apis assert_equal expected, actual end + + def test_custom_ca_cert + zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( + "https://#{@host}:#{@https_port}", + 'dummy-client', 'dummy-secret', + transport_options: TransportOptions.new(ca_cert_path: @ca_cert_path) + ) + + response = zitadel.settings.get_general_settings({}) + assert_equal 'https', response.default_language + end + + def test_insecure_mode + zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( + "https://#{@host}:#{@https_port}", + 'dummy-client', 'dummy-secret', + transport_options: TransportOptions.new(insecure: true) + ) + + response = zitadel.settings.get_general_settings({}) + assert_equal 'https', response.default_language + end + + def test_default_headers + opts = TransportOptions.new(default_headers: { 'X-Custom-Header' => 'test-value' }) + zitadel = ::Zitadel::Client::Zitadel.with_client_credentials( + "http://#{@host}:#{@http_port}", + 'dummy-client', 'dummy-secret', + transport_options: opts + ) + + response = zitadel.settings.get_general_settings({}) + assert_equal 'http', response.default_language + assert_equal 'test-value', response.default_org_id + end + + def test_proxy_url + zitadel = ::Zitadel::Client::Zitadel.with_access_token( + 'http://wiremock:8080', + 'test-token', + transport_options: TransportOptions.new(proxy_url: "http://#{@host}:#{@proxy_port}") + ) + + response = zitadel.settings.get_general_settings({}) + assert_equal 'http', response.default_language + end + + def test_no_ca_cert_fails + assert_raises(StandardError) do + ::Zitadel::Client::Zitadel.with_client_credentials( + "https://#{@host}:#{@https_port}", + 'dummy-client', 'dummy-secret' + ) + end + end end end end From 1a93ed86afa77cd4324e00ddf98ba8f20e0dec95 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Tue, 10 Mar 2026 08:36:57 +1100 Subject: [PATCH 41/42] Fix rubocop offenses in transport options and zitadel tests --- test/zitadel/client/transport_options_test.rb | 20 +++++++++++++++---- test/zitadel/client/zitadel_test.rb | 6 +++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index 6f5c8961..ee769b1c 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -9,12 +9,13 @@ class TransportOptionsTest < Minitest::Test FIXTURES_DIR = File.join(__dir__, '..', '..', 'fixtures') def test_defaults_returns_empty - assert_equal({}, TransportOptions.defaults.to_connection_opts) + assert_empty(TransportOptions.defaults.to_connection_opts) end def test_insecure_sets_ssl_verify opts = TransportOptions.new(insecure: true) result = opts.to_connection_opts + assert_equal({ verify: false }, result[:ssl]) end @@ -22,19 +23,22 @@ def test_ca_cert_path_sets_cert_store ca_path = File.join(FIXTURES_DIR, 'ca.pem') opts = TransportOptions.new(ca_cert_path: ca_path) result = opts.to_connection_opts + assert_instance_of OpenSSL::X509::Store, result[:ssl][:cert_store] - assert_equal true, result[:ssl][:verify] + assert result[:ssl][:verify] end def test_proxy_url_sets_proxy opts = TransportOptions.new(proxy_url: 'http://proxy:3128') result = opts.to_connection_opts + assert_equal 'http://proxy:3128', result[:proxy] end def test_default_headers_sets_headers opts = TransportOptions.new(default_headers: { 'X-Custom' => 'value' }) result = opts.to_connection_opts + assert_equal({ 'X-Custom' => 'value' }, result[:headers]) end @@ -42,19 +46,27 @@ def test_insecure_takes_precedence_over_ca_cert ca_path = File.join(FIXTURES_DIR, 'ca.pem') opts = TransportOptions.new(insecure: true, ca_cert_path: ca_path) result = opts.to_connection_opts + assert_equal({ verify: false }, result[:ssl]) end def test_frozen opts = TransportOptions.defaults + assert_predicate opts, :frozen? end def test_defaults_factory opts = TransportOptions.defaults - assert_equal({}, opts.default_headers) + + assert_empty(opts.default_headers) assert_nil opts.ca_cert_path - assert_equal false, opts.insecure + refute opts.insecure + end + + def test_defaults_factory_proxy_url + opts = TransportOptions.defaults + assert_nil opts.proxy_url end end diff --git a/test/zitadel/client/zitadel_test.rb b/test/zitadel/client/zitadel_test.rb index 4145fe98..91ccc50b 100644 --- a/test/zitadel/client/zitadel_test.rb +++ b/test/zitadel/client/zitadel_test.rb @@ -11,7 +11,7 @@ module Zitadel module Client - class ZitadelTest < Minitest::Test + class ZitadelTest < Minitest::Test # rubocop:disable Metrics/ClassLength include Minitest::Hooks # rubocop:disable Metrics/MethodLength @@ -91,6 +91,7 @@ def test_custom_ca_cert ) response = zitadel.settings.get_general_settings({}) + assert_equal 'https', response.default_language end @@ -102,6 +103,7 @@ def test_insecure_mode ) response = zitadel.settings.get_general_settings({}) + assert_equal 'https', response.default_language end @@ -114,6 +116,7 @@ def test_default_headers ) response = zitadel.settings.get_general_settings({}) + assert_equal 'http', response.default_language assert_equal 'test-value', response.default_org_id end @@ -126,6 +129,7 @@ def test_proxy_url ) response = zitadel.settings.get_general_settings({}) + assert_equal 'http', response.default_language end From c45317205342c567ceca5e7acfef2514f33a316b Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Tue, 10 Mar 2026 09:31:43 +1100 Subject: [PATCH 42/42] Harden insecure precedence test with nonexistent CA cert path --- test/zitadel/client/transport_options_test.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb index ee769b1c..7c0a11a7 100644 --- a/test/zitadel/client/transport_options_test.rb +++ b/test/zitadel/client/transport_options_test.rb @@ -43,8 +43,7 @@ def test_default_headers_sets_headers end def test_insecure_takes_precedence_over_ca_cert - ca_path = File.join(FIXTURES_DIR, 'ca.pem') - opts = TransportOptions.new(insecure: true, ca_cert_path: ca_path) + opts = TransportOptions.new(insecure: true, ca_cert_path: '/nonexistent/ca.pem') result = opts.to_connection_opts assert_equal({ verify: false }, result[:ssl])