diff --git a/README.md b/README.md index 923979d9..92116050 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,24 @@ Console.WriteLine($"User: {user}"); ### With Authentication and TLS ```csharp -var config = new StandaloneClientConfigurationBuilder() - .WithAddress("secure-server.example.com", 6380) +// Password-based authentication with TLS. +var passwordConfig = new StandaloneClientConfigurationBuilder() + .WithAddress(host, port) .WithAuthentication("username", "password") .WithTls() .Build(); -using var client = await GlideClient.CreateClient(config); +using var passwordClient = await GlideClient.CreateClient(passwordConfig); + +// IAM authentication with TLS. +var iamAuthConfig = new IamAuthConfig("my-cluster", ServiceType.ElastiCache, "us-east-1"); +var iamConfig = new ClusterClientConfigurationBuilder() + .WithAddress(host, port) + .WithAuthentication("username", iamAuthConfig) + .WithTls(true) + .Build(); + +using var iamClient = await GlideClient.CreateClient(iamConfig); ``` ## Core API Examples diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index d72f4514..bc718dde 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -7,8 +7,7 @@ use std::{ use glide_core::{ client::{ - AuthenticationInfo, ConnectionRequest, ConnectionRetryStrategy, NodeAddress, - ReadFrom as coreReadFrom, TlsMode, + ConnectionRequest, ConnectionRetryStrategy, NodeAddress, ReadFrom as coreReadFrom, TlsMode, }, request_type::RequestType, }; @@ -65,7 +64,7 @@ pub struct ConnectionConfig { pub has_connection_retry_strategy: bool, pub connection_retry_strategy: ConnectionRetryStrategy, pub has_authentication_info: bool, - pub authentication_info: Credentials, + pub authentication_info: AuthenticationInfo, pub database_id: u32, pub has_protocol: bool, pub protocol: redis::ProtocolVersion, @@ -114,10 +113,32 @@ pub(crate) unsafe fn create_connection_request( client_name: unsafe { ptr_to_opt_str(config.client_name) }, lib_name: option_env!("GLIDE_NAME").map(|s| s.to_string()), authentication_info: if config.has_authentication_info { - Some(AuthenticationInfo { - username: unsafe { ptr_to_opt_str(config.authentication_info.username) }, - password: unsafe { ptr_to_opt_str(config.authentication_info.password) }, - iam_config: None, + let auth_info = config.authentication_info; + let iam_config = if auth_info.has_iam_credentials { + Some(glide_core::client::IamAuthenticationConfig { + cluster_name: unsafe { ptr_to_str(auth_info.iam_credentials.cluster_name) }, + region: unsafe { ptr_to_str(auth_info.iam_credentials.region) }, + service_type: match auth_info.iam_credentials.service_type { + ServiceType::ElastiCache => glide_core::iam::ServiceType::ElastiCache, + ServiceType::MemoryDB => glide_core::iam::ServiceType::MemoryDB, + }, + refresh_interval_seconds: if auth_info + .iam_credentials + .has_refresh_interval_seconds + { + Some(auth_info.iam_credentials.refresh_interval_seconds) + } else { + None + }, + }) + } else { + None + }; + + Some(glide_core::client::AuthenticationInfo { + username: unsafe { ptr_to_opt_str(auth_info.username) }, + password: unsafe { ptr_to_opt_str(auth_info.password) }, + iam_config, }) } else { None @@ -210,11 +231,29 @@ pub enum ReadFromStrategy { /// A mirror of [`AuthenticationInfo`] adopted for FFI. #[repr(C)] #[derive(Debug, Clone, Copy)] -pub struct Credentials { - /// zero pointer is valid, means no username is given (`None`) +pub struct AuthenticationInfo { pub username: *const c_char, - /// zero pointer is valid, means no password is given (`None`) pub password: *const c_char, + pub has_iam_credentials: bool, + pub iam_credentials: IamCredentials, +} + +/// A mirror of [`IamCredentials`] adopted for FFI. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct IamCredentials { + pub cluster_name: *const c_char, + pub region: *const c_char, + pub service_type: ServiceType, + pub has_refresh_interval_seconds: bool, + pub refresh_interval_seconds: u32, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub enum ServiceType { + ElastiCache = 0, + MemoryDB = 1, } #[repr(C)] diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 3515d526..24900b10 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -741,3 +741,66 @@ unsafe fn convert_string_pointer_array_to_vector<'a>( result } + +/// Manually refresh the IAM authentication token. +/// +/// This function triggers an immediate refresh of the IAM token and updates the connection. +/// It is only available if the client was created with IAM authentication. +/// +/// # Arguments +/// +/// * `client_ptr` - A pointer to a valid client returned from [`create_client`]. +/// * `callback_index` - A unique identifier for the callback to be called when the command completes. +/// +/// # Safety +/// +/// * `client_ptr` must not be `null`. +/// * `client_ptr` must be able to be safely casted to a valid [`Arc`] via [`Arc::from_raw`]. See the safety documentation of [`Arc::from_raw`]. +/// * This function should only be called with a `client_ptr` created by [`create_client`], before [`close_client`] was called with the pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn refresh_iam_token( + client_ptr: *const c_void, + callback_index: usize, +) { + let client = unsafe { + Arc::increment_strong_count(client_ptr); + Arc::from_raw(client_ptr as *mut Client) + }; + let core = client.core.clone(); + + let mut panic_guard = PanicGuard { + panicked: true, + failure_callback: core.failure_callback, + callback_index, + }; + + client.runtime.spawn(async move { + let mut async_panic_guard = PanicGuard { + panicked: true, + failure_callback: core.failure_callback, + callback_index, + }; + + let result = core.client.clone().refresh_iam_token().await; + match result { + Ok(()) => { + let response = ResponseValue::from_value(redis::Value::Okay); + let ptr = Box::into_raw(Box::new(response)); + unsafe { (core.success_callback)(callback_index, ptr) }; + } + Err(err) => unsafe { + report_error( + core.failure_callback, + callback_index, + error_message(&err), + error_type(&err), + ); + }, + }; + async_panic_guard.panicked = false; + drop(async_panic_guard); + }); + + panic_guard.panicked = false; + drop(panic_guard); +} diff --git a/sources/Valkey.Glide/Abstract/ConnectionMultiplexer.cs b/sources/Valkey.Glide/Abstract/ConnectionMultiplexer.cs index d71c7669..dfe6ad8c 100644 --- a/sources/Valkey.Glide/Abstract/ConnectionMultiplexer.cs +++ b/sources/Valkey.Glide/Abstract/ConnectionMultiplexer.cs @@ -180,7 +180,7 @@ internal static T CreateClientConfigBuilder(ConfigurationOptions configuratio config.UseTls = configuration.Ssl; _ = configuration.ConnectTimeout.HasValue ? config.ConnectionTimeout = TimeSpan.FromMilliseconds(configuration.ConnectTimeout.Value) : new(); _ = configuration.ResponseTimeout.HasValue ? config.RequestTimeout = TimeSpan.FromMilliseconds(configuration.ResponseTimeout.Value) : new(); - _ = (configuration.User ?? configuration.Password) is not null ? config.Authentication = (configuration.User, configuration.Password!) : new(); + _ = (configuration.User ?? configuration.Password) is not null ? config.WithAuthentication(configuration.User, configuration.Password!) : new(); _ = configuration.ClientName is not null ? config.ClientName = configuration.ClientName : ""; if (configuration.Protocol is not null) { diff --git a/sources/Valkey.Glide/BaseClient.cs b/sources/Valkey.Glide/BaseClient.cs index 4573d74e..e98f4c5f 100644 --- a/sources/Valkey.Glide/BaseClient.cs +++ b/sources/Valkey.Glide/BaseClient.cs @@ -38,6 +38,26 @@ public void Dispose() public override int GetHashCode() => (int)ClientPointer; + /// + /// Manually refresh the IAM authentication token. + /// This method is only available when the client is configured with IAM authentication. + /// + /// A task that completes when the refresh attempt finishes. + public async Task RefreshIamTokenAsync() + { + Message message = MessageContainer.GetMessageForCall(); + RefreshIamTokenFfi(ClientPointer, (ulong)message.Index); + IntPtr response = await message; + try + { + HandleResponse(response); + } + finally + { + FreeResponse(response); + } + } + #endregion public methods #region protected methods diff --git a/sources/Valkey.Glide/ConnectionConfiguration.cs b/sources/Valkey.Glide/ConnectionConfiguration.cs index 3dc75448..be273100 100644 --- a/sources/Valkey.Glide/ConnectionConfiguration.cs +++ b/sources/Valkey.Glide/ConnectionConfiguration.cs @@ -181,13 +181,14 @@ internal StandaloneClientConfiguration() { } /// /// Configuration for a standalone client. /// - /// /// /// /// /// /// /// + /// The username for authentication. + /// The password for authentication. /// /// /// @@ -214,7 +215,7 @@ public StandaloneClientConfiguration( _ = connectionTimeout.HasValue ? builder.ConnectionTimeout = connectionTimeout.Value : new(); _ = readFrom.HasValue ? builder.ReadFrom = readFrom.Value : new(); _ = retryStrategy.HasValue ? builder.ConnectionRetryStrategy = retryStrategy.Value : new(); - _ = (username ?? password) is not null ? builder.Authentication = (username, password!) : new(); + _ = (username ?? password) is not null ? builder.WithAuthentication(username, password!) : new(); _ = databaseId.HasValue ? builder.DataBaseId = databaseId.Value : new(); _ = protocol.HasValue ? builder.ProtocolVersion = protocol.Value : new(); _ = clientName is not null ? builder.ClientName = clientName : ""; @@ -233,13 +234,14 @@ internal ClusterClientConfiguration() { } /// /// Configuration for a cluster client. /// - /// /// /// /// /// /// /// + /// The username for authentication. + /// The password for authentication. /// /// /// @@ -266,7 +268,7 @@ public ClusterClientConfiguration( _ = connectionTimeout.HasValue ? builder.ConnectionTimeout = connectionTimeout.Value : new(); _ = readFrom.HasValue ? builder.ReadFrom = readFrom.Value : new(); _ = retryStrategy.HasValue ? builder.ConnectionRetryStrategy = retryStrategy.Value : new(); - _ = (username ?? password) is not null ? builder.Authentication = (username, password!) : new(); + _ = (username ?? password) is not null ? builder.WithAuthentication(username, password!) : new(); _ = databaseId.HasValue ? builder.DataBaseId = databaseId.Value : new(); _ = protocol.HasValue ? builder.ProtocolVersion = protocol.Value : new(); _ = clientName is not null ? builder.ClientName = clientName : ""; @@ -284,6 +286,10 @@ public abstract class ClientConfigurationBuilder { internal ConnectionConfig Config; + /// + /// Initializes a new instance of the ClientConfigurationBuilder class. + /// + /// Whether this is a cluster mode configuration. protected ClientConfigurationBuilder(bool clusterMode) { Config = new ConnectionConfig { ClusterMode = clusterMode }; @@ -383,8 +389,8 @@ public T WithTls(bool useTls) /// public T WithTls() { - UseTls = true; - return (T)this; + + return WithTls(true); } #endregion #region Request Timeout @@ -443,29 +449,73 @@ public T WithReadFrom(ReadFrom readFrom) #endregion #region Authentication /// - /// Configure credentials for authentication process. If none are set, the client will not authenticate itself with the server. + /// Configure server credentials for authentication process. + /// Supports both password-based and IAM authentication. /// - /// - /// username - The username that will be used for authenticating connections to the servers. If not supplied, "default" will be used.
- /// password - The password that will be used for authenticating connections to the servers. - ///
- public (string? username, string password) Authentication + /// The server credentials for authentication. + /// The builder instance for method chaining. + public T WithCredentials(ServerCredentials credentials) { - set => Config.AuthenticationInfo = new AuthenticationInfo - ( - value.username, - value.password - ); + ArgumentNullException.ThrowIfNull(credentials); + + IamCredentials? iamCredentials = null; + if (credentials.IamConfig != null) + { + var serviceType = credentials.IamConfig.ServiceType switch + { + ServiceType.ElastiCache => FFI.ServiceType.ElastiCache, + ServiceType.MemoryDB => FFI.ServiceType.MemoryDB, + _ => throw new ArgumentOutOfRangeException(nameof(credentials.IamConfig.ServiceType)) + }; + + iamCredentials = new IamCredentials( + credentials.IamConfig.ClusterName, + credentials.IamConfig.Region, + serviceType, + credentials.IamConfig.RefreshIntervalSeconds + ); + } + + Config.AuthenticationInfo = new AuthenticationInfo + ( + credentials.Username, + credentials.Password, + iamCredentials + ); + + return (T)this; } + /// - /// Configure credentials for authentication process. If none are set, the client will not authenticate itself with the server. + /// Configure server credentials for password-based authentication. /// - /// The username that will be used for authenticating connections to the servers. If not supplied, "default" will be used. - /// The password that will be used for authenticating connections to the servers. + /// The username for authentication. If null, "default" will be used. + /// The password for authentication. + /// The builder instance for method chaining. public T WithAuthentication(string? username, string password) { - Authentication = (username, password); - return (T)this; + return WithCredentials(new ServerCredentials(username, password)); + } + + /// + /// Configure server credentials for password-based authentication with username "default". + /// + /// The password for authentication. + /// The builder instance for method chaining. + public T WithAuthentication(string password) + { + return WithCredentials(new ServerCredentials(password)); + } + + /// + /// Configure server credentials for IAM authentication. + /// + /// The username for authentication. + /// The IAM authentication configuration. + /// The builder instance for method chaining. + public T WithAuthentication(string username, IamAuthConfig iamConfig) + { + return WithCredentials(new ServerCredentials(username, iamConfig)); } #endregion #region Protocol @@ -568,6 +618,9 @@ public T WithLazyConnect(bool lazyConnect) /// public class StandaloneClientConfigurationBuilder : ClientConfigurationBuilder { + /// + /// Initializes a new instance of the StandaloneClientConfigurationBuilder class. + /// public StandaloneClientConfigurationBuilder() : base(false) { } /// @@ -582,6 +635,9 @@ public StandaloneClientConfigurationBuilder() : base(false) { } /// public class ClusterClientConfigurationBuilder : ClientConfigurationBuilder { + /// + /// Initializes a new instance of the ClusterClientConfigurationBuilder class. + /// public ClusterClientConfigurationBuilder() : base(true) { } /// diff --git a/sources/Valkey.Glide/IamAuthConfig.cs b/sources/Valkey.Glide/IamAuthConfig.cs new file mode 100644 index 00000000..93806ed4 --- /dev/null +++ b/sources/Valkey.Glide/IamAuthConfig.cs @@ -0,0 +1,33 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +namespace Valkey.Glide; + +/// +/// Configuration for IAM authentication with AWS services. +/// +/// The name of the cluster. +/// The AWS service type. +/// The AWS region where the cluster is located. +/// Optional refresh interval in seconds. +public class IamAuthConfig(string clusterName, ServiceType serviceType, string region, uint? refreshIntervalSeconds = null) +{ + /// + /// The name of the cluster. + /// + public string ClusterName { get; set; } = clusterName ?? throw new ArgumentNullException(nameof(clusterName)); + + /// + /// The AWS service type. + /// + public ServiceType ServiceType { get; set; } = serviceType; + + /// + /// The AWS region where the cluster is located. + /// + public string Region { get; set; } = region ?? throw new ArgumentNullException(nameof(region)); + + /// + /// Optional refresh interval in seconds. + /// + public uint? RefreshIntervalSeconds { get; set; } = refreshIntervalSeconds; +} diff --git a/sources/Valkey.Glide/Internals/FFI.methods.cs b/sources/Valkey.Glide/Internals/FFI.methods.cs index 6bd49dbd..e6a343f8 100644 --- a/sources/Valkey.Glide/Internals/FFI.methods.cs +++ b/sources/Valkey.Glide/Internals/FFI.methods.cs @@ -38,6 +38,10 @@ internal partial class FFI [LibraryImport("libglide_rs", EntryPoint = "remove_cluster_scan_cursor")] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] public static partial void RemoveClusterScanCursorFfi(IntPtr cursorId); + + [LibraryImport("libglide_rs", EntryPoint = "refresh_iam_token")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void RefreshIamTokenFfi(IntPtr client, ulong index); #else [DllImport("libglide_rs", CallingConvention = CallingConvention.Cdecl, EntryPoint = "command")] public static extern void CommandFfi(IntPtr client, ulong index, IntPtr cmdInfo, IntPtr routeInfo); @@ -59,5 +63,7 @@ internal partial class FFI [DllImport("libglide_rs", CallingConvention = CallingConvention.Cdecl, EntryPoint = "remove_cluster_scan_cursor")] public static extern void RemoveClusterScanCursorFfi(IntPtr cursorId); + [DllImport("libglide_rs", CallingConvention = CallingConvention.Cdecl, EntryPoint = "refresh_iam_token")] + public static extern void RefreshIamTokenFfi(IntPtr client, ulong index); #endif } diff --git a/sources/Valkey.Glide/Internals/FFI.structs.cs b/sources/Valkey.Glide/Internals/FFI.structs.cs index 442ace74..9c1fee9a 100644 --- a/sources/Valkey.Glide/Internals/FFI.structs.cs +++ b/sources/Valkey.Glide/Internals/FFI.structs.cs @@ -797,12 +797,59 @@ internal struct NodeAddress } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] - internal struct AuthenticationInfo(string? username, string password) + internal readonly struct AuthenticationInfo(string? username, string? password, IamCredentials? iamCredentials) { + /// + /// Username for authentication. + /// [MarshalAs(UnmanagedType.LPStr)] - public string? Username = username; + public readonly string? Username = username; + + /// + /// Password for authentication. + /// [MarshalAs(UnmanagedType.LPStr)] - public string Password = password; + public readonly string? Password = password; + + /// + /// IAM credentials for authentication. + /// + [MarshalAs(UnmanagedType.U1)] + public readonly bool HasIamCredentials = iamCredentials.HasValue; + public readonly IamCredentials IamCredentials = iamCredentials ?? default; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] + internal readonly struct IamCredentials(string clusterName, string region, ServiceType serviceType, uint? refreshIntervalSeconds) + { + /// + /// The name of the cluster for IAM authentication. + /// + [MarshalAs(UnmanagedType.LPStr)] + public readonly string ClusterName = clusterName; + + /// + /// The AWS region for IAM authentication. + /// + [MarshalAs(UnmanagedType.LPStr)] + public readonly string Region = region; + + /// + /// The AWS service type for IAM authentication. + /// + public readonly ServiceType ServiceType = serviceType; + + /// + /// The refresh interval in seconds for IAM authentication. + /// + public readonly bool HasRefreshIntervalSeconds = refreshIntervalSeconds.HasValue; + public readonly uint? RefreshIntervalSeconds = refreshIntervalSeconds ?? default; + } + + internal enum ServiceType : uint + { + ElastiCache = 0, + MemoryDB = 1, } internal enum TlsMode : uint diff --git a/sources/Valkey.Glide/ServerCredentials.cs b/sources/Valkey.Glide/ServerCredentials.cs new file mode 100644 index 00000000..3328355d --- /dev/null +++ b/sources/Valkey.Glide/ServerCredentials.cs @@ -0,0 +1,69 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +namespace Valkey.Glide; + +/// +/// Represents the credentials for connecting to a server. +/// Supports both password-based and IAM authentication modes, which are mutually exclusive. +/// +public class ServerCredentials +{ + /// + /// The username that will be used for authenticating connections to the servers. + /// If not supplied, "default" will be used. + /// + public string? Username { get; set; } + + /// + /// The password that will be used for authenticating connections to the servers. + /// Required for password-based authentication, must be null for IAM authentication. + /// + public string? Password { get; set; } + + /// + /// IAM authentication configuration. + /// Required for IAM authentication, must be null for password-based authentication. + /// + public IamAuthConfig? IamConfig { get; set; } + + /// + /// Creates server credentials for password-based authentication. + /// + /// The username for authentication. If null, "default" will be used. + /// The password for authentication. + public ServerCredentials(string? username, string password) + { + Username = username; + Password = password ?? throw new ArgumentNullException(nameof(password)); + IamConfig = null; + } + + /// + /// Creates server credentials for password-based authentication. + /// Username "default" will be used. + /// + /// The password for authentication. + public ServerCredentials(string password) + { + Username = null; + Password = password ?? throw new ArgumentNullException(nameof(password)); + IamConfig = null; + } + + /// + /// Creates server credentials for IAM authentication. + /// + /// The username for authentication. + /// The IAM authentication configuration. + public ServerCredentials(string username, IamAuthConfig iamConfig) + { + Username = username ?? throw new ArgumentNullException(nameof(username)); + IamConfig = iamConfig ?? throw new ArgumentNullException(nameof(iamConfig)); + Password = null; + } + + /// + /// Returns true if this instance is configured for IAM authentication. + /// + public bool IsIamAuth() => IamConfig != null; +} diff --git a/sources/Valkey.Glide/ServiceType.cs b/sources/Valkey.Glide/ServiceType.cs new file mode 100644 index 00000000..90534849 --- /dev/null +++ b/sources/Valkey.Glide/ServiceType.cs @@ -0,0 +1,19 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +namespace Valkey.Glide; + +/// +/// Specifies the AWS service type for IAM authentication. +/// +public enum ServiceType +{ + /// + /// AWS ElastiCache service. + /// + ElastiCache, + + /// + /// AWS MemoryDB service. + /// + MemoryDB +} diff --git a/tests/Valkey.Glide.UnitTests/ConnectionConfigurationTests.cs b/tests/Valkey.Glide.UnitTests/ConnectionConfigurationTests.cs new file mode 100644 index 00000000..bbcb04c2 --- /dev/null +++ b/tests/Valkey.Glide.UnitTests/ConnectionConfigurationTests.cs @@ -0,0 +1,175 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using Valkey.Glide.Internals; + +using static Valkey.Glide.ConnectionConfiguration; + +namespace Valkey.Glide.UnitTests; + +public class ConnectionConfigurationTests +{ + // Test constants + private const string Username = "testUsername"; + private const string Password = "testPassword"; + private const string ClusterName = "testClusterName"; + private const string Region = "testRegion"; + private const uint RefreshIntervalSeconds = 600; + + [Fact] + public void WithAuthentication_UsernamePassword() + { + var builder = new StandaloneClientConfigurationBuilder(); + builder.WithAuthentication(Username, Password); + + var config = builder.Build(); + var authenticationInfo = config!.Request.AuthenticationInfo!.Value; + + Assert.Equal(Username, authenticationInfo.Username); + Assert.Equal(Password, authenticationInfo.Password); + Assert.False(authenticationInfo.HasIamCredentials); + + // Password cannot be null. + Assert.Throws(() => builder.WithAuthentication(Username, (string)null!)); + } + + [Fact] + public void WithAuthentication_PasswordOnly() + { + var builder = new StandaloneClientConfigurationBuilder(); + builder.WithAuthentication(Password); + + var config = builder.Build(); + var authenticationInfo = config!.Request.AuthenticationInfo!.Value; + + Assert.Null(authenticationInfo.Username); + Assert.Equal(Password, authenticationInfo.Password); + Assert.False(authenticationInfo.HasIamCredentials); + + // Password cannot be null. + Assert.Throws(() => builder.WithAuthentication(null!)); + } + + [Fact] + public void WithAuthentication_UsernameIamAuthConfig_ConfiguresCorrectly() + { + var iamConfig = new IamAuthConfig(ClusterName, ServiceType.ElastiCache, Region, RefreshIntervalSeconds); + var builder = new StandaloneClientConfigurationBuilder(); + builder.WithAuthentication(Username, iamConfig); + + var config = builder.Build(); + var authenticationInfo = config!.Request.AuthenticationInfo!.Value; + + Assert.Equal(Username, authenticationInfo.Username); + Assert.Null(authenticationInfo.Password); + Assert.True(authenticationInfo.HasIamCredentials); + + var iamCredentials = authenticationInfo.IamCredentials!; + Assert.Equal(ClusterName, iamCredentials.ClusterName); + Assert.Equal(Region, iamCredentials.Region); + Assert.Equal(FFI.ServiceType.ElastiCache, iamCredentials.ServiceType); + Assert.True(iamCredentials.HasRefreshIntervalSeconds); + Assert.Equal(600u, iamCredentials.RefreshIntervalSeconds); + + // Username and IamAuthConfig cannot be null. + Assert.Throws(() => builder.WithAuthentication(null!, iamConfig)); + Assert.Throws(() => builder.WithAuthentication(Username, (IamAuthConfig)null!)); + } + + [Fact] + public void WithAuthentication_MultipleCalls_LastWins() + { + // Password-based authentication last. + var builder = new StandaloneClientConfigurationBuilder(); + var iamConfig = new IamAuthConfig(ClusterName, ServiceType.MemoryDB, Region); + builder.WithAuthentication(Username, iamConfig); + builder.WithAuthentication(Username, Password); + + var config = builder.Build(); + var authenticationInfo = config.Request.AuthenticationInfo!.Value; + + Assert.Equal(Username, authenticationInfo.Username); + Assert.Equal(Password, authenticationInfo.Password); + Assert.False(authenticationInfo.HasIamCredentials); + + // IAM authentication last. + builder = new StandaloneClientConfigurationBuilder(); + builder.WithAuthentication(Username, Password); + builder.WithAuthentication(Username, iamConfig); + + config = builder.Build(); + authenticationInfo = config!.Request.AuthenticationInfo!.Value; + + Assert.Equal(Username, authenticationInfo.Username); + Assert.Null(authenticationInfo.Password); + Assert.True(authenticationInfo.HasIamCredentials); + + var iamCredentials = authenticationInfo.IamCredentials!; + Assert.Equal(ClusterName, iamCredentials.ClusterName); + Assert.Equal(Region, iamCredentials.Region); + Assert.Equal(FFI.ServiceType.MemoryDB, iamCredentials.ServiceType); + Assert.False(iamCredentials.HasRefreshIntervalSeconds); + } + + [Fact] + public void WithCredentials() + { + var iamConfig = new IamAuthConfig(ClusterName, ServiceType.MemoryDB, Region); + var credentials = new ServerCredentials(Username, iamConfig); + var builder = new StandaloneClientConfigurationBuilder(); + builder.WithCredentials(credentials); + + var config = builder.Build(); + var authenticationInfo = config.Request.AuthenticationInfo!.Value; + + Assert.Equal(Username, authenticationInfo.Username); + Assert.Null(authenticationInfo.Password); + Assert.True(authenticationInfo.HasIamCredentials); + + var iamCredentials = authenticationInfo.IamCredentials!; + Assert.Equal(ClusterName, iamCredentials.ClusterName); + Assert.Equal(Region, iamCredentials.Region); + Assert.Equal(FFI.ServiceType.MemoryDB, iamCredentials.ServiceType); + Assert.False(iamCredentials.HasRefreshIntervalSeconds); + + // Credentials cannot be null. + Assert.Throws(() => builder.WithCredentials(null!)); + } + + [Fact] + public void WithCredentials_MultipleCalls_LastWins() + { + var iamConfig = new IamAuthConfig(ClusterName, ServiceType.MemoryDB, Region); + var iamServerCredentials = new ServerCredentials(Username, iamConfig); + var passwordServerCredentials = new ServerCredentials(Username, Password); + + // Password-based authentication last. + var builder = new StandaloneClientConfigurationBuilder(); + builder.WithCredentials(iamServerCredentials); + builder.WithCredentials(passwordServerCredentials); + + var config = builder.Build(); + var authenticationInfo = config.Request.AuthenticationInfo!.Value; + + Assert.Equal(Username, authenticationInfo.Username); + Assert.Equal(Password, authenticationInfo.Password); + Assert.False(authenticationInfo.HasIamCredentials); + + // IAM authentication last. + builder = new StandaloneClientConfigurationBuilder(); + builder.WithCredentials(passwordServerCredentials); + builder.WithCredentials(iamServerCredentials); + + config = builder.Build(); + authenticationInfo = config!.Request.AuthenticationInfo!.Value; + + Assert.Equal(Username, authenticationInfo.Username); + Assert.Null(authenticationInfo.Password); + Assert.True(authenticationInfo.HasIamCredentials); + + var iamCredentials = authenticationInfo.IamCredentials!; + Assert.Equal(ClusterName, iamCredentials.ClusterName); + Assert.Equal(Region, iamCredentials.Region); + Assert.Equal(FFI.ServiceType.MemoryDB, iamCredentials.ServiceType); + Assert.False(iamCredentials.HasRefreshIntervalSeconds); + } +} diff --git a/tests/Valkey.Glide.UnitTests/ServerCredentialsTests.cs b/tests/Valkey.Glide.UnitTests/ServerCredentialsTests.cs new file mode 100644 index 00000000..c9ff2c73 --- /dev/null +++ b/tests/Valkey.Glide.UnitTests/ServerCredentialsTests.cs @@ -0,0 +1,73 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +namespace Valkey.Glide.UnitTests; + +public class ServerCredentialsTests +{ + // Test constants + private const string Username = "testUsername"; + private const string Password = "testPassword"; + private const string ClusterName = "testClusterName"; + private const string Region = "testRegion"; + + [Fact] + public void ServerCredentials_UsernamePassword() + { + var credentials = new ServerCredentials(Username, Password); + + Assert.Equal(Username, credentials.Username); + Assert.Equal(Password, credentials.Password); + Assert.Null(credentials.IamConfig); + Assert.False(credentials.IsIamAuth()); + } + + [Fact] + public void ServerCredentials_PasswordOnly() + { + var credentials = new ServerCredentials(Password); + + Assert.Null(credentials.Username); + Assert.Equal(Password, credentials.Password); + Assert.Null(credentials.IamConfig); + Assert.False(credentials.IsIamAuth()); + } + + [Fact] + public void ServerCredentials_UsernameIamAuthConfig() + { + var iamConfig = new IamAuthConfig(ClusterName, ServiceType.ElastiCache, Region); + var credentials = new ServerCredentials(Username, iamConfig); + + Assert.Equal(Username, credentials.Username); + Assert.Null(credentials.Password); + Assert.Equal(ClusterName, credentials.IamConfig!.ClusterName); + Assert.Equal(ServiceType.ElastiCache, credentials.IamConfig!.ServiceType); + Assert.Equal(Region, credentials.IamConfig!.Region); + Assert.Null(credentials.IamConfig!.RefreshIntervalSeconds); + Assert.True(credentials.IsIamAuth()); + } + + [Fact] + public void ServerCredentials_UsernameIamAuthConfigWithCustomRefresh() + { + var iamConfig = new IamAuthConfig(ClusterName, ServiceType.MemoryDB, Region, 600); + var credentials = new ServerCredentials("iamUser", iamConfig); + + Assert.Equal(ServiceType.MemoryDB, credentials.IamConfig!.ServiceType); + Assert.Equal(600u, credentials.IamConfig!.RefreshIntervalSeconds); + Assert.True(credentials.IsIamAuth()); + } + + [Fact] + public void ServerCredentials_ThrowsArgumentNullException() + { + // Password-based authentication. + Assert.Throws(() => new ServerCredentials(null!)); + Assert.Throws(() => new ServerCredentials(Username, (string)null!)); + + // IAM authentication. + var iamConfig = new IamAuthConfig(ClusterName, ServiceType.ElastiCache, Region); + Assert.Throws(() => new ServerCredentials(null!, iamConfig)); + Assert.Throws(() => new ServerCredentials(Username, (IamAuthConfig)null!)); + } +}