diff --git a/README.md b/README.md index 90f21121..4f7a64d5 100644 --- a/README.md +++ b/README.md @@ -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. 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..7135e43d 100644 --- a/lib/zitadel/client/auth/authenticator.rb +++ b/lib/zitadel/client/auth/authenticator.rb @@ -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 diff --git a/lib/zitadel/client/auth/client_credentials_authenticator.rb b/lib/zitadel/client/auth/client_credentials_authenticator.rb index b726a5c2..22d3b0e0 100644 --- a/lib/zitadel/client/auth/client_credentials_authenticator.rb +++ b/lib/zitadel/client/auth/client_credentials_authenticator.rb @@ -11,12 +11,18 @@ 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) + # @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. @@ -24,9 +30,11 @@ def initialize(open_id, client_id, client_secret, auth_scopes) # @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 @@ -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 @@ -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 diff --git a/lib/zitadel/client/auth/o_auth_authenticator.rb b/lib/zitadel/client/auth/o_auth_authenticator.rb index c6efd107..093c317f 100644 --- a/lib/zitadel/client/auth/o_auth_authenticator.rb +++ b/lib/zitadel/client/auth/o_auth_authenticator.rb @@ -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] 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(' ') diff --git a/lib/zitadel/client/auth/open_id.rb b/lib/zitadel/client/auth/open_id.rb index 7283e7f8..b3000880 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 @@ -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) @@ -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. diff --git a/lib/zitadel/client/auth/web_token_authenticator.rb b/lib/zitadel/client/auth/web_token_authenticator.rb index e0c6c718..8f5d5972 100644 --- a/lib/zitadel/client/auth/web_token_authenticator.rb +++ b/lib/zitadel/client/auth/web_token_authenticator.rb @@ -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 @@ -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. # @@ -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}" @@ -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 @@ -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. # @@ -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 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..144a7597 --- /dev/null +++ b/lib/zitadel/client/transport_options.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'openssl' + +module Zitadel + module Client + # Immutable transport options for configuring HTTP connections. + class TransportOptions + # @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 custom CA certificate file for TLS verification. + attr_reader :ca_cert_path + + # @return [Boolean] Whether to disable TLS certificate verification. + attr_reader :insecure + + # @return [String, nil] Proxy URL for HTTP connections. + attr_reader :proxy_url + + # Creates a new TransportOptions instance. + # + # @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 + @ca_cert_path = ca_cert_path&.dup&.freeze + @insecure = insecure + @proxy_url = proxy_url&.dup&.freeze + freeze + end + + # Returns a TransportOptions instance with all default values. + # + # @return [TransportOptions] a default transport options instance. + def self.defaults + new + end + + # 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 + 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 + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end + end +end diff --git a/lib/zitadel/client/zitadel.rb b/lib/zitadel/client/zitadel.rb index 27a8613e..3fb670e5 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, @@ -90,10 +90,15 @@ class << self # # @param host [String] API URL (e.g. "https://api.zitadel.example.com"). # @param access_token [String] Personal Access Token for Bearer authentication. - # @return [Zitadel] SDK client configured with PAT authentication. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. + # @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) - new(Auth::PersonalAccessTokenAuthenticator.new(host, access_token)) + 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 # Initialize the SDK using OAuth2 Client Credentials flow. @@ -101,27 +106,50 @@ def with_access_token(host, access_token) # @param host [String] API URL. # @param client_id [String] OAuth2 client identifier. # @param client_secret [String] OAuth2 client secret. - # @return [Zitadel] SDK client with automatic token acquisition & refresh. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. + # @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) + def with_client_credentials(host, client_id, client_secret, transport_options: nil, &block) + resolved = transport_options || TransportOptions.defaults new( Auth::ClientCredentialsAuthenticator - .builder(host, client_id, client_secret) + .builder(host, client_id, client_secret, transport_options: resolved) .build - ) + ) do |config| + apply_transport_options(config, resolved) + block&.call(config) + end end # 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. - # @return [Zitadel] SDK client using JWT assertion for secure, secret-less auth. + # @param transport_options [TransportOptions, nil] Optional transport options for TLS, proxy, and headers. + # @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) - new(Auth::WebTokenAuthenticator.from_json(host, key_file)) + 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 # @!endgroup + + private + + 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 1a77650f..5f340e08 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,16 @@ 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 + def to_connection_opts: -> Hash[Symbol, untyped] + end + module Auth class Authenticator @@ -217,17 +229,17 @@ 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 - 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 +260,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 @@ -259,8 +271,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 @@ -278,7 +291,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,13 +313,13 @@ 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 - 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 @@ -316,7 +329,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 +342,17 @@ module Zitadel class Zitadel - def self.with_access_token: (String, String) -> 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?) ?{ (Configuration) -> void } -> Zitadel + + def self.with_private_key: (String, String, ?transport_options: TransportOptions?) ?{ (Configuration) -> void } -> Zitadel + + private - def self.with_client_credentials: (String, String, String) -> Zitadel + def self.apply_transport_options: (Configuration, TransportOptions) -> void - def self.with_private_key: (String, String) -> Zitadel + public attr_reader configuration: Configuration attr_reader features: Api::FeatureServiceApi 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 00000000..9f1c66b1 Binary files /dev/null and b/test/fixtures/keystore.p12 differ diff --git a/test/fixtures/mappings/oidc-discovery.json b/test/fixtures/mappings/oidc-discovery.json new file mode 100644 index 00000000..3e2930a3 --- /dev/null +++ b/test/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/test/fixtures/mappings/settings.json b/test/fixtures/mappings/settings.json new file mode 100644 index 00000000..524a367a --- /dev/null +++ b/test/fixtures/mappings/settings.json @@ -0,0 +1,16 @@ +{ + "request": { + "method": "POST", + "url": "/zitadel.settings.v2.SettingsService/GetGeneralSettings" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "defaultLanguage": "{{request.scheme}}", + "defaultOrgId": "{{request.headers.X-Custom-Header}}" + } + } +} diff --git a/test/fixtures/mappings/token.json b/test/fixtures/mappings/token.json new file mode 100644 index 00000000..b4227d3f --- /dev/null +++ b/test/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/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/zitadel/client/transport_options_test.rb b/test/zitadel/client/transport_options_test.rb new file mode 100644 index 00000000..7c0a11a7 --- /dev/null +++ b/test/zitadel/client/transport_options_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/autorun' + +module Zitadel + module Client + class TransportOptionsTest < Minitest::Test + FIXTURES_DIR = File.join(__dir__, '..', '..', 'fixtures') + + def test_defaults_returns_empty + 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 + + 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 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 + + def test_insecure_takes_precedence_over_ca_cert + opts = TransportOptions.new(insecure: true, ca_cert_path: '/nonexistent/ca.pem') + 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_empty(opts.default_headers) + assert_nil opts.ca_cert_path + refute opts.insecure + end + + def test_defaults_factory_proxy_url + opts = TransportOptions.defaults + + assert_nil opts.proxy_url + end + end + end +end diff --git a/test/zitadel/client/zitadel_test.rb b/test/zitadel/client/zitadel_test.rb index b2a0ccd9..91ccc50b 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 + class ZitadelTest < Minitest::Test # rubocop:disable Metrics/ClassLength + 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,65 @@ 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