Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
b8b69fd
Add TransportOptions for configuring TLS, proxy, and default headers
mridang Mar 4, 2026
4e456f4
Fix hostname verification for custom CA certificates
mridang Mar 4, 2026
d591eff
Merge custom CA with default trust store instead of replacing it
mridang Mar 4, 2026
3f807ba
Fix contradictory README example showing insecure with ca_cert_path
mridang Mar 4, 2026
316b199
Fix SSL socket resource leak in transport options test
mridang Mar 4, 2026
666391b
Apply transport options to OAuth token exchange requests
mridang Mar 4, 2026
ab4a73e
Fix custom CA cert test and apply transport options to token exchange
mridang Mar 4, 2026
111c861
Standardize transport options tests across SDKs
mridang Mar 4, 2026
c8d3c36
Copy frozen default_headers before assigning to mutable config
mridang Mar 4, 2026
7c8e778
Extract config mutation helper to eliminate duplication across factor…
mridang Mar 4, 2026
15ee731
Centralize connection opts building in TransportOptions
mridang Mar 4, 2026
0a03429
Verify default headers on API calls via WireMock verification
mridang Mar 4, 2026
2d71b76
Fix README: correct HTTP library name and debug example
mridang Mar 4, 2026
12e91ee
Fix rubocop offenses in transport options and tests
mridang Mar 4, 2026
87f8dbc
Remove individual transport params from factory methods
mridang Mar 4, 2026
9ac8dd2
Use withAccessToken for proxy test reliability
mridang Mar 4, 2026
f8c59d1
Align README Advanced Configuration with canonical structure
mridang Mar 4, 2026
0ed69d0
Add explicit require for openssl in transport_options
mridang Mar 4, 2026
1218dc4
Forward caller-provided block in factory methods
mridang Mar 4, 2026
6a975d9
Add real proxy container to transport options test
mridang Mar 4, 2026
7fa50cd
chore: align docs and remove inline comments
mridang Mar 5, 2026
e50662a
Update lib/zitadel/client/transport_options.rb
mridang Mar 5, 2026
d62e280
replace tinyproxy with ubuntu/squid:6.10-24.10_beta
mridang Mar 5, 2026
8183a18
use unique network name to avoid collisions
mridang Mar 5, 2026
858ec8d
use public _id accessor instead of instance_variable_get
mridang Mar 5, 2026
fb8d48e
docs: fix proxy auth docs to use URL credentials instead of default h…
mridang Mar 5, 2026
83a4188
fix: add proxy container wait strategy to prevent flaky tests
mridang Mar 5, 2026
0813387
fix: remove unused openssl require from client_credentials_authenticator
mridang Mar 5, 2026
fc5b238
fix: resolve rubocop warnings in wait_for_port
mridang Mar 5, 2026
5402efb
Add missing @param transport_options YARD tags
mridang Mar 8, 2026
3ffee8a
Standardize @param transport_options descriptions
mridang Mar 8, 2026
0c783ab
Standardize factory method return descriptions\n\nAlign @return text …
mridang Mar 8, 2026
20cac97
Standardize WireMock version to 3.12.1
mridang Mar 9, 2026
ccae94f
Update test/zitadel/client/transport_options_test.rb
mridang Mar 9, 2026
5cdec2b
Clarify default_headers are sent to the origin server
mridang Mar 9, 2026
d13a05e
Replace hand-written wait_for_port with testcontainers built-in
mridang Mar 9, 2026
9c35d42
Replace programmatic WireMock stubs with static JSON mapping files\n\…
mridang Mar 9, 2026
465054f
Fix mapping files path and filesystem_binds argument count\n\nMove ma…
mridang Mar 9, 2026
d4cf583
Standardize TransportOptions docstrings for cross-SDK consistency
mridang Mar 9, 2026
e65caae
Restructure tests: split TransportOptions unit tests from Zitadel int…
mridang Mar 9, 2026
1a93ed8
Fix rubocop offenses in transport options and zitadel tests
mridang Mar 9, 2026
c453172
Harden insecure precedence test with nonexistent CA cert path
mridang Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 81 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,22 +194,99 @@ 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.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'
) do |config|
config.debugging = true
end
```

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 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
custom routing or tracing headers:

```ruby
options = Zitadel::Client::TransportOptions.new(
default_headers: { 'X-Custom-Header' => 'my-value' }
)

zitadel = Zitadel::Client::Zitadel.with_client_credentials(
'https://your-instance.zitadel.cloud',
'client-id',
'client-secret',
transport_options: options
)
```

### Proxy Configuration

If your environment requires routing traffic through an HTTP proxy, you can
specify the proxy URL. To authenticate with the proxy, embed the credentials
directly in the URL:

```ruby
options = Zitadel::Client::TransportOptions.new(proxy_url: 'http://user:pass@proxy:8080')

zitadel = Zitadel::Client::Zitadel.with_client_credentials(
'https://your-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
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.

Expand Down
5 changes: 4 additions & 1 deletion lib/zitadel/client/api_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions lib/zitadel/client/auth/authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ 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)
@open_id = OpenId.new(host)
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

Expand Down
26 changes: 18 additions & 8 deletions lib/zitadel/client/auth/client_credentials_authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,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<String>] The scope(s) for the token request.
def initialize(open_id, client_id, client_secret, auth_scopes)
# @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

conn_opts = transport_options.to_connection_opts

# 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

# Returns a new builder for constructing a ClientCredentialsAuthenticator.
#
# @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)
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
Expand All @@ -45,9 +53,10 @@ 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)
# @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)
super(host, transport_options: transport_options)
@client_id = client_id
@client_secret = client_secret
end
Expand All @@ -56,7 +65,8 @@ def initialize(host, client_id, client_secret)
#
# @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
Expand Down
7 changes: 5 additions & 2 deletions lib/zitadel/client/auth/o_auth_authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@ 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<String>] 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)
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(' ')
Expand Down
28 changes: 26 additions & 2 deletions lib/zitadel/client/auth/open_id.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require 'json'
require 'uri'
require 'net/http'
require 'openssl'

module Zitadel
module Client
Expand All @@ -20,16 +21,38 @@ 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
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,
proxy_uri.user, proxy_uri.password)
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
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)
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)
Expand All @@ -38,6 +61,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.
Expand Down
40 changes: 28 additions & 12 deletions lib/zitadel/client/auth/web_token_authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,20 @@ 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
# @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)
jwt_lifetime: 3600, jwt_algorithm: 'RS256', key_id: nil, transport_options: nil)
transport_options ||= TransportOptions.defaults

conn_opts = transport_options.to_connection_opts

# 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
Expand All @@ -47,7 +53,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

# Creates a WebTokenAuthenticator instance from a JSON configuration file.
#
Expand All @@ -62,9 +68,11 @@ 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.
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}"
Expand All @@ -76,17 +84,21 @@ 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.
#
# @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)
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
Expand Down Expand Up @@ -130,15 +142,18 @@ 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)
# @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
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.
#
Expand All @@ -159,7 +174,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
Expand Down
Loading
Loading