diff --git a/.talismanrc b/.talismanrc index 1b6345c..41a15a5 100644 --- a/.talismanrc +++ b/.talismanrc @@ -7,4 +7,8 @@ fileignoreconfig: checksum: 854eb83dcacd62d3bf233c82e5cfd0c69dd20478fa0e7c6af9028f6c6386749d - filename: Contentstack.Management.Core/Attributes/CSMJsonConverterAttribute.cs checksum: 774bc2a4cf7f62fb890ba39ba1319769f0ff4e13d94781d394fcac2adf14381e +- filename: Contentstack.Management.Core.Unit.Tests/OAuth/OAuthOptionsTest.cs + checksum: 3d1ed19a9c7d311d9662632f48169d3a9013f605674cbd18b9f45039b0f83ff6 +- filename: Contentstack.Management.Core/Models/OAuthOptions.cs + checksum: c328bfd5241e11e6e9d630527ba9084fb5b361abac48e8af2f96379dd6357c6c version: "" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b35a7a..e5a76b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ # Changelog +## [v0.4.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.4.0) + - Feat + - **MFA Support**: Added Multi-Factor Authentication (MFA) support for login operations + - Added `mfaSecret` parameter to `Login` and `LoginAsync` methods for TOTP generation + - Automatic TOTP token generation from Base32-encoded MFA secrets using Otp.NET library + - Comprehensive test coverage for MFA functionality including unit and integration tests + - Supports both explicit token and MFA secret-based authentication flows + - Added Support for OAuth + - Added Comprehensive test coverage for OAuth Functionality in Unit Test cases. + - Supports both Login with and without OAuth Flows + ## [v0.3.2](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.3.2) - Fix - Added Test cases for the Release diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs index c500203..cf5824e 100644 --- a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs @@ -175,5 +175,123 @@ public void Test007_Should_Return_Loggedin_User_With_Organizations_detail() Assert.Fail(e.Message); } } + + [TestMethod] + [DoNotParallelize] + public void Test008_Should_Fail_Login_With_Invalid_MfaSecret() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string invalidMfaSecret = "INVALID_BASE32_SECRET!@#"; + + try + { + ContentstackResponse contentstackResponse = client.Login(credentials, null, invalidMfaSecret); + Assert.Fail("Expected exception for invalid MFA secret"); + } + catch (ArgumentException) + { + // Expected exception for invalid Base32 encoding + Assert.IsTrue(true); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test009_Should_Generate_TOTP_Token_With_Valid_MfaSecret() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string validMfaSecret = "JBSWY3DPEHPK3PXP"; // Valid Base32 test secret + + try + { + // This should fail due to invalid credentials, but should succeed in generating TOTP + ContentstackResponse contentstackResponse = client.Login(credentials, null, validMfaSecret); + } + catch (ContentstackErrorException errorException) + { + // Expected to fail due to invalid credentials, but we verify it processed the MFA secret + // The error should be about credentials, not about MFA secret format + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode); + Assert.IsTrue(errorException.Message.Contains("email or password") || + errorException.Message.Contains("credentials") || + errorException.Message.Contains("authentication")); + } + catch (ArgumentException) + { + Assert.Fail("Should not throw ArgumentException for valid MFA secret"); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async System.Threading.Tasks.Task Test010_Should_Generate_TOTP_Token_With_Valid_MfaSecret_Async() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string validMfaSecret = "JBSWY3DPEHPK3PXP"; // Valid Base32 test secret + + try + { + // This should fail due to invalid credentials, but should succeed in generating TOTP + ContentstackResponse contentstackResponse = await client.LoginAsync(credentials, null, validMfaSecret); + } + catch (ContentstackErrorException errorException) + { + // Expected to fail due to invalid credentials, but we verify it processed the MFA secret + // The error should be about credentials, not about MFA secret format + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode); + Assert.IsTrue(errorException.Message.Contains("email or password") || + errorException.Message.Contains("credentials") || + errorException.Message.Contains("authentication")); + } + catch (ArgumentException) + { + Assert.Fail("Should not throw ArgumentException for valid MFA secret"); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test011_Should_Prefer_Explicit_Token_Over_MfaSecret() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string validMfaSecret = "JBSWY3DPEHPK3PXP"; + string explicitToken = "123456"; + + try + { + // This should fail due to invalid credentials, but should use explicit token + ContentstackResponse contentstackResponse = client.Login(credentials, explicitToken, validMfaSecret); + } + catch (ContentstackErrorException errorException) + { + // Expected to fail due to invalid credentials + // The important thing is that it didn't throw an exception about MFA secret processing + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode); + } + catch (ArgumentException) + { + Assert.Fail("Should not throw ArgumentException when explicit token is provided"); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } } } diff --git a/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs b/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs index 2f0f946..ed7dfdd 100644 --- a/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs @@ -50,6 +50,107 @@ public void Should_Allow_Credentials_With_Token() Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\",\"tfa_token\":\"token\"}}", Encoding.Default.GetString(loginService.ByteContent)); } + [TestMethod] + public void Should_Allow_Credentials_With_MfaSecret() + { + + string testMfaSecret = "JBSWY3DPEHPK3PXP"; // Base32 encoded "Hello!" + var loginService = new LoginService(serializer, credentials, null, testMfaSecret); + loginService.ContentBody(); + + Assert.IsNotNull(loginService); + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + Assert.IsTrue(contentString.Contains("\"email\":\"name\"")); + Assert.IsTrue(contentString.Contains("\"password\":\"password\"")); + Assert.IsTrue(contentString.Contains("\"tfa_token\":")); + + // Verify the tfa_token is not null or empty in the JSON + Assert.IsFalse(contentString.Contains("\"tfa_token\":null")); + Assert.IsFalse(contentString.Contains("\"tfa_token\":\"\"")); + } + + [TestMethod] + public void Should_Generate_TOTP_Token_When_MfaSecret_Provided() + { + string testMfaSecret = "JBSWY3DPEHPK3PXP"; // Base32 encoded "Hello!" + var loginService1 = new LoginService(serializer, credentials, null, testMfaSecret); + var loginService2 = new LoginService(serializer, credentials, null, testMfaSecret); + + loginService1.ContentBody(); + loginService2.ContentBody(); + + var content1 = Encoding.Default.GetString(loginService1.ByteContent); + var content2 = Encoding.Default.GetString(loginService2.ByteContent); + + // Both should contain tfa_token + Assert.IsTrue(content1.Contains("\"tfa_token\":")); + Assert.IsTrue(content2.Contains("\"tfa_token\":")); + + // Extract the tokens for comparison (tokens should be 6 digits) + var token1Match = System.Text.RegularExpressions.Regex.Match(content1, "\"tfa_token\":\"(\\d{6})\""); + var token2Match = System.Text.RegularExpressions.Regex.Match(content2, "\"tfa_token\":\"(\\d{6})\""); + + Assert.IsTrue(token1Match.Success); + Assert.IsTrue(token2Match.Success); + + // Tokens should be valid 6-digit numbers + Assert.AreEqual(6, token1Match.Groups[1].Value.Length); + Assert.AreEqual(6, token2Match.Groups[1].Value.Length); + } + + [TestMethod] + public void Should_Prefer_Explicit_Token_Over_MfaSecret() + { + string testMfaSecret = "JBSWY3DPEHPK3PXP"; + // file deepcode ignore NoHardcodedCredentials/test: random test token + string explicitToken = "123456"; + + var loginService = new LoginService(serializer, credentials, explicitToken, testMfaSecret); + loginService.ContentBody(); + + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + // Should use the explicit token, not generate one from MFA secret + Assert.IsTrue(contentString.Contains("\"tfa_token\":\"123456\"")); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void Should_Throw_Exception_For_Invalid_Base32_MfaSecret() + { + // Invalid Base32 secret (contains invalid characters) + string invalidMfaSecret = "INVALID_BASE32_123!@#"; + + var loginService = new LoginService(serializer, credentials, null, invalidMfaSecret); + } + + [TestMethod] + public void Should_Not_Generate_Token_When_MfaSecret_Is_Empty() + { + var loginService = new LoginService(serializer, credentials, null, ""); + loginService.ContentBody(); + + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + // Should not contain tfa_token when MFA secret is empty + Assert.IsFalse(contentString.Contains("\"tfa_token\":")); + Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\"}}", contentString); + } + + [TestMethod] + public void Should_Not_Generate_Token_When_MfaSecret_Is_Null() + { + var loginService = new LoginService(serializer, credentials, null, null); + loginService.ContentBody(); + + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + // Should not contain tfa_token when MFA secret is null + Assert.IsFalse(contentString.Contains("\"tfa_token\":")); + Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\"}}", contentString); + } + [TestMethod] public void Should_Override_Authtoken_To_ContentstackOptions_On_Success() { diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthExceptionTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthExceptionTest.cs new file mode 100644 index 0000000..dd44bc2 --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthExceptionTest.cs @@ -0,0 +1,212 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Contentstack.Management.Core.Exceptions; + +namespace Contentstack.Management.Core.Unit.Tests.OAuth +{ + [TestClass] + public class OAuthExceptionTest + { + [TestMethod] + public void OAuthException_DefaultConstructor_ShouldUseDefaultMessage() + { + + var exception = new OAuthException(); + Assert.AreEqual("OAuth operation failed.", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthException_WithMessage_ShouldUseProvidedMessage() + { + + var message = "Custom OAuth error message"; + var exception = new OAuthException(message); + Assert.AreEqual(message, exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthException_WithMessageAndInnerException_ShouldUseBoth() + { + + var message = "Custom OAuth error message"; + var innerException = new InvalidOperationException("Inner exception"); + var exception = new OAuthException(message, innerException); + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void OAuthConfigurationException_DefaultConstructor_ShouldUseDefaultMessage() + { + + var exception = new OAuthConfigurationException(); + Assert.AreEqual("OAuth configuration is invalid.", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthConfigurationException_WithMessage_ShouldUseProvidedMessage() + { + + var message = "Custom configuration error message"; + var exception = new OAuthConfigurationException(message); + Assert.AreEqual(message, exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthConfigurationException_WithMessageAndInnerException_ShouldUseBoth() + { + + var message = "Custom configuration error message"; + var innerException = new ArgumentException("Inner exception"); + var exception = new OAuthConfigurationException(message, innerException); + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void OAuthTokenException_DefaultConstructor_ShouldUseDefaultMessage() + { + + var exception = new OAuthTokenException(); + Assert.AreEqual("OAuth token operation failed.", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthTokenException_WithMessage_ShouldUseProvidedMessage() + { + + var message = "Custom token error message"; + var exception = new OAuthTokenException(message); + Assert.AreEqual(message, exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthTokenException_WithMessageAndInnerException_ShouldUseBoth() + { + + var message = "Custom token error message"; + var innerException = new InvalidOperationException("Inner exception"); + var exception = new OAuthTokenException(message, innerException); + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void OAuthAuthorizationException_DefaultConstructor_ShouldUseDefaultMessage() + { + + var exception = new OAuthAuthorizationException(); + Assert.AreEqual("OAuth authorization failed.", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthAuthorizationException_WithMessage_ShouldUseProvidedMessage() + { + + var message = "Custom authorization error message"; + var exception = new OAuthAuthorizationException(message); + Assert.AreEqual(message, exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthAuthorizationException_WithMessageAndInnerException_ShouldUseBoth() + { + + var message = "Custom authorization error message"; + var innerException = new InvalidOperationException("Inner exception"); + var exception = new OAuthAuthorizationException(message, innerException); + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void OAuthTokenRefreshException_DefaultConstructor_ShouldUseDefaultMessage() + { + + var exception = new OAuthTokenRefreshException(); + Assert.AreEqual("OAuth token refresh failed.", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthTokenRefreshException_WithMessage_ShouldUseProvidedMessage() + { + + var message = "Custom refresh error message"; + var exception = new OAuthTokenRefreshException(message); + Assert.AreEqual(message, exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthTokenRefreshException_WithMessageAndInnerException_ShouldUseBoth() + { + + var message = "Custom refresh error message"; + var innerException = new InvalidOperationException("Inner exception"); + var exception = new OAuthTokenRefreshException(message, innerException); + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void OAuthException_Inheritance_ShouldBeCorrect() + { + + var oauthException = new OAuthException(); + var configException = new OAuthConfigurationException(); + var tokenException = new OAuthTokenException(); + var authException = new OAuthAuthorizationException(); + var refreshException = new OAuthTokenRefreshException(); + Assert.IsInstanceOfType(oauthException, typeof(Exception)); + Assert.IsInstanceOfType(configException, typeof(OAuthException)); + Assert.IsInstanceOfType(tokenException, typeof(OAuthException)); + Assert.IsInstanceOfType(authException, typeof(OAuthException)); + Assert.IsInstanceOfType(refreshException, typeof(OAuthTokenException)); + } + + [TestMethod] + public void OAuthException_Serialization_ShouldWork() + { + + var originalException = new OAuthException("Test message", new InvalidOperationException("Inner")); + Assert.IsNotNull(originalException); + Assert.AreEqual("Test message", originalException.Message); + Assert.IsNotNull(originalException.InnerException); + } + + [TestMethod] + public void OAuthException_ToString_ShouldIncludeMessage() + { + + var message = "Test OAuth error message"; + var exception = new OAuthException(message); + var result = exception.ToString(); + Assert.IsTrue(result.Contains(message)); + Assert.IsTrue(result.Contains("OAuthException")); + } + + [TestMethod] + public void OAuthException_WithInnerException_ToString_ShouldIncludeBoth() + { + + var message = "Test OAuth error message"; + var innerMessage = "Inner exception message"; + var innerException = new InvalidOperationException(innerMessage); + var exception = new OAuthException(message, innerException); + var result = exception.ToString(); + Assert.IsTrue(result.Contains(message)); + Assert.IsTrue(result.Contains(innerMessage)); + Assert.IsTrue(result.Contains("OAuthException")); + Assert.IsTrue(result.Contains("InvalidOperationException")); + } + } +} diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs new file mode 100644 index 0000000..9d1bb13 --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs @@ -0,0 +1,876 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Exceptions; +using Contentstack.Management.Core.Utils; + +namespace Contentstack.Management.Core.Unit.Tests.OAuth +{ + [TestClass] + public class OAuthHandlerTest + { + private ContentstackClient _client; + private OAuthOptions _options; + + [TestInitialize] + public void Setup() + { + _client = new ContentstackClient(); + _options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + } + + [TestCleanup] + public void Cleanup() + { + + _client.ClearOAuthTokens(_options.ClientId); + } + + [TestMethod] + public void OAuthHandler_Constructor_WithValidParameters_ShouldCreateInstance() + { + + var handler = new OAuthHandler(_client, _options); + Assert.IsNotNull(handler); + Assert.AreEqual(_options.ClientId, handler.ClientId); + Assert.AreEqual(_options.AppId, handler.AppId); + Assert.AreEqual(_options.RedirectUri, handler.RedirectUri); + Assert.AreEqual(_options.UsePkce, handler.UsePkce); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void OAuthHandler_Constructor_WithNullClient_ShouldThrowException() + { + + new OAuthHandler(null, _options); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void OAuthHandler_Constructor_WithNullOptions_ShouldThrowException() + { + + new OAuthHandler(_client, null); + } + + [TestMethod] + [ExpectedException(typeof(OAuthConfigurationException))] + public void OAuthHandler_Constructor_WithInvalidOptions_ShouldThrowException() + { + + var invalidOptions = new OAuthOptions + { + AppId = "", // Invalid + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + + + new OAuthHandler(_client, invalidOptions); + } + + [TestMethod] + public void OAuthHandler_GetCurrentTokens_WithNoTokens_ShouldReturnNull() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = handler.GetCurrentTokens(); + Assert.IsNull(tokens); + } + + [TestMethod] + public void OAuthHandler_GetCurrentTokens_WithStoredTokens_ShouldReturnTokens() + { + + var handler = new OAuthHandler(_client, _options); + var expectedTokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, expectedTokens); + var tokens = handler.GetCurrentTokens(); + Assert.IsNotNull(tokens); + Assert.AreEqual("test-token", tokens.AccessToken); + } + + [TestMethod] + public void OAuthHandler_HasValidTokens_WithNoTokens_ShouldReturnFalse() + { + + var handler = new OAuthHandler(_client, _options); + + + Assert.IsFalse(handler.HasValidTokens()); + } + + [TestMethod] + public void OAuthHandler_HasValidTokens_WithValidTokens_ShouldReturnTrue() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + + + Assert.IsTrue(handler.HasValidTokens()); + } + + [TestMethod] + public void OAuthHandler_HasValidTokens_WithExpiredTokens_ShouldReturnFalse() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + + + Assert.IsFalse(handler.HasValidTokens()); + } + + [TestMethod] + public void OAuthHandler_HasTokens_WithNoTokens_ShouldReturnFalse() + { + + var handler = new OAuthHandler(_client, _options); + + + Assert.IsFalse(handler.HasTokens()); + } + + [TestMethod] + public void OAuthHandler_HasTokens_WithTokens_ShouldReturnTrue() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + + + Assert.IsTrue(handler.HasTokens()); + } + + [TestMethod] + public void OAuthHandler_ClearTokens_ShouldRemoveTokens() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + Assert.IsTrue(handler.HasTokens()); + handler.ClearTokens(); + Assert.IsFalse(handler.HasTokens()); + Assert.IsNull(handler.GetCurrentTokens()); + } + + [TestMethod] + public void OAuthHandler_AuthorizeAsync_WithPKCE_ShouldReturnValidUrl() + { + + var handler = new OAuthHandler(_client, _options); + var authUrl = handler.AuthorizeAsync().Result; + Assert.IsNotNull(authUrl); + Assert.IsTrue(authUrl.Contains("response_type=code")); + Assert.IsTrue(authUrl.Contains($"client_id={_options.ClientId}")); + Assert.IsTrue(authUrl.Contains($"redirect_uri={Uri.EscapeDataString(_options.RedirectUri)}")); + Assert.IsTrue(authUrl.Contains("code_challenge=")); + Assert.IsTrue(authUrl.Contains("code_challenge_method=S256")); + } + + [TestMethod] + public void OAuthHandler_AuthorizeAsync_WithTraditionalOAuth_ShouldReturnValidUrl() + { + + var traditionalOptions = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code", + ClientSecret = "test-secret" + }; + var handler = new OAuthHandler(_client, traditionalOptions); + var authUrl = handler.AuthorizeAsync().Result; + Assert.IsNotNull(authUrl); + Assert.IsTrue(authUrl.Contains("response_type=code")); + Assert.IsTrue(authUrl.Contains($"client_id={traditionalOptions.ClientId}")); + Assert.IsTrue(authUrl.Contains($"redirect_uri={Uri.EscapeDataString(traditionalOptions.RedirectUri)}")); + Assert.IsFalse(authUrl.Contains("code_challenge=")); + } + + [TestMethod] + public void OAuthHandler_AuthorizeAsync_WithScopes_ShouldIncludeScopes() + { + + _options.Scope = new[] { "read", "write" }; + var handler = new OAuthHandler(_client, _options); + var authUrl = handler.AuthorizeAsync().Result; + Assert.IsTrue(authUrl.Contains("scope=read%20write")); + } + + [TestMethod] + public void OAuthHandler_AuthorizeAsync_ShouldGenerateCodeVerifierForPKCE() + { + + var handler = new OAuthHandler(_client, _options); + var authUrl = handler.AuthorizeAsync().Result; + Assert.IsNotNull(authUrl); + Assert.IsTrue(authUrl.Contains("code_challenge=")); + Assert.IsTrue(authUrl.Contains("code_challenge_method=S256")); + // Note: Code verifier is stored in handler instance, not in client tokens + } + + [TestMethod] + public void OAuthHandler_ToString_ShouldReturnFormattedString() + { + + var handler = new OAuthHandler(_client, _options); + var result = handler.ToString(); + Assert.IsTrue(result.Contains(_options.ClientId)); + Assert.IsTrue(result.Contains(_options.AppId)); + Assert.IsTrue(result.Contains("True")); // UsePkce + Assert.IsTrue(result.Contains("False")); // HasTokens + } + + [TestMethod] + public void OAuthHandler_ExchangeCodeForTokenAsync_WithEmptyCode_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + + try + { + handler.ExchangeCodeForTokenAsync("").Wait(); + Assert.Fail("Should have thrown ArgumentException"); + } + catch (AggregateException ex) when (ex.InnerException is ArgumentException) + { + + } + } + + [TestMethod] + public void OAuthHandler_ExchangeCodeForTokenAsync_WithNullCode_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + + + try + { + handler.ExchangeCodeForTokenAsync(null).Wait(); + Assert.Fail("Should have thrown ArgumentException"); + } + catch (AggregateException ex) when (ex.InnerException is ArgumentException) + { + + } + } + + [TestMethod] + public void OAuthHandler_ExchangeCodeForTokenAsync_WithoutCodeVerifier_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + + + try + { + handler.ExchangeCodeForTokenAsync("test-code").Wait(); + Assert.Fail("Should have thrown OAuthConfigurationException"); + } + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthConfigurationException) + { + // Expected - PKCE flow requires code verifier to be generated first + } + } + + [TestMethod] + public void OAuthHandler_RefreshTokenAsync_WithNoTokens_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + + + try + { + handler.RefreshTokenAsync().Wait(); + Assert.Fail("Should have thrown OAuthTokenRefreshException"); + } + catch (AggregateException ex) when (ex.InnerException is OAuthTokenRefreshException) + { + + } + } + + [TestMethod] + public void OAuthHandler_RefreshTokenAsync_WithEmptyRefreshToken_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + + + try + { + handler.RefreshTokenAsync("").Wait(); + Assert.Fail("Should have thrown OAuthTokenRefreshException"); + } + catch (AggregateException ex) when (ex.InnerException is OAuthTokenRefreshException) + { + + } + } + + [TestMethod] + public void OAuthHandler_LogoutAsync_WithNoTokens_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + + + try + { + handler.LogoutAsync().Wait(); + Assert.Fail("Expected OAuthException to be thrown"); + } + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthException) + { + // Expected - async methods wrap exceptions in AggregateException + Assert.IsTrue(ex.InnerException.Message.Contains("No OAuth tokens found")); + } + } + + [TestMethod] + public void OAuthHandler_LogoutAsync_WithTokens_ShouldReturnSuccessMessage() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + + + try + { + var result = handler.LogoutAsync().Result; + // If successful, should return success message and clear tokens + Assert.IsNotNull(result); + Assert.IsTrue(result.Contains("successfully")); + Assert.IsFalse(handler.HasTokens()); + } + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthException) + { + // Expected - the actual API call will fail in unit tests + // This confirms that the method attempted to call the revocation API + Assert.IsTrue(ex.InnerException.Message.Contains("Failed to get OAuth app authorization")); + } + } + + #region Getter Methods Tests + [TestMethod] + public void OAuthHandler_GetAccessToken_WithValidTokens_ShouldReturnAccessToken() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + var result = handler.GetAccessToken(); + Assert.AreEqual("test-access-token", result); + } + + [TestMethod] + public void OAuthHandler_GetAccessToken_WithNoTokens_ShouldReturnNull() + { + + var handler = new OAuthHandler(_client, _options); + var result = handler.GetAccessToken(); + Assert.IsNull(result); + } + + [TestMethod] + public void OAuthHandler_GetRefreshToken_WithValidTokens_ShouldReturnRefreshToken() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + RefreshToken = "test-refresh-token", + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + var result = handler.GetRefreshToken(); + Assert.AreEqual("test-refresh-token", result); + } + + [TestMethod] + public void OAuthHandler_GetRefreshToken_WithNoTokens_ShouldReturnNull() + { + + var handler = new OAuthHandler(_client, _options); + var result = handler.GetRefreshToken(); + Assert.IsNull(result); + } + + [TestMethod] + public void OAuthHandler_GetOrganizationUID_WithValidTokens_ShouldReturnOrganizationUID() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + OrganizationUid = "test-org-uid", + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + var result = handler.GetOrganizationUID(); + Assert.AreEqual("test-org-uid", result); + } + + [TestMethod] + public void OAuthHandler_GetOrganizationUID_WithNoTokens_ShouldReturnNull() + { + + var handler = new OAuthHandler(_client, _options); + var result = handler.GetOrganizationUID(); + Assert.IsNull(result); + } + + [TestMethod] + public void OAuthHandler_GetUserUID_WithValidTokens_ShouldReturnUserUID() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + UserUid = "test-user-uid", + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + var result = handler.GetUserUID(); + Assert.AreEqual("test-user-uid", result); + } + + [TestMethod] + public void OAuthHandler_GetUserUID_WithNoTokens_ShouldReturnNull() + { + + var handler = new OAuthHandler(_client, _options); + var result = handler.GetUserUID(); + Assert.IsNull(result); + } + + [TestMethod] + public void OAuthHandler_GetTokenExpiryTime_WithValidTokens_ShouldReturnExpiryTime() + { + + var handler = new OAuthHandler(_client, _options); + var expiryTime = DateTime.UtcNow.AddHours(1); + var tokens = new OAuthTokens + { + ExpiresAt = expiryTime, + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + var result = handler.GetTokenExpiryTime(); + Assert.AreEqual(expiryTime, result); + } + + [TestMethod] + public void OAuthHandler_GetTokenExpiryTime_WithNoTokens_ShouldReturnNull() + { + + var handler = new OAuthHandler(_client, _options); + var result = handler.GetTokenExpiryTime(); + Assert.IsNull(result); + } + #endregion + + #region Setter Methods Tests + [TestMethod] + public void OAuthHandler_SetAccessToken_WithValidToken_ShouldUpdateTokens() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + handler.SetAccessToken("new-access-token"); + var updatedTokens = _client.GetOAuthTokens(_options.ClientId); + Assert.AreEqual("new-access-token", updatedTokens.AccessToken); + } + + [TestMethod] + public void OAuthHandler_SetAccessToken_WithNoExistingTokens_ShouldCreateNewTokens() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetAccessToken("new-access-token"); + var tokens = _client.GetOAuthTokens(_options.ClientId); + Assert.IsNotNull(tokens); + Assert.AreEqual("new-access-token", tokens.AccessToken); + Assert.AreEqual(_options.ClientId, tokens.ClientId); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetAccessToken_WithNullToken_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetAccessToken(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetAccessToken_WithEmptyToken_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetAccessToken(""); + } + + [TestMethod] + public void OAuthHandler_SetRefreshToken_WithValidToken_ShouldUpdateTokens() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + handler.SetRefreshToken("new-refresh-token"); + var updatedTokens = _client.GetOAuthTokens(_options.ClientId); + Assert.AreEqual("new-refresh-token", updatedTokens.RefreshToken); + } + + [TestMethod] + public void OAuthHandler_SetRefreshToken_WithNoExistingTokens_ShouldCreateNewTokens() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetRefreshToken("new-refresh-token"); + var tokens = _client.GetOAuthTokens(_options.ClientId); + Assert.IsNotNull(tokens); + Assert.AreEqual("new-refresh-token", tokens.RefreshToken); + Assert.AreEqual(_options.ClientId, tokens.ClientId); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetRefreshToken_WithNullToken_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetRefreshToken(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetRefreshToken_WithEmptyToken_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetRefreshToken(""); + } + + [TestMethod] + public void OAuthHandler_SetOrganizationUID_WithValidUID_ShouldUpdateTokens() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + handler.SetOrganizationUID("new-org-uid"); + var updatedTokens = _client.GetOAuthTokens(_options.ClientId); + Assert.AreEqual("new-org-uid", updatedTokens.OrganizationUid); + } + + [TestMethod] + public void OAuthHandler_SetOrganizationUID_WithNoExistingTokens_ShouldCreateNewTokens() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetOrganizationUID("new-org-uid"); + var tokens = _client.GetOAuthTokens(_options.ClientId); + Assert.IsNotNull(tokens); + Assert.AreEqual("new-org-uid", tokens.OrganizationUid); + Assert.AreEqual(_options.ClientId, tokens.ClientId); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetOrganizationUID_WithNullUID_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetOrganizationUID(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetOrganizationUID_WithEmptyUID_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetOrganizationUID(""); + } + + [TestMethod] + public void OAuthHandler_SetUserUID_WithValidUID_ShouldUpdateTokens() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + handler.SetUserUID("new-user-uid"); + var updatedTokens = _client.GetOAuthTokens(_options.ClientId); + Assert.AreEqual("new-user-uid", updatedTokens.UserUid); + } + + [TestMethod] + public void OAuthHandler_SetUserUID_WithNoExistingTokens_ShouldCreateNewTokens() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetUserUID("new-user-uid"); + var tokens = _client.GetOAuthTokens(_options.ClientId); + Assert.IsNotNull(tokens); + Assert.AreEqual("new-user-uid", tokens.UserUid); + Assert.AreEqual(_options.ClientId, tokens.ClientId); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetUserUID_WithNullUID_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetUserUID(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetUserUID_WithEmptyUID_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + handler.SetUserUID(""); + } + + [TestMethod] + public void OAuthHandler_SetTokenExpiryTime_WithValidTime_ShouldUpdateTokens() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + var newExpiryTime = DateTime.UtcNow.AddHours(2); + handler.SetTokenExpiryTime(newExpiryTime); + var updatedTokens = _client.GetOAuthTokens(_options.ClientId); + Assert.AreEqual(newExpiryTime, updatedTokens.ExpiresAt); + } + + [TestMethod] + public void OAuthHandler_SetTokenExpiryTime_WithNoExistingTokens_ShouldCreateNewTokens() + { + + var handler = new OAuthHandler(_client, _options); + var newExpiryTime = DateTime.UtcNow.AddHours(2); + handler.SetTokenExpiryTime(newExpiryTime); + var tokens = _client.GetOAuthTokens(_options.ClientId); + Assert.IsNotNull(tokens); + Assert.AreEqual(newExpiryTime, tokens.ExpiresAt); + Assert.AreEqual(_options.ClientId, tokens.ClientId); + } + #endregion + + #region HandleRedirectAsync Tests + [TestMethod] + public async Task OAuthHandler_HandleRedirectAsync_WithValidUrl_ShouldExchangeCodeForTokens() + { + + var handler = new OAuthHandler(_client, _options); + var redirectUrl = "https://example.com/callback?code=test-auth-code&state=test-state"; + + + try + { + await handler.HandleRedirectAsync(redirectUrl); + // If we get here, the method completed without throwing an exception + // The actual token exchange would fail in a real test due to mocking, but the URL parsing works + } + catch (Exceptions.OAuthConfigurationException) + { + // Expected - PKCE flow requires code verifier to be generated first + // This confirms that the URL parsing worked and the method attempted the token exchange + } + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task OAuthHandler_HandleRedirectAsync_WithNullUrl_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + await handler.HandleRedirectAsync(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task OAuthHandler_HandleRedirectAsync_WithEmptyUrl_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + await handler.HandleRedirectAsync(""); + } + + [TestMethod] + [ExpectedException(typeof(Exceptions.OAuthException))] + public async Task OAuthHandler_HandleRedirectAsync_WithUrlMissingCode_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + var redirectUrl = "https://example.com/callback?state=test-state"; + await handler.HandleRedirectAsync(redirectUrl); + } + + [TestMethod] + [ExpectedException(typeof(Exceptions.OAuthException))] + public async Task OAuthHandler_HandleRedirectAsync_WithUrlContainingEmptyCode_ShouldThrowException() + { + + var handler = new OAuthHandler(_client, _options); + var redirectUrl = "https://example.com/callback?code=&state=test-state"; + await handler.HandleRedirectAsync(redirectUrl); + } + + [TestMethod] + public async Task OAuthHandler_HandleRedirectAsync_WithComplexUrl_ShouldParseCorrectly() + { + + var handler = new OAuthHandler(_client, _options); + var redirectUrl = "https://example.com/callback?code=test-auth-code&state=test-state&other=value"; + + + try + { + await handler.HandleRedirectAsync(redirectUrl); + } + catch (Exceptions.OAuthConfigurationException) + { + + } + } + #endregion + + #region Updated LogoutAsync Tests + [TestMethod] + public async Task OAuthHandler_LogoutAsync_WithValidTokens_ShouldCallRevocationAPI() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + UserUid = "test-user-uid", + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + + + try + { + var result = await handler.LogoutAsync(); + // If we get here, the method completed without throwing an exception + Assert.IsNotNull(result); + Assert.IsTrue(result.Contains("successfully")); + } + catch (Exceptions.OAuthException) + { + } + } + + [TestMethod] + [ExpectedException(typeof(Exceptions.OAuthException))] + public async Task OAuthHandler_LogoutAsync_WithNoTokens_ShouldThrowOAuthException() + { + + var handler = new OAuthHandler(_client, _options); + await handler.LogoutAsync(); + } + + [TestMethod] + public async Task OAuthHandler_LogoutAsync_ShouldClearTokensAfterSuccessfulRevocation() + { + + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + UserUid = "test-user-uid", + ClientId = _options.ClientId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + + + try + { + await handler.LogoutAsync(); + // If successful, tokens should be cleared + Assert.IsFalse(handler.HasTokens()); + } + catch (Exceptions.OAuthException) + { + Assert.IsTrue(handler.HasTokens()); + } + } + #endregion + } +} diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthOptionsTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthOptionsTest.cs new file mode 100644 index 0000000..9e75640 --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthOptionsTest.cs @@ -0,0 +1,268 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Exceptions; + +namespace Contentstack.Management.Core.Unit.Tests.OAuth +{ + [TestClass] + public class OAuthOptionsTest + { + [TestMethod] + public void OAuthOptions_DefaultValues_ShouldBeCorrect() + { + + var options = new OAuthOptions(); + Assert.IsTrue(options.UsePkce); + Assert.AreEqual("code", options.ResponseType); + Assert.AreEqual("6400aa06db64de001a31c8a9", options.AppId); + Assert.AreEqual("Ie0FEfTzlfAHL4xM", options.ClientId); + Assert.AreEqual("http://localhost:8184", options.RedirectUri); + Assert.IsNull(options.ClientSecret); + Assert.IsNull(options.Scope); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithValidPKCEOptions_ShouldReturnTrue() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + // UsePkce is automatically true when ClientSecret is null/empty + }; + var isValid = options.IsValid(); + Assert.IsTrue(isValid); + Assert.IsTrue(options.UsePkce); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithValidTraditionalOAuthOptions_ShouldReturnTrue() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code", + ClientSecret = "test-secret" + // UsePkce is automatically false when ClientSecret is provided + }; + var isValid = options.IsValid(); + Assert.IsTrue(isValid); + Assert.IsFalse(options.UsePkce); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithMissingAppId_ShouldReturnFalse() + { + + var options = new OAuthOptions + { + AppId = "", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + var isValid = options.IsValid(out var errorMessage); + Assert.IsFalse(isValid); + Assert.AreEqual("AppId is required for OAuth configuration.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithMissingClientId_ShouldReturnFalse() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + var isValid = options.IsValid(out var errorMessage); + Assert.IsFalse(isValid); + Assert.AreEqual("ClientId is required for OAuth configuration.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithMissingRedirectUri_ShouldReturnFalse() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "", + ResponseType = "code" + }; + var isValid = options.IsValid(out var errorMessage); + Assert.IsFalse(isValid); + Assert.AreEqual("RedirectUri is required for OAuth configuration.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithInvalidRedirectUri_ShouldReturnFalse() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "not-a-valid-uri", + ResponseType = "code" + }; + var isValid = options.IsValid(out var errorMessage); + Assert.IsFalse(isValid); + Assert.AreEqual("RedirectUri must be a valid absolute URI.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithNonHttpRedirectUri_ShouldReturnFalse() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "ftp://example.com/callback", + ResponseType = "code" + }; + var isValid = options.IsValid(out var errorMessage); + Assert.IsFalse(isValid); + Assert.AreEqual("RedirectUri must use http or https scheme.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithMissingResponseType_ShouldReturnFalse() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "" + }; + var isValid = options.IsValid(out var errorMessage); + Assert.IsFalse(isValid); + Assert.AreEqual("ResponseType is required for OAuth configuration.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithInvalidResponseType_ShouldReturnFalse() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "token" + }; + var isValid = options.IsValid(out var errorMessage); + Assert.IsFalse(isValid); + Assert.AreEqual("ResponseType must be 'code' for authorization code flow.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithTraditionalOAuthMissingClientSecret_ShouldReturnFalse() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + // UsePkce will be true because ClientSecret is null + }; + var isValid = options.IsValid(out var errorMessage); + Assert.IsTrue(isValid); // This will actually be valid because UsePkce is true + Assert.IsTrue(options.UsePkce); + } + + [TestMethod] + public void OAuthOptions_Validate_WithValidOptions_ShouldNotThrow() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + + // Should not throw + options.Validate(); + } + + [TestMethod] + [ExpectedException(typeof(OAuthConfigurationException))] + public void OAuthOptions_Validate_WithInvalidOptions_ShouldThrowException() + { + + var options = new OAuthOptions + { + AppId = "", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + options.Validate(); + } + + [TestMethod] + public void OAuthOptions_WithScopes_ShouldBeValid() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code", + Scope = new[] { "read", "write", "admin" } + }; + var isValid = options.IsValid(); + Assert.IsTrue(isValid); + } + + [TestMethod] + public void OAuthOptions_WithEmptyScopes_ShouldBeValid() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code", + Scope = new string[0] + }; + var isValid = options.IsValid(); + Assert.IsTrue(isValid); + } + + [TestMethod] + public void OAuthOptions_WithNullScopes_ShouldBeValid() + { + + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code", + Scope = null + }; + var isValid = options.IsValid(); + Assert.IsTrue(isValid); + } + } +} diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenRefreshExceptionTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenRefreshExceptionTest.cs new file mode 100644 index 0000000..32b65f4 --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenRefreshExceptionTest.cs @@ -0,0 +1,264 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Exceptions; + +namespace Contentstack.Management.Core.Unit.Tests.OAuth +{ + [TestClass] + public class OAuthTokenRefreshExceptionTest + { + private ContentstackClient _client; + private OAuthOptions _options; + + [TestInitialize] + public void Setup() + { + _client = new ContentstackClient(); + _options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + } + + [TestCleanup] + public void Cleanup() + { + + _client.ClearOAuthTokens(_options.ClientId); + } + + [TestMethod] + public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithExpiredToken_ShouldRefreshSuccessfully() + { + + var expiredTokens = new OAuthTokens + { + AccessToken = "expired-token", + RefreshToken = "valid-refresh-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), // Expired + ClientId = _options.ClientId, + AppId = _options.AppId + }; + _client.StoreOAuthTokens(_options.ClientId, expiredTokens); + + // Set OAuth token through options + _client.contentstackOptions.Authtoken = expiredTokens.AccessToken; + _client.contentstackOptions.IsOAuthToken = true; + + + try + { + var result = _client.GetUserAsync().Result; + + } + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthException) + { + Assert.IsTrue(ex.InnerException.Message.Contains("OAuth token refresh failed for client")); + } + } + + [TestMethod] + public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithInvalidRefreshToken_ShouldThrowOAuthTokenRefreshException() + { + + var tokensWithInvalidRefresh = new OAuthTokens + { + AccessToken = "expired-token", + RefreshToken = "invalid-refresh-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), // Expired + ClientId = _options.ClientId, + AppId = _options.AppId + }; + _client.StoreOAuthTokens(_options.ClientId, tokensWithInvalidRefresh); + + // Set OAuth token through options + _client.contentstackOptions.Authtoken = tokensWithInvalidRefresh.AccessToken; + _client.contentstackOptions.IsOAuthToken = true; + + + try + { + var result = _client.GetUserAsync().Result; + Assert.Fail("Should have thrown OAuthException"); + } + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthException) + { + Assert.IsTrue(ex.InnerException.Message.Contains("OAuth token refresh failed for client")); + Assert.IsTrue(ex.InnerException.Message.Contains(_options.ClientId)); + } + } + + [TestMethod] + public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithNullRefreshToken_ShouldThrowOAuthTokenRefreshException() + { + + var tokensWithNullRefresh = new OAuthTokens + { + AccessToken = "expired-token", + RefreshToken = null, + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), // Expired + ClientId = _options.ClientId, + AppId = _options.AppId + }; + _client.StoreOAuthTokens(_options.ClientId, tokensWithNullRefresh); + // Set OAuth token through options + _client.contentstackOptions.Authtoken = tokensWithNullRefresh.AccessToken; + _client.contentstackOptions.IsOAuthToken = true; + + + try + { + var result = _client.GetUserAsync().Result; + Assert.Fail("Should have thrown OAuthException"); + } + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthException) + { + // Expected - null refresh token should cause refresh to fail + Assert.IsTrue(ex.InnerException.Message.Contains("OAuth token refresh failed for client")); + Assert.IsTrue(ex.InnerException.Message.Contains(_options.ClientId)); + } + } + + [TestMethod] + public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithValidToken_ShouldNotAttemptRefresh() + { + + var validTokens = new OAuthTokens + { + AccessToken = "valid-token", + RefreshToken = "valid-refresh-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), // Valid for 1 hour + ClientId = _options.ClientId, + AppId = _options.AppId + }; + _client.StoreOAuthTokens(_options.ClientId, validTokens); + // Set OAuth token through options + _client.contentstackOptions.Authtoken = validTokens.AccessToken; + _client.contentstackOptions.IsOAuthToken = true; + + + try + { + var result = _client.GetUserAsync().Result; + + } + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthException) + { + // This should NOT happen with a valid token + Assert.Fail("Should not have attempted token refresh for valid token"); + } + catch (Exception) + { + // Other exceptions are expected due to API mocking in unit tests + } + } + + [TestMethod] + public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithTokenNeedingRefresh_ShouldAttemptRefresh() + { + + var tokensNeedingRefresh = new OAuthTokens + { + AccessToken = "token-needing-refresh", + RefreshToken = "valid-refresh-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(2), // Will need refresh soon + ClientId = _options.ClientId, + AppId = _options.AppId + }; + _client.StoreOAuthTokens(_options.ClientId, tokensNeedingRefresh); + // Set OAuth token through options + _client.contentstackOptions.Authtoken = tokensNeedingRefresh.AccessToken; + _client.contentstackOptions.IsOAuthToken = true; + + + try + { + var result = _client.GetUserAsync().Result; + Assert.Fail("Should have thrown OAuthException"); + } + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthException) + { + Assert.IsTrue(ex.InnerException.Message.Contains("OAuth token refresh failed for client")); + Assert.IsTrue(ex.InnerException.Message.Contains(_options.ClientId)); + } + } + + [TestMethod] + public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithMultipleClients_ShouldHandleCorrectClient() + { + + var client1Tokens = new OAuthTokens + { + AccessToken = "client1-token", + RefreshToken = "client1-refresh-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), // Expired + ClientId = "client-1", + AppId = "app-1" + }; + var client2Tokens = new OAuthTokens + { + AccessToken = "client2-token", + RefreshToken = "client2-refresh-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), // Valid + ClientId = "client-2", + AppId = "app-2" + }; + + _client.StoreOAuthTokens("client-1", client1Tokens); + _client.StoreOAuthTokens("client-2", client2Tokens); + // Set OAuth token through options + _client.contentstackOptions.Authtoken = "client1-token"; // Use client1's expired token + _client.contentstackOptions.IsOAuthToken = true; + + + try + { + var result = _client.GetUserAsync().Result; + Assert.Fail("Should have thrown OAuthException"); + } + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthException) + { + Assert.IsTrue(ex.InnerException.Message.Contains("OAuth token refresh failed for client")); + Assert.IsTrue(ex.InnerException.Message.Contains("client-1")); + Assert.IsFalse(ex.InnerException.Message.Contains("client-2")); + } + } + + [TestMethod] + public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithNoMatchingTokens_ShouldNotThrow() + { + + var tokens = new OAuthTokens + { + AccessToken = "some-other-token", + RefreshToken = "some-refresh-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), // Expired + ClientId = _options.ClientId, + AppId = _options.AppId + }; + _client.StoreOAuthTokens(_options.ClientId, tokens); + // Set OAuth token through options + _client.contentstackOptions.Authtoken = "different-token"; // Different token + _client.contentstackOptions.IsOAuthToken = true; + + + try + { + var result = _client.GetUserAsync().Result; + } + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthException) + { + Assert.Fail("Should not have attempted token refresh for non-matching token"); + } + catch (Exception) + { + } + } + } +} diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenStorageTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenStorageTest.cs new file mode 100644 index 0000000..58d6803 --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenStorageTest.cs @@ -0,0 +1,199 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Models; + +namespace Contentstack.Management.Core.Unit.Tests.OAuth +{ + [TestClass] + public class OAuthTokenStorageTest + { + private const string TestClientId = "test-client-id"; + private ContentstackClient _client; + + [TestInitialize] + public void Setup() + { + _client = new ContentstackClient(); + } + + [TestCleanup] + public void Cleanup() + { + // Clear any test tokens after each test + _client.ClearOAuthTokens(TestClientId); + } + + [TestMethod] + public void OAuthTokenStorage_SetAndGetTokens_ShouldWork() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + RefreshToken = "test-refresh-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + OrganizationUid = "test-org-uid", + UserUid = "test-user-uid", + ClientId = TestClientId + }; + _client.StoreOAuthTokens(TestClientId, tokens); + var retrievedTokens = _client.GetOAuthTokens(TestClientId); + Assert.IsNotNull(retrievedTokens); + Assert.AreEqual("test-access-token", retrievedTokens.AccessToken); + Assert.AreEqual("test-refresh-token", retrievedTokens.RefreshToken); + Assert.AreEqual("test-org-uid", retrievedTokens.OrganizationUid); + Assert.AreEqual("test-user-uid", retrievedTokens.UserUid); + Assert.AreEqual(TestClientId, retrievedTokens.ClientId); + } + + [TestMethod] + public void OAuthTokenStorage_GetTokens_WithNonExistentClientId_ShouldReturnNull() + { + + var tokens = _client.GetOAuthTokens("non-existent-client-id"); + Assert.IsNull(tokens); + } + + [TestMethod] + public void OAuthTokenStorage_HasTokens_WithExistingTokens_ShouldReturnTrue() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = TestClientId + }; + _client.StoreOAuthTokens(TestClientId, tokens); + + + Assert.IsTrue(_client.HasOAuthTokens(TestClientId)); + } + + [TestMethod] + public void OAuthTokenStorage_HasTokens_WithNoTokens_ShouldReturnFalse() + { + + Assert.IsFalse(_client.HasOAuthTokens(TestClientId)); + } + + [TestMethod] + public void OAuthTokenStorage_HasValidTokens_WithValidTokens_ShouldReturnTrue() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = TestClientId + }; + _client.StoreOAuthTokens(TestClientId, tokens); + + + Assert.IsTrue(_client.HasValidOAuthTokens(TestClientId)); + } + + [TestMethod] + public void OAuthTokenStorage_HasValidTokens_WithExpiredTokens_ShouldReturnFalse() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), + ClientId = TestClientId + }; + _client.StoreOAuthTokens(TestClientId, tokens); + + + Assert.IsFalse(_client.HasValidOAuthTokens(TestClientId)); + } + + [TestMethod] + public void OAuthTokenStorage_HasValidTokens_WithNoTokens_ShouldReturnFalse() + { + + Assert.IsFalse(_client.HasValidOAuthTokens(TestClientId)); + } + + [TestMethod] + public void OAuthTokenStorage_ClearTokens_ShouldRemoveTokens() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = TestClientId + }; + _client.StoreOAuthTokens(TestClientId, tokens); + Assert.IsTrue(_client.HasOAuthTokens(TestClientId)); + _client.ClearOAuthTokens(TestClientId); + Assert.IsNull(_client.GetOAuthTokens(TestClientId)); + Assert.IsFalse(_client.HasOAuthTokens(TestClientId)); + Assert.IsFalse(_client.HasOAuthTokens(TestClientId)); + } + + [TestMethod] + public void OAuthTokenStorage_ClearTokens_WithNonExistentClientId_ShouldNotThrow() + { + // Should not throw + _client.ClearOAuthTokens("non-existent-client-id"); + } + [TestMethod] + public void OAuthTokenStorage_ThreadSafety_ShouldHandleConcurrentAccess() + { + + var tokens1 = new OAuthTokens + { + AccessToken = "token-1", + ClientId = "client-1" + }; + var tokens2 = new OAuthTokens + { + AccessToken = "token-2", + ClientId = "client-2" + }; + + // Act - Simulate concurrent access + var task1 = Task.Run(() => + { + _client.StoreOAuthTokens("client-1", tokens1); + return _client.GetOAuthTokens("client-1"); + }); + + var task2 = Task.Run(() => + { + _client.StoreOAuthTokens("client-2", tokens2); + return _client.GetOAuthTokens("client-2"); + }); + + Task.WaitAll(task1, task2); + Assert.AreEqual("token-1", task1.Result.AccessToken); + Assert.AreEqual("token-2", task2.Result.AccessToken); + } + + [TestMethod] + public void OAuthTokenStorage_UpdateTokens_ShouldReplaceExistingTokens() + { + + var originalTokens = new OAuthTokens + { + AccessToken = "original-token", + ClientId = TestClientId + }; + _client.StoreOAuthTokens(TestClientId, originalTokens); + + var updatedTokens = new OAuthTokens + { + AccessToken = "updated-token", + RefreshToken = "new-refresh-token", + ClientId = TestClientId + }; + _client.StoreOAuthTokens(TestClientId, updatedTokens); + var retrievedTokens = _client.GetOAuthTokens(TestClientId); + Assert.AreEqual("updated-token", retrievedTokens.AccessToken); + Assert.AreEqual("new-refresh-token", retrievedTokens.RefreshToken); + } + } +} diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenTest.cs new file mode 100644 index 0000000..8a7ce25 --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenTest.cs @@ -0,0 +1,271 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Contentstack.Management.Core.Models; + +namespace Contentstack.Management.Core.Unit.Tests.OAuth +{ + [TestClass] + public class OAuthTokenTest + { + [TestMethod] + public void OAuthTokens_DefaultValues_ShouldBeCorrect() + { + + var tokens = new OAuthTokens(); + Assert.IsNull(tokens.AccessToken); + Assert.IsNull(tokens.RefreshToken); + Assert.IsNull(tokens.OrganizationUid); + Assert.IsNull(tokens.UserUid); + Assert.IsNull(tokens.ClientId); + Assert.IsNull(tokens.AppId); + Assert.AreEqual(default(DateTime), tokens.ExpiresAt); + } + + [TestMethod] + public void OAuthTokens_IsValid_WithValidToken_ShouldReturnTrue() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = "test-client-id" + }; + var isValid = tokens.IsValid; + Assert.IsTrue(isValid); + } + + [TestMethod] + public void OAuthTokens_IsValid_WithNullAccessToken_ShouldReturnFalse() + { + + var tokens = new OAuthTokens + { + AccessToken = null, + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = "test-client-id" + }; + var isValid = tokens.IsValid; + Assert.IsFalse(isValid); + } + + [TestMethod] + public void OAuthTokens_IsValid_WithEmptyAccessToken_ShouldReturnFalse() + { + + var tokens = new OAuthTokens + { + AccessToken = "", + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = "test-client-id" + }; + var isValid = tokens.IsValid; + Assert.IsFalse(isValid); + } + + [TestMethod] + public void OAuthTokens_IsValid_WithExpiredToken_ShouldReturnFalse() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), + ClientId = "test-client-id" + }; + var isValid = tokens.IsValid; + Assert.IsFalse(isValid); + } + + [TestMethod] + public void OAuthTokens_IsValid_WithDefaultExpiryTime_ShouldReturnFalse() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = default(DateTime), + ClientId = "test-client-id" + }; + var isValid = tokens.IsValid; + Assert.IsFalse(isValid); + } + + [TestMethod] + public void OAuthTokens_IsExpired_WithFutureExpiryTime_ShouldReturnFalse() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = "test-client-id" + }; + var isExpired = tokens.IsExpired; + Assert.IsFalse(isExpired); + } + + [TestMethod] + public void OAuthTokens_IsExpired_WithPastExpiryTime_ShouldReturnTrue() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), + ClientId = "test-client-id" + }; + var isExpired = tokens.IsExpired; + Assert.IsTrue(isExpired); + } + + [TestMethod] + public void OAuthTokens_IsExpired_WithDefaultExpiryTime_ShouldReturnTrue() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = default(DateTime), + ClientId = "test-client-id" + }; + var isExpired = tokens.IsExpired; + Assert.IsTrue(isExpired); + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithTokenExpiringSoon_ShouldReturnTrue() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(2), // Less than 5 minutes + ClientId = "test-client-id" + }; + var needsRefresh = tokens.NeedsRefresh; + Assert.IsTrue(needsRefresh); + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithTokenNotExpiringSoon_ShouldReturnFalse() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(10), // More than 5 minutes + ClientId = "test-client-id" + }; + var needsRefresh = tokens.NeedsRefresh; + Assert.IsFalse(needsRefresh); + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithExpiredToken_ShouldReturnTrue() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), + ClientId = "test-client-id" + }; + var needsRefresh = tokens.NeedsRefresh; + Assert.IsTrue(needsRefresh); + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithNoRefreshToken_ShouldReturnTrue() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(2), + RefreshToken = null, + ClientId = "test-client-id" + }; + var needsRefresh = tokens.NeedsRefresh; + Assert.IsTrue(needsRefresh); // NeedsRefresh is based on expiry time, not refresh token presence + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithEmptyRefreshToken_ShouldReturnTrue() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(2), + RefreshToken = "", + ClientId = "test-client-id" + }; + var needsRefresh = tokens.NeedsRefresh; + Assert.IsTrue(needsRefresh); // NeedsRefresh is based on expiry time, not refresh token presence + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithValidRefreshToken_ShouldReturnTrue() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(2), + RefreshToken = "test-refresh-token", + ClientId = "test-client-id" + }; + var needsRefresh = tokens.NeedsRefresh; + Assert.IsTrue(needsRefresh); + } + + [TestMethod] + public void OAuthTokens_ToString_ShouldReturnTypeName() + { + + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + RefreshToken = "test-refresh-token", + OrganizationUid = "test-org-uid", + UserUid = "test-user-uid", + ClientId = "test-client-id", + AppId = "test-app-id", + ExpiresAt = DateTime.UtcNow.AddHours(1) + }; + var result = tokens.ToString(); + Assert.IsTrue(result.Contains("OAuthTokens")); // Default ToString returns type name + } + + [TestMethod] + public void OAuthTokens_WithAllProperties_ShouldSetCorrectly() + { + + var accessToken = "test-access-token"; + var refreshToken = "test-refresh-token"; + var organizationUid = "test-org-uid"; + var userUid = "test-user-uid"; + var clientId = "test-client-id"; + var appId = "test-app-id"; + var expiresAt = DateTime.UtcNow.AddHours(1); + var tokens = new OAuthTokens + { + AccessToken = accessToken, + RefreshToken = refreshToken, + OrganizationUid = organizationUid, + UserUid = userUid, + ClientId = clientId, + AppId = appId, + ExpiresAt = expiresAt + }; + Assert.AreEqual(accessToken, tokens.AccessToken); + Assert.AreEqual(refreshToken, tokens.RefreshToken); + Assert.AreEqual(organizationUid, tokens.OrganizationUid); + Assert.AreEqual(userUid, tokens.UserUid); + Assert.AreEqual(clientId, tokens.ClientId); + Assert.AreEqual(appId, tokens.AppId); + Assert.AreEqual(expiresAt, tokens.ExpiresAt); + } + } +} diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs new file mode 100644 index 0000000..085ede1 --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs @@ -0,0 +1,242 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Contentstack.Management.Core.Utils; + +namespace Contentstack.Management.Core.Unit.Tests.OAuth +{ + [TestClass] + public class PkceHelperTest + { + [TestMethod] + public void PkceHelper_GenerateCodeVerifier_ShouldReturnValidCodeVerifier() + { + + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + Assert.IsNotNull(codeVerifier); + Assert.IsTrue(PkceHelper.IsValidCodeVerifier(codeVerifier)); + Assert.IsTrue(codeVerifier.Length >= 43); + Assert.IsTrue(codeVerifier.Length <= 128); + } + + [TestMethod] + public void PkceHelper_GenerateCodeVerifier_MultipleCalls_ShouldReturnDifferentValues() + { + + var verifier1 = PkceHelper.GenerateCodeVerifier(); + var verifier2 = PkceHelper.GenerateCodeVerifier(); + Assert.AreNotEqual(verifier1, verifier2); + } + + [TestMethod] + public void PkceHelper_GenerateCodeChallenge_WithValidCodeVerifier_ShouldReturnValidChallenge() + { + + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + Assert.IsNotNull(codeChallenge); + Assert.IsTrue(PkceHelper.IsValidCodeChallenge(codeChallenge)); + Assert.AreEqual(43, codeChallenge.Length); // Base64URL encoded SHA256 hash is always 43 characters + } + + [TestMethod] + public void PkceHelper_GenerateCodeChallenge_WithSameCodeVerifier_ShouldReturnSameChallenge() + { + + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var challenge1 = PkceHelper.GenerateCodeChallenge(codeVerifier); + var challenge2 = PkceHelper.GenerateCodeChallenge(codeVerifier); + Assert.AreEqual(challenge1, challenge2); + } + + [TestMethod] + public void PkceHelper_GenerateCodeChallenge_WithDifferentCodeVerifiers_ShouldReturnDifferentChallenges() + { + + var codeVerifier1 = PkceHelper.GenerateCodeVerifier(); + var codeVerifier2 = PkceHelper.GenerateCodeVerifier(); + var challenge1 = PkceHelper.GenerateCodeChallenge(codeVerifier1); + var challenge2 = PkceHelper.GenerateCodeChallenge(codeVerifier2); + Assert.AreNotEqual(challenge1, challenge2); + } + + [TestMethod] + public void PkceHelper_VerifyCodeChallenge_WithValidPair_ShouldReturnTrue() + { + + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + var isValid = PkceHelper.VerifyCodeChallenge(codeVerifier, codeChallenge); + Assert.IsTrue(isValid); + } + + [TestMethod] + public void PkceHelper_VerifyCodeChallenge_WithInvalidPair_ShouldReturnFalse() + { + + var codeVerifier1 = PkceHelper.GenerateCodeVerifier(); + var codeVerifier2 = PkceHelper.GenerateCodeVerifier(); + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier1); + var isValid = PkceHelper.VerifyCodeChallenge(codeVerifier2, codeChallenge); + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_GeneratePkcePair_ShouldReturnValidPair() + { + + var pkcePair = PkceHelper.GeneratePkcePair(); + Assert.IsNotNull(pkcePair); + Assert.IsNotNull(pkcePair.CodeVerifier); + Assert.IsNotNull(pkcePair.CodeChallenge); + Assert.IsTrue(PkceHelper.IsValidCodeVerifier(pkcePair.CodeVerifier)); + Assert.IsTrue(PkceHelper.IsValidCodeChallenge(pkcePair.CodeChallenge)); + Assert.IsTrue(PkceHelper.VerifyCodeChallenge(pkcePair.CodeVerifier, pkcePair.CodeChallenge)); + } + + [TestMethod] + public void PkceHelper_IsValidCodeVerifier_WithValidVerifier_ShouldReturnTrue() + { + + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var isValid = PkceHelper.IsValidCodeVerifier(codeVerifier); + Assert.IsTrue(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeVerifier_WithNullVerifier_ShouldReturnFalse() + { + + var isValid = PkceHelper.IsValidCodeVerifier(null); + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeVerifier_WithEmptyVerifier_ShouldReturnFalse() + { + + var isValid = PkceHelper.IsValidCodeVerifier(""); + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeVerifier_WithTooShortVerifier_ShouldReturnFalse() + { + + var shortVerifier = "short"; // Less than 43 characters + var isValid = PkceHelper.IsValidCodeVerifier(shortVerifier); + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeVerifier_WithTooLongVerifier_ShouldReturnFalse() + { + + var longVerifier = new string('a', 129); // More than 128 characters + var isValid = PkceHelper.IsValidCodeVerifier(longVerifier); + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeVerifier_WithInvalidCharacters_ShouldReturnFalse() + { + + var invalidVerifier = "invalid-characters!@#$%^&*()"; // Contains invalid characters + var isValid = PkceHelper.IsValidCodeVerifier(invalidVerifier); + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeChallenge_WithValidChallenge_ShouldReturnTrue() + { + + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + var isValid = PkceHelper.IsValidCodeChallenge(codeChallenge); + Assert.IsTrue(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeChallenge_WithNullChallenge_ShouldReturnFalse() + { + + var isValid = PkceHelper.IsValidCodeChallenge(null); + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeChallenge_WithEmptyChallenge_ShouldReturnFalse() + { + + var isValid = PkceHelper.IsValidCodeChallenge(""); + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeChallenge_WithWrongLengthChallenge_ShouldReturnFalse() + { + + var wrongLengthChallenge = "wrong-length"; // Should be 43 characters + var isValid = PkceHelper.IsValidCodeChallenge(wrongLengthChallenge); + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeChallenge_WithInvalidCharacters_ShouldReturnFalse() + { + + var invalidChallenge = "invalid-characters!@#$%^&*()"; // Contains invalid characters + var isValid = PkceHelper.IsValidCodeChallenge(invalidChallenge); + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_CodeVerifier_ShouldBeUrlSafe() + { + + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + Assert.IsFalse(codeVerifier.Contains("+")); + Assert.IsFalse(codeVerifier.Contains("/")); + Assert.IsFalse(codeVerifier.Contains("=")); + } + + [TestMethod] + public void PkceHelper_CodeChallenge_ShouldBeUrlSafe() + { + + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + Assert.IsFalse(codeChallenge.Contains("+")); + Assert.IsFalse(codeChallenge.Contains("/")); + Assert.IsFalse(codeChallenge.Contains("=")); + } + + [TestMethod] + public void PkceHelper_CodeVerifier_ShouldContainOnlyValidCharacters() + { + + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + foreach (char c in codeVerifier) + { + Assert.IsTrue( + char.IsLetterOrDigit(c) || c == '-' || c == '.' || c == '_' || c == '~', + $"Character '{c}' is not valid in code verifier" + ); + } + } + + [TestMethod] + public void PkceHelper_CodeChallenge_ShouldContainOnlyValidCharacters() + { + + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + foreach (char c in codeChallenge) + { + Assert.IsTrue( + char.IsLetterOrDigit(c) || c == '-' || c == '.' || c == '_' || c == '~', + $"Character '{c}' is not valid in code challenge" + ); + } + } + } +} \ No newline at end of file diff --git a/Contentstack.Management.Core/ContentstackClient.cs b/Contentstack.Management.Core/ContentstackClient.cs index ddbcc25..bff3aa3 100644 --- a/Contentstack.Management.Core/ContentstackClient.cs +++ b/Contentstack.Management.Core/ContentstackClient.cs @@ -35,8 +35,13 @@ public class ContentstackClient : IContentstackClient private HttpClient _httpClient; private bool _disposed = false; - private string Version => "0.3.2"; + private string Version => "0.4.0"; private string xUserAgent => $"contentstack-management-dotnet/{Version}"; + + // OAuth token storage + private readonly Dictionary _oauthTokens = new Dictionary(); + + private bool _isRefreshingToken = false; #endregion @@ -214,6 +219,8 @@ internal ContentstackResponse InvokeSync(TRequest request, bool addAcc { ThrowIfDisposed(); + // OAuth token validation is handled in the async method + ExecutionContext context = new ExecutionContext( new RequestContext() { @@ -225,12 +232,18 @@ internal ContentstackResponse InvokeSync(TRequest request, bool addAcc return (ContentstackResponse)ContentstackPipeline.InvokeSync(context, addAcceptMediaHeader, apiVersion).httpResponse; } - internal Task InvokeAsync(TRequest request, bool addAcceptMediaHeader = false, string apiVersion = null) + internal async Task InvokeAsync(TRequest request, bool addAcceptMediaHeader = false, string apiVersion = null) where TRequest : IContentstackService where TResponse : ContentstackResponse { ThrowIfDisposed(); + // Check and refresh OAuth tokens if needed before making API calls + if (contentstackOptions.IsOAuthToken && !string.IsNullOrEmpty(contentstackOptions.Authtoken)) + { + await EnsureOAuthTokenIsValidAsync(); + } + ExecutionContext context = new ExecutionContext( new RequestContext() { @@ -238,7 +251,7 @@ internal Task InvokeAsync(TRequest request, bool service = request }, new ResponseContext()); - return ContentstackPipeline.InvokeAsync(context, addAcceptMediaHeader, apiVersion); + return await ContentstackPipeline.InvokeAsync(context, addAcceptMediaHeader, apiVersion); } #region Dispose methods @@ -334,6 +347,7 @@ public Stack Stack(string apiKey = null, string managementToken = null, string b /// /// User credentials for login. /// The optional 2FA token. + /// The optional MFA Secret for 2FA token. /// ///

         /// ContentstackClient client = new ContentstackClient("", "");
@@ -342,10 +356,10 @@ public Stack Stack(string apiKey = null, string managementToken = null, string b
         /// 
///
/// The - public ContentstackResponse Login(ICredentials credentials, string token = null) + public ContentstackResponse Login(ICredentials credentials, string token = null, string mfaSecret = null) { ThrowIfAlreadyLoggedIn(); - LoginService Login = new LoginService(serializer, credentials, token); + LoginService Login = new LoginService(serializer, credentials, token, mfaSecret); return InvokeSync(Login); } @@ -355,6 +369,7 @@ public ContentstackResponse Login(ICredentials credentials, string token = null) /// /// User credentials for login. /// The optional 2FA token. + /// The optional MFA Secret for 2FA token. /// ///

         /// ContentstackClient client = new ContentstackClient("", "");
@@ -363,10 +378,10 @@ public ContentstackResponse Login(ICredentials credentials, string token = null)
         /// 
///
/// The Task. - public Task LoginAsync(ICredentials credentials, string token = null) + public Task LoginAsync(ICredentials credentials, string token = null, string mfaSecret = null) { ThrowIfAlreadyLoggedIn(); - LoginService Login = new LoginService(serializer, credentials, token); + LoginService Login = new LoginService(serializer, credentials, token, mfaSecret); return InvokeAsync(Login); } @@ -430,6 +445,211 @@ public Task LogoutAsync(string authtoken = null) } #endregion + #region OAuth Methods + /// + /// Creates an OAuth handler for OAuth 2.0 authentication flow. + /// This method allows you to use OAuth instead of traditional authtoken authentication. + /// + /// The OAuth configuration options. + /// + ///

+        /// ContentstackClient client = new ContentstackClient();
+        /// var oauthOptions = new OAuthOptions
+        /// {
+        ///     AppId = "your-app-id",
+        ///     ClientId = "your-client-id",
+        ///     RedirectUri = "http://localhost:8184"
+        /// };
+        /// OAuthHandler oauthHandler = client.OAuth(oauthOptions);
+        /// 
+        /// // Get authorization URL
+        /// string authUrl = oauthHandler.GetAuthorizationUrl();
+        /// 
+        /// // After user authorization, exchange code for tokens
+        /// var tokens = await oauthHandler.ExchangeCodeForTokenAsync("authorization_code");
+        /// 
+ ///
+ /// The for managing OAuth flow. + public OAuthHandler OAuth(OAuthOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options), "OAuth options cannot be null."); + + return new OAuthHandler(this, options); + } + + /// + /// Creates an OAuth handler with default OAuth options. + /// Uses the default AppId, ClientId, and RedirectUri. + /// + /// + ///

+        /// ContentstackClient client = new ContentstackClient();
+        /// OAuthHandler oauthHandler = client.OAuth();
+        /// 
+        /// // Get authorization URL with default options
+        /// string authUrl = oauthHandler.GetAuthorizationUrl();
+        /// 
+ ///
+ /// The with default OAuth options. + public OAuthHandler OAuth() + { + var defaultOptions = new OAuthOptions(); + return new OAuthHandler(this, defaultOptions); + } + + /// + /// Sets OAuth tokens for the client to use for authenticated requests. + /// This method is called internally by the OAuthHandler after successful token exchange or refresh. + /// + /// The OAuth tokens to use for authentication. + /// Thrown when tokens is null. + internal void SetOAuthTokens(OAuthTokens tokens) + { + if (tokens == null) + throw new ArgumentNullException(nameof(tokens), "OAuth tokens cannot be null."); + + if (string.IsNullOrEmpty(tokens.AccessToken)) + throw new ArgumentException("Access token cannot be null or empty.", nameof(tokens)); + + // Store the access token in the client options for use in HTTP requests + // This will be used by the HTTP pipeline to inject the Bearer token + // Note: We need both IsOAuthToken=true AND Authtoken=AccessToken because + // the HTTP pipeline only has access to ContentstackClientOptions, not the full client + contentstackOptions.Authtoken = tokens.AccessToken; + contentstackOptions.IsOAuthToken = true; + } + + /// + /// Gets the current OAuth tokens for the specified client ID. + /// This method allows other SDKs (like contentstack-model-generator) to access OAuth tokens. + /// + /// The OAuth client ID to get tokens for. + /// The OAuth tokens if available, null otherwise. + public OAuthTokens GetOAuthTokens(string clientId) + { + if (string.IsNullOrEmpty(clientId)) + throw new ArgumentException("Client ID cannot be null or empty.", nameof(clientId)); + + return GetStoredOAuthTokens(clientId); + } + + /// + /// Checks if OAuth tokens are available for the specified client ID (regardless of validity). + /// + /// The OAuth client ID to check tokens for. + /// True if tokens are available, false otherwise. + public bool HasOAuthTokens(string clientId) + { + if (string.IsNullOrEmpty(clientId)) + return false; + + return HasStoredOAuthTokens(clientId); + } + + /// + /// Checks if valid OAuth tokens are available for the specified client ID. + /// + /// The OAuth client ID to check tokens for. + /// True if valid tokens are available, false otherwise. + public bool HasValidOAuthTokens(string clientId) + { + if (string.IsNullOrEmpty(clientId)) + return false; + + var tokens = GetStoredOAuthTokens(clientId); + return tokens?.IsValid == true; + } + + /// + /// Clears OAuth tokens and resets the client to use traditional authentication. + /// This method should be called when logging out or switching authentication methods. + /// + /// The OAuth client ID to clear tokens for. + public void ClearOAuthTokens(string clientId = null) + { + if (!string.IsNullOrEmpty(clientId)) + { + ClearStoredOAuthTokens(clientId); + } + else + { + _oauthTokens.Clear(); + } + + // Reset OAuth flag and clear authtoken if it was an OAuth token + if (contentstackOptions.IsOAuthToken) + { + contentstackOptions.IsOAuthToken = false; + contentstackOptions.Authtoken = null; + } + } + #endregion + + #region Internal OAuth Token Management + /// + /// Stores OAuth tokens for the specified client ID. + /// + /// The OAuth client ID. + /// The OAuth tokens to store. + internal void StoreOAuthTokens(string clientId, OAuthTokens tokens) + { + if (string.IsNullOrEmpty(clientId)) + throw new ArgumentException("Client ID cannot be null or empty.", nameof(clientId)); + + if (tokens == null) + throw new ArgumentNullException(nameof(tokens)); + + _oauthTokens[clientId] = tokens; + } + + /// + /// Gets OAuth tokens for the specified client ID. + /// + /// The OAuth client ID. + /// The OAuth tokens if found, null otherwise. + internal OAuthTokens GetStoredOAuthTokens(string clientId) + { + if (string.IsNullOrEmpty(clientId)) + return null; + + return _oauthTokens.TryGetValue(clientId, out var tokens) ? tokens : null; + } + + /// + /// Checks if OAuth tokens exist for the specified client ID. + /// + /// The OAuth client ID. + /// True if tokens exist, false otherwise. + internal bool HasStoredOAuthTokens(string clientId) + { + if (string.IsNullOrEmpty(clientId)) + return false; + + return _oauthTokens.ContainsKey(clientId); + } + + /// + /// Removes OAuth tokens for the specified client ID. + /// + /// The OAuth client ID. + internal void ClearStoredOAuthTokens(string clientId) + { + if (string.IsNullOrEmpty(clientId)) + return; + + _oauthTokens.Remove(clientId); + } + + /// + /// Clears all OAuth tokens (useful for cleanup). + /// + internal void ClearAllOAuthTokens() + { + _oauthTokens.Clear(); + } + #endregion + /// /// The Get user call returns comprehensive information of an existing user account. /// @@ -467,5 +687,74 @@ public Task GetUserAsync(ParameterCollection collection = return InvokeAsync(getUser); } + + /// + /// Ensures that the current OAuth token is valid and refreshes it if needed. + /// This method is called before each API request to automatically handle token refresh. + /// + private async Task EnsureOAuthTokenIsValidAsync() + { + // Prevent recursive calls + if (_isRefreshingToken) + { + return; + } + + try + { + // Find the OAuth tokens that match the current access token + foreach (var kvp in _oauthTokens) + { + var clientId = kvp.Key; + var tokens = kvp.Value; + + if (tokens?.AccessToken == contentstackOptions.Authtoken && tokens.NeedsRefresh) + { + // Set flag to prevent recursive calls + _isRefreshingToken = true; + + try + { + // Get the OAuth handler for this client + var oauthHandler = OAuth(new Models.OAuthOptions + { + ClientId = clientId, + AppId = tokens.AppId + }); + + // Refresh the tokens + var refreshedTokens = await oauthHandler.RefreshTokenAsync(tokens.RefreshToken); + + if (refreshedTokens != null) + { + // Update the stored tokens + StoreOAuthTokens(clientId, refreshedTokens); + + // Update the client's current authentication + contentstackOptions.Authtoken = refreshedTokens.AccessToken; + contentstackOptions.IsOAuthToken = true; // Ensure OAuth flag is maintained + } + } + catch (Exception ex) + { + // Wrap any exception in OAuth exception with context + throw new Exceptions.OAuthException( + $"OAuth token refresh failed for client '{clientId}': {ex.Message}", ex); + } + finally + { + _isRefreshingToken = false; + } + } + } + } + catch (Exception ex) + { + // Wrap any exception in OAuth exception with context + throw new Exceptions.OAuthException( + $"OAuth token validation failed: {ex.Message}", ex); + } + } } } + diff --git a/Contentstack.Management.Core/ContentstackClientOptions.cs b/Contentstack.Management.Core/ContentstackClientOptions.cs index ac9b0ed..af46f30 100644 --- a/Contentstack.Management.Core/ContentstackClientOptions.cs +++ b/Contentstack.Management.Core/ContentstackClientOptions.cs @@ -17,6 +17,13 @@ public class ContentstackClientOptions /// public string Authtoken { get; set; } + /// + /// Indicates whether the current authtoken is an OAuth Bearer token. + /// When true, the authtoken will be sent as "Authorization: Bearer {token}" header. + /// When false, the authtoken will be sent as "authtoken: {token}" header. + /// + public bool IsOAuthToken { get; set; } = false; + /// /// The Host used to set host url for the Contentstack Management API. /// diff --git a/Contentstack.Management.Core/Exceptions/OAuthException.cs b/Contentstack.Management.Core/Exceptions/OAuthException.cs new file mode 100644 index 0000000..3691092 --- /dev/null +++ b/Contentstack.Management.Core/Exceptions/OAuthException.cs @@ -0,0 +1,154 @@ +using System; + +namespace Contentstack.Management.Core.Exceptions +{ + /// + /// Base exception class for OAuth-related errors in the Contentstack Management SDK. + /// + public class OAuthException : ContentstackException + { + /// + /// Initializes a new instance of the OAuthException class. + /// + public OAuthException() : base("OAuth operation failed.") + { + } + + /// + /// Initializes a new instance of the OAuthException class with a specified error message. + /// + /// The message that describes the error. + public OAuthException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the OAuthException class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public OAuthException(string message, Exception innerException) : base(message, innerException) + { + } + } + + /// + /// Exception thrown when OAuth configuration is invalid or missing required parameters. + /// + public class OAuthConfigurationException : OAuthException + { + /// + /// Initializes a new instance of the OAuthConfigurationException class. + /// + public OAuthConfigurationException() : base("OAuth configuration is invalid.") + { + } + + /// + /// Initializes a new instance of the OAuthConfigurationException class with a specified error message. + /// + /// The message that describes the error. + public OAuthConfigurationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the OAuthConfigurationException class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public OAuthConfigurationException(string message, Exception innerException) : base(message, innerException) + { + } + } + + /// + /// Exception thrown when OAuth token operations fail. + /// + public class OAuthTokenException : OAuthException + { + /// + /// Initializes a new instance of the OAuthTokenException class. + /// + public OAuthTokenException() : base("OAuth token operation failed.") + { + } + + /// + /// Initializes a new instance of the OAuthTokenException class with a specified error message. + /// + /// The message that describes the error. + public OAuthTokenException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the OAuthTokenException class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public OAuthTokenException(string message, Exception innerException) : base(message, innerException) + { + } + } + + /// + /// Exception thrown when OAuth authorization fails. + /// + public class OAuthAuthorizationException : OAuthException + { + /// + /// Initializes a new instance of the OAuthAuthorizationException class. + /// + public OAuthAuthorizationException() : base("OAuth authorization failed.") + { + } + + /// + /// Initializes a new instance of the OAuthAuthorizationException class with a specified error message. + /// + /// The message that describes the error. + public OAuthAuthorizationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the OAuthAuthorizationException class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public OAuthAuthorizationException(string message, Exception innerException) : base(message, innerException) + { + } + } + + /// + /// Exception thrown when OAuth token refresh fails. + /// + public class OAuthTokenRefreshException : OAuthTokenException + { + /// + /// Initializes a new instance of the OAuthTokenRefreshException class. + /// + public OAuthTokenRefreshException() : base("OAuth token refresh failed.") + { + } + + /// + /// Initializes a new instance of the OAuthTokenRefreshException class with a specified error message. + /// + /// The message that describes the error. + public OAuthTokenRefreshException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the OAuthTokenRefreshException class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public OAuthTokenRefreshException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/Contentstack.Management.Core/Models/OAuthAppAuthorizationResponse.cs b/Contentstack.Management.Core/Models/OAuthAppAuthorizationResponse.cs new file mode 100644 index 0000000..6f875a7 --- /dev/null +++ b/Contentstack.Management.Core/Models/OAuthAppAuthorizationResponse.cs @@ -0,0 +1,40 @@ +using System; +using Newtonsoft.Json; + +namespace Contentstack.Management.Core.Models +{ + /// + /// Represents the response from the OAuth app authorization API. + /// + public class OAuthAppAuthorizationResponse + { + + [JsonProperty("data")] + public OAuthAppAuthorizationData[] Data { get; set; } + } + + /// + /// Represents OAuth app authorization data. + /// + public class OAuthAppAuthorizationData + { + + [JsonProperty("authorization_uid")] + public string AuthorizationUid { get; set; } + + + [JsonProperty("user")] + public OAuthUser User { get; set; } + } + + + public class OAuthUser + { + + [JsonProperty("uid")] + public string Uid { get; set; } + } +} + + + diff --git a/Contentstack.Management.Core/Models/OAuthOptions.cs b/Contentstack.Management.Core/Models/OAuthOptions.cs new file mode 100644 index 0000000..e6b9862 --- /dev/null +++ b/Contentstack.Management.Core/Models/OAuthOptions.cs @@ -0,0 +1,139 @@ +using System; + +namespace Contentstack.Management.Core.Models +{ + /// + /// Configuration options for OAuth authentication. + /// + public class OAuthOptions + { + /// + /// The OAuth application ID. Defaults to the Contentstack app ID. + /// + public string AppId { get; set; } = "6400aa06db64de001a31c8a9"; + + /// + /// The OAuth client ID. Defaults to the Contentstack client ID. + /// + public string ClientId { get; set; } = "Ie0FEfTzlfAHL4xM"; + + /// + /// The redirect URI for OAuth callbacks. Defaults to localhost:8184. + /// + public string RedirectUri { get; set; } = "http://localhost:8184"; + + /// + /// The OAuth client secret. If provided, PKCE flow will be skipped. + /// If null or empty, PKCE flow will be used for enhanced security. + /// + public string ClientSecret { get; set; } + + /// + /// The OAuth response type. Defaults to "code" for authorization code flow. + /// + public string ResponseType { get; set; } = "code"; + + /// + /// The OAuth scopes to request. Optional array of permission scopes. + /// + public string[] Scope { get; set; } + + /// + /// Indicates whether PKCE (Proof Key for Code Exchange) flow should be used. + /// This is automatically determined based on whether ClientSecret is provided. + /// + public bool UsePkce => string.IsNullOrEmpty(ClientSecret); + + /// + /// Validates the OAuth options configuration. + /// + /// True if the configuration is valid, false otherwise. + public bool IsValid() + { + return IsValid(out _); + } + + /// + /// Validates the OAuth options configuration and provides detailed error information. + /// + /// The validation error message if validation fails. + /// True if the configuration is valid, false otherwise. + public bool IsValid(out string errorMessage) + { + errorMessage = null; + + if (string.IsNullOrWhiteSpace(AppId)) + { + errorMessage = "AppId is required for OAuth configuration."; + return false; + } + + if (string.IsNullOrWhiteSpace(ClientId)) + { + errorMessage = "ClientId is required for OAuth configuration."; + return false; + } + + if (string.IsNullOrWhiteSpace(RedirectUri)) + { + errorMessage = "RedirectUri is required for OAuth configuration."; + return false; + } + + if (!Uri.TryCreate(RedirectUri, UriKind.Absolute, out var redirectUri)) + { + errorMessage = "RedirectUri must be a valid absolute URI."; + return false; + } + + if (redirectUri.Scheme != "http" && redirectUri.Scheme != "https") + { + errorMessage = "RedirectUri must use http or https scheme."; + return false; + } + + if (string.IsNullOrWhiteSpace(ResponseType)) + { + errorMessage = "ResponseType is required for OAuth configuration."; + return false; + } + + if (ResponseType != "code") + { + errorMessage = "ResponseType must be 'code' for authorization code flow."; + return false; + } + + // For traditional OAuth flow (non-PKCE), client secret is required + if (!UsePkce && string.IsNullOrWhiteSpace(ClientSecret)) + { + errorMessage = "ClientSecret is required for traditional OAuth flow. Use PKCE flow (leave ClientSecret empty) for public clients."; + return false; + } + + return true; + } + + /// + /// Validates the OAuth options configuration and throws an exception if invalid. + /// + /// Thrown when the configuration is invalid. + public void Validate() + { + if (!IsValid(out var errorMessage)) + { + throw new Exceptions.OAuthConfigurationException(errorMessage); + } + } + + /// + /// Gets a string representation of the OAuth options for debugging. + /// + /// A string representation of the OAuth options. + public override string ToString() + { + return $"OAuthOptions: AppId={AppId}, ClientId={ClientId}, RedirectUri={RedirectUri}, " + + $"ResponseType={ResponseType}, UsePkce={UsePkce}, HasScope={Scope?.Length > 0}"; + } + } +} diff --git a/Contentstack.Management.Core/Models/OAuthResponse.cs b/Contentstack.Management.Core/Models/OAuthResponse.cs new file mode 100644 index 0000000..714bc0d --- /dev/null +++ b/Contentstack.Management.Core/Models/OAuthResponse.cs @@ -0,0 +1,33 @@ +using System; +using Newtonsoft.Json; + +namespace Contentstack.Management.Core.Models +{ + /// + /// Represents the response from OAuth token exchange operations. + /// + public class OAuthResponse + { + + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + + + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + + + [JsonProperty("organization_uid")] + public string OrganizationUid { get; set; } + + + [JsonProperty("user_uid")] + public string UserUid { get; set; } + } +} + + diff --git a/Contentstack.Management.Core/Models/OAuthTokens.cs b/Contentstack.Management.Core/Models/OAuthTokens.cs new file mode 100644 index 0000000..4b83b37 --- /dev/null +++ b/Contentstack.Management.Core/Models/OAuthTokens.cs @@ -0,0 +1,53 @@ +using System; + +namespace Contentstack.Management.Core.Models +{ + /// + /// Represents OAuth tokens stored in memory for cross-SDK access. + /// This class enables sharing OAuth tokens between the Management SDK and other SDKs + /// + public class OAuthTokens + { + + public string AccessToken { get; set; } + + public string RefreshToken { get; set; } + + public DateTime ExpiresAt { get; set; } + + public string OrganizationUid { get; set; } + + public string UserUid { get; set; } + + public string ClientId { get; set; } + + public string AppId { get; set; } + + public bool IsExpired => ExpiresAt == DateTime.MinValue || DateTime.UtcNow >= ExpiresAt; + + public bool NeedsRefresh + { + get + { + // If ExpiresAt is not set or is MinValue, consider it expired + if (ExpiresAt == DateTime.MinValue) + return true; + + try + { + // Check if we need to refresh (5 minutes before expiration) + var refreshTime = ExpiresAt.AddMinutes(-5); + return DateTime.UtcNow >= refreshTime || IsExpired; + } + catch (ArgumentOutOfRangeException) + { + // If the calculation results in an unrepresentable DateTime, consider it expired + return true; + } + } + } + + public bool IsValid => !string.IsNullOrEmpty(AccessToken) && !IsExpired; + } +} + diff --git a/Contentstack.Management.Core/OAuthHandler.cs b/Contentstack.Management.Core/OAuthHandler.cs new file mode 100644 index 0000000..f5994a6 --- /dev/null +++ b/Contentstack.Management.Core/OAuthHandler.cs @@ -0,0 +1,699 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Utils; +using Contentstack.Management.Core.Services.OAuth; + +namespace Contentstack.Management.Core +{ + /// + /// Handles OAuth 2.0 authentication flow for Contentstack Management API. + /// Supports both traditional OAuth flow (with client secret) and PKCE flow (without client secret). + /// + public class OAuthHandler + { + #region Private Fields + private readonly ContentstackClient _client; + private readonly OAuthOptions _options; + private readonly string _clientId; + + private string codeVerifier = ""; + + private string codeChallenge = ""; + #endregion + + #region Constructor + /// + /// Initializes a new instance of the OAuthHandler class. + /// + /// The Contentstack client instance. + /// The OAuth configuration options. + /// Thrown when client or options is null. + /// Thrown when options are invalid. + public OAuthHandler(ContentstackClient client, OAuthOptions options) + { + if (client == null) + throw new ArgumentNullException(nameof(client), "Contentstack client cannot be null."); + + if (options == null) + throw new ArgumentNullException(nameof(options), "OAuth options cannot be null."); + + // Validate OAuth options and throw specific exception if invalid + options.Validate(); + + _client = client; + _options = options; + _clientId = options.ClientId; + } + #endregion + + #region Public Properties + public string ClientId => _clientId; + + public string AppId => _options.AppId; + public string RedirectUri => _options.RedirectUri; + public bool UsePkce => _options.UsePkce; + public string[] Scope => _options.Scope; + #endregion + + #region Helper Methods + private OAuthTokens ValidateAndGetTokens() + { + var tokens = GetCurrentTokens(); + if (tokens == null) + { + throw new Exceptions.OAuthException("No OAuth tokens found. Please authenticate first."); + } + return tokens; + } + + private void SetClientOAuthTokens(OAuthTokens tokens) + { + GetClient().contentstackOptions.Authtoken = tokens.AccessToken; + GetClient().contentstackOptions.IsOAuthToken = true; + } + + private void UpdateTokenProperty(Action setter, T value) + { + var tokens = GetCurrentTokens(); + if (tokens == null) + { + tokens = new OAuthTokens { ClientId = _clientId }; + } + setter(tokens, value); + StoreTokens(tokens); + } + + private string LogoutInternal() + { + var currentTokens = ValidateAndGetTokens(); + + // Try to revoke the OAuth app authorization (optional - if it fails, we still clear tokens) + // Only attempt revocation if we have valid tokens + if (!string.IsNullOrEmpty(currentTokens.AccessToken)) + { + try + { + var authorizationId = GetOauthAppAuthorizationAsync().GetAwaiter().GetResult(); + RevokeOauthAppAuthorizationAsync(authorizationId).GetAwaiter().GetResult(); + } + catch + { + // If revocation fails, continue with logout + // This is common in OAuth implementations where revocation is optional + } + } + + // Clear tokens from memory store + ClearTokens(); + + // Return success message + return "Logged out successfully"; + } + + private Exceptions.OAuthException HandleOAuthException(Exception ex, string operation) + { + if (ex is Exceptions.OAuthException) + return (Exceptions.OAuthException)ex; + + return new Exceptions.OAuthException($"Failed to {operation}: {ex.Message}", ex); + } + #endregion + + #region Public Methods + public OAuthTokens GetCurrentTokens() + { + return _client.GetStoredOAuthTokens(_clientId); + } + + public bool HasValidTokens() + { + var tokens = _client.GetStoredOAuthTokens(_clientId); + return tokens?.IsValid == true; + } + + public bool HasTokens() + { + return _client.HasStoredOAuthTokens(_clientId); + } + + public void ClearTokens() + { + _client.ClearStoredOAuthTokens(_clientId); + } + + private void StoreTokens(OAuthTokens tokens) + { + _client.StoreOAuthTokens(_clientId, tokens); + } + + public override string ToString() + { + return $"OAuthHandler: ClientId={_clientId}, AppId={_options.AppId}, UsePkce={_options.UsePkce}, HasTokens={HasTokens()}"; + } + + #region Token Getter Methods + public string GetAccessToken() + { + var tokens = GetCurrentTokens(); + return tokens?.AccessToken; + } + + public string GetRefreshToken() + { + var tokens = GetCurrentTokens(); + return tokens?.RefreshToken; + } + + public string GetOrganizationUID() + { + var tokens = GetCurrentTokens(); + return tokens?.OrganizationUid; + } + + public string GetUserUID() + { + var tokens = GetCurrentTokens(); + return tokens?.UserUid; + } + + public DateTime? GetTokenExpiryTime() + { + var tokens = GetCurrentTokens(); + return tokens?.ExpiresAt; + } + #endregion + + #region Token Setter Methods + public void SetAccessToken(string token) + { + if (string.IsNullOrEmpty(token)) + throw new ArgumentException("Access token is required.", nameof(token)); + + UpdateTokenProperty((t, v) => t.AccessToken = v, token); + } + + public void SetRefreshToken(string token) + { + if (string.IsNullOrEmpty(token)) + throw new ArgumentException("Refresh token is required.", nameof(token)); + + UpdateTokenProperty((t, v) => t.RefreshToken = v, token); + } + + public void SetOrganizationUID(string organizationUID) + { + if (string.IsNullOrEmpty(organizationUID)) + throw new ArgumentException("Organization UID is required.", nameof(organizationUID)); + + UpdateTokenProperty((t, v) => t.OrganizationUid = v, organizationUID); + } + + public void SetUserUID(string userUID) + { + if (string.IsNullOrEmpty(userUID)) + throw new ArgumentException("User UID is required.", nameof(userUID)); + + UpdateTokenProperty((t, v) => t.UserUid = v, userUID); + } + + public void SetTokenExpiryTime(DateTime expiryTime) + { + if (expiryTime == default(DateTime)) + throw new ArgumentException("Token expiry time is required.", nameof(expiryTime)); + + UpdateTokenProperty((t, v) => t.ExpiresAt = v, expiryTime); + } + #endregion + + #endregion + + #region Protected Methods + protected ContentstackClient GetClient() + { + return _client; + } + + protected OAuthOptions GetOptions() + { + return _options; + } + #endregion + + #region OAuth Flow Methods + /// + /// Generates the OAuth authorization URL for user authentication. + /// + public async Task AuthorizeAsync() + { + // AppId validation is now handled by OAuthOptions.Validate() in constructor + + try + { + + // Build the base authorization URL using the correct OAuth hostname + // Transform api.contentstack.io -> app.contentstack.com for OAuth authorization + var oauthHost = GetOAuthHost(GetClient().contentstackOptions.Host); + + var baseUrl = $"https://{oauthHost}/#!/apps/{_options.AppId}/authorize"; + + var authUrl = new UriBuilder(baseUrl); + + // Add required OAuth parameters + var queryParams = new List + { + $"response_type={Uri.EscapeDataString(_options.ResponseType)}", + $"client_id={Uri.EscapeDataString(_options.ClientId)}", + $"redirect_uri={Uri.EscapeDataString(_options.RedirectUri)}" + }; + + // Add scopes if provided + if (_options.Scope != null && _options.Scope.Length > 0) + { + var scopeString = string.Join(" ", _options.Scope); + queryParams.Add($"scope={Uri.EscapeDataString(scopeString)}"); + } + + // Handle PKCE vs Traditional OAuth flow + if (_options.UsePkce) + { + // PKCE flow - generate code verifier and challenge + this.codeVerifier = PkceHelper.GenerateCodeVerifier(); + this.codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + + // Add PKCE parameters + queryParams.Add($"code_challenge={Uri.EscapeDataString(codeChallenge)}"); + queryParams.Add("code_challenge_method=S256"); + } + // Traditional OAuth flow - no additional parameters needed + + // Build the complete URL + authUrl.Query = string.Join("&", queryParams); + return await Task.FromResult(authUrl.ToString()); + } + catch (Exception ex) when (!(ex is Exceptions.OAuthException)) + { + throw new Exceptions.OAuthAuthorizationException($"Failed to generate OAuth authorization URL: {ex.Message}", ex); + } + } + + /// + /// Exchanges an authorization code for OAuth access and refresh tokens. + /// + public async Task ExchangeCodeForTokenAsync(string authorizationCode) + { + if (string.IsNullOrEmpty(authorizationCode)) + { + throw new ArgumentException("Authorization code is required.", nameof(authorizationCode)); + } + + try + { + + // Create the OAuth token service for authorization code exchange + OAuthTokenService tokenService; + + if (_options.UsePkce) + { + // PKCE code verifier should be available from the instance + if (string.IsNullOrEmpty(this.codeVerifier)) + { + throw new Exceptions.OAuthConfigurationException( + "PKCE code verifier not found. Make sure to call AuthorizeAsync() before ExchangeCodeForTokenAsync()."); + } + } + + if (_options.UsePkce && !string.IsNullOrEmpty(this.codeVerifier)) + { + tokenService = OAuthTokenService.CreateForAuthorizationCode( + serializer: GetClient().serializer, + authorizationCode: authorizationCode, + clientId: _options.ClientId, + redirectUri: _options.RedirectUri, + codeVerifier: this.codeVerifier + ); + } + else + { + if (string.IsNullOrEmpty(_options.ClientSecret)) + { + throw new Exceptions.OAuthConfigurationException( + "Client secret is required for traditional OAuth flow. Set ClientSecret in OAuth options or use PKCE flow."); + } + + tokenService = OAuthTokenService.CreateForAuthorizationCode( + serializer: GetClient().serializer, + authorizationCode: authorizationCode, + clientId: _options.ClientId, + redirectUri: _options.RedirectUri, + clientSecret: _options.ClientSecret + ); + } + + // Make the token exchange request + var response = await GetClient().InvokeAsync(tokenService); + + // Parse the OAuth response from the ContentstackResponse + var oauthResponse = response.OpenTResponse(); + + // Create OAuth tokens from the response + var tokens = new OAuthTokens + { + AccessToken = oauthResponse.AccessToken, + RefreshToken = oauthResponse.RefreshToken, + ExpiresAt = DateTime.UtcNow.AddSeconds(oauthResponse.ExpiresIn - 60), // 60 second buffer + OrganizationUid = oauthResponse.OrganizationUid, + UserUid = oauthResponse.UserUid, + ClientId = _clientId, + AppId = _options.AppId + }; + + // Store tokens in memory for future use + StoreTokens(tokens); + + // Set OAuth tokens in the client for authenticated requests + SetClientOAuthTokens(tokens); + + return tokens; + } + catch (Exception ex) when (!(ex is ArgumentException || ex is Exceptions.OAuthException)) + { + throw HandleOAuthException(ex, "exchange authorization code for tokens"); + } + } + + /// + /// Refreshes the OAuth access token using the refresh token. + /// + public async Task RefreshTokenAsync(string refreshToken = null) + { + // Get the refresh token to use + string tokenToUse = refreshToken; + + if (string.IsNullOrEmpty(tokenToUse)) + { + // Get refresh token from stored tokens + var storedTokens = GetCurrentTokens(); + if (storedTokens?.RefreshToken == null) + { + throw new Exceptions.OAuthTokenRefreshException( + "No refresh token available. Please authenticate first."); + } + tokenToUse = storedTokens.RefreshToken; + } + + try + { + // Create the OAuth token service for token refresh + OAuthTokenService tokenService; + + if (_options.UsePkce) + { + // PKCE flow - no client secret needed + tokenService = OAuthTokenService.CreateForRefreshToken( + serializer: GetClient().serializer, + refreshToken: tokenToUse, + clientId: _options.ClientId + ); + } + else + { + // Traditional OAuth flow - use client secret + if (string.IsNullOrEmpty(_options.ClientSecret)) + { + throw new Exceptions.OAuthConfigurationException( + "Client secret is required for traditional OAuth flow."); + } + + tokenService = OAuthTokenService.CreateForRefreshToken( + serializer: GetClient().serializer, + refreshToken: tokenToUse, + clientId: _options.ClientId, + clientSecret: _options.ClientSecret + ); + } + + // Make the token refresh request + var response = await GetClient().InvokeAsync(tokenService); + + // Parse the OAuth response from the ContentstackResponse + var oauthResponse = response.OpenTResponse(); + + // Create new OAuth tokens from the response + var newTokens = new OAuthTokens + { + AccessToken = oauthResponse.AccessToken, + RefreshToken = oauthResponse.RefreshToken ?? tokenToUse, // Keep existing refresh token if not provided + ExpiresAt = DateTime.UtcNow.AddSeconds(oauthResponse.ExpiresIn - 60), // 60 second buffer + OrganizationUid = oauthResponse.OrganizationUid, + UserUid = oauthResponse.UserUid, + ClientId = _clientId, + AppId = _options.AppId + }; + + // Store the new tokens in memory + StoreTokens(newTokens); + + // Set OAuth tokens in the client for authenticated requests + SetClientOAuthTokens(newTokens); + + return newTokens; + } + catch (Exception ex) when (!(ex is Exceptions.OAuthException)) + { + throw HandleOAuthException(ex, "refresh OAuth tokens"); + } + } + + /// + /// Logs out the user by clearing OAuth tokens. + /// + public async Task LogoutAsync() + { + try + { + var currentTokens = ValidateAndGetTokens(); + + // Try to revoke the OAuth app authorization (optional - if it fails, we still clear tokens) + // Only attempt revocation if we have valid tokens + if (!string.IsNullOrEmpty(currentTokens.AccessToken)) + { + try + { + var authorizationId = await GetOauthAppAuthorizationAsync(); + await RevokeOauthAppAuthorizationAsync(authorizationId); + } + catch + { + // If revocation fails, continue with logout + // This is common in OAuth implementations where revocation is optional + } + } + + // Clear tokens from memory store + ClearTokens(); + + // Return success message + return "Logged out successfully"; + } + catch (Exception ex) when (!(ex is Exceptions.OAuthException)) + { + throw new InvalidOperationException($"Failed to logout: {ex.Message}", ex); + } + } + + /// + /// Logs out the user synchronously by clearing OAuth tokens. + /// + public string Logout() + { + try + { + return LogoutInternal(); + } + catch (Exception ex) when (!(ex is InvalidOperationException)) + { + throw new InvalidOperationException($"Failed to logout: {ex.Message}", ex); + } + } + + /// + /// Handles the redirect URL after OAuth authorization and exchanges the authorization code for tokens. + /// + public async Task HandleRedirectAsync(string url) + { + if (string.IsNullOrEmpty(url)) + throw new ArgumentException("Redirect URL is required.", nameof(url)); + + try + { + // Parse the URL to extract the authorization code + var uri = new Uri(url); + var query = uri.Query.TrimStart('?'); + var queryParams = new Dictionary(); + + if (!string.IsNullOrEmpty(query)) + { + foreach (var param in query.Split('&')) + { + var parts = param.Split('='); + if (parts.Length == 2) + { + queryParams[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]); + } + } + } + + var code = queryParams.ContainsKey("code") ? queryParams["code"] : null; + + if (string.IsNullOrEmpty(code)) + { + throw new Exceptions.OAuthException("Authorization code not found in redirect URL."); + } + + // Exchange the authorization code for tokens + await ExchangeCodeForTokenAsync(code); + } + catch (Exception ex) when (!(ex is ArgumentException || ex is Exceptions.OAuthException)) + { + throw HandleOAuthException(ex, "handle redirect URL"); + } + } + #endregion + + #region Private Methods + private static string GetOAuthHost(string baseHost) + { + if (string.IsNullOrEmpty(baseHost)) + return baseHost; + + // Extract hostname from URL if it contains protocol + var oauthHost = baseHost; + if (oauthHost.StartsWith("https://")) + { + oauthHost = oauthHost.Substring(8); // Remove "https://" + } + else if (oauthHost.StartsWith("http://")) + { + oauthHost = oauthHost.Substring(7); // Remove "http://" + } + + // Transform api.contentstack.io -> app.contentstack.com + // Replace .io with .com + if (oauthHost.EndsWith(".io")) + { + oauthHost = oauthHost.Replace(".io", ".com"); + } + + // Replace 'api' with 'app' + if (oauthHost.Contains("api.")) + { + oauthHost = oauthHost.Replace("api.", "app."); + } + + return oauthHost; + } + + private async Task GetOauthAppAuthorizationAsync() + { + var tokens = ValidateAndGetTokens(); + + try + { + // Create a service to get OAuth app authorizations + var service = new OAuthAppAuthorizationService( + GetClient().serializer, + _options.AppId, + tokens.OrganizationUid + ); + + SetClientOAuthTokens(tokens); + + try + { + // Make the API call to get authorizations + var response = await GetClient().InvokeAsync(service); + + var authResponse = response.OpenTResponse(); + + if (authResponse?.Data?.Length > 0) + { + var userUid = tokens.UserUid; + var currentUserAuthorization = authResponse.Data.FirstOrDefault(auth => auth.User?.Uid == userUid); + + if (currentUserAuthorization == null) + { + throw new Exceptions.OAuthException("No authorizations found for current user."); + } + + return currentUserAuthorization.AuthorizationUid; + } + else + { + throw new Exceptions.OAuthException("No authorizations found for the app."); + } + } + catch (Exception ex) when (!(ex is Exceptions.OAuthException)) + { + throw HandleOAuthException(ex, "get OAuth app authorization"); + } + } + catch (Exception ex) when (!(ex is Exceptions.OAuthException)) + { + throw HandleOAuthException(ex, "get OAuth app authorization"); + } + } + + private async Task RevokeOauthAppAuthorizationAsync(string authorizationId) + { + if (string.IsNullOrEmpty(authorizationId)) + { + throw new ArgumentException("Authorization ID is required.", nameof(authorizationId)); + } + + try + { + // Get current tokens to access organization UID + var tokens = GetCurrentTokens(); + var organizationUid = tokens?.OrganizationUid; + + // Create a service to revoke OAuth app authorization + var service = new Services.OAuth.OAuthAppRevocationService( + GetClient().serializer, + _options.AppId, + authorizationId, + organizationUid + ); + + SetClientOAuthTokens(tokens); + + try + { + // Make the API call to revoke authorization + var response = await GetClient().InvokeAsync(service); + } + catch (Exception ex) + { + throw ex; + } + finally + { + // Clear OAuth tokens after successful revocation (for logout scenario) + GetClient().contentstackOptions.Authtoken = null; + GetClient().contentstackOptions.IsOAuthToken = false; + } + } + catch (Exception ex) when (!(ex is ArgumentException || ex is Exceptions.OAuthException)) + { + throw HandleOAuthException(ex, "revoke OAuth app authorization"); + } + } + + #endregion + + } +} diff --git a/Contentstack.Management.Core/Services/ContentstackService.cs b/Contentstack.Management.Core/Services/ContentstackService.cs index 771b7f1..dcffbe4 100644 --- a/Contentstack.Management.Core/Services/ContentstackService.cs +++ b/Contentstack.Management.Core/Services/ContentstackService.cs @@ -168,6 +168,14 @@ public virtual IHttpRequest CreateHttpRequest(HttpClient httpClient, Contentstac { Headers["authorization"] = this.ManagementToken; } + else if (config.IsOAuthToken) + { + if (!string.IsNullOrEmpty(config.Authtoken)) + { + + Headers["authorization"] = $"Bearer {config.Authtoken}"; + } + } else if (!string.IsNullOrEmpty(config.Authtoken)) { Headers["authtoken"] = config.Authtoken; diff --git a/Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs b/Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs new file mode 100644 index 0000000..1570c21 --- /dev/null +++ b/Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Http; + +namespace Contentstack.Management.Core.Services.OAuth +{ + /// + /// Service for getting OAuth app authorizations. + /// + internal class OAuthAppAuthorizationService : ContentstackService + { + private readonly string _appId; + private readonly string _organizationUid; + + /// + /// Initializes a new instance of the OAuthAppAuthorizationService class. + /// + /// The JSON serializer. + /// The OAuth app ID. + /// The organization UID for OAuth operations. + internal OAuthAppAuthorizationService(JsonSerializer serializer, string appId, string organizationUid = null) + : base(serializer) + { + if (string.IsNullOrEmpty(appId)) + throw new ArgumentException("App ID cannot be null or empty.", nameof(appId)); + + _appId = appId; + _organizationUid = organizationUid; + InitializeService(); + } + + /// + /// Initializes the service properties. + /// + private void InitializeService() + { + HttpMethod = "GET"; + ResourcePath = $"manifests/{_appId}/authorizations"; + } + + /// + /// Creates the HTTP request for OAuth app authorization operations. + /// Overrides the base implementation to use the Developer Hub API URL. + /// + /// The HTTP client to use for the request. + /// The Contentstack client configuration. + /// Whether to add accept media headers. + /// The API version to use. + /// The HTTP request for OAuth app authorization operations. + public override IHttpRequest CreateHttpRequest(System.Net.Http.HttpClient httpClient, ContentstackClientOptions config, bool addAcceptMediaHeader = false, string apiVersion = null) + { + // Create a custom config with Developer Hub hostname for OAuth app authorization operations + // OAuth endpoints don't use API versioning, so we set Version to empty + var devHubConfig = new ContentstackClientOptions + { + Host = GetDeveloperHubHostname(config.Host), + Port = config.Port, + Version = "", // OAuth endpoints don't use versioning + Authtoken = config.Authtoken, // This should contain the OAuth access token + IsOAuthToken = true // This service requires OAuth authentication + }; + + // Set the required headers BEFORE calling base.CreateHttpRequest + Headers["Content-Type"] = "application/json"; + + // Add organization_uid header if available + if (!string.IsNullOrEmpty(_organizationUid)) + { + Headers["organization_uid"] = _organizationUid; + } + + var request = base.CreateHttpRequest(httpClient, devHubConfig, addAcceptMediaHeader, apiVersion); + + return request; + } + + /// + /// Transforms the base hostname to the Developer Hub API hostname. + /// + /// The base hostname (e.g., api.contentstack.io) + /// The transformed Developer Hub hostname (e.g., developerhub-api.contentstack.com) + private static string GetDeveloperHubHostname(string baseHost) + { + if (string.IsNullOrEmpty(baseHost)) + return baseHost; + + // Transform api.contentstack.io -> developerhub-api.contentstack.com + var devHubHost = baseHost; + + // Replace 'api' with 'developerhub-api' + if (devHubHost.Contains("api.")) + { + devHubHost = devHubHost.Replace("api.", "developerhub-api."); + } + + // Replace .io with .com + if (devHubHost.EndsWith(".io")) + { + devHubHost = devHubHost.Replace(".io", ".com"); + } + + // Ensure https:// protocol + if (!devHubHost.StartsWith("http")) + { + devHubHost = "https://" + devHubHost; + } + + return devHubHost; + } + } +} diff --git a/Contentstack.Management.Core/Services/OAuth/OAuthAppRevocationService.cs b/Contentstack.Management.Core/Services/OAuth/OAuthAppRevocationService.cs new file mode 100644 index 0000000..3ae5cdb --- /dev/null +++ b/Contentstack.Management.Core/Services/OAuth/OAuthAppRevocationService.cs @@ -0,0 +1,114 @@ +using System; +using Newtonsoft.Json; +using Contentstack.Management.Core.Http; + +namespace Contentstack.Management.Core.Services.OAuth +{ + /// + /// Service for revoking OAuth app authorizations. + /// + internal class OAuthAppRevocationService : ContentstackService + { + private readonly string _appId; + private readonly string _authorizationId; + private readonly string _organizationUid; + + /// + /// Initializes a new instance of the OAuthAppRevocationService class. + /// + /// The JSON serializer. + /// The OAuth app ID. + /// The authorization ID to revoke. + /// The organization UID for OAuth operations. + internal OAuthAppRevocationService(JsonSerializer serializer, string appId, string authorizationId, string organizationUid = null) + : base(serializer) + { + if (string.IsNullOrEmpty(appId)) + throw new ArgumentException("App ID cannot be null or empty.", nameof(appId)); + if (string.IsNullOrEmpty(authorizationId)) + throw new ArgumentException("Authorization ID cannot be null or empty.", nameof(authorizationId)); + + _appId = appId; + _authorizationId = authorizationId; + _organizationUid = organizationUid; + InitializeService(); + } + + /// + /// Initializes the service properties. + /// + private void InitializeService() + { + HttpMethod = "DELETE"; + ResourcePath = $"manifests/{_appId}/authorizations/{_authorizationId}"; + } + + /// + /// Creates the HTTP request for OAuth app revocation operations. + /// Overrides the base implementation to use the Developer Hub API URL. + /// + /// The HTTP client to use for the request. + /// The Contentstack client configuration. + /// Whether to add accept media headers. + /// The API version to use. + /// The HTTP request for OAuth app revocation operations. + public override IHttpRequest CreateHttpRequest(System.Net.Http.HttpClient httpClient, ContentstackClientOptions config, bool addAcceptMediaHeader = false, string apiVersion = null) + { + // Create a custom config with Developer Hub hostname for OAuth app revocation operations + // OAuth endpoints don't use API versioning, so we set Version to empty + var devHubConfig = new ContentstackClientOptions + { + Host = GetDeveloperHubHostname(config.Host), + Port = config.Port, + Version = "", // OAuth endpoints don't use versioning + Authtoken = config.Authtoken, + IsOAuthToken = true // This service requires OAuth authentication + }; + + // Set the required headers BEFORE calling base.CreateHttpRequest + // Add organization_uid header if available + if (!string.IsNullOrEmpty(_organizationUid)) + { + Headers["organization_uid"] = _organizationUid; + } + + var request = base.CreateHttpRequest(httpClient, devHubConfig, addAcceptMediaHeader, apiVersion); + + return request; + } + + /// + /// Transforms the base hostname to the Developer Hub API hostname. + /// + /// The base hostname (e.g., api.contentstack.io) + /// The transformed Developer Hub hostname (e.g., developerhub-api.contentstack.com) + private static string GetDeveloperHubHostname(string baseHost) + { + if (string.IsNullOrEmpty(baseHost)) + return baseHost; + + // Transform api.contentstack.io -> developerhub-api.contentstack.com + var devHubHost = baseHost; + + // Replace 'api' with 'developerhub-api' + if (devHubHost.Contains("api.")) + { + devHubHost = devHubHost.Replace("api.", "developerhub-api."); + } + + // Replace .io with .com + if (devHubHost.EndsWith(".io")) + { + devHubHost = devHubHost.Replace(".io", ".com"); + } + + // Ensure https:// protocol + if (!devHubHost.StartsWith("http")) + { + devHubHost = "https://" + devHubHost; + } + + return devHubHost; + } + } +} diff --git a/Contentstack.Management.Core/Services/OAuth/OAuthTokenService.cs b/Contentstack.Management.Core/Services/OAuth/OAuthTokenService.cs new file mode 100644 index 0000000..533870b --- /dev/null +++ b/Contentstack.Management.Core/Services/OAuth/OAuthTokenService.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Net; +using Newtonsoft.Json; +using Contentstack.Management.Core.Http; +using Contentstack.Management.Core.Models; + +namespace Contentstack.Management.Core.Services.OAuth +{ + /// + /// Service class for OAuth token operations including token exchange and refresh. + /// + internal class OAuthTokenService : ContentstackService + { + #region Private Fields + private readonly Dictionary _requestBody; + + // Constants for OAuth grant types + private const string AuthorizationCodeGrantType = "authorization_code"; + private const string RefreshTokenGrantType = "refresh_token"; + #endregion + + #region Constructor + /// + /// Initializes a new instance of the OAuthTokenService class. + /// + /// The JSON serializer to use. + /// The request body parameters for the OAuth token request. + /// Thrown when serializer or requestBody is null. + internal OAuthTokenService(JsonSerializer serializer, Dictionary requestBody) + : base(serializer) + { + if (requestBody == null) + throw new ArgumentNullException(nameof(requestBody), "Request body cannot be null."); + + _requestBody = requestBody; + HttpMethod = "POST"; + ResourcePath = "token"; + } + #endregion + + #region Public Methods + /// + /// Creates the content body for the OAuth token request. + /// The body is formatted as application/x-www-form-urlencoded as required by OAuth 2.0. + /// + public override void ContentBody() + { + if (_requestBody == null || _requestBody.Count == 0) + { + throw new InvalidOperationException("Request body cannot be null or empty for OAuth token requests."); + } + + // Create form-encoded data as required by OAuth 2.0 specification + var formData = string.Join("&", _requestBody.Select(kvp => + $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? string.Empty)}")); + + ByteContent = Encoding.UTF8.GetBytes(formData); + } + + /// + /// Creates the HTTP request for OAuth token operations. + /// Overrides the base implementation to set the correct content type and URL for OAuth requests. + /// + /// The HTTP client to use for the request. + /// The Contentstack client configuration. + /// Whether to add accept media headers. + /// The API version to use. + /// The HTTP request for OAuth token operations. + public override IHttpRequest CreateHttpRequest(System.Net.Http.HttpClient httpClient, ContentstackClientOptions config, bool addAcceptMediaHeader = false, string apiVersion = null) + { + // Create a custom config with Developer Hub hostname for OAuth token operations + // OAuth token endpoints don't use API versioning, so we set Version to empty + var devHubConfig = new ContentstackClientOptions + { + Host = GetDeveloperHubHostname(config.Host), + Port = config.Port, + Version = "", // OAuth endpoints don't use versioning + }; + + var request = base.CreateHttpRequest(httpClient, devHubConfig, addAcceptMediaHeader, apiVersion); + + // OAuth token requests require application/x-www-form-urlencoded content type + Headers["Content-Type"] = "application/x-www-form-urlencoded"; + + return request; + } + + /// + /// Transforms the base hostname to the Developer Hub API hostname. + /// + /// The base hostname (e.g., api.contentstack.io) + /// The transformed Developer Hub hostname (e.g., developerhub-api.contentstack.com) + private static string GetDeveloperHubHostname(string baseHost) + { + if (string.IsNullOrEmpty(baseHost)) + return baseHost; + + // Transform api.contentstack.io -> developerhub-api.contentstack.com + var devHubHost = baseHost; + + // Replace 'api' with 'developerhub-api' + if (devHubHost.Contains("api.")) + { + devHubHost = devHubHost.Replace("api.", "developerhub-api."); + } + + // Replace .io with .com + if (devHubHost.EndsWith(".io")) + { + devHubHost = devHubHost.Replace(".io", ".com"); + } + + // Ensure https:// protocol + if (!devHubHost.StartsWith("http")) + { + devHubHost = "https://" + devHubHost; + } + + return devHubHost; + } + + /// + /// Handles the response from OAuth token operations. + /// This method is called after the HTTP request completes. + /// + /// The HTTP response from the OAuth token request. + /// The Contentstack client configuration. + public override void OnResponse(IResponse httpResponse, ContentstackClientOptions config) + { + // OAuth token service doesn't need to modify the client configuration + // The response handling is done by the OAuthHandler class + // This method is provided for future extensibility + } + #endregion + + #region Static Factory Methods + /// + /// Creates an OAuth token service for authorization code exchange. + /// + /// The JSON serializer to use. + /// The authorization code received from the OAuth provider. + /// The OAuth client ID. + /// The redirect URI used in the authorization request. + /// The OAuth client secret (optional, for traditional OAuth flow). + /// The PKCE code verifier (optional, for PKCE flow). + /// An OAuth token service configured for authorization code exchange. + public static OAuthTokenService CreateForAuthorizationCode( + JsonSerializer serializer, + string authorizationCode, + string clientId, + string redirectUri, + string clientSecret = null, + string codeVerifier = null) + { + if (string.IsNullOrEmpty(authorizationCode)) + throw new ArgumentException("Authorization code cannot be null or empty.", nameof(authorizationCode)); + if (string.IsNullOrEmpty(clientId)) + throw new ArgumentException("Client ID cannot be null or empty.", nameof(clientId)); + if (string.IsNullOrEmpty(redirectUri)) + throw new ArgumentException("Redirect URI cannot be null or empty.", nameof(redirectUri)); + + var requestBody = new Dictionary + { + ["grant_type"] = AuthorizationCodeGrantType, + ["code"] = authorizationCode, + ["redirect_uri"] = redirectUri, + ["client_id"] = clientId + }; + + // Add either client_secret (traditional OAuth) or code_verifier (PKCE) + if (!string.IsNullOrEmpty(clientSecret)) + { + requestBody["client_secret"] = clientSecret; + } + else if (!string.IsNullOrEmpty(codeVerifier)) + { + requestBody["code_verifier"] = codeVerifier; + } + else + { + throw new ArgumentException("Either client_secret or code_verifier must be provided."); + } + + return new OAuthTokenService(serializer, requestBody); + } + + /// + /// Creates an OAuth token service for token refresh. + /// + /// The JSON serializer to use. + /// The refresh token to use for obtaining new access tokens. + /// The OAuth client ID. + /// The redirect URI used in the original authorization request. + /// An OAuth token service configured for token refresh. + public static OAuthTokenService CreateForTokenRefresh( + JsonSerializer serializer, + string refreshToken, + string clientId, + string redirectUri) + { + if (string.IsNullOrEmpty(refreshToken)) + throw new ArgumentException("Refresh token cannot be null or empty.", nameof(refreshToken)); + if (string.IsNullOrEmpty(clientId)) + throw new ArgumentException("Client ID cannot be null or empty.", nameof(clientId)); + if (string.IsNullOrEmpty(redirectUri)) + throw new ArgumentException("Redirect URI cannot be null or empty.", nameof(redirectUri)); + + var requestBody = new Dictionary + { + ["grant_type"] = RefreshTokenGrantType, + ["refresh_token"] = refreshToken, + ["client_id"] = clientId, + ["redirect_uri"] = redirectUri + }; + + return new OAuthTokenService(serializer, requestBody); + } + + /// + /// Creates an OAuth token service for token refresh with optional client secret. + /// This method supports both PKCE flow (without client secret) and traditional OAuth flow (with client secret). + /// + /// The JSON serializer to use. + /// The refresh token to use for obtaining new access tokens. + /// The OAuth client ID. + /// The OAuth client secret (optional, for traditional OAuth flow). + /// An OAuth token service configured for token refresh. + public static OAuthTokenService CreateForRefreshToken( + JsonSerializer serializer, + string refreshToken, + string clientId, + string clientSecret = null) + { + if (string.IsNullOrEmpty(refreshToken)) + throw new ArgumentException("Refresh token cannot be null or empty.", nameof(refreshToken)); + if (string.IsNullOrEmpty(clientId)) + throw new ArgumentException("Client ID cannot be null or empty.", nameof(clientId)); + + var requestBody = new Dictionary + { + ["grant_type"] = RefreshTokenGrantType, + ["refresh_token"] = refreshToken, + ["client_id"] = clientId + }; + + // Add client secret for traditional OAuth flow + if (!string.IsNullOrEmpty(clientSecret)) + { + requestBody["client_secret"] = clientSecret; + } + + return new OAuthTokenService(serializer, requestBody); + } + #endregion + } +} diff --git a/Contentstack.Management.Core/Services/User/LoginService.cs b/Contentstack.Management.Core/Services/User/LoginService.cs index 5c5c180..9dbaf05 100644 --- a/Contentstack.Management.Core/Services/User/LoginService.cs +++ b/Contentstack.Management.Core/Services/User/LoginService.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using System.Globalization; using Newtonsoft.Json.Linq; +using OtpNet; using Contentstack.Management.Core.Http; namespace Contentstack.Management.Core.Services.User @@ -16,7 +17,7 @@ internal class LoginService : ContentstackService #endregion #region Constructor - internal LoginService(JsonSerializer serializer, ICredentials credentials, string token = null): base(serializer) + internal LoginService(JsonSerializer serializer, ICredentials credentials, string token = null, string mfaSecret = null): base(serializer) { this.HttpMethod = "POST"; this.ResourcePath = "user-session"; @@ -27,7 +28,18 @@ internal LoginService(JsonSerializer serializer, ICredentials credentials, strin } _credentials = credentials; - _token = token; + + if (string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(mfaSecret)) + { + var secretBytes = Base32Encoding.ToBytes(mfaSecret); + + var totp = new Totp(secretBytes); + _token = totp.ComputeTotp(); + } + else + { + _token = token; + } } #endregion diff --git a/Contentstack.Management.Core/Utils/PkceHelper.cs b/Contentstack.Management.Core/Utils/PkceHelper.cs new file mode 100644 index 0000000..cfb9199 --- /dev/null +++ b/Contentstack.Management.Core/Utils/PkceHelper.cs @@ -0,0 +1,206 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Contentstack.Management.Core.Utils +{ + /// + /// Helper class for PKCE (Proof Key for Code Exchange) operations in OAuth 2.0. + /// PKCE enhances security for OAuth flows, especially for public clients that cannot securely store client secrets. + /// + public static class PkceHelper + { + /// + /// Generates a cryptographically random code verifier for PKCE. + /// The code verifier is a high-entropy cryptographic random string. + /// + /// A URL-safe base64-encoded code verifier. + /// Thrown when cryptographic operations fail. + public static string GenerateCodeVerifier() + { + try + { + // Generate 32 random bytes (256 bits) + var bytes = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes); + } + + // Convert to URL-safe base64 string + return Convert.ToBase64String(bytes) + .TrimEnd('=') // Remove padding + .Replace('+', '-') // Replace + with - + .Replace('/', '_'); // Replace / with _ + } + catch (Exception ex) + { + throw new CryptographicException("Failed to generate code verifier", ex); + } + } + + /// + /// Generates a code challenge from a code verifier using SHA256. + /// The code challenge is the SHA256 hash of the code verifier, base64url-encoded. + /// + /// The code verifier to hash. + /// A URL-safe base64-encoded code challenge. + /// Thrown when codeVerifier is null or empty. + /// Thrown when cryptographic operations fail. + public static string GenerateCodeChallenge(string codeVerifier) + { + if (string.IsNullOrEmpty(codeVerifier)) + throw new ArgumentNullException(nameof(codeVerifier), "Code verifier cannot be null or empty."); + + try + { + // Compute SHA256 hash of the code verifier + using (var sha256 = SHA256.Create()) + { + var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); + + // Convert to URL-safe base64 string + return Convert.ToBase64String(challengeBytes) + .TrimEnd('=') // Remove padding + .Replace('+', '-') // Replace + with - + .Replace('/', '_'); // Replace / with _ + } + } + catch (Exception ex) + { + throw new CryptographicException("Failed to generate code challenge", ex); + } + } + + /// + /// Validates a code verifier format. + /// A valid code verifier must be 43-128 characters long and contain only URL-safe characters. + /// + /// The code verifier to validate. + /// True if the code verifier is valid, false otherwise. + public static bool IsValidCodeVerifier(string codeVerifier) + { + if (string.IsNullOrEmpty(codeVerifier)) + return false; + + // Check length (43-128 characters as per RFC 7636) + if (codeVerifier.Length < 43 || codeVerifier.Length > 128) + return false; + + // Check for URL-safe characters only (A-Z, a-z, 0-9, -, _, .) + foreach (char c in codeVerifier) + { + if (!IsUrlSafeCharacter(c)) + return false; + } + + return true; + } + + /// + /// Validates a code challenge format. + /// A valid code challenge must be 43 characters long and contain only URL-safe characters. + /// + /// The code challenge to validate. + /// True if the code challenge is valid, false otherwise. + public static bool IsValidCodeChallenge(string codeChallenge) + { + if (string.IsNullOrEmpty(codeChallenge)) + return false; + + // SHA256 hash in base64url should be exactly 43 characters + if (codeChallenge.Length != 43) + return false; + + // Check for URL-safe characters only + foreach (char c in codeChallenge) + { + if (!IsUrlSafeCharacter(c)) + return false; + } + + return true; + } + + /// + /// Verifies that a code challenge matches a code verifier. + /// This is used during the token exchange to ensure the client possesses the original code verifier. + /// + /// The original code verifier. + /// The code challenge to verify against. + /// True if the code challenge matches the code verifier, false otherwise. + /// Thrown when either parameter is null or empty. + public static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge) + { + if (string.IsNullOrEmpty(codeVerifier)) + throw new ArgumentNullException(nameof(codeVerifier), "Code verifier cannot be null or empty."); + + if (string.IsNullOrEmpty(codeChallenge)) + throw new ArgumentNullException(nameof(codeChallenge), "Code challenge cannot be null or empty."); + + try + { + // Generate the expected code challenge from the verifier + var expectedChallenge = GenerateCodeChallenge(codeVerifier); + + // Compare using constant-time comparison to prevent timing attacks + return ConstantTimeEquals(expectedChallenge, codeChallenge); + } + catch + { + return false; + } + } + + /// + /// Generates a complete PKCE pair (code verifier and code challenge). + /// + /// A tuple containing the code verifier and code challenge. + /// Thrown when cryptographic operations fail. + public static (string CodeVerifier, string CodeChallenge) GeneratePkcePair() + { + var codeVerifier = GenerateCodeVerifier(); + var codeChallenge = GenerateCodeChallenge(codeVerifier); + return (codeVerifier, codeChallenge); + } + + /// + /// Checks if a character is URL-safe according to RFC 3986. + /// URL-safe characters are: A-Z, a-z, 0-9, -, _, ., ~ + /// + /// The character to check. + /// True if the character is URL-safe, false otherwise. + private static bool IsUrlSafeCharacter(char c) + { + return (c >= 'A' && c <= 'Z') || // A-Z + (c >= 'a' && c <= 'z') || // a-z + (c >= '0' && c <= '9') || // 0-9 + c == '-' || c == '_' || c == '.' || c == '~'; // Special URL-safe characters + } + + /// + /// Performs a constant-time string comparison to prevent timing attacks. + /// + /// First string to compare. + /// Second string to compare. + /// True if strings are equal, false otherwise. + private static bool ConstantTimeEquals(string a, string b) + { + if (a == null || b == null) + return a == b; + + if (a.Length != b.Length) + return false; + + int result = 0; + for (int i = 0; i < a.Length; i++) + { + result |= a[i] ^ b[i]; + } + + return result == 0; + } + } +} + + diff --git a/Contentstack.Management.Core/contentstack.management.core.csproj b/Contentstack.Management.Core/contentstack.management.core.csproj index 1fd8f92..0f14e8c 100644 --- a/Contentstack.Management.Core/contentstack.management.core.csproj +++ b/Contentstack.Management.Core/contentstack.management.core.csproj @@ -11,7 +11,6 @@ LICENSE.txt https://github.com/contentstack/contentstack-management-dotnet README.md - ContentType query issue resolved Contentstack management API $(Version) $(Version) @@ -63,6 +62,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index 216b2fd..2627f35 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 0.3.2 + 0.4.0