From 1bfceee7f30e48d5122272607e5066526c2c317c Mon Sep 17 00:00:00 2001 From: raj pandey Date: Mon, 8 Sep 2025 16:26:39 +0530 Subject: [PATCH 1/8] Feat Adding oauth support in .NET management SDK --- .../OAuth/InMemoryOAuthTokenStoreTest.cs | 237 ++++ .../OAuth/OAuthHandlerTest.cs | 1023 +++++++++++++++++ .../OAuth/PkceHelperTest.cs | 242 ++++ .../ContentstackClient.cs | 121 ++ .../ContentstackClientOptions.cs | 7 + .../Exceptions/OAuthException.cs | 154 +++ .../Models/OAuthAppAuthorizationResponse.cs | 50 + .../Models/OAuthOptions.cs | 139 +++ .../Models/OAuthResponse.cs | 55 + .../Models/OAuthTokens.cs | 59 + Contentstack.Management.Core/OAuthHandler.cs | 787 +++++++++++++ .../Services/ContentstackService.cs | 11 +- .../OAuth/OAuthAppAuthorizationService.cs | 104 ++ .../OAuth/OAuthAppRevocationService.cs | 104 ++ .../Services/OAuth/OAuthTokenService.cs | 261 +++++ .../Utils/InMemoryOAuthTokenStore.cs | 167 +++ .../Utils/PkceHelper.cs | 206 ++++ 17 files changed, 3726 insertions(+), 1 deletion(-) create mode 100644 Contentstack.Management.Core.Unit.Tests/OAuth/InMemoryOAuthTokenStoreTest.cs create mode 100644 Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs create mode 100644 Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs create mode 100644 Contentstack.Management.Core/Exceptions/OAuthException.cs create mode 100644 Contentstack.Management.Core/Models/OAuthAppAuthorizationResponse.cs create mode 100644 Contentstack.Management.Core/Models/OAuthOptions.cs create mode 100644 Contentstack.Management.Core/Models/OAuthResponse.cs create mode 100644 Contentstack.Management.Core/Models/OAuthTokens.cs create mode 100644 Contentstack.Management.Core/OAuthHandler.cs create mode 100644 Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs create mode 100644 Contentstack.Management.Core/Services/OAuth/OAuthAppRevocationService.cs create mode 100644 Contentstack.Management.Core/Services/OAuth/OAuthTokenService.cs create mode 100644 Contentstack.Management.Core/Utils/InMemoryOAuthTokenStore.cs create mode 100644 Contentstack.Management.Core/Utils/PkceHelper.cs diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/InMemoryOAuthTokenStoreTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/InMemoryOAuthTokenStoreTest.cs new file mode 100644 index 0000000..3c13ca0 --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/InMemoryOAuthTokenStoreTest.cs @@ -0,0 +1,237 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Utils; + +namespace Contentstack.Management.Core.Unit.Tests.OAuth +{ + [TestClass] + public class InMemoryOAuthTokenStoreTest + { + private const string TestClientId = "test-client-id"; + + [TestCleanup] + public void Cleanup() + { + // Clear any test tokens after each test + InMemoryOAuthTokenStore.ClearTokens(TestClientId); + } + + [TestMethod] + public void InMemoryOAuthTokenStore_SetAndGetTokens_ShouldWork() + { + // Arrange + 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 + }; + + // Act + InMemoryOAuthTokenStore.SetTokens(TestClientId, tokens); + var retrievedTokens = InMemoryOAuthTokenStore.GetTokens(TestClientId); + + // Assert + 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 InMemoryOAuthTokenStore_GetTokens_WithNonExistentClientId_ShouldReturnNull() + { + // Act + var tokens = InMemoryOAuthTokenStore.GetTokens("non-existent-client-id"); + + // Assert + Assert.IsNull(tokens); + } + + [TestMethod] + public void InMemoryOAuthTokenStore_HasTokens_WithExistingTokens_ShouldReturnTrue() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = TestClientId + }; + InMemoryOAuthTokenStore.SetTokens(TestClientId, tokens); + + // Act & Assert + Assert.IsTrue(InMemoryOAuthTokenStore.HasTokens(TestClientId)); + } + + [TestMethod] + public void InMemoryOAuthTokenStore_HasTokens_WithNoTokens_ShouldReturnFalse() + { + // Act & Assert + Assert.IsFalse(InMemoryOAuthTokenStore.HasTokens(TestClientId)); + } + + [TestMethod] + public void InMemoryOAuthTokenStore_HasValidTokens_WithValidTokens_ShouldReturnTrue() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = TestClientId + }; + InMemoryOAuthTokenStore.SetTokens(TestClientId, tokens); + + // Act & Assert + Assert.IsTrue(InMemoryOAuthTokenStore.HasValidTokens(TestClientId)); + } + + [TestMethod] + public void InMemoryOAuthTokenStore_HasValidTokens_WithExpiredTokens_ShouldReturnFalse() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), + ClientId = TestClientId + }; + InMemoryOAuthTokenStore.SetTokens(TestClientId, tokens); + + // Act & Assert + Assert.IsFalse(InMemoryOAuthTokenStore.HasValidTokens(TestClientId)); + } + + [TestMethod] + public void InMemoryOAuthTokenStore_HasValidTokens_WithNoTokens_ShouldReturnFalse() + { + // Act & Assert + Assert.IsFalse(InMemoryOAuthTokenStore.HasValidTokens(TestClientId)); + } + + [TestMethod] + public void InMemoryOAuthTokenStore_ClearTokens_ShouldRemoveTokens() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = TestClientId + }; + InMemoryOAuthTokenStore.SetTokens(TestClientId, tokens); + Assert.IsTrue(InMemoryOAuthTokenStore.HasTokens(TestClientId)); + + // Act + InMemoryOAuthTokenStore.ClearTokens(TestClientId); + + // Assert + Assert.IsNull(InMemoryOAuthTokenStore.GetTokens(TestClientId)); + Assert.IsFalse(InMemoryOAuthTokenStore.HasTokens(TestClientId)); + Assert.IsFalse(InMemoryOAuthTokenStore.HasValidTokens(TestClientId)); + } + + [TestMethod] + public void InMemoryOAuthTokenStore_ClearTokens_WithNonExistentClientId_ShouldNotThrow() + { + // Act & Assert - Should not throw + InMemoryOAuthTokenStore.ClearTokens("non-existent-client-id"); + } + + [TestMethod] + public void InMemoryOAuthTokenStore_GetRefreshLock_ShouldReturnSemaphore() + { + // Act + var lock1 = InMemoryOAuthTokenStore.GetRefreshLock(TestClientId); + var lock2 = InMemoryOAuthTokenStore.GetRefreshLock(TestClientId); + + // Assert + Assert.IsNotNull(lock1); + Assert.IsNotNull(lock2); + Assert.AreSame(lock1, lock2); // Should return the same instance + } + + [TestMethod] + public void InMemoryOAuthTokenStore_GetRefreshLock_DifferentClientIds_ShouldReturnDifferentSemaphores() + { + // Act + var lock1 = InMemoryOAuthTokenStore.GetRefreshLock("client-1"); + var lock2 = InMemoryOAuthTokenStore.GetRefreshLock("client-2"); + + // Assert + Assert.IsNotNull(lock1); + Assert.IsNotNull(lock2); + Assert.AreNotSame(lock1, lock2); // Should return different instances + } + + [TestMethod] + public void InMemoryOAuthTokenStore_ThreadSafety_ShouldHandleConcurrentAccess() + { + // Arrange + 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(() => + { + InMemoryOAuthTokenStore.SetTokens("client-1", tokens1); + return InMemoryOAuthTokenStore.GetTokens("client-1"); + }); + + var task2 = Task.Run(() => + { + InMemoryOAuthTokenStore.SetTokens("client-2", tokens2); + return InMemoryOAuthTokenStore.GetTokens("client-2"); + }); + + Task.WaitAll(task1, task2); + + // Assert + Assert.AreEqual("token-1", task1.Result.AccessToken); + Assert.AreEqual("token-2", task2.Result.AccessToken); + } + + [TestMethod] + public void InMemoryOAuthTokenStore_UpdateTokens_ShouldReplaceExistingTokens() + { + // Arrange + var originalTokens = new OAuthTokens + { + AccessToken = "original-token", + ClientId = TestClientId + }; + InMemoryOAuthTokenStore.SetTokens(TestClientId, originalTokens); + + var updatedTokens = new OAuthTokens + { + AccessToken = "updated-token", + RefreshToken = "new-refresh-token", + ClientId = TestClientId + }; + + // Act + InMemoryOAuthTokenStore.SetTokens(TestClientId, updatedTokens); + var retrievedTokens = InMemoryOAuthTokenStore.GetTokens(TestClientId); + + // Assert + Assert.AreEqual("updated-token", retrievedTokens.AccessToken); + Assert.AreEqual("new-refresh-token", retrievedTokens.RefreshToken); + } + } +} + + 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..31deb8b --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs @@ -0,0 +1,1023 @@ +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() + { + // Clear any test tokens + InMemoryOAuthTokenStore.ClearTokens(_options.ClientId); + } + + [TestMethod] + public void OAuthHandler_Constructor_WithValidParameters_ShouldCreateInstance() + { + // Act + var handler = new OAuthHandler(_client, _options); + + // Assert + 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() + { + // Act & Assert + new OAuthHandler(null, _options); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void OAuthHandler_Constructor_WithNullOptions_ShouldThrowException() + { + // Act & Assert + new OAuthHandler(_client, null); + } + + [TestMethod] + [ExpectedException(typeof(OAuthConfigurationException))] + public void OAuthHandler_Constructor_WithInvalidOptions_ShouldThrowException() + { + // Arrange + var invalidOptions = new OAuthOptions + { + AppId = "", // Invalid + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + + // Act & Assert + new OAuthHandler(_client, invalidOptions); + } + + [TestMethod] + public void OAuthHandler_GetCurrentTokens_WithNoTokens_ShouldReturnNull() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + var tokens = handler.GetCurrentTokens(); + + // Assert + Assert.IsNull(tokens); + } + + [TestMethod] + public void OAuthHandler_GetCurrentTokens_WithStoredTokens_ShouldReturnTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var expectedTokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, expectedTokens); + + // Act + var tokens = handler.GetCurrentTokens(); + + // Assert + Assert.IsNotNull(tokens); + Assert.AreEqual("test-token", tokens.AccessToken); + } + + [TestMethod] + public void OAuthHandler_HasValidTokens_WithNoTokens_ShouldReturnFalse() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act & Assert + Assert.IsFalse(handler.HasValidTokens()); + } + + [TestMethod] + public void OAuthHandler_HasValidTokens_WithValidTokens_ShouldReturnTrue() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act & Assert + Assert.IsTrue(handler.HasValidTokens()); + } + + [TestMethod] + public void OAuthHandler_HasValidTokens_WithExpiredTokens_ShouldReturnFalse() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act & Assert + Assert.IsFalse(handler.HasValidTokens()); + } + + [TestMethod] + public void OAuthHandler_HasTokens_WithNoTokens_ShouldReturnFalse() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act & Assert + Assert.IsFalse(handler.HasTokens()); + } + + [TestMethod] + public void OAuthHandler_HasTokens_WithTokens_ShouldReturnTrue() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act & Assert + Assert.IsTrue(handler.HasTokens()); + } + + [TestMethod] + public void OAuthHandler_ClearTokens_ShouldRemoveTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + Assert.IsTrue(handler.HasTokens()); + + // Act + handler.ClearTokens(); + + // Assert + Assert.IsFalse(handler.HasTokens()); + Assert.IsNull(handler.GetCurrentTokens()); + } + + [TestMethod] + public void OAuthHandler_GetAuthorizationUrl_WithPKCE_ShouldReturnValidUrl() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + var authUrl = handler.GetAuthorizationUrl(); + + // Assert + 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_GetAuthorizationUrl_WithTraditionalOAuth_ShouldReturnValidUrl() + { + // Arrange + 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); + + // Act + var authUrl = handler.GetAuthorizationUrl(); + + // Assert + 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_GetAuthorizationUrl_WithScopes_ShouldIncludeScopes() + { + // Arrange + _options.Scope = new[] { "read", "write" }; + var handler = new OAuthHandler(_client, _options); + + // Act + var authUrl = handler.GetAuthorizationUrl(); + + // Assert + Assert.IsTrue(authUrl.Contains("scope=read%20write")); + } + + [TestMethod] + public void OAuthHandler_GetAuthorizationUrl_ShouldStoreCodeVerifierForPKCE() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.GetAuthorizationUrl(); + + // Assert + var storedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + Assert.IsNotNull(storedTokens); + Assert.IsNotNull(storedTokens.AccessToken); // This is the code verifier + Assert.IsTrue(PkceHelper.IsValidCodeVerifier(storedTokens.AccessToken)); + } + + [TestMethod] + public void OAuthHandler_ToString_ShouldReturnFormattedString() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + var result = handler.ToString(); + + // Assert + 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() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act & Assert + try + { + handler.ExchangeCodeForTokenAsync("").Wait(); + Assert.Fail("Should have thrown ArgumentException"); + } + catch (AggregateException ex) when (ex.InnerException is ArgumentException) + { + // Expected + } + } + + [TestMethod] + public void OAuthHandler_ExchangeCodeForTokenAsync_WithNullCode_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act & Assert + try + { + handler.ExchangeCodeForTokenAsync(null).Wait(); + Assert.Fail("Should have thrown ArgumentException"); + } + catch (AggregateException ex) when (ex.InnerException is ArgumentException) + { + // Expected + } + } + + [TestMethod] + public void OAuthHandler_ExchangeCodeForTokenAsync_WithoutCodeVerifier_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act & Assert + try + { + handler.ExchangeCodeForTokenAsync("test-code").Wait(); + Assert.Fail("Should have thrown OAuthTokenException"); + } + catch (AggregateException ex) when (ex.InnerException is OAuthTokenException) + { + // Expected + } + } + + [TestMethod] + public void OAuthHandler_RefreshTokenAsync_WithNoTokens_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act & Assert + try + { + handler.RefreshTokenAsync().Wait(); + Assert.Fail("Should have thrown OAuthTokenRefreshException"); + } + catch (AggregateException ex) when (ex.InnerException is OAuthTokenRefreshException) + { + // Expected + } + } + + [TestMethod] + public void OAuthHandler_RefreshTokenAsync_WithEmptyRefreshToken_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act & Assert + try + { + handler.RefreshTokenAsync("").Wait(); + Assert.Fail("Should have thrown OAuthTokenRefreshException"); + } + catch (AggregateException ex) when (ex.InnerException is OAuthTokenRefreshException) + { + // Expected + } + } + + [TestMethod] + public void OAuthHandler_LogoutAsync_WithNoTokens_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act & Assert + 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() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act & Assert + 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() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act + var result = handler.GetAccessToken(); + + // Assert + Assert.AreEqual("test-access-token", result); + } + + [TestMethod] + public void OAuthHandler_GetAccessToken_WithNoTokens_ShouldReturnNull() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + var result = handler.GetAccessToken(); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void OAuthHandler_GetRefreshToken_WithValidTokens_ShouldReturnRefreshToken() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + RefreshToken = "test-refresh-token", + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act + var result = handler.GetRefreshToken(); + + // Assert + Assert.AreEqual("test-refresh-token", result); + } + + [TestMethod] + public void OAuthHandler_GetRefreshToken_WithNoTokens_ShouldReturnNull() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + var result = handler.GetRefreshToken(); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void OAuthHandler_GetOrganizationUID_WithValidTokens_ShouldReturnOrganizationUID() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + OrganizationUid = "test-org-uid", + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act + var result = handler.GetOrganizationUID(); + + // Assert + Assert.AreEqual("test-org-uid", result); + } + + [TestMethod] + public void OAuthHandler_GetOrganizationUID_WithNoTokens_ShouldReturnNull() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + var result = handler.GetOrganizationUID(); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void OAuthHandler_GetUserUID_WithValidTokens_ShouldReturnUserUID() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + UserUid = "test-user-uid", + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act + var result = handler.GetUserUID(); + + // Assert + Assert.AreEqual("test-user-uid", result); + } + + [TestMethod] + public void OAuthHandler_GetUserUID_WithNoTokens_ShouldReturnNull() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + var result = handler.GetUserUID(); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void OAuthHandler_GetTokenExpiryTime_WithValidTokens_ShouldReturnExpiryTime() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var expiryTime = DateTime.UtcNow.AddHours(1); + var tokens = new OAuthTokens + { + ExpiresAt = expiryTime, + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act + var result = handler.GetTokenExpiryTime(); + + // Assert + Assert.AreEqual(expiryTime, result); + } + + [TestMethod] + public void OAuthHandler_GetTokenExpiryTime_WithNoTokens_ShouldReturnNull() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + var result = handler.GetTokenExpiryTime(); + + // Assert + Assert.IsNull(result); + } + #endregion + + #region Setter Methods Tests + [TestMethod] + public void OAuthHandler_SetAccessToken_WithValidToken_ShouldUpdateTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act + handler.SetAccessToken("new-access-token"); + + // Assert + var updatedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + Assert.AreEqual("new-access-token", updatedTokens.AccessToken); + } + + [TestMethod] + public void OAuthHandler_SetAccessToken_WithNoExistingTokens_ShouldCreateNewTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetAccessToken("new-access-token"); + + // Assert + var tokens = InMemoryOAuthTokenStore.GetTokens(_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() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetAccessToken(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetAccessToken_WithEmptyToken_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetAccessToken(""); + } + + [TestMethod] + public void OAuthHandler_SetRefreshToken_WithValidToken_ShouldUpdateTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act + handler.SetRefreshToken("new-refresh-token"); + + // Assert + var updatedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + Assert.AreEqual("new-refresh-token", updatedTokens.RefreshToken); + } + + [TestMethod] + public void OAuthHandler_SetRefreshToken_WithNoExistingTokens_ShouldCreateNewTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetRefreshToken("new-refresh-token"); + + // Assert + var tokens = InMemoryOAuthTokenStore.GetTokens(_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() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetRefreshToken(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetRefreshToken_WithEmptyToken_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetRefreshToken(""); + } + + [TestMethod] + public void OAuthHandler_SetOrganizationUID_WithValidUID_ShouldUpdateTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act + handler.SetOrganizationUID("new-org-uid"); + + // Assert + var updatedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + Assert.AreEqual("new-org-uid", updatedTokens.OrganizationUid); + } + + [TestMethod] + public void OAuthHandler_SetOrganizationUID_WithNoExistingTokens_ShouldCreateNewTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetOrganizationUID("new-org-uid"); + + // Assert + var tokens = InMemoryOAuthTokenStore.GetTokens(_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() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetOrganizationUID(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetOrganizationUID_WithEmptyUID_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetOrganizationUID(""); + } + + [TestMethod] + public void OAuthHandler_SetUserUID_WithValidUID_ShouldUpdateTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act + handler.SetUserUID("new-user-uid"); + + // Assert + var updatedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + Assert.AreEqual("new-user-uid", updatedTokens.UserUid); + } + + [TestMethod] + public void OAuthHandler_SetUserUID_WithNoExistingTokens_ShouldCreateNewTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetUserUID("new-user-uid"); + + // Assert + var tokens = InMemoryOAuthTokenStore.GetTokens(_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() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetUserUID(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void OAuthHandler_SetUserUID_WithEmptyUID_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + handler.SetUserUID(""); + } + + [TestMethod] + public void OAuthHandler_SetTokenExpiryTime_WithValidTime_ShouldUpdateTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + var newExpiryTime = DateTime.UtcNow.AddHours(2); + + // Act + handler.SetTokenExpiryTime(newExpiryTime); + + // Assert + var updatedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + Assert.AreEqual(newExpiryTime, updatedTokens.ExpiresAt); + } + + [TestMethod] + public void OAuthHandler_SetTokenExpiryTime_WithNoExistingTokens_ShouldCreateNewTokens() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var newExpiryTime = DateTime.UtcNow.AddHours(2); + + // Act + handler.SetTokenExpiryTime(newExpiryTime); + + // Assert + var tokens = InMemoryOAuthTokenStore.GetTokens(_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() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var redirectUrl = "https://example.com/callback?code=test-auth-code&state=test-state"; + + // Act & Assert + 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.OAuthTokenException) + { + // Expected - the actual API call will fail in unit tests + // 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() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + await handler.HandleRedirectAsync(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task OAuthHandler_HandleRedirectAsync_WithEmptyUrl_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + await handler.HandleRedirectAsync(""); + } + + [TestMethod] + [ExpectedException(typeof(Exceptions.OAuthException))] + public async Task OAuthHandler_HandleRedirectAsync_WithUrlMissingCode_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var redirectUrl = "https://example.com/callback?state=test-state"; + + // Act + await handler.HandleRedirectAsync(redirectUrl); + } + + [TestMethod] + [ExpectedException(typeof(Exceptions.OAuthException))] + public async Task OAuthHandler_HandleRedirectAsync_WithUrlContainingEmptyCode_ShouldThrowException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var redirectUrl = "https://example.com/callback?code=&state=test-state"; + + // Act + await handler.HandleRedirectAsync(redirectUrl); + } + + [TestMethod] + public async Task OAuthHandler_HandleRedirectAsync_WithComplexUrl_ShouldParseCorrectly() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var redirectUrl = "https://example.com/callback?code=test-auth-code&state=test-state&other=value"; + + // Act & Assert + try + { + await handler.HandleRedirectAsync(redirectUrl); + // If we get here, the method completed without throwing an exception + } + catch (Exceptions.OAuthTokenException) + { + // Expected - the actual API call will fail in unit tests + // This confirms that the URL parsing worked correctly + } + } + #endregion + + #region Updated LogoutAsync Tests + [TestMethod] + public async Task OAuthHandler_LogoutAsync_WithValidTokens_ShouldCallRevocationAPI() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + UserUid = "test-user-uid", + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act & Assert + 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) + { + // Expected - the actual API call will fail in unit tests due to mocking + // This confirms that the method attempted to call the revocation API + } + } + + [TestMethod] + [ExpectedException(typeof(Exceptions.OAuthException))] + public async Task OAuthHandler_LogoutAsync_WithNoTokens_ShouldThrowOAuthException() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + + // Act + await handler.LogoutAsync(); + } + + [TestMethod] + public async Task OAuthHandler_LogoutAsync_ShouldClearTokensAfterSuccessfulRevocation() + { + // Arrange + var handler = new OAuthHandler(_client, _options); + var tokens = new OAuthTokens + { + AccessToken = "test-token", + UserUid = "test-user-uid", + ClientId = _options.ClientId + }; + InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + + // Act & Assert + try + { + await handler.LogoutAsync(); + // If successful, tokens should be cleared + Assert.IsFalse(handler.HasTokens()); + } + catch (Exceptions.OAuthException) + { + // Expected - the actual API call will fail in unit tests + // In this case, tokens are NOT cleared because the API call failed + Assert.IsTrue(handler.HasTokens()); + } + } + #endregion + } +} 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..4da0c92 --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs @@ -0,0 +1,242 @@ +using System; +using System.Text.RegularExpressions; +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_ShouldReturnValidVerifier() + { + // Act + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + + // Assert + Assert.IsNotNull(codeVerifier); + Assert.IsTrue(codeVerifier.Length >= 43); + Assert.IsTrue(codeVerifier.Length <= 128); + Assert.IsTrue(PkceHelper.IsValidCodeVerifier(codeVerifier)); + } + + [TestMethod] + public void PkceHelper_GenerateCodeVerifier_MultipleCalls_ShouldReturnDifferentValues() + { + // Act + var verifier1 = PkceHelper.GenerateCodeVerifier(); + var verifier2 = PkceHelper.GenerateCodeVerifier(); + + // Assert + Assert.AreNotEqual(verifier1, verifier2); + } + + [TestMethod] + public void PkceHelper_GenerateCodeChallenge_ShouldReturnValidChallenge() + { + // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + + // Act + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + + // Assert + Assert.IsNotNull(codeChallenge); + Assert.AreEqual(43, codeChallenge.Length); + Assert.IsTrue(PkceHelper.IsValidCodeChallenge(codeChallenge)); + } + + [TestMethod] + public void PkceHelper_GenerateCodeChallenge_SameVerifier_ShouldReturnSameChallenge() + { + // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + + // Act + var challenge1 = PkceHelper.GenerateCodeChallenge(codeVerifier); + var challenge2 = PkceHelper.GenerateCodeChallenge(codeVerifier); + + // Assert + Assert.AreEqual(challenge1, challenge2); + } + + [TestMethod] + public void PkceHelper_GenerateCodeChallenge_DifferentVerifiers_ShouldReturnDifferentChallenges() + { + // Arrange + var verifier1 = PkceHelper.GenerateCodeVerifier(); + var verifier2 = PkceHelper.GenerateCodeVerifier(); + + // Act + var challenge1 = PkceHelper.GenerateCodeChallenge(verifier1); + var challenge2 = PkceHelper.GenerateCodeChallenge(verifier2); + + // Assert + Assert.AreNotEqual(challenge1, challenge2); + } + + [TestMethod] + public void PkceHelper_VerifyCodeChallenge_WithValidPair_ShouldReturnTrue() + { + // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + + // Act & Assert + Assert.IsTrue(PkceHelper.VerifyCodeChallenge(codeVerifier, codeChallenge)); + } + + [TestMethod] + public void PkceHelper_VerifyCodeChallenge_WithInvalidChallenge_ShouldReturnFalse() + { + // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var invalidChallenge = "invalid-challenge"; + + // Act & Assert + Assert.IsFalse(PkceHelper.VerifyCodeChallenge(codeVerifier, invalidChallenge)); + } + + [TestMethod] + public void PkceHelper_VerifyCodeChallenge_WithInvalidVerifier_ShouldReturnFalse() + { + // Arrange + var invalidVerifier = "invalid-verifier"; + var codeChallenge = PkceHelper.GenerateCodeChallenge(PkceHelper.GenerateCodeVerifier()); + + // Act & Assert + Assert.IsFalse(PkceHelper.VerifyCodeChallenge(invalidVerifier, codeChallenge)); + } + + [TestMethod] + public void PkceHelper_GeneratePkcePair_ShouldReturnValidPair() + { + // Act + var (verifier, challenge) = PkceHelper.GeneratePkcePair(); + + // Assert + Assert.IsNotNull(verifier); + Assert.IsNotNull(challenge); + Assert.IsTrue(PkceHelper.IsValidCodeVerifier(verifier)); + Assert.IsTrue(PkceHelper.IsValidCodeChallenge(challenge)); + Assert.IsTrue(PkceHelper.VerifyCodeChallenge(verifier, challenge)); + } + + [TestMethod] + public void PkceHelper_IsValidCodeVerifier_WithValidVerifier_ShouldReturnTrue() + { + // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + + // Act & Assert + Assert.IsTrue(PkceHelper.IsValidCodeVerifier(codeVerifier)); + } + + [TestMethod] + public void PkceHelper_IsValidCodeVerifier_WithTooShortVerifier_ShouldReturnFalse() + { + // Arrange + var shortVerifier = "short"; + + // Act & Assert + Assert.IsFalse(PkceHelper.IsValidCodeVerifier(shortVerifier)); + } + + [TestMethod] + public void PkceHelper_IsValidCodeVerifier_WithTooLongVerifier_ShouldReturnFalse() + { + // Arrange + var longVerifier = new string('a', 129); // 129 characters + + // Act & Assert + Assert.IsFalse(PkceHelper.IsValidCodeVerifier(longVerifier)); + } + + [TestMethod] + public void PkceHelper_IsValidCodeVerifier_WithInvalidCharacters_ShouldReturnFalse() + { + // Arrange + var invalidVerifier = "invalid+characters!@#"; + + // Act & Assert + Assert.IsFalse(PkceHelper.IsValidCodeVerifier(invalidVerifier)); + } + + [TestMethod] + public void PkceHelper_IsValidCodeChallenge_WithValidChallenge_ShouldReturnTrue() + { + // Arrange + var codeChallenge = PkceHelper.GenerateCodeChallenge(PkceHelper.GenerateCodeVerifier()); + + // Act & Assert + Assert.IsTrue(PkceHelper.IsValidCodeChallenge(codeChallenge)); + } + + [TestMethod] + public void PkceHelper_IsValidCodeChallenge_WithWrongLength_ShouldReturnFalse() + { + // Arrange + var wrongLengthChallenge = "wrong-length"; + + // Act & Assert + Assert.IsFalse(PkceHelper.IsValidCodeChallenge(wrongLengthChallenge)); + } + + [TestMethod] + public void PkceHelper_IsValidCodeChallenge_WithInvalidCharacters_ShouldReturnFalse() + { + // Arrange + var invalidChallenge = "invalid+characters!@#"; + + // Act & Assert + Assert.IsFalse(PkceHelper.IsValidCodeChallenge(invalidChallenge)); + } + + [TestMethod] + public void PkceHelper_CodeVerifier_ShouldContainOnlyValidCharacters() + { + // Arrange & Act + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + + // Assert - Should only contain URL-safe base64 characters + var validPattern = @"^[A-Za-z0-9\-._~]+$"; + Assert.IsTrue(Regex.IsMatch(codeVerifier, validPattern), + $"Code verifier contains invalid characters: {codeVerifier}"); + } + + [TestMethod] + public void PkceHelper_CodeChallenge_ShouldContainOnlyValidCharacters() + { + // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + + // Act + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + + // Assert - Should only contain URL-safe base64 characters + var validPattern = @"^[A-Za-z0-9\-._~]+$"; + Assert.IsTrue(Regex.IsMatch(codeChallenge, validPattern), + $"Code challenge contains invalid characters: {codeChallenge}"); + } + + [TestMethod] + public void PkceHelper_GenerateCodeVerifier_ShouldBeCryptographicallySecure() + { + // Arrange + var verifiers = new string[100]; + + // Act + for (int i = 0; i < 100; i++) + { + verifiers[i] = PkceHelper.GenerateCodeVerifier(); + } + + // Assert - All verifiers should be unique + var uniqueVerifiers = new System.Collections.Generic.HashSet(verifiers); + Assert.AreEqual(100, uniqueVerifiers.Count, "Generated code verifiers should be unique"); + } + } +} + + diff --git a/Contentstack.Management.Core/ContentstackClient.cs b/Contentstack.Management.Core/ContentstackClient.cs index ddbcc25..215f8e6 100644 --- a/Contentstack.Management.Core/ContentstackClient.cs +++ b/Contentstack.Management.Core/ContentstackClient.cs @@ -430,6 +430,127 @@ 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 + 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 InMemoryOAuthTokenStore.GetTokens(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; + + return InMemoryOAuthTokenStore.HasValidTokens(clientId); + } + + /// + /// 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)) + { + InMemoryOAuthTokenStore.ClearTokens(clientId); + } + + // Reset OAuth flag and clear authtoken if it was an OAuth token + if (contentstackOptions.IsOAuthToken) + { + contentstackOptions.IsOAuthToken = false; + contentstackOptions.Authtoken = null; + } + } + #endregion + /// /// The Get user call returns comprehensive information of an existing user account. /// 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..9d2b6b3 --- /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("An OAuth error occurred.") + { + } + + /// + /// 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 error occurred.") + { + } + + /// + /// 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 error occurred.") + { + } + + /// + /// 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 error occurred.") + { + } + + /// + /// 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 error occurred.") + { + } + + /// + /// 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..a32968c --- /dev/null +++ b/Contentstack.Management.Core/Models/OAuthAppAuthorizationResponse.cs @@ -0,0 +1,50 @@ +using System; +using Newtonsoft.Json; + +namespace Contentstack.Management.Core.Models +{ + /// + /// Represents the response from the OAuth app authorization API. + /// + public class OAuthAppAuthorizationResponse + { + /// + /// Array of OAuth app authorization data. + /// + [JsonProperty("data")] + public OAuthAppAuthorizationData[] Data { get; set; } + } + + /// + /// Represents OAuth app authorization data. + /// + public class OAuthAppAuthorizationData + { + /// + /// The authorization UID. + /// + [JsonProperty("authorization_uid")] + public string AuthorizationUid { get; set; } + + /// + /// The user information. + /// + [JsonProperty("user")] + public OAuthUser User { get; set; } + } + + /// + /// Represents OAuth user information. + /// + public class OAuthUser + { + /// + /// The user UID. + /// + [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..f76a981 --- /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 demo app ID. + /// + public string AppId { get; set; } = "6400aa06db64de001a31c8a9"; + + /// + /// The OAuth client ID. Defaults to the Contentstack demo 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..09092be --- /dev/null +++ b/Contentstack.Management.Core/Models/OAuthResponse.cs @@ -0,0 +1,55 @@ +using System; +using Newtonsoft.Json; + +namespace Contentstack.Management.Core.Models +{ + /// + /// Represents the response from OAuth token exchange operations. + /// + public class OAuthResponse + { + /// + /// The access token used for API authentication. + /// + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + /// + /// The refresh token used to obtain new access tokens. + /// + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + + /// + /// The number of seconds until the access token expires. + /// + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + + /// + /// The organization UID associated with the OAuth tokens. + /// + [JsonProperty("organization_uid")] + public string OrganizationUid { get; set; } + + /// + /// The user UID associated with the OAuth tokens. + /// + [JsonProperty("user_uid")] + public string UserUid { get; set; } + + /// + /// The type of authorization (e.g., "oauth"). + /// + [JsonProperty("authorization_type")] + public string AuthorizationType { get; set; } + + /// + /// The stack API key associated with the OAuth tokens. + /// + [JsonProperty("stack_api_key")] + public string StackApiKey { get; set; } + } +} + + diff --git a/Contentstack.Management.Core/Models/OAuthTokens.cs b/Contentstack.Management.Core/Models/OAuthTokens.cs new file mode 100644 index 0000000..5f15bd2 --- /dev/null +++ b/Contentstack.Management.Core/Models/OAuthTokens.cs @@ -0,0 +1,59 @@ +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 like Model Generator. + /// + public class OAuthTokens + { + /// + /// Gets or sets the access token used for API authentication. + /// + public string AccessToken { get; set; } + + /// + /// Gets or sets the refresh token used to obtain new access tokens. + /// + public string RefreshToken { get; set; } + + /// + /// Gets or sets the date and time when the access token expires. + /// + public DateTime ExpiresAt { get; set; } + + /// + /// Gets or sets the organization UID associated with the OAuth tokens. + /// + public string OrganizationUid { get; set; } + + /// + /// Gets or sets the user UID associated with the OAuth tokens. + /// + public string UserUid { get; set; } + + /// + /// Gets or sets the OAuth client ID associated with these tokens. + /// + public string ClientId { get; set; } + + /// + /// Gets a value indicating whether the access token has expired. + /// + public bool IsExpired => DateTime.UtcNow >= ExpiresAt; + + /// + /// Gets a value indicating whether the access token needs to be refreshed. + /// Tokens are considered to need refresh if they expire within 5 minutes or are already expired. + /// + public bool NeedsRefresh => DateTime.UtcNow >= ExpiresAt.AddMinutes(-5) || IsExpired; + + /// + /// Gets a value indicating whether the OAuth tokens are valid for use. + /// Tokens are valid if they have an access token and are not expired. + /// + 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..9943e40 --- /dev/null +++ b/Contentstack.Management.Core/OAuthHandler.cs @@ -0,0 +1,787 @@ +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; + #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 + /// + /// Gets the OAuth client ID. + /// + public string ClientId => _clientId; + + /// + /// Gets the OAuth application ID. + /// + public string AppId => _options.AppId; + + /// + /// Gets the redirect URI for OAuth callbacks. + /// + public string RedirectUri => _options.RedirectUri; + + /// + /// Gets a value indicating whether PKCE flow is being used. + /// + public bool UsePkce => _options.UsePkce; + + /// + /// Gets the OAuth scopes. + /// + public string[] Scope => _options.Scope; + #endregion + + #region Public Methods + /// + /// Gets the current OAuth tokens for this client. + /// + /// The OAuth tokens if available, null otherwise. + public OAuthTokens GetCurrentTokens() + { + return InMemoryOAuthTokenStore.GetTokens(_clientId); + } + + /// + /// Checks if valid OAuth tokens are available. + /// + /// True if valid tokens are available, false otherwise. + public bool HasValidTokens() + { + return InMemoryOAuthTokenStore.HasValidTokens(_clientId); + } + + /// + /// Checks if OAuth tokens exist (regardless of validity). + /// + /// True if tokens exist, false otherwise. + public bool HasTokens() + { + return InMemoryOAuthTokenStore.HasTokens(_clientId); + } + + /// + /// Clears the OAuth tokens for this client. + /// + public void ClearTokens() + { + InMemoryOAuthTokenStore.ClearTokens(_clientId); + } + + /// + /// Gets a string representation of the OAuth handler for debugging. + /// + /// A string representation of the OAuth handler. + public override string ToString() + { + return $"OAuthHandler: ClientId={_clientId}, AppId={_options.AppId}, UsePkce={_options.UsePkce}, HasTokens={HasTokens()}"; + } + + #region Token Getter Methods + /// + /// Gets the current access token. + /// + /// The access token if available, null otherwise. + public string GetAccessToken() + { + var tokens = GetCurrentTokens(); + return tokens?.AccessToken; + } + + /// + /// Gets the current refresh token. + /// + /// The refresh token if available, null otherwise. + public string GetRefreshToken() + { + var tokens = GetCurrentTokens(); + return tokens?.RefreshToken; + } + + /// + /// Gets the current organization UID. + /// + /// The organization UID if available, null otherwise. + public string GetOrganizationUID() + { + var tokens = GetCurrentTokens(); + return tokens?.OrganizationUid; + } + + /// + /// Gets the current user UID. + /// + /// The user UID if available, null otherwise. + public string GetUserUID() + { + var tokens = GetCurrentTokens(); + return tokens?.UserUid; + } + + /// + /// Gets the current token expiry time. + /// + /// The token expiry time if available, null otherwise. + public DateTime? GetTokenExpiryTime() + { + var tokens = GetCurrentTokens(); + return tokens?.ExpiresAt; + } + #endregion + + #region Token Setter Methods + /// + /// Sets the access token in the stored OAuth tokens. + /// + /// The access token to set. + /// Thrown when the token is null or empty. + public void SetAccessToken(string token) + { + if (string.IsNullOrEmpty(token)) + throw new ArgumentException("Access token cannot be null or empty.", nameof(token)); + + var tokens = GetCurrentTokens(); + if (tokens == null) + { + tokens = new OAuthTokens { ClientId = _clientId }; + } + tokens.AccessToken = token; + InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + } + + /// + /// Sets the refresh token in the stored OAuth tokens. + /// + /// The refresh token to set. + /// Thrown when the token is null or empty. + public void SetRefreshToken(string token) + { + if (string.IsNullOrEmpty(token)) + throw new ArgumentException("Refresh token cannot be null or empty.", nameof(token)); + + var tokens = GetCurrentTokens(); + if (tokens == null) + { + tokens = new OAuthTokens { ClientId = _clientId }; + } + tokens.RefreshToken = token; + InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + } + + /// + /// Sets the organization UID in the stored OAuth tokens. + /// + /// The organization UID to set. + /// Thrown when the organization UID is null or empty. + public void SetOrganizationUID(string organizationUID) + { + if (string.IsNullOrEmpty(organizationUID)) + throw new ArgumentException("Organization UID cannot be null or empty.", nameof(organizationUID)); + + var tokens = GetCurrentTokens(); + if (tokens == null) + { + tokens = new OAuthTokens { ClientId = _clientId }; + } + tokens.OrganizationUid = organizationUID; + InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + } + + /// + /// Sets the user UID in the stored OAuth tokens. + /// + /// The user UID to set. + /// Thrown when the user UID is null or empty. + public void SetUserUID(string userUID) + { + if (string.IsNullOrEmpty(userUID)) + throw new ArgumentException("User UID cannot be null or empty.", nameof(userUID)); + + var tokens = GetCurrentTokens(); + if (tokens == null) + { + tokens = new OAuthTokens { ClientId = _clientId }; + } + tokens.UserUid = userUID; + InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + } + + /// + /// Sets the token expiry time in the stored OAuth tokens. + /// + /// The token expiry time to set. + /// Thrown when the expiry time is not provided. + public void SetTokenExpiryTime(DateTime expiryTime) + { + if (expiryTime == default(DateTime)) + throw new ArgumentException("Token expiry time cannot be default value.", nameof(expiryTime)); + + var tokens = GetCurrentTokens(); + if (tokens == null) + { + tokens = new OAuthTokens { ClientId = _clientId }; + } + tokens.ExpiresAt = expiryTime; + InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + } + #endregion + + #endregion + + #region Protected Methods + /// + /// Gets the Contentstack client instance. + /// + /// The Contentstack client instance. + protected ContentstackClient GetClient() + { + return _client; + } + + /// + /// Gets the OAuth options. + /// + /// The OAuth options. + protected OAuthOptions GetOptions() + { + return _options; + } + #endregion + + #region OAuth Flow Methods + /// + /// Generates the OAuth authorization URL for user authentication (async version). + /// This URL should be opened in a browser to start the OAuth flow. + /// + /// A task that represents the asynchronous operation. The task result contains the authorization URL. + /// Thrown when the OAuth configuration is invalid. + /// Thrown when PKCE code generation fails. + public async Task AuthorizeAsync() + { + return await Task.FromResult(GetAuthorizationUrl()); + } + + /// + /// Generates the OAuth authorization URL for user authentication. + /// This URL should be opened in a browser to start the OAuth flow. + /// + /// The authorization URL. + /// Thrown when the OAuth configuration is invalid. + /// Thrown when PKCE code generation fails. + [Obsolete("Use AuthorizeAsync()")] + public string GetAuthorizationUrl() + { + // 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 = $"{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 + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + + // Add PKCE parameters + queryParams.Add($"code_challenge={Uri.EscapeDataString(codeChallenge)}"); + queryParams.Add("code_challenge_method=S256"); + + // Store code verifier temporarily for later use in token exchange + var tempTokens = new OAuthTokens + { + ClientId = _clientId, + AccessToken = codeVerifier // Temporarily store code verifier + }; + InMemoryOAuthTokenStore.SetTokens(_clientId, tempTokens); + } + // Traditional OAuth flow - no additional parameters needed + + // Build the complete URL + authUrl.Query = string.Join("&", queryParams); + return 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. + /// This method should be called after the user completes the OAuth authorization flow. + /// + /// The authorization code received from the OAuth provider. + /// A task that represents the asynchronous operation. The task result contains the OAuth tokens. + /// Thrown when the authorization code is null or empty. + /// Thrown when the OAuth configuration is invalid or code verifier is missing. + /// Thrown when the token exchange request fails. + public async Task ExchangeCodeForTokenAsync(string authorizationCode) + { + if (string.IsNullOrEmpty(authorizationCode)) + { + throw new ArgumentException("Authorization code cannot be null or empty.", nameof(authorizationCode)); + } + + try + { + // Create the OAuth token service for authorization code exchange + OAuthTokenService tokenService; + + if (_options.UsePkce) + { + // PKCE flow - get stored code verifier + var storedTokens = InMemoryOAuthTokenStore.GetTokens(_clientId); + if (storedTokens?.AccessToken == null) + { + throw new Exceptions.OAuthTokenException( + "Code verifier not found. Please call GetAuthorizationUrl() first to generate the authorization URL and code verifier."); + } + + var codeVerifier = storedTokens.AccessToken; // This is the stored code verifier + tokenService = OAuthTokenService.CreateForAuthorizationCode( + serializer: GetClient().serializer, + authorizationCode: authorizationCode, + clientId: _options.ClientId, + redirectUri: _options.RedirectUri, + codeVerifier: codeVerifier + ); + } + else + { + // Traditional OAuth flow - use client secret + if (string.IsNullOrEmpty(_options.ClientSecret)) + { + throw new Exceptions.OAuthConfigurationException( + "Client secret is required for traditional OAuth flow. Please set the 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 + }; + + // Store tokens in memory for future use + InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + + // Set OAuth tokens in the client for authenticated requests + GetClient().SetOAuthTokens(tokens); + + return tokens; + } + catch (Exception ex) when (!(ex is ArgumentException || ex is Exceptions.OAuthException)) + { + throw new Exceptions.OAuthTokenException($"Failed to exchange authorization code for tokens: {ex.Message}", ex); + } + } + + /// + /// Refreshes the OAuth access token using the refresh token. + /// This method automatically handles token refresh and updates the stored tokens. + /// + /// The refresh token to use. If null, uses the stored refresh token. + /// A task that represents the asynchronous operation. The task result contains the new OAuth tokens. + /// Thrown when no refresh token is available or the refresh request fails. + /// Thrown when the token refresh request fails. + 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 = InMemoryOAuthTokenStore.GetTokens(_clientId); + if (storedTokens?.RefreshToken == null) + { + throw new Exceptions.OAuthTokenRefreshException( + "No refresh token available. Please provide a refresh token or ensure tokens are stored from a previous OAuth flow."); + } + 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. Please set the ClientSecret in OAuth options or use PKCE 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 + }; + + // Store the new tokens in memory + InMemoryOAuthTokenStore.SetTokens(_clientId, newTokens); + + // Set OAuth tokens in the client for authenticated requests + GetClient().SetOAuthTokens(newTokens); + + return newTokens; + } + catch (Exception ex) when (!(ex is Exceptions.OAuthException)) + { + throw new Exceptions.OAuthTokenRefreshException($"Failed to refresh OAuth tokens: {ex.Message}", ex); + } + } + + /// + /// Logs out the user by clearing OAuth tokens and resetting the client authentication state. + /// This method clears the stored tokens and resets the client to use traditional authentication. + /// + /// A task that represents the asynchronous operation. The task result contains a success message. + /// Thrown when no OAuth tokens are available to logout. + public async Task LogoutAsync() + { + try + { + // Check if we have tokens to logout + var currentTokens = InMemoryOAuthTokenStore.GetTokens(_clientId); + if (currentTokens == null) + { + throw new Exceptions.OAuthException("No OAuth tokens found. User is not logged in via OAuth."); + } + + // Try to revoke the OAuth app authorization (optional - if it fails, we still clear tokens) + try + { + var authorizationId = await GetOauthAppAuthorizationAsync(); + await RevokeOauthAppAuthorizationAsync(authorizationId); + } + catch (Exception ex) + { + // Log the revocation failure but don't fail the logout + // This is common in OAuth implementations where revocation is optional + System.Diagnostics.Debug.WriteLine($"OAuth authorization revocation failed (non-critical): {ex.Message}"); + } + + // Clear tokens from memory store + InMemoryOAuthTokenStore.ClearTokens(_clientId); + + // Clear OAuth tokens from the client + GetClient().ClearOAuthTokens(_clientId); + + // 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 and resetting the client authentication state. + /// This method clears the stored tokens and resets the client to use traditional authentication. + /// + /// A success message. + /// Thrown when no OAuth tokens are available to logout. + public string Logout() + { + try + { + // Check if we have tokens to logout + var currentTokens = InMemoryOAuthTokenStore.GetTokens(_clientId); + if (currentTokens == null) + { + throw new Exceptions.OAuthException("No OAuth tokens found. User is not logged in via OAuth."); + } + + // Clear tokens from memory store + InMemoryOAuthTokenStore.ClearTokens(_clientId); + + // Clear OAuth tokens from the client + GetClient().ClearOAuthTokens(_clientId); + + // Return success message + return "Logged out successfully"; + } + 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. + /// + /// The redirect URL containing the authorization code. + /// A task that represents the asynchronous operation. + /// Thrown when the URL is null or empty. + /// Thrown when the authorization code is not found in the URL. + /// Thrown when token exchange fails. + public async Task HandleRedirectAsync(string url) + { + if (string.IsNullOrEmpty(url)) + throw new ArgumentException("Redirect URL cannot be null or empty.", 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 new Exceptions.OAuthTokenException($"Failed to handle redirect URL: {ex.Message}", ex); + } + } + #endregion + + #region Private Methods + /// + /// Transforms the base hostname to the appropriate OAuth hostname. + /// + /// The base hostname (e.g., api.contentstack.io) + /// The transformed OAuth hostname (e.g., app.contentstack.com) + private static string GetOAuthHost(string baseHost) + { + if (string.IsNullOrEmpty(baseHost)) + return baseHost; + + // Transform api.contentstack.io -> app.contentstack.com + var oauthHost = baseHost; + + // 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; + } + + /// + /// Gets the OAuth app authorization for the current user. + /// + /// A task that represents the asynchronous operation. The task result contains the authorization ID. + /// Thrown when no authorization is found. + private async Task GetOauthAppAuthorizationAsync() + { + var tokens = GetCurrentTokens(); + if (tokens == null) + { + throw new Exceptions.OAuthException("No OAuth tokens found. User is not logged in via OAuth."); + } + + try + { + // Create a service to get OAuth app authorizations + var service = new Services.OAuth.OAuthAppAuthorizationService( + GetClient().serializer, + _options.AppId + ); + + // 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 new Exceptions.OAuthException($"Failed to get OAuth app authorization: {ex.Message}", ex); + } + } + + /// + /// Revokes the OAuth app authorization for the current user. + /// + /// The authorization ID to revoke. + /// A task that represents the asynchronous operation. + /// Thrown when the authorization ID is null or empty. + /// Thrown when revocation fails. + private async Task RevokeOauthAppAuthorizationAsync(string authorizationId) + { + if (string.IsNullOrEmpty(authorizationId)) + throw new ArgumentException("Authorization ID cannot be null or empty.", nameof(authorizationId)); + + try + { + // Create a service to revoke OAuth app authorization + var service = new Services.OAuth.OAuthAppRevocationService( + GetClient().serializer, + _options.AppId, + authorizationId + ); + + // Make the API call to revoke authorization + await GetClient().InvokeAsync(service); + } + catch (Exception ex) when (!(ex is ArgumentException || ex is Exceptions.OAuthException)) + { + throw new Exceptions.OAuthException($"Failed to revoke OAuth app authorization: {ex.Message}", ex); + } + } + #endregion + + #region Future Method Placeholders + // These methods will be implemented in subsequent phases: + // - GetValidTokensAsync() + #endregion + } +} diff --git a/Contentstack.Management.Core/Services/ContentstackService.cs b/Contentstack.Management.Core/Services/ContentstackService.cs index 771b7f1..1b58170 100644 --- a/Contentstack.Management.Core/Services/ContentstackService.cs +++ b/Contentstack.Management.Core/Services/ContentstackService.cs @@ -170,7 +170,16 @@ public virtual IHttpRequest CreateHttpRequest(HttpClient httpClient, Contentstac } else if (!string.IsNullOrEmpty(config.Authtoken)) { - Headers["authtoken"] = config.Authtoken; + if (config.IsOAuthToken) + { + // OAuth Bearer token format + Headers["authorization"] = $"Bearer {config.Authtoken}"; + } + else + { + // Traditional authtoken format + Headers["authtoken"] = config.Authtoken; + } } if (!string.IsNullOrEmpty(apiVersion)) diff --git a/Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs b/Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs new file mode 100644 index 0000000..489ee97 --- /dev/null +++ b/Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs @@ -0,0 +1,104 @@ +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; + + /// + /// Initializes a new instance of the OAuthAppAuthorizationService class. + /// + /// The JSON serializer. + /// The OAuth app ID. + internal OAuthAppAuthorizationService(JsonSerializer serializer, string appId) + : base(serializer) + { + if (string.IsNullOrEmpty(appId)) + throw new ArgumentException("App ID cannot be null or empty.", nameof(appId)); + + _appId = appId; + 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, + IsOAuthToken = true // This service requires OAuth authentication + }; + + var request = base.CreateHttpRequest(httpClient, devHubConfig, addAcceptMediaHeader, apiVersion); + + // Set the required headers for OAuth app authorization API + Headers["Content-Type"] = "application/json"; + + 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..beb18fd --- /dev/null +++ b/Contentstack.Management.Core/Services/OAuth/OAuthAppRevocationService.cs @@ -0,0 +1,104 @@ +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; + + /// + /// Initializes a new instance of the OAuthAppRevocationService class. + /// + /// The JSON serializer. + /// The OAuth app ID. + /// The authorization ID to revoke. + internal OAuthAppRevocationService(JsonSerializer serializer, string appId, string authorizationId) + : 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; + 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 + }; + + 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..1aed33f --- /dev/null +++ b/Contentstack.Management.Core/Services/OAuth/OAuthTokenService.cs @@ -0,0 +1,261 @@ +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 + Authtoken = config.Authtoken, + IsOAuthToken = config.IsOAuthToken + }; + + 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/Utils/InMemoryOAuthTokenStore.cs b/Contentstack.Management.Core/Utils/InMemoryOAuthTokenStore.cs new file mode 100644 index 0000000..b8d7782 --- /dev/null +++ b/Contentstack.Management.Core/Utils/InMemoryOAuthTokenStore.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Contentstack.Management.Core.Models; + +namespace Contentstack.Management.Core.Utils +{ + /// + /// Thread-safe in-memory storage for OAuth tokens that can be accessed across SDK instances. + /// This enables sharing OAuth tokens between the Management SDK and other SDKs like Model Generator. + /// + public static class InMemoryOAuthTokenStore + { + private static readonly ConcurrentDictionary _tokens = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary _refreshLocks = new ConcurrentDictionary(); + + /// + /// Gets OAuth tokens for the specified client ID. + /// + /// The OAuth client ID. + /// The OAuth tokens if found, null otherwise. + public static OAuthTokens GetTokens(string clientId) + { + if (string.IsNullOrEmpty(clientId)) + return null; + + _tokens.TryGetValue(clientId, out var tokens); + return tokens; + } + + /// + /// Stores OAuth tokens for the specified client ID. + /// + /// The OAuth client ID. + /// The OAuth tokens to store. + public static void SetTokens(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)); + + _tokens.AddOrUpdate(clientId, tokens, (key, oldValue) => tokens); + } + + /// + /// Removes OAuth tokens for the specified client ID. + /// + /// The OAuth client ID. + public static void ClearTokens(string clientId) + { + if (string.IsNullOrEmpty(clientId)) + return; + + _tokens.TryRemove(clientId, out _); + + // Clean up refresh lock + if (_refreshLocks.TryRemove(clientId, out var semaphore)) + { + semaphore?.Dispose(); + } + } + + /// + /// Gets or creates a semaphore for token refresh operations to prevent race conditions. + /// + /// The OAuth client ID. + /// A semaphore for coordinating refresh operations. + public static SemaphoreSlim GetRefreshLock(string clientId) + { + if (string.IsNullOrEmpty(clientId)) + throw new ArgumentException("Client ID cannot be null or empty.", nameof(clientId)); + + return _refreshLocks.GetOrAdd(clientId, _ => new SemaphoreSlim(1, 1)); + } + + /// + /// Checks if valid OAuth tokens exist for the specified client ID. + /// + /// The OAuth client ID. + /// True if valid tokens exist, false otherwise. + public static bool HasValidTokens(string clientId) + { + var tokens = GetTokens(clientId); + return tokens?.IsValid == true; + } + + /// + /// Checks if OAuth tokens exist for the specified client ID (regardless of validity). + /// + /// The OAuth client ID. + /// True if tokens exist, false otherwise. + public static bool HasTokens(string clientId) + { + return !string.IsNullOrEmpty(clientId) && _tokens.ContainsKey(clientId); + } + + /// + /// Gets the number of stored token sets. + /// + /// The number of stored token sets. + public static int TokenCount => _tokens.Count; + + /// + /// Clears all stored OAuth tokens and disposes of all refresh locks. + /// + public static void ClearAllTokens() + { + _tokens.Clear(); + + // Dispose all semaphores + foreach (var kvp in _refreshLocks) + { + kvp.Value?.Dispose(); + } + _refreshLocks.Clear(); + } + + /// + /// Gets a snapshot of all stored client IDs. + /// + /// An array of all stored client IDs. + public static string[] GetAllClientIds() + { + var keys = new string[_tokens.Count]; + _tokens.Keys.CopyTo(keys, 0); + return keys; + } + + /// + /// Waits for a refresh lock to be available and returns a disposable lock. + /// + /// The OAuth client ID. + /// A task that resolves to a disposable refresh lock. + public static async Task WaitForRefreshLockAsync(string clientId) + { + var semaphore = GetRefreshLock(clientId); + await semaphore.WaitAsync(); + return new RefreshLock(semaphore); + } + + /// + /// Disposable wrapper for refresh locks to ensure proper cleanup. + /// + private sealed class RefreshLock : IDisposable + { + private readonly SemaphoreSlim _semaphore; + private bool _disposed = false; + + public RefreshLock(SemaphoreSlim semaphore) + { + _semaphore = semaphore; + } + + public void Dispose() + { + if (!_disposed) + { + _semaphore?.Release(); + _disposed = true; + } + } + } + } +} 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; + } + } +} + + From 6296468e6f1b9054f3733d962e75fedd5ac7e16c Mon Sep 17 00:00:00 2001 From: raj pandey Date: Tue, 9 Sep 2025 21:17:06 +0530 Subject: [PATCH 2/8] Using Dictonary for storing the token --- .../OAuth/OAuthHandlerTest.cs | 60 +++---- ...nStoreTest.cs => OAuthTokenStorageTest.cs} | 110 +++++------- .../ContentstackClient.cs | 83 ++++++++- Contentstack.Management.Core/OAuthHandler.cs | 142 ++++++++++----- .../OAuth/OAuthAppAuthorizationService.cs | 19 +- .../OAuth/OAuthAppRevocationService.cs | 12 +- .../Utils/InMemoryOAuthTokenStore.cs | 167 ------------------ 7 files changed, 278 insertions(+), 315 deletions(-) rename Contentstack.Management.Core.Unit.Tests/OAuth/{InMemoryOAuthTokenStoreTest.cs => OAuthTokenStorageTest.cs} (51%) delete mode 100644 Contentstack.Management.Core/Utils/InMemoryOAuthTokenStore.cs diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs index 31deb8b..49f7a37 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs @@ -31,7 +31,7 @@ public void Setup() public void Cleanup() { // Clear any test tokens - InMemoryOAuthTokenStore.ClearTokens(_options.ClientId); + _client.ClearOAuthTokens(_options.ClientId); } [TestMethod] @@ -104,7 +104,7 @@ public void OAuthHandler_GetCurrentTokens_WithStoredTokens_ShouldReturnTokens() AccessToken = "test-token", ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, expectedTokens); + _client.StoreOAuthTokens(_options.ClientId, expectedTokens); // Act var tokens = handler.GetCurrentTokens(); @@ -135,7 +135,7 @@ public void OAuthHandler_HasValidTokens_WithValidTokens_ShouldReturnTrue() ExpiresAt = DateTime.UtcNow.AddHours(1), ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act & Assert Assert.IsTrue(handler.HasValidTokens()); @@ -152,7 +152,7 @@ public void OAuthHandler_HasValidTokens_WithExpiredTokens_ShouldReturnFalse() ExpiresAt = DateTime.UtcNow.AddMinutes(-5), ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act & Assert Assert.IsFalse(handler.HasValidTokens()); @@ -178,7 +178,7 @@ public void OAuthHandler_HasTokens_WithTokens_ShouldReturnTrue() AccessToken = "test-token", ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act & Assert Assert.IsTrue(handler.HasTokens()); @@ -194,7 +194,7 @@ public void OAuthHandler_ClearTokens_ShouldRemoveTokens() AccessToken = "test-token", ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); Assert.IsTrue(handler.HasTokens()); // Act @@ -272,7 +272,7 @@ public void OAuthHandler_GetAuthorizationUrl_ShouldStoreCodeVerifierForPKCE() handler.GetAuthorizationUrl(); // Assert - var storedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var storedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(storedTokens); Assert.IsNotNull(storedTokens.AccessToken); // This is the code verifier Assert.IsTrue(PkceHelper.IsValidCodeVerifier(storedTokens.AccessToken)); @@ -413,7 +413,7 @@ public void OAuthHandler_LogoutAsync_WithTokens_ShouldReturnSuccessMessage() AccessToken = "test-token", ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act & Assert try @@ -443,7 +443,7 @@ public void OAuthHandler_GetAccessToken_WithValidTokens_ShouldReturnAccessToken( AccessToken = "test-access-token", ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act var result = handler.GetAccessToken(); @@ -475,7 +475,7 @@ public void OAuthHandler_GetRefreshToken_WithValidTokens_ShouldReturnRefreshToke RefreshToken = "test-refresh-token", ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act var result = handler.GetRefreshToken(); @@ -507,7 +507,7 @@ public void OAuthHandler_GetOrganizationUID_WithValidTokens_ShouldReturnOrganiza OrganizationUid = "test-org-uid", ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act var result = handler.GetOrganizationUID(); @@ -539,7 +539,7 @@ public void OAuthHandler_GetUserUID_WithValidTokens_ShouldReturnUserUID() UserUid = "test-user-uid", ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act var result = handler.GetUserUID(); @@ -572,7 +572,7 @@ public void OAuthHandler_GetTokenExpiryTime_WithValidTokens_ShouldReturnExpiryTi ExpiresAt = expiryTime, ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act var result = handler.GetTokenExpiryTime(); @@ -605,13 +605,13 @@ public void OAuthHandler_SetAccessToken_WithValidToken_ShouldUpdateTokens() { ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act handler.SetAccessToken("new-access-token"); // Assert - var updatedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var updatedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.AreEqual("new-access-token", updatedTokens.AccessToken); } @@ -625,7 +625,7 @@ public void OAuthHandler_SetAccessToken_WithNoExistingTokens_ShouldCreateNewToke handler.SetAccessToken("new-access-token"); // Assert - var tokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var tokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(tokens); Assert.AreEqual("new-access-token", tokens.AccessToken); Assert.AreEqual(_options.ClientId, tokens.ClientId); @@ -662,13 +662,13 @@ public void OAuthHandler_SetRefreshToken_WithValidToken_ShouldUpdateTokens() { ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act handler.SetRefreshToken("new-refresh-token"); // Assert - var updatedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var updatedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.AreEqual("new-refresh-token", updatedTokens.RefreshToken); } @@ -682,7 +682,7 @@ public void OAuthHandler_SetRefreshToken_WithNoExistingTokens_ShouldCreateNewTok handler.SetRefreshToken("new-refresh-token"); // Assert - var tokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var tokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(tokens); Assert.AreEqual("new-refresh-token", tokens.RefreshToken); Assert.AreEqual(_options.ClientId, tokens.ClientId); @@ -719,13 +719,13 @@ public void OAuthHandler_SetOrganizationUID_WithValidUID_ShouldUpdateTokens() { ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act handler.SetOrganizationUID("new-org-uid"); // Assert - var updatedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var updatedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.AreEqual("new-org-uid", updatedTokens.OrganizationUid); } @@ -739,7 +739,7 @@ public void OAuthHandler_SetOrganizationUID_WithNoExistingTokens_ShouldCreateNew handler.SetOrganizationUID("new-org-uid"); // Assert - var tokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var tokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(tokens); Assert.AreEqual("new-org-uid", tokens.OrganizationUid); Assert.AreEqual(_options.ClientId, tokens.ClientId); @@ -776,13 +776,13 @@ public void OAuthHandler_SetUserUID_WithValidUID_ShouldUpdateTokens() { ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act handler.SetUserUID("new-user-uid"); // Assert - var updatedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var updatedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.AreEqual("new-user-uid", updatedTokens.UserUid); } @@ -796,7 +796,7 @@ public void OAuthHandler_SetUserUID_WithNoExistingTokens_ShouldCreateNewTokens() handler.SetUserUID("new-user-uid"); // Assert - var tokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var tokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(tokens); Assert.AreEqual("new-user-uid", tokens.UserUid); Assert.AreEqual(_options.ClientId, tokens.ClientId); @@ -833,14 +833,14 @@ public void OAuthHandler_SetTokenExpiryTime_WithValidTime_ShouldUpdateTokens() { ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); var newExpiryTime = DateTime.UtcNow.AddHours(2); // Act handler.SetTokenExpiryTime(newExpiryTime); // Assert - var updatedTokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var updatedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.AreEqual(newExpiryTime, updatedTokens.ExpiresAt); } @@ -855,7 +855,7 @@ public void OAuthHandler_SetTokenExpiryTime_WithNoExistingTokens_ShouldCreateNew handler.SetTokenExpiryTime(newExpiryTime); // Assert - var tokens = InMemoryOAuthTokenStore.GetTokens(_options.ClientId); + var tokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(tokens); Assert.AreEqual(newExpiryTime, tokens.ExpiresAt); Assert.AreEqual(_options.ClientId, tokens.ClientId); @@ -963,7 +963,7 @@ public async Task OAuthHandler_LogoutAsync_WithValidTokens_ShouldCallRevocationA UserUid = "test-user-uid", ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act & Assert try @@ -1002,7 +1002,7 @@ public async Task OAuthHandler_LogoutAsync_ShouldClearTokensAfterSuccessfulRevoc UserUid = "test-user-uid", ClientId = _options.ClientId }; - InMemoryOAuthTokenStore.SetTokens(_options.ClientId, tokens); + _client.StoreOAuthTokens(_options.ClientId, tokens); // Act & Assert try diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/InMemoryOAuthTokenStoreTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenStorageTest.cs similarity index 51% rename from Contentstack.Management.Core.Unit.Tests/OAuth/InMemoryOAuthTokenStoreTest.cs rename to Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenStorageTest.cs index 3c13ca0..adaac06 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/InMemoryOAuthTokenStoreTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenStorageTest.cs @@ -1,25 +1,32 @@ using System; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Contentstack.Management.Core; using Contentstack.Management.Core.Models; -using Contentstack.Management.Core.Utils; namespace Contentstack.Management.Core.Unit.Tests.OAuth { [TestClass] - public class InMemoryOAuthTokenStoreTest + 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 - InMemoryOAuthTokenStore.ClearTokens(TestClientId); + _client.ClearOAuthTokens(TestClientId); } [TestMethod] - public void InMemoryOAuthTokenStore_SetAndGetTokens_ShouldWork() + public void OAuthTokenStorage_SetAndGetTokens_ShouldWork() { // Arrange var tokens = new OAuthTokens @@ -33,8 +40,8 @@ public void InMemoryOAuthTokenStore_SetAndGetTokens_ShouldWork() }; // Act - InMemoryOAuthTokenStore.SetTokens(TestClientId, tokens); - var retrievedTokens = InMemoryOAuthTokenStore.GetTokens(TestClientId); + _client.StoreOAuthTokens(TestClientId, tokens); + var retrievedTokens = _client.GetOAuthTokens(TestClientId); // Assert Assert.IsNotNull(retrievedTokens); @@ -46,17 +53,17 @@ public void InMemoryOAuthTokenStore_SetAndGetTokens_ShouldWork() } [TestMethod] - public void InMemoryOAuthTokenStore_GetTokens_WithNonExistentClientId_ShouldReturnNull() + public void OAuthTokenStorage_GetTokens_WithNonExistentClientId_ShouldReturnNull() { // Act - var tokens = InMemoryOAuthTokenStore.GetTokens("non-existent-client-id"); + var tokens = _client.GetOAuthTokens("non-existent-client-id"); // Assert Assert.IsNull(tokens); } [TestMethod] - public void InMemoryOAuthTokenStore_HasTokens_WithExistingTokens_ShouldReturnTrue() + public void OAuthTokenStorage_HasTokens_WithExistingTokens_ShouldReturnTrue() { // Arrange var tokens = new OAuthTokens @@ -64,21 +71,21 @@ public void InMemoryOAuthTokenStore_HasTokens_WithExistingTokens_ShouldReturnTru AccessToken = "test-token", ClientId = TestClientId }; - InMemoryOAuthTokenStore.SetTokens(TestClientId, tokens); + _client.StoreOAuthTokens(TestClientId, tokens); // Act & Assert - Assert.IsTrue(InMemoryOAuthTokenStore.HasTokens(TestClientId)); + Assert.IsTrue(_client.HasOAuthTokens(TestClientId)); } [TestMethod] - public void InMemoryOAuthTokenStore_HasTokens_WithNoTokens_ShouldReturnFalse() + public void OAuthTokenStorage_HasTokens_WithNoTokens_ShouldReturnFalse() { // Act & Assert - Assert.IsFalse(InMemoryOAuthTokenStore.HasTokens(TestClientId)); + Assert.IsFalse(_client.HasOAuthTokens(TestClientId)); } [TestMethod] - public void InMemoryOAuthTokenStore_HasValidTokens_WithValidTokens_ShouldReturnTrue() + public void OAuthTokenStorage_HasValidTokens_WithValidTokens_ShouldReturnTrue() { // Arrange var tokens = new OAuthTokens @@ -87,14 +94,14 @@ public void InMemoryOAuthTokenStore_HasValidTokens_WithValidTokens_ShouldReturnT ExpiresAt = DateTime.UtcNow.AddHours(1), ClientId = TestClientId }; - InMemoryOAuthTokenStore.SetTokens(TestClientId, tokens); + _client.StoreOAuthTokens(TestClientId, tokens); // Act & Assert - Assert.IsTrue(InMemoryOAuthTokenStore.HasValidTokens(TestClientId)); + Assert.IsTrue(_client.HasValidOAuthTokens(TestClientId)); } [TestMethod] - public void InMemoryOAuthTokenStore_HasValidTokens_WithExpiredTokens_ShouldReturnFalse() + public void OAuthTokenStorage_HasValidTokens_WithExpiredTokens_ShouldReturnFalse() { // Arrange var tokens = new OAuthTokens @@ -103,21 +110,21 @@ public void InMemoryOAuthTokenStore_HasValidTokens_WithExpiredTokens_ShouldRetur ExpiresAt = DateTime.UtcNow.AddMinutes(-5), ClientId = TestClientId }; - InMemoryOAuthTokenStore.SetTokens(TestClientId, tokens); + _client.StoreOAuthTokens(TestClientId, tokens); // Act & Assert - Assert.IsFalse(InMemoryOAuthTokenStore.HasValidTokens(TestClientId)); + Assert.IsFalse(_client.HasValidOAuthTokens(TestClientId)); } [TestMethod] - public void InMemoryOAuthTokenStore_HasValidTokens_WithNoTokens_ShouldReturnFalse() + public void OAuthTokenStorage_HasValidTokens_WithNoTokens_ShouldReturnFalse() { // Act & Assert - Assert.IsFalse(InMemoryOAuthTokenStore.HasValidTokens(TestClientId)); + Assert.IsFalse(_client.HasValidOAuthTokens(TestClientId)); } [TestMethod] - public void InMemoryOAuthTokenStore_ClearTokens_ShouldRemoveTokens() + public void OAuthTokenStorage_ClearTokens_ShouldRemoveTokens() { // Arrange var tokens = new OAuthTokens @@ -125,53 +132,28 @@ public void InMemoryOAuthTokenStore_ClearTokens_ShouldRemoveTokens() AccessToken = "test-token", ClientId = TestClientId }; - InMemoryOAuthTokenStore.SetTokens(TestClientId, tokens); - Assert.IsTrue(InMemoryOAuthTokenStore.HasTokens(TestClientId)); + _client.StoreOAuthTokens(TestClientId, tokens); + Assert.IsTrue(_client.HasOAuthTokens(TestClientId)); // Act - InMemoryOAuthTokenStore.ClearTokens(TestClientId); + _client.ClearOAuthTokens(TestClientId); // Assert - Assert.IsNull(InMemoryOAuthTokenStore.GetTokens(TestClientId)); - Assert.IsFalse(InMemoryOAuthTokenStore.HasTokens(TestClientId)); - Assert.IsFalse(InMemoryOAuthTokenStore.HasValidTokens(TestClientId)); + Assert.IsNull(_client.GetOAuthTokens(TestClientId)); + Assert.IsFalse(_client.HasOAuthTokens(TestClientId)); + Assert.IsFalse(_client.HasOAuthTokens(TestClientId)); } [TestMethod] - public void InMemoryOAuthTokenStore_ClearTokens_WithNonExistentClientId_ShouldNotThrow() + public void OAuthTokenStorage_ClearTokens_WithNonExistentClientId_ShouldNotThrow() { // Act & Assert - Should not throw - InMemoryOAuthTokenStore.ClearTokens("non-existent-client-id"); - } - - [TestMethod] - public void InMemoryOAuthTokenStore_GetRefreshLock_ShouldReturnSemaphore() - { - // Act - var lock1 = InMemoryOAuthTokenStore.GetRefreshLock(TestClientId); - var lock2 = InMemoryOAuthTokenStore.GetRefreshLock(TestClientId); - - // Assert - Assert.IsNotNull(lock1); - Assert.IsNotNull(lock2); - Assert.AreSame(lock1, lock2); // Should return the same instance + _client.ClearOAuthTokens("non-existent-client-id"); } - [TestMethod] - public void InMemoryOAuthTokenStore_GetRefreshLock_DifferentClientIds_ShouldReturnDifferentSemaphores() - { - // Act - var lock1 = InMemoryOAuthTokenStore.GetRefreshLock("client-1"); - var lock2 = InMemoryOAuthTokenStore.GetRefreshLock("client-2"); - - // Assert - Assert.IsNotNull(lock1); - Assert.IsNotNull(lock2); - Assert.AreNotSame(lock1, lock2); // Should return different instances - } [TestMethod] - public void InMemoryOAuthTokenStore_ThreadSafety_ShouldHandleConcurrentAccess() + public void OAuthTokenStorage_ThreadSafety_ShouldHandleConcurrentAccess() { // Arrange var tokens1 = new OAuthTokens @@ -188,14 +170,14 @@ public void InMemoryOAuthTokenStore_ThreadSafety_ShouldHandleConcurrentAccess() // Act - Simulate concurrent access var task1 = Task.Run(() => { - InMemoryOAuthTokenStore.SetTokens("client-1", tokens1); - return InMemoryOAuthTokenStore.GetTokens("client-1"); + _client.StoreOAuthTokens("client-1", tokens1); + return _client.GetOAuthTokens("client-1"); }); var task2 = Task.Run(() => { - InMemoryOAuthTokenStore.SetTokens("client-2", tokens2); - return InMemoryOAuthTokenStore.GetTokens("client-2"); + _client.StoreOAuthTokens("client-2", tokens2); + return _client.GetOAuthTokens("client-2"); }); Task.WaitAll(task1, task2); @@ -206,7 +188,7 @@ public void InMemoryOAuthTokenStore_ThreadSafety_ShouldHandleConcurrentAccess() } [TestMethod] - public void InMemoryOAuthTokenStore_UpdateTokens_ShouldReplaceExistingTokens() + public void OAuthTokenStorage_UpdateTokens_ShouldReplaceExistingTokens() { // Arrange var originalTokens = new OAuthTokens @@ -214,7 +196,7 @@ public void InMemoryOAuthTokenStore_UpdateTokens_ShouldReplaceExistingTokens() AccessToken = "original-token", ClientId = TestClientId }; - InMemoryOAuthTokenStore.SetTokens(TestClientId, originalTokens); + _client.StoreOAuthTokens(TestClientId, originalTokens); var updatedTokens = new OAuthTokens { @@ -224,8 +206,8 @@ public void InMemoryOAuthTokenStore_UpdateTokens_ShouldReplaceExistingTokens() }; // Act - InMemoryOAuthTokenStore.SetTokens(TestClientId, updatedTokens); - var retrievedTokens = InMemoryOAuthTokenStore.GetTokens(TestClientId); + _client.StoreOAuthTokens(TestClientId, updatedTokens); + var retrievedTokens = _client.GetOAuthTokens(TestClientId); // Assert Assert.AreEqual("updated-token", retrievedTokens.AccessToken); diff --git a/Contentstack.Management.Core/ContentstackClient.cs b/Contentstack.Management.Core/ContentstackClient.cs index 215f8e6..16d4a80 100644 --- a/Contentstack.Management.Core/ContentstackClient.cs +++ b/Contentstack.Management.Core/ContentstackClient.cs @@ -37,6 +37,9 @@ public class ContentstackClient : IContentstackClient private string Version => "0.3.2"; private string xUserAgent => $"contentstack-management-dotnet/{Version}"; + + // OAuth token storage + private readonly Dictionary _oauthTokens = new Dictionary(); #endregion @@ -514,7 +517,20 @@ public OAuthTokens GetOAuthTokens(string clientId) if (string.IsNullOrEmpty(clientId)) throw new ArgumentException("Client ID cannot be null or empty.", nameof(clientId)); - return InMemoryOAuthTokenStore.GetTokens(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); } /// @@ -527,7 +543,8 @@ public bool HasValidOAuthTokens(string clientId) if (string.IsNullOrEmpty(clientId)) return false; - return InMemoryOAuthTokenStore.HasValidTokens(clientId); + var tokens = GetStoredOAuthTokens(clientId); + return tokens?.IsValid == true; } /// @@ -539,7 +556,11 @@ public void ClearOAuthTokens(string clientId = null) { if (!string.IsNullOrEmpty(clientId)) { - InMemoryOAuthTokenStore.ClearTokens(clientId); + ClearStoredOAuthTokens(clientId); + } + else + { + _oauthTokens.Clear(); } // Reset OAuth flag and clear authtoken if it was an OAuth token @@ -551,6 +572,62 @@ public void ClearOAuthTokens(string clientId = 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); + } + #endregion + /// /// The Get user call returns comprehensive information of an existing user account. /// diff --git a/Contentstack.Management.Core/OAuthHandler.cs b/Contentstack.Management.Core/OAuthHandler.cs index 9943e40..35fc22b 100644 --- a/Contentstack.Management.Core/OAuthHandler.cs +++ b/Contentstack.Management.Core/OAuthHandler.cs @@ -81,7 +81,7 @@ public OAuthHandler(ContentstackClient client, OAuthOptions options) /// The OAuth tokens if available, null otherwise. public OAuthTokens GetCurrentTokens() { - return InMemoryOAuthTokenStore.GetTokens(_clientId); + return _client.GetStoredOAuthTokens(_clientId); } /// @@ -90,7 +90,8 @@ public OAuthTokens GetCurrentTokens() /// True if valid tokens are available, false otherwise. public bool HasValidTokens() { - return InMemoryOAuthTokenStore.HasValidTokens(_clientId); + var tokens = _client.GetStoredOAuthTokens(_clientId); + return tokens?.IsValid == true; } /// @@ -99,7 +100,7 @@ public bool HasValidTokens() /// True if tokens exist, false otherwise. public bool HasTokens() { - return InMemoryOAuthTokenStore.HasTokens(_clientId); + return _client.HasStoredOAuthTokens(_clientId); } /// @@ -107,7 +108,16 @@ public bool HasTokens() /// public void ClearTokens() { - InMemoryOAuthTokenStore.ClearTokens(_clientId); + _client.ClearStoredOAuthTokens(_clientId); + } + + /// + /// Stores OAuth tokens in the client. + /// + /// The OAuth tokens to store. + private void StoreTokens(OAuthTokens tokens) + { + _client.StoreOAuthTokens(_clientId, tokens); } /// @@ -188,7 +198,7 @@ public void SetAccessToken(string token) tokens = new OAuthTokens { ClientId = _clientId }; } tokens.AccessToken = token; - InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + StoreTokens(tokens); } /// @@ -207,7 +217,7 @@ public void SetRefreshToken(string token) tokens = new OAuthTokens { ClientId = _clientId }; } tokens.RefreshToken = token; - InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + StoreTokens(tokens); } /// @@ -226,7 +236,7 @@ public void SetOrganizationUID(string organizationUID) tokens = new OAuthTokens { ClientId = _clientId }; } tokens.OrganizationUid = organizationUID; - InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + StoreTokens(tokens); } /// @@ -245,7 +255,7 @@ public void SetUserUID(string userUID) tokens = new OAuthTokens { ClientId = _clientId }; } tokens.UserUid = userUID; - InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + StoreTokens(tokens); } /// @@ -264,7 +274,7 @@ public void SetTokenExpiryTime(DateTime expiryTime) tokens = new OAuthTokens { ClientId = _clientId }; } tokens.ExpiresAt = expiryTime; - InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + StoreTokens(tokens); } #endregion @@ -355,7 +365,7 @@ public string GetAuthorizationUrl() ClientId = _clientId, AccessToken = codeVerifier // Temporarily store code verifier }; - InMemoryOAuthTokenStore.SetTokens(_clientId, tempTokens); + StoreTokens(tempTokens); } // Traditional OAuth flow - no additional parameters needed @@ -393,7 +403,7 @@ public async Task ExchangeCodeForTokenAsync(string authorizationCod if (_options.UsePkce) { // PKCE flow - get stored code verifier - var storedTokens = InMemoryOAuthTokenStore.GetTokens(_clientId); + var storedTokens = GetCurrentTokens(); if (storedTokens?.AccessToken == null) { throw new Exceptions.OAuthTokenException( @@ -445,7 +455,7 @@ public async Task ExchangeCodeForTokenAsync(string authorizationCod }; // Store tokens in memory for future use - InMemoryOAuthTokenStore.SetTokens(_clientId, tokens); + StoreTokens(tokens); // Set OAuth tokens in the client for authenticated requests GetClient().SetOAuthTokens(tokens); @@ -474,7 +484,7 @@ public async Task RefreshTokenAsync(string refreshToken = null) if (string.IsNullOrEmpty(tokenToUse)) { // Get refresh token from stored tokens - var storedTokens = InMemoryOAuthTokenStore.GetTokens(_clientId); + var storedTokens = GetCurrentTokens(); if (storedTokens?.RefreshToken == null) { throw new Exceptions.OAuthTokenRefreshException( @@ -532,7 +542,7 @@ public async Task RefreshTokenAsync(string refreshToken = null) }; // Store the new tokens in memory - InMemoryOAuthTokenStore.SetTokens(_clientId, newTokens); + StoreTokens(newTokens); // Set OAuth tokens in the client for authenticated requests GetClient().SetOAuthTokens(newTokens); @@ -556,27 +566,31 @@ public async Task LogoutAsync() try { // Check if we have tokens to logout - var currentTokens = InMemoryOAuthTokenStore.GetTokens(_clientId); + var currentTokens = GetCurrentTokens(); if (currentTokens == null) { throw new Exceptions.OAuthException("No OAuth tokens found. User is not logged in via OAuth."); } // Try to revoke the OAuth app authorization (optional - if it fails, we still clear tokens) - try - { - var authorizationId = await GetOauthAppAuthorizationAsync(); - await RevokeOauthAppAuthorizationAsync(authorizationId); - } - catch (Exception ex) + // Only attempt revocation if we have valid tokens + if (currentTokens != null && !string.IsNullOrEmpty(currentTokens.AccessToken)) { - // Log the revocation failure but don't fail the logout - // This is common in OAuth implementations where revocation is optional - System.Diagnostics.Debug.WriteLine($"OAuth authorization revocation failed (non-critical): {ex.Message}"); + try + { + var authorizationId = await GetOauthAppAuthorizationAsync(); + await RevokeOauthAppAuthorizationAsync(authorizationId); + } + catch (Exception ex) + { + // Log the revocation failure but don't fail the logout + // This is common in OAuth implementations where revocation is optional + System.Diagnostics.Debug.WriteLine($"OAuth authorization revocation failed (non-critical): {ex.Message}"); + } } // Clear tokens from memory store - InMemoryOAuthTokenStore.ClearTokens(_clientId); + ClearTokens(); // Clear OAuth tokens from the client GetClient().ClearOAuthTokens(_clientId); @@ -601,14 +615,14 @@ public string Logout() try { // Check if we have tokens to logout - var currentTokens = InMemoryOAuthTokenStore.GetTokens(_clientId); + var currentTokens = GetCurrentTokens(); if (currentTokens == null) { throw new Exceptions.OAuthException("No OAuth tokens found. User is not logged in via OAuth."); } // Clear tokens from memory store - InMemoryOAuthTokenStore.ClearTokens(_clientId); + ClearTokens(); // Clear OAuth tokens from the client GetClient().ClearOAuthTokens(_clientId); @@ -718,28 +732,45 @@ private async Task GetOauthAppAuthorizationAsync() // Create a service to get OAuth app authorizations var service = new Services.OAuth.OAuthAppAuthorizationService( GetClient().serializer, - _options.AppId + _options.AppId, + tokens.OrganizationUid ); - // Make the API call to get authorizations - var response = await GetClient().InvokeAsync(service); - var authResponse = response.OpenTResponse(); - - if (authResponse?.Data?.Length > 0) + // Configure the client with OAuth access token for this request + var originalAuthtoken = GetClient().contentstackOptions.Authtoken; + var originalIsOAuthToken = GetClient().contentstackOptions.IsOAuthToken; + + GetClient().contentstackOptions.Authtoken = tokens.AccessToken; + GetClient().contentstackOptions.IsOAuthToken = true; + + try { - var userUid = tokens.UserUid; - var currentUserAuthorization = authResponse.Data.FirstOrDefault(auth => auth.User?.Uid == userUid); - - if (currentUserAuthorization == null) + // 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 current user!"); + throw new Exceptions.OAuthException("No authorizations found for the app!"); } - - return currentUserAuthorization.AuthorizationUid; } - else + finally { - throw new Exceptions.OAuthException("No authorizations found for the app!"); + // Restore original client configuration + GetClient().contentstackOptions.Authtoken = originalAuthtoken; + GetClient().contentstackOptions.IsOAuthToken = originalIsOAuthToken; } } catch (Exception ex) when (!(ex is Exceptions.OAuthException)) @@ -762,15 +793,36 @@ private async Task RevokeOauthAppAuthorizationAsync(string 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 + authorizationId, + organizationUid ); - // Make the API call to revoke authorization - await GetClient().InvokeAsync(service); + // Configure the client with OAuth access token for this request + var originalAuthtoken = GetClient().contentstackOptions.Authtoken; + var originalIsOAuthToken = GetClient().contentstackOptions.IsOAuthToken; + + GetClient().contentstackOptions.Authtoken = tokens.AccessToken; + GetClient().contentstackOptions.IsOAuthToken = true; + + try + { + // Make the API call to revoke authorization + await GetClient().InvokeAsync(service); + } + finally + { + // Restore original client configuration + GetClient().contentstackOptions.Authtoken = originalAuthtoken; + GetClient().contentstackOptions.IsOAuthToken = originalIsOAuthToken; + } } catch (Exception ex) when (!(ex is ArgumentException || ex is Exceptions.OAuthException)) { diff --git a/Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs b/Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs index 489ee97..1570c21 100644 --- a/Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs +++ b/Contentstack.Management.Core/Services/OAuth/OAuthAppAuthorizationService.cs @@ -12,19 +12,22 @@ namespace Contentstack.Management.Core.Services.OAuth 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. - internal OAuthAppAuthorizationService(JsonSerializer serializer, string appId) + /// 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(); } @@ -55,15 +58,21 @@ public override IHttpRequest CreateHttpRequest(System.Net.Http.HttpClient httpCl Host = GetDeveloperHubHostname(config.Host), Port = config.Port, Version = "", // OAuth endpoints don't use versioning - Authtoken = config.Authtoken, + Authtoken = config.Authtoken, // This should contain the OAuth access token IsOAuthToken = true // This service requires OAuth authentication }; - var request = base.CreateHttpRequest(httpClient, devHubConfig, addAcceptMediaHeader, apiVersion); - - // Set the required headers for OAuth app authorization API + // 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; } diff --git a/Contentstack.Management.Core/Services/OAuth/OAuthAppRevocationService.cs b/Contentstack.Management.Core/Services/OAuth/OAuthAppRevocationService.cs index beb18fd..3ae5cdb 100644 --- a/Contentstack.Management.Core/Services/OAuth/OAuthAppRevocationService.cs +++ b/Contentstack.Management.Core/Services/OAuth/OAuthAppRevocationService.cs @@ -11,6 +11,7 @@ internal class OAuthAppRevocationService : ContentstackService { private readonly string _appId; private readonly string _authorizationId; + private readonly string _organizationUid; /// /// Initializes a new instance of the OAuthAppRevocationService class. @@ -18,7 +19,8 @@ internal class OAuthAppRevocationService : ContentstackService /// The JSON serializer. /// The OAuth app ID. /// The authorization ID to revoke. - internal OAuthAppRevocationService(JsonSerializer serializer, string appId, string authorizationId) + /// The organization UID for OAuth operations. + internal OAuthAppRevocationService(JsonSerializer serializer, string appId, string authorizationId, string organizationUid = null) : base(serializer) { if (string.IsNullOrEmpty(appId)) @@ -28,6 +30,7 @@ internal OAuthAppRevocationService(JsonSerializer serializer, string appId, stri _appId = appId; _authorizationId = authorizationId; + _organizationUid = organizationUid; InitializeService(); } @@ -62,6 +65,13 @@ public override IHttpRequest CreateHttpRequest(System.Net.Http.HttpClient httpCl 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; diff --git a/Contentstack.Management.Core/Utils/InMemoryOAuthTokenStore.cs b/Contentstack.Management.Core/Utils/InMemoryOAuthTokenStore.cs deleted file mode 100644 index b8d7782..0000000 --- a/Contentstack.Management.Core/Utils/InMemoryOAuthTokenStore.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; -using Contentstack.Management.Core.Models; - -namespace Contentstack.Management.Core.Utils -{ - /// - /// Thread-safe in-memory storage for OAuth tokens that can be accessed across SDK instances. - /// This enables sharing OAuth tokens between the Management SDK and other SDKs like Model Generator. - /// - public static class InMemoryOAuthTokenStore - { - private static readonly ConcurrentDictionary _tokens = new ConcurrentDictionary(); - private static readonly ConcurrentDictionary _refreshLocks = new ConcurrentDictionary(); - - /// - /// Gets OAuth tokens for the specified client ID. - /// - /// The OAuth client ID. - /// The OAuth tokens if found, null otherwise. - public static OAuthTokens GetTokens(string clientId) - { - if (string.IsNullOrEmpty(clientId)) - return null; - - _tokens.TryGetValue(clientId, out var tokens); - return tokens; - } - - /// - /// Stores OAuth tokens for the specified client ID. - /// - /// The OAuth client ID. - /// The OAuth tokens to store. - public static void SetTokens(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)); - - _tokens.AddOrUpdate(clientId, tokens, (key, oldValue) => tokens); - } - - /// - /// Removes OAuth tokens for the specified client ID. - /// - /// The OAuth client ID. - public static void ClearTokens(string clientId) - { - if (string.IsNullOrEmpty(clientId)) - return; - - _tokens.TryRemove(clientId, out _); - - // Clean up refresh lock - if (_refreshLocks.TryRemove(clientId, out var semaphore)) - { - semaphore?.Dispose(); - } - } - - /// - /// Gets or creates a semaphore for token refresh operations to prevent race conditions. - /// - /// The OAuth client ID. - /// A semaphore for coordinating refresh operations. - public static SemaphoreSlim GetRefreshLock(string clientId) - { - if (string.IsNullOrEmpty(clientId)) - throw new ArgumentException("Client ID cannot be null or empty.", nameof(clientId)); - - return _refreshLocks.GetOrAdd(clientId, _ => new SemaphoreSlim(1, 1)); - } - - /// - /// Checks if valid OAuth tokens exist for the specified client ID. - /// - /// The OAuth client ID. - /// True if valid tokens exist, false otherwise. - public static bool HasValidTokens(string clientId) - { - var tokens = GetTokens(clientId); - return tokens?.IsValid == true; - } - - /// - /// Checks if OAuth tokens exist for the specified client ID (regardless of validity). - /// - /// The OAuth client ID. - /// True if tokens exist, false otherwise. - public static bool HasTokens(string clientId) - { - return !string.IsNullOrEmpty(clientId) && _tokens.ContainsKey(clientId); - } - - /// - /// Gets the number of stored token sets. - /// - /// The number of stored token sets. - public static int TokenCount => _tokens.Count; - - /// - /// Clears all stored OAuth tokens and disposes of all refresh locks. - /// - public static void ClearAllTokens() - { - _tokens.Clear(); - - // Dispose all semaphores - foreach (var kvp in _refreshLocks) - { - kvp.Value?.Dispose(); - } - _refreshLocks.Clear(); - } - - /// - /// Gets a snapshot of all stored client IDs. - /// - /// An array of all stored client IDs. - public static string[] GetAllClientIds() - { - var keys = new string[_tokens.Count]; - _tokens.Keys.CopyTo(keys, 0); - return keys; - } - - /// - /// Waits for a refresh lock to be available and returns a disposable lock. - /// - /// The OAuth client ID. - /// A task that resolves to a disposable refresh lock. - public static async Task WaitForRefreshLockAsync(string clientId) - { - var semaphore = GetRefreshLock(clientId); - await semaphore.WaitAsync(); - return new RefreshLock(semaphore); - } - - /// - /// Disposable wrapper for refresh locks to ensure proper cleanup. - /// - private sealed class RefreshLock : IDisposable - { - private readonly SemaphoreSlim _semaphore; - private bool _disposed = false; - - public RefreshLock(SemaphoreSlim semaphore) - { - _semaphore = semaphore; - } - - public void Dispose() - { - if (!_disposed) - { - _semaphore?.Release(); - _disposed = true; - } - } - } - } -} From 0a902b3ee123eed584f4985441072e32a7a50fc5 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Thu, 11 Sep 2025 20:02:25 +0530 Subject: [PATCH 3/8] Oauth Support in Dotnet Management SDK --- .../ContentstackClient.cs | 83 +++- .../Exceptions/OAuthException.cs | 10 +- .../Models/OAuthAppAuthorizationResponse.cs | 20 +- .../Models/OAuthOptions.cs | 4 +- .../Models/OAuthResponse.cs | 32 +- .../Models/OAuthTokens.cs | 34 +- Contentstack.Management.Core/OAuthHandler.cs | 425 ++++++------------ 7 files changed, 233 insertions(+), 375 deletions(-) diff --git a/Contentstack.Management.Core/ContentstackClient.cs b/Contentstack.Management.Core/ContentstackClient.cs index 16d4a80..5a3c7b2 100644 --- a/Contentstack.Management.Core/ContentstackClient.cs +++ b/Contentstack.Management.Core/ContentstackClient.cs @@ -40,6 +40,8 @@ public class ContentstackClient : IContentstackClient // OAuth token storage private readonly Dictionary _oauthTokens = new Dictionary(); + + private bool _isRefreshingToken = false; #endregion @@ -228,12 +230,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() { @@ -241,7 +249,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 @@ -502,6 +510,8 @@ internal void SetOAuthTokens(OAuthTokens 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; } @@ -665,5 +675,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/Exceptions/OAuthException.cs b/Contentstack.Management.Core/Exceptions/OAuthException.cs index 9d2b6b3..3691092 100644 --- a/Contentstack.Management.Core/Exceptions/OAuthException.cs +++ b/Contentstack.Management.Core/Exceptions/OAuthException.cs @@ -10,7 +10,7 @@ public class OAuthException : ContentstackException /// /// Initializes a new instance of the OAuthException class. /// - public OAuthException() : base("An OAuth error occurred.") + public OAuthException() : base("OAuth operation failed.") { } @@ -40,7 +40,7 @@ public class OAuthConfigurationException : OAuthException /// /// Initializes a new instance of the OAuthConfigurationException class. /// - public OAuthConfigurationException() : base("OAuth configuration error occurred.") + public OAuthConfigurationException() : base("OAuth configuration is invalid.") { } @@ -70,7 +70,7 @@ public class OAuthTokenException : OAuthException /// /// Initializes a new instance of the OAuthTokenException class. /// - public OAuthTokenException() : base("OAuth token error occurred.") + public OAuthTokenException() : base("OAuth token operation failed.") { } @@ -100,7 +100,7 @@ public class OAuthAuthorizationException : OAuthException /// /// Initializes a new instance of the OAuthAuthorizationException class. /// - public OAuthAuthorizationException() : base("OAuth authorization error occurred.") + public OAuthAuthorizationException() : base("OAuth authorization failed.") { } @@ -130,7 +130,7 @@ public class OAuthTokenRefreshException : OAuthTokenException /// /// Initializes a new instance of the OAuthTokenRefreshException class. /// - public OAuthTokenRefreshException() : base("OAuth token refresh error occurred.") + public OAuthTokenRefreshException() : base("OAuth token refresh failed.") { } diff --git a/Contentstack.Management.Core/Models/OAuthAppAuthorizationResponse.cs b/Contentstack.Management.Core/Models/OAuthAppAuthorizationResponse.cs index a32968c..6f875a7 100644 --- a/Contentstack.Management.Core/Models/OAuthAppAuthorizationResponse.cs +++ b/Contentstack.Management.Core/Models/OAuthAppAuthorizationResponse.cs @@ -8,9 +8,7 @@ namespace Contentstack.Management.Core.Models /// public class OAuthAppAuthorizationResponse { - /// - /// Array of OAuth app authorization data. - /// + [JsonProperty("data")] public OAuthAppAuthorizationData[] Data { get; set; } } @@ -20,27 +18,19 @@ public class OAuthAppAuthorizationResponse /// public class OAuthAppAuthorizationData { - /// - /// The authorization UID. - /// + [JsonProperty("authorization_uid")] public string AuthorizationUid { get; set; } - /// - /// The user information. - /// + [JsonProperty("user")] public OAuthUser User { get; set; } } - /// - /// Represents OAuth user information. - /// + public class OAuthUser { - /// - /// The user UID. - /// + [JsonProperty("uid")] public string Uid { get; set; } } diff --git a/Contentstack.Management.Core/Models/OAuthOptions.cs b/Contentstack.Management.Core/Models/OAuthOptions.cs index f76a981..e6b9862 100644 --- a/Contentstack.Management.Core/Models/OAuthOptions.cs +++ b/Contentstack.Management.Core/Models/OAuthOptions.cs @@ -8,12 +8,12 @@ namespace Contentstack.Management.Core.Models public class OAuthOptions { /// - /// The OAuth application ID. Defaults to the Contentstack demo app ID. + /// The OAuth application ID. Defaults to the Contentstack app ID. /// public string AppId { get; set; } = "6400aa06db64de001a31c8a9"; /// - /// The OAuth client ID. Defaults to the Contentstack demo client ID. + /// The OAuth client ID. Defaults to the Contentstack client ID. /// public string ClientId { get; set; } = "Ie0FEfTzlfAHL4xM"; diff --git a/Contentstack.Management.Core/Models/OAuthResponse.cs b/Contentstack.Management.Core/Models/OAuthResponse.cs index 09092be..714bc0d 100644 --- a/Contentstack.Management.Core/Models/OAuthResponse.cs +++ b/Contentstack.Management.Core/Models/OAuthResponse.cs @@ -8,47 +8,25 @@ namespace Contentstack.Management.Core.Models /// public class OAuthResponse { - /// - /// The access token used for API authentication. - /// + [JsonProperty("access_token")] public string AccessToken { get; set; } - /// - /// The refresh token used to obtain new access tokens. - /// + [JsonProperty("refresh_token")] public string RefreshToken { get; set; } - /// - /// The number of seconds until the access token expires. - /// + [JsonProperty("expires_in")] public int ExpiresIn { get; set; } - /// - /// The organization UID associated with the OAuth tokens. - /// + [JsonProperty("organization_uid")] public string OrganizationUid { get; set; } - /// - /// The user UID associated with the OAuth tokens. - /// + [JsonProperty("user_uid")] public string UserUid { get; set; } - - /// - /// The type of authorization (e.g., "oauth"). - /// - [JsonProperty("authorization_type")] - public string AuthorizationType { get; set; } - - /// - /// The stack API key associated with the OAuth tokens. - /// - [JsonProperty("stack_api_key")] - public string StackApiKey { get; set; } } } diff --git a/Contentstack.Management.Core/Models/OAuthTokens.cs b/Contentstack.Management.Core/Models/OAuthTokens.cs index 5f15bd2..a111ec8 100644 --- a/Contentstack.Management.Core/Models/OAuthTokens.cs +++ b/Contentstack.Management.Core/Models/OAuthTokens.cs @@ -4,55 +4,29 @@ 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 like Model Generator. + /// This class enables sharing OAuth tokens between the Management SDK and other SDKs /// public class OAuthTokens { - /// - /// Gets or sets the access token used for API authentication. - /// + public string AccessToken { get; set; } - /// - /// Gets or sets the refresh token used to obtain new access tokens. - /// public string RefreshToken { get; set; } - /// - /// Gets or sets the date and time when the access token expires. - /// public DateTime ExpiresAt { get; set; } - /// - /// Gets or sets the organization UID associated with the OAuth tokens. - /// public string OrganizationUid { get; set; } - /// - /// Gets or sets the user UID associated with the OAuth tokens. - /// public string UserUid { get; set; } - /// - /// Gets or sets the OAuth client ID associated with these tokens. - /// public string ClientId { get; set; } - /// - /// Gets a value indicating whether the access token has expired. - /// + public string AppId { get; set; } + public bool IsExpired => DateTime.UtcNow >= ExpiresAt; - /// - /// Gets a value indicating whether the access token needs to be refreshed. - /// Tokens are considered to need refresh if they expire within 5 minutes or are already expired. - /// public bool NeedsRefresh => DateTime.UtcNow >= ExpiresAt.AddMinutes(-5) || IsExpired; - /// - /// Gets a value indicating whether the OAuth tokens are valid for use. - /// Tokens are valid if they have an access token and are not expired. - /// public bool IsValid => !string.IsNullOrEmpty(AccessToken) && !IsExpired; } } diff --git a/Contentstack.Management.Core/OAuthHandler.cs b/Contentstack.Management.Core/OAuthHandler.cs index 35fc22b..aea4111 100644 --- a/Contentstack.Management.Core/OAuthHandler.cs +++ b/Contentstack.Management.Core/OAuthHandler.cs @@ -20,6 +20,10 @@ public class OAuthHandler private readonly ContentstackClient _client; private readonly OAuthOptions _options; private readonly string _clientId; + + private string codeVerifier = ""; + + private string codeChallenge = ""; #endregion #region Constructor @@ -48,132 +52,135 @@ public OAuthHandler(ContentstackClient client, OAuthOptions options) #endregion #region Public Properties - /// - /// Gets the OAuth client ID. - /// public string ClientId => _clientId; - /// - /// Gets the OAuth application ID. - /// public string AppId => _options.AppId; - - /// - /// Gets the redirect URI for OAuth callbacks. - /// public string RedirectUri => _options.RedirectUri; - - /// - /// Gets a value indicating whether PKCE flow is being used. - /// public bool UsePkce => _options.UsePkce; - - /// - /// Gets the OAuth scopes. - /// 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 - /// - /// Gets the current OAuth tokens for this client. - /// - /// The OAuth tokens if available, null otherwise. public OAuthTokens GetCurrentTokens() { return _client.GetStoredOAuthTokens(_clientId); } - /// - /// Checks if valid OAuth tokens are available. - /// - /// True if valid tokens are available, false otherwise. public bool HasValidTokens() { var tokens = _client.GetStoredOAuthTokens(_clientId); return tokens?.IsValid == true; } - /// - /// Checks if OAuth tokens exist (regardless of validity). - /// - /// True if tokens exist, false otherwise. public bool HasTokens() { return _client.HasStoredOAuthTokens(_clientId); } - /// - /// Clears the OAuth tokens for this client. - /// public void ClearTokens() { _client.ClearStoredOAuthTokens(_clientId); } - /// - /// Stores OAuth tokens in the client. - /// - /// The OAuth tokens to store. private void StoreTokens(OAuthTokens tokens) { _client.StoreOAuthTokens(_clientId, tokens); } - /// - /// Gets a string representation of the OAuth handler for debugging. - /// - /// A string representation of the OAuth handler. public override string ToString() { return $"OAuthHandler: ClientId={_clientId}, AppId={_options.AppId}, UsePkce={_options.UsePkce}, HasTokens={HasTokens()}"; } #region Token Getter Methods - /// - /// Gets the current access token. - /// - /// The access token if available, null otherwise. public string GetAccessToken() { var tokens = GetCurrentTokens(); return tokens?.AccessToken; } - /// - /// Gets the current refresh token. - /// - /// The refresh token if available, null otherwise. public string GetRefreshToken() { var tokens = GetCurrentTokens(); return tokens?.RefreshToken; } - /// - /// Gets the current organization UID. - /// - /// The organization UID if available, null otherwise. public string GetOrganizationUID() { var tokens = GetCurrentTokens(); return tokens?.OrganizationUid; } - /// - /// Gets the current user UID. - /// - /// The user UID if available, null otherwise. public string GetUserUID() { var tokens = GetCurrentTokens(); return tokens?.UserUid; } - /// - /// Gets the current token expiry time. - /// - /// The token expiry time if available, null otherwise. public DateTime? GetTokenExpiryTime() { var tokens = GetCurrentTokens(); @@ -182,118 +189,55 @@ public string GetUserUID() #endregion #region Token Setter Methods - /// - /// Sets the access token in the stored OAuth tokens. - /// - /// The access token to set. - /// Thrown when the token is null or empty. public void SetAccessToken(string token) { if (string.IsNullOrEmpty(token)) - throw new ArgumentException("Access token cannot be null or empty.", nameof(token)); + throw new ArgumentException("Access token is required.", nameof(token)); - var tokens = GetCurrentTokens(); - if (tokens == null) - { - tokens = new OAuthTokens { ClientId = _clientId }; - } - tokens.AccessToken = token; - StoreTokens(tokens); + UpdateTokenProperty((t, v) => t.AccessToken = v, token); } - /// - /// Sets the refresh token in the stored OAuth tokens. - /// - /// The refresh token to set. - /// Thrown when the token is null or empty. public void SetRefreshToken(string token) { if (string.IsNullOrEmpty(token)) - throw new ArgumentException("Refresh token cannot be null or empty.", nameof(token)); + throw new ArgumentException("Refresh token is required.", nameof(token)); - var tokens = GetCurrentTokens(); - if (tokens == null) - { - tokens = new OAuthTokens { ClientId = _clientId }; - } - tokens.RefreshToken = token; - StoreTokens(tokens); + UpdateTokenProperty((t, v) => t.RefreshToken = v, token); } - /// - /// Sets the organization UID in the stored OAuth tokens. - /// - /// The organization UID to set. - /// Thrown when the organization UID is null or empty. public void SetOrganizationUID(string organizationUID) { if (string.IsNullOrEmpty(organizationUID)) - throw new ArgumentException("Organization UID cannot be null or empty.", nameof(organizationUID)); + throw new ArgumentException("Organization UID is required.", nameof(organizationUID)); - var tokens = GetCurrentTokens(); - if (tokens == null) - { - tokens = new OAuthTokens { ClientId = _clientId }; - } - tokens.OrganizationUid = organizationUID; - StoreTokens(tokens); + UpdateTokenProperty((t, v) => t.OrganizationUid = v, organizationUID); } - /// - /// Sets the user UID in the stored OAuth tokens. - /// - /// The user UID to set. - /// Thrown when the user UID is null or empty. public void SetUserUID(string userUID) { if (string.IsNullOrEmpty(userUID)) - throw new ArgumentException("User UID cannot be null or empty.", nameof(userUID)); + throw new ArgumentException("User UID is required.", nameof(userUID)); - var tokens = GetCurrentTokens(); - if (tokens == null) - { - tokens = new OAuthTokens { ClientId = _clientId }; - } - tokens.UserUid = userUID; - StoreTokens(tokens); + UpdateTokenProperty((t, v) => t.UserUid = v, userUID); } - /// - /// Sets the token expiry time in the stored OAuth tokens. - /// - /// The token expiry time to set. - /// Thrown when the expiry time is not provided. public void SetTokenExpiryTime(DateTime expiryTime) { if (expiryTime == default(DateTime)) - throw new ArgumentException("Token expiry time cannot be default value.", nameof(expiryTime)); + throw new ArgumentException("Token expiry time is required.", nameof(expiryTime)); - var tokens = GetCurrentTokens(); - if (tokens == null) - { - tokens = new OAuthTokens { ClientId = _clientId }; - } - tokens.ExpiresAt = expiryTime; - StoreTokens(tokens); + UpdateTokenProperty((t, v) => t.ExpiresAt = v, expiryTime); } #endregion #endregion #region Protected Methods - /// - /// Gets the Contentstack client instance. - /// - /// The Contentstack client instance. protected ContentstackClient GetClient() { return _client; } - /// - /// Gets the OAuth options. - /// - /// The OAuth options. protected OAuthOptions GetOptions() { return _options; @@ -301,27 +245,10 @@ protected OAuthOptions GetOptions() #endregion #region OAuth Flow Methods - /// - /// Generates the OAuth authorization URL for user authentication (async version). - /// This URL should be opened in a browser to start the OAuth flow. - /// - /// A task that represents the asynchronous operation. The task result contains the authorization URL. - /// Thrown when the OAuth configuration is invalid. - /// Thrown when PKCE code generation fails. - public async Task AuthorizeAsync() - { - return await Task.FromResult(GetAuthorizationUrl()); - } - /// /// Generates the OAuth authorization URL for user authentication. - /// This URL should be opened in a browser to start the OAuth flow. /// - /// The authorization URL. - /// Thrown when the OAuth configuration is invalid. - /// Thrown when PKCE code generation fails. - [Obsolete("Use AuthorizeAsync()")] - public string GetAuthorizationUrl() + public async Task AuthorizeAsync() { // AppId validation is now handled by OAuthOptions.Validate() in constructor @@ -330,7 +257,7 @@ public string GetAuthorizationUrl() // 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 = $"{oauthHost}/#!/apps/{_options.AppId}/authorize"; + var baseUrl = $"https://{oauthHost}/#!/apps/{_options.AppId}/authorize"; var authUrl = new UriBuilder(baseUrl); // Add required OAuth parameters @@ -352,26 +279,18 @@ public string GetAuthorizationUrl() if (_options.UsePkce) { // PKCE flow - generate code verifier and challenge - var codeVerifier = PkceHelper.GenerateCodeVerifier(); - var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + 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"); - - // Store code verifier temporarily for later use in token exchange - var tempTokens = new OAuthTokens - { - ClientId = _clientId, - AccessToken = codeVerifier // Temporarily store code verifier - }; - StoreTokens(tempTokens); } // Traditional OAuth flow - no additional parameters needed // Build the complete URL authUrl.Query = string.Join("&", queryParams); - return authUrl.ToString(); + return await Task.FromResult(authUrl.ToString()); } catch (Exception ex) when (!(ex is Exceptions.OAuthException)) { @@ -381,18 +300,12 @@ public string GetAuthorizationUrl() /// /// Exchanges an authorization code for OAuth access and refresh tokens. - /// This method should be called after the user completes the OAuth authorization flow. /// - /// The authorization code received from the OAuth provider. - /// A task that represents the asynchronous operation. The task result contains the OAuth tokens. - /// Thrown when the authorization code is null or empty. - /// Thrown when the OAuth configuration is invalid or code verifier is missing. - /// Thrown when the token exchange request fails. public async Task ExchangeCodeForTokenAsync(string authorizationCode) { if (string.IsNullOrEmpty(authorizationCode)) { - throw new ArgumentException("Authorization code cannot be null or empty.", nameof(authorizationCode)); + throw new ArgumentException("Authorization code is required.", nameof(authorizationCode)); } try @@ -400,32 +313,22 @@ public async Task ExchangeCodeForTokenAsync(string authorizationCod // Create the OAuth token service for authorization code exchange OAuthTokenService tokenService; - if (_options.UsePkce) + if (_options.UsePkce && !string.IsNullOrEmpty(this.codeVerifier) ) { - // PKCE flow - get stored code verifier - var storedTokens = GetCurrentTokens(); - if (storedTokens?.AccessToken == null) - { - throw new Exceptions.OAuthTokenException( - "Code verifier not found. Please call GetAuthorizationUrl() first to generate the authorization URL and code verifier."); - } - - var codeVerifier = storedTokens.AccessToken; // This is the stored code verifier tokenService = OAuthTokenService.CreateForAuthorizationCode( serializer: GetClient().serializer, authorizationCode: authorizationCode, clientId: _options.ClientId, redirectUri: _options.RedirectUri, - codeVerifier: codeVerifier + codeVerifier: this.codeVerifier ); } else { - // Traditional OAuth flow - use client secret if (string.IsNullOrEmpty(_options.ClientSecret)) { throw new Exceptions.OAuthConfigurationException( - "Client secret is required for traditional OAuth flow. Please set the ClientSecret in OAuth options or use PKCE flow."); + "Client secret is required for traditional OAuth flow. Set ClientSecret in OAuth options or use PKCE flow."); } tokenService = OAuthTokenService.CreateForAuthorizationCode( @@ -451,31 +354,27 @@ public async Task ExchangeCodeForTokenAsync(string authorizationCod ExpiresAt = DateTime.UtcNow.AddSeconds(oauthResponse.ExpiresIn - 60), // 60 second buffer OrganizationUid = oauthResponse.OrganizationUid, UserUid = oauthResponse.UserUid, - ClientId = _clientId + ClientId = _clientId, + AppId = _options.AppId }; // Store tokens in memory for future use StoreTokens(tokens); // Set OAuth tokens in the client for authenticated requests - GetClient().SetOAuthTokens(tokens); + SetClientOAuthTokens(tokens); return tokens; } catch (Exception ex) when (!(ex is ArgumentException || ex is Exceptions.OAuthException)) { - throw new Exceptions.OAuthTokenException($"Failed to exchange authorization code for tokens: {ex.Message}", ex); + throw HandleOAuthException(ex, "exchange authorization code for tokens"); } } /// /// Refreshes the OAuth access token using the refresh token. - /// This method automatically handles token refresh and updates the stored tokens. /// - /// The refresh token to use. If null, uses the stored refresh token. - /// A task that represents the asynchronous operation. The task result contains the new OAuth tokens. - /// Thrown when no refresh token is available or the refresh request fails. - /// Thrown when the token refresh request fails. public async Task RefreshTokenAsync(string refreshToken = null) { // Get the refresh token to use @@ -488,7 +387,7 @@ public async Task RefreshTokenAsync(string refreshToken = null) if (storedTokens?.RefreshToken == null) { throw new Exceptions.OAuthTokenRefreshException( - "No refresh token available. Please provide a refresh token or ensure tokens are stored from a previous OAuth flow."); + "No refresh token available. Please authenticate first."); } tokenToUse = storedTokens.RefreshToken; } @@ -513,7 +412,7 @@ public async Task RefreshTokenAsync(string refreshToken = null) if (string.IsNullOrEmpty(_options.ClientSecret)) { throw new Exceptions.OAuthConfigurationException( - "Client secret is required for traditional OAuth flow. Please set the ClientSecret in OAuth options or use PKCE flow."); + "Client secret is required for traditional OAuth flow."); } tokenService = OAuthTokenService.CreateForRefreshToken( @@ -538,63 +437,52 @@ public async Task RefreshTokenAsync(string refreshToken = null) ExpiresAt = DateTime.UtcNow.AddSeconds(oauthResponse.ExpiresIn - 60), // 60 second buffer OrganizationUid = oauthResponse.OrganizationUid, UserUid = oauthResponse.UserUid, - ClientId = _clientId + ClientId = _clientId, + AppId = _options.AppId }; // Store the new tokens in memory StoreTokens(newTokens); // Set OAuth tokens in the client for authenticated requests - GetClient().SetOAuthTokens(newTokens); + SetClientOAuthTokens(newTokens); return newTokens; } catch (Exception ex) when (!(ex is Exceptions.OAuthException)) { - throw new Exceptions.OAuthTokenRefreshException($"Failed to refresh OAuth tokens: {ex.Message}", ex); + throw HandleOAuthException(ex, "refresh OAuth tokens"); } } /// - /// Logs out the user by clearing OAuth tokens and resetting the client authentication state. - /// This method clears the stored tokens and resets the client to use traditional authentication. + /// Logs out the user by clearing OAuth tokens. /// - /// A task that represents the asynchronous operation. The task result contains a success message. - /// Thrown when no OAuth tokens are available to logout. public async Task LogoutAsync() { try { - // Check if we have tokens to logout - var currentTokens = GetCurrentTokens(); - if (currentTokens == null) - { - throw new Exceptions.OAuthException("No OAuth tokens found. User is not logged in via OAuth."); - } + 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 (currentTokens != null && !string.IsNullOrEmpty(currentTokens.AccessToken)) + if (!string.IsNullOrEmpty(currentTokens.AccessToken)) { try { var authorizationId = await GetOauthAppAuthorizationAsync(); await RevokeOauthAppAuthorizationAsync(authorizationId); } - catch (Exception ex) + catch { - // Log the revocation failure but don't fail the logout + // If revocation fails, continue with logout // This is common in OAuth implementations where revocation is optional - System.Diagnostics.Debug.WriteLine($"OAuth authorization revocation failed (non-critical): {ex.Message}"); } } // Clear tokens from memory store ClearTokens(); - // Clear OAuth tokens from the client - GetClient().ClearOAuthTokens(_clientId); - // Return success message return "Logged out successfully"; } @@ -605,30 +493,13 @@ public async Task LogoutAsync() } /// - /// Logs out the user synchronously by clearing OAuth tokens and resetting the client authentication state. - /// This method clears the stored tokens and resets the client to use traditional authentication. + /// Logs out the user synchronously by clearing OAuth tokens. /// - /// A success message. - /// Thrown when no OAuth tokens are available to logout. public string Logout() { try { - // Check if we have tokens to logout - var currentTokens = GetCurrentTokens(); - if (currentTokens == null) - { - throw new Exceptions.OAuthException("No OAuth tokens found. User is not logged in via OAuth."); - } - - // Clear tokens from memory store - ClearTokens(); - - // Clear OAuth tokens from the client - GetClient().ClearOAuthTokens(_clientId); - - // Return success message - return "Logged out successfully"; + return LogoutInternal(); } catch (Exception ex) when (!(ex is InvalidOperationException)) { @@ -639,15 +510,10 @@ public string Logout() /// /// Handles the redirect URL after OAuth authorization and exchanges the authorization code for tokens. /// - /// The redirect URL containing the authorization code. - /// A task that represents the asynchronous operation. - /// Thrown when the URL is null or empty. - /// Thrown when the authorization code is not found in the URL. - /// Thrown when token exchange fails. public async Task HandleRedirectAsync(string url) { if (string.IsNullOrEmpty(url)) - throw new ArgumentException("Redirect URL cannot be null or empty.", nameof(url)); + throw new ArgumentException("Redirect URL is required.", nameof(url)); try { @@ -680,17 +546,12 @@ public async Task HandleRedirectAsync(string url) } catch (Exception ex) when (!(ex is ArgumentException || ex is Exceptions.OAuthException)) { - throw new Exceptions.OAuthTokenException($"Failed to handle redirect URL: {ex.Message}", ex); + throw HandleOAuthException(ex, "handle redirect URL"); } } #endregion #region Private Methods - /// - /// Transforms the base hostname to the appropriate OAuth hostname. - /// - /// The base hostname (e.g., api.contentstack.io) - /// The transformed OAuth hostname (e.g., app.contentstack.com) private static string GetOAuthHost(string baseHost) { if (string.IsNullOrEmpty(baseHost)) @@ -714,40 +575,27 @@ private static string GetOAuthHost(string baseHost) return oauthHost; } - /// - /// Gets the OAuth app authorization for the current user. - /// - /// A task that represents the asynchronous operation. The task result contains the authorization ID. - /// Thrown when no authorization is found. private async Task GetOauthAppAuthorizationAsync() { - var tokens = GetCurrentTokens(); - if (tokens == null) - { - throw new Exceptions.OAuthException("No OAuth tokens found. User is not logged in via OAuth."); - } + var tokens = ValidateAndGetTokens(); try { // Create a service to get OAuth app authorizations - var service = new Services.OAuth.OAuthAppAuthorizationService( + var service = new OAuthAppAuthorizationService( GetClient().serializer, _options.AppId, tokens.OrganizationUid ); - // Configure the client with OAuth access token for this request - var originalAuthtoken = GetClient().contentstackOptions.Authtoken; - var originalIsOAuthToken = GetClient().contentstackOptions.IsOAuthToken; - - GetClient().contentstackOptions.Authtoken = tokens.AccessToken; - GetClient().contentstackOptions.IsOAuthToken = true; + SetClientOAuthTokens(tokens); try { // Make the API call to get authorizations - var response = await GetClient().InvokeAsync(service); - var authResponse = response.OpenTResponse(); + var response = await GetClient().InvokeAsync(service); + + var authResponse = response.OpenTResponse(); if (authResponse?.Data?.Length > 0) { @@ -756,40 +604,33 @@ private async Task GetOauthAppAuthorizationAsync() if (currentUserAuthorization == null) { - throw new Exceptions.OAuthException("No authorizations found for current user!"); + throw new Exceptions.OAuthException("No authorizations found for current user."); } return currentUserAuthorization.AuthorizationUid; } else { - throw new Exceptions.OAuthException("No authorizations found for the app!"); + throw new Exceptions.OAuthException("No authorizations found for the app."); } } - finally + catch (Exception ex) when (!(ex is Exceptions.OAuthException)) { - // Restore original client configuration - GetClient().contentstackOptions.Authtoken = originalAuthtoken; - GetClient().contentstackOptions.IsOAuthToken = originalIsOAuthToken; + throw HandleOAuthException(ex, "get OAuth app authorization"); } } catch (Exception ex) when (!(ex is Exceptions.OAuthException)) { - throw new Exceptions.OAuthException($"Failed to get OAuth app authorization: {ex.Message}", ex); + throw HandleOAuthException(ex, "get OAuth app authorization"); } } - /// - /// Revokes the OAuth app authorization for the current user. - /// - /// The authorization ID to revoke. - /// A task that represents the asynchronous operation. - /// Thrown when the authorization ID is null or empty. - /// Thrown when revocation fails. private async Task RevokeOauthAppAuthorizationAsync(string authorizationId) { if (string.IsNullOrEmpty(authorizationId)) - throw new ArgumentException("Authorization ID cannot be null or empty.", nameof(authorizationId)); + { + throw new ArgumentException("Authorization ID is required.", nameof(authorizationId)); + } try { @@ -804,36 +645,32 @@ private async Task RevokeOauthAppAuthorizationAsync(string authorizationId) authorizationId, organizationUid ); - - // Configure the client with OAuth access token for this request - var originalAuthtoken = GetClient().contentstackOptions.Authtoken; - var originalIsOAuthToken = GetClient().contentstackOptions.IsOAuthToken; - GetClient().contentstackOptions.Authtoken = tokens.AccessToken; - GetClient().contentstackOptions.IsOAuthToken = true; + SetClientOAuthTokens(tokens); try { // Make the API call to revoke authorization - await GetClient().InvokeAsync(service); + var response = await GetClient().InvokeAsync(service); + } + catch + { + throw; } finally { - // Restore original client configuration - GetClient().contentstackOptions.Authtoken = originalAuthtoken; - GetClient().contentstackOptions.IsOAuthToken = originalIsOAuthToken; + // 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 new Exceptions.OAuthException($"Failed to revoke OAuth app authorization: {ex.Message}", ex); + throw HandleOAuthException(ex, "revoke OAuth app authorization"); } } - #endregion - #region Future Method Placeholders - // These methods will be implemented in subsequent phases: - // - GetValidTokensAsync() #endregion + } } From 167ede239d9990dc7e6defeb70ce3a6d8478f004 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Thu, 11 Sep 2025 22:23:34 +0530 Subject: [PATCH 4/8] Test Cases for Oauth --- .../OAuth/OAuthExceptionTest.cs | 275 ++++++++++++++ .../OAuth/OAuthHandlerTest.cs | 44 +-- .../OAuth/OAuthOptionsTest.cs | 324 +++++++++++++++++ .../OAuth/OAuthTokenRefreshExceptionTest.cs | 264 ++++++++++++++ .../OAuth/OAuthTokenTest.cs | 337 ++++++++++++++++++ .../OAuth/PkceHelperTest.cs | 226 ++++++++---- 6 files changed, 1369 insertions(+), 101 deletions(-) create mode 100644 Contentstack.Management.Core.Unit.Tests/OAuth/OAuthExceptionTest.cs create mode 100644 Contentstack.Management.Core.Unit.Tests/OAuth/OAuthOptionsTest.cs create mode 100644 Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenRefreshExceptionTest.cs create mode 100644 Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenTest.cs 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..883419d --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthExceptionTest.cs @@ -0,0 +1,275 @@ +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() + { + // Act + var exception = new OAuthException(); + + // Assert + Assert.AreEqual("OAuth operation failed.", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthException_WithMessage_ShouldUseProvidedMessage() + { + // Arrange + var message = "Custom OAuth error message"; + + // Act + var exception = new OAuthException(message); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthException_WithMessageAndInnerException_ShouldUseBoth() + { + // Arrange + var message = "Custom OAuth error message"; + var innerException = new InvalidOperationException("Inner exception"); + + // Act + var exception = new OAuthException(message, innerException); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void OAuthConfigurationException_DefaultConstructor_ShouldUseDefaultMessage() + { + // Act + var exception = new OAuthConfigurationException(); + + // Assert + Assert.AreEqual("OAuth configuration is invalid.", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthConfigurationException_WithMessage_ShouldUseProvidedMessage() + { + // Arrange + var message = "Custom configuration error message"; + + // Act + var exception = new OAuthConfigurationException(message); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthConfigurationException_WithMessageAndInnerException_ShouldUseBoth() + { + // Arrange + var message = "Custom configuration error message"; + var innerException = new ArgumentException("Inner exception"); + + // Act + var exception = new OAuthConfigurationException(message, innerException); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void OAuthTokenException_DefaultConstructor_ShouldUseDefaultMessage() + { + // Act + var exception = new OAuthTokenException(); + + // Assert + Assert.AreEqual("OAuth token operation failed.", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthTokenException_WithMessage_ShouldUseProvidedMessage() + { + // Arrange + var message = "Custom token error message"; + + // Act + var exception = new OAuthTokenException(message); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthTokenException_WithMessageAndInnerException_ShouldUseBoth() + { + // Arrange + var message = "Custom token error message"; + var innerException = new InvalidOperationException("Inner exception"); + + // Act + var exception = new OAuthTokenException(message, innerException); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void OAuthAuthorizationException_DefaultConstructor_ShouldUseDefaultMessage() + { + // Act + var exception = new OAuthAuthorizationException(); + + // Assert + Assert.AreEqual("OAuth authorization failed.", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthAuthorizationException_WithMessage_ShouldUseProvidedMessage() + { + // Arrange + var message = "Custom authorization error message"; + + // Act + var exception = new OAuthAuthorizationException(message); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthAuthorizationException_WithMessageAndInnerException_ShouldUseBoth() + { + // Arrange + var message = "Custom authorization error message"; + var innerException = new InvalidOperationException("Inner exception"); + + // Act + var exception = new OAuthAuthorizationException(message, innerException); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void OAuthTokenRefreshException_DefaultConstructor_ShouldUseDefaultMessage() + { + // Act + var exception = new OAuthTokenRefreshException(); + + // Assert + Assert.AreEqual("OAuth token refresh failed.", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthTokenRefreshException_WithMessage_ShouldUseProvidedMessage() + { + // Arrange + var message = "Custom refresh error message"; + + // Act + var exception = new OAuthTokenRefreshException(message); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void OAuthTokenRefreshException_WithMessageAndInnerException_ShouldUseBoth() + { + // Arrange + var message = "Custom refresh error message"; + var innerException = new InvalidOperationException("Inner exception"); + + // Act + var exception = new OAuthTokenRefreshException(message, innerException); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void OAuthException_Inheritance_ShouldBeCorrect() + { + // Act + var oauthException = new OAuthException(); + var configException = new OAuthConfigurationException(); + var tokenException = new OAuthTokenException(); + var authException = new OAuthAuthorizationException(); + var refreshException = new OAuthTokenRefreshException(); + + // Assert + 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() + { + // Arrange + 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() + { + // Arrange + var message = "Test OAuth error message"; + var exception = new OAuthException(message); + + // Act + var result = exception.ToString(); + + // Assert + Assert.IsTrue(result.Contains(message)); + Assert.IsTrue(result.Contains("OAuthException")); + } + + [TestMethod] + public void OAuthException_WithInnerException_ToString_ShouldIncludeBoth() + { + // Arrange + var message = "Test OAuth error message"; + var innerMessage = "Inner exception message"; + var innerException = new InvalidOperationException(innerMessage); + var exception = new OAuthException(message, innerException); + + // Act + var result = exception.ToString(); + + // Assert + 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 index 49f7a37..3473043 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs @@ -206,13 +206,13 @@ public void OAuthHandler_ClearTokens_ShouldRemoveTokens() } [TestMethod] - public void OAuthHandler_GetAuthorizationUrl_WithPKCE_ShouldReturnValidUrl() + public void OAuthHandler_AuthorizeAsync_WithPKCE_ShouldReturnValidUrl() { // Arrange var handler = new OAuthHandler(_client, _options); // Act - var authUrl = handler.GetAuthorizationUrl(); + var authUrl = handler.AuthorizeAsync().Result; // Assert Assert.IsNotNull(authUrl); @@ -224,7 +224,7 @@ public void OAuthHandler_GetAuthorizationUrl_WithPKCE_ShouldReturnValidUrl() } [TestMethod] - public void OAuthHandler_GetAuthorizationUrl_WithTraditionalOAuth_ShouldReturnValidUrl() + public void OAuthHandler_AuthorizeAsync_WithTraditionalOAuth_ShouldReturnValidUrl() { // Arrange var traditionalOptions = new OAuthOptions @@ -238,7 +238,7 @@ public void OAuthHandler_GetAuthorizationUrl_WithTraditionalOAuth_ShouldReturnVa var handler = new OAuthHandler(_client, traditionalOptions); // Act - var authUrl = handler.GetAuthorizationUrl(); + var authUrl = handler.AuthorizeAsync().Result; // Assert Assert.IsNotNull(authUrl); @@ -249,33 +249,33 @@ public void OAuthHandler_GetAuthorizationUrl_WithTraditionalOAuth_ShouldReturnVa } [TestMethod] - public void OAuthHandler_GetAuthorizationUrl_WithScopes_ShouldIncludeScopes() + public void OAuthHandler_AuthorizeAsync_WithScopes_ShouldIncludeScopes() { // Arrange _options.Scope = new[] { "read", "write" }; var handler = new OAuthHandler(_client, _options); // Act - var authUrl = handler.GetAuthorizationUrl(); + var authUrl = handler.AuthorizeAsync().Result; // Assert Assert.IsTrue(authUrl.Contains("scope=read%20write")); } [TestMethod] - public void OAuthHandler_GetAuthorizationUrl_ShouldStoreCodeVerifierForPKCE() + public void OAuthHandler_AuthorizeAsync_ShouldGenerateCodeVerifierForPKCE() { // Arrange var handler = new OAuthHandler(_client, _options); // Act - handler.GetAuthorizationUrl(); + var authUrl = handler.AuthorizeAsync().Result; // Assert - var storedTokens = _client.GetOAuthTokens(_options.ClientId); - Assert.IsNotNull(storedTokens); - Assert.IsNotNull(storedTokens.AccessToken); // This is the code verifier - Assert.IsTrue(PkceHelper.IsValidCodeVerifier(storedTokens.AccessToken)); + 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] @@ -340,11 +340,11 @@ public void OAuthHandler_ExchangeCodeForTokenAsync_WithoutCodeVerifier_ShouldThr try { handler.ExchangeCodeForTokenAsync("test-code").Wait(); - Assert.Fail("Should have thrown OAuthTokenException"); + Assert.Fail("Should have thrown OAuthConfigurationException"); } - catch (AggregateException ex) when (ex.InnerException is OAuthTokenException) + catch (AggregateException ex) when (ex.InnerException is Exceptions.OAuthConfigurationException) { - // Expected + // Expected - PKCE flow requires code verifier to be generated first } } @@ -877,9 +877,9 @@ public async Task OAuthHandler_HandleRedirectAsync_WithValidUrl_ShouldExchangeCo // 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.OAuthTokenException) + catch (Exceptions.OAuthConfigurationException) { - // Expected - the actual API call will fail in unit tests + // Expected - PKCE flow requires code verifier to be generated first // This confirms that the URL parsing worked and the method attempted the token exchange } } @@ -941,12 +941,10 @@ public async Task OAuthHandler_HandleRedirectAsync_WithComplexUrl_ShouldParseCor try { await handler.HandleRedirectAsync(redirectUrl); - // If we get here, the method completed without throwing an exception } - catch (Exceptions.OAuthTokenException) + catch (Exceptions.OAuthConfigurationException) { - // Expected - the actual API call will fail in unit tests - // This confirms that the URL parsing worked correctly + } } #endregion @@ -975,8 +973,6 @@ public async Task OAuthHandler_LogoutAsync_WithValidTokens_ShouldCallRevocationA } catch (Exceptions.OAuthException) { - // Expected - the actual API call will fail in unit tests due to mocking - // This confirms that the method attempted to call the revocation API } } @@ -1013,8 +1009,6 @@ public async Task OAuthHandler_LogoutAsync_ShouldClearTokensAfterSuccessfulRevoc } catch (Exceptions.OAuthException) { - // Expected - the actual API call will fail in unit tests - // In this case, tokens are NOT cleared because the API call failed Assert.IsTrue(handler.HasTokens()); } } 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..03f7f0d --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthOptionsTest.cs @@ -0,0 +1,324 @@ +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() + { + // Act + var options = new OAuthOptions(); + + // Assert + 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() + { + // Arrange + 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 + }; + + // Act + var isValid = options.IsValid(); + + // Assert + Assert.IsTrue(isValid); + Assert.IsTrue(options.UsePkce); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithValidTraditionalOAuthOptions_ShouldReturnTrue() + { + // Arrange + 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 + }; + + // Act + var isValid = options.IsValid(); + + // Assert + Assert.IsTrue(isValid); + Assert.IsFalse(options.UsePkce); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithMissingAppId_ShouldReturnFalse() + { + // Arrange + var options = new OAuthOptions + { + AppId = "", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + + // Act + var isValid = options.IsValid(out var errorMessage); + + // Assert + Assert.IsFalse(isValid); + Assert.AreEqual("AppId is required for OAuth configuration.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithMissingClientId_ShouldReturnFalse() + { + // Arrange + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + + // Act + var isValid = options.IsValid(out var errorMessage); + + // Assert + Assert.IsFalse(isValid); + Assert.AreEqual("ClientId is required for OAuth configuration.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithMissingRedirectUri_ShouldReturnFalse() + { + // Arrange + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "", + ResponseType = "code" + }; + + // Act + var isValid = options.IsValid(out var errorMessage); + + // Assert + Assert.IsFalse(isValid); + Assert.AreEqual("RedirectUri is required for OAuth configuration.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithInvalidRedirectUri_ShouldReturnFalse() + { + // Arrange + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "not-a-valid-uri", + ResponseType = "code" + }; + + // Act + var isValid = options.IsValid(out var errorMessage); + + // Assert + Assert.IsFalse(isValid); + Assert.AreEqual("RedirectUri must be a valid absolute URI.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithNonHttpRedirectUri_ShouldReturnFalse() + { + // Arrange + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "ftp://example.com/callback", + ResponseType = "code" + }; + + // Act + var isValid = options.IsValid(out var errorMessage); + + // Assert + Assert.IsFalse(isValid); + Assert.AreEqual("RedirectUri must use http or https scheme.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithMissingResponseType_ShouldReturnFalse() + { + // Arrange + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "" + }; + + // Act + var isValid = options.IsValid(out var errorMessage); + + // Assert + Assert.IsFalse(isValid); + Assert.AreEqual("ResponseType is required for OAuth configuration.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithInvalidResponseType_ShouldReturnFalse() + { + // Arrange + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "token" + }; + + // Act + var isValid = options.IsValid(out var errorMessage); + + // Assert + Assert.IsFalse(isValid); + Assert.AreEqual("ResponseType must be 'code' for authorization code flow.", errorMessage); + } + + [TestMethod] + public void OAuthOptions_IsValid_WithTraditionalOAuthMissingClientSecret_ShouldReturnFalse() + { + // Arrange + 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 + }; + + // Act + var isValid = options.IsValid(out var errorMessage); + + // Assert + Assert.IsTrue(isValid); // This will actually be valid because UsePkce is true + Assert.IsTrue(options.UsePkce); + } + + [TestMethod] + public void OAuthOptions_Validate_WithValidOptions_ShouldNotThrow() + { + // Arrange + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + + // Act & Assert - Should not throw + options.Validate(); + } + + [TestMethod] + [ExpectedException(typeof(OAuthConfigurationException))] + public void OAuthOptions_Validate_WithInvalidOptions_ShouldThrowException() + { + // Arrange + var options = new OAuthOptions + { + AppId = "", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code" + }; + + // Act + options.Validate(); + } + + [TestMethod] + public void OAuthOptions_WithScopes_ShouldBeValid() + { + // Arrange + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code", + Scope = new[] { "read", "write", "admin" } + }; + + // Act + var isValid = options.IsValid(); + + // Assert + Assert.IsTrue(isValid); + } + + [TestMethod] + public void OAuthOptions_WithEmptyScopes_ShouldBeValid() + { + // Arrange + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code", + Scope = new string[0] + }; + + // Act + var isValid = options.IsValid(); + + // Assert + Assert.IsTrue(isValid); + } + + [TestMethod] + public void OAuthOptions_WithNullScopes_ShouldBeValid() + { + // Arrange + var options = new OAuthOptions + { + AppId = "test-app-id", + ClientId = "test-client-id", + RedirectUri = "https://example.com/callback", + ResponseType = "code", + Scope = null + }; + + // Act + var isValid = options.IsValid(); + + // Assert + 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..22092bd --- /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() + { + // Clear any test tokens + _client.ClearOAuthTokens(_options.ClientId); + } + + [TestMethod] + public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithExpiredToken_ShouldRefreshSuccessfully() + { + // Arrange + 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; + + // Act & Assert + 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() + { + // Arrange + 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; + + // Act & Assert + 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() + { + // Arrange + 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; + + // Act & Assert + 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() + { + // Arrange + 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; + + // Act & Assert + 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() + { + // Arrange + 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; + + // Act & Assert + 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() + { + // Arrange + 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; + + // Act & Assert + 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() + { + // Arrange + 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; + + // Act & Assert + 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/OAuthTokenTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenTest.cs new file mode 100644 index 0000000..10657cf --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenTest.cs @@ -0,0 +1,337 @@ +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() + { + // Act + var tokens = new OAuthTokens(); + + // Assert + 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() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = "test-client-id" + }; + + // Act + var isValid = tokens.IsValid; + + // Assert + Assert.IsTrue(isValid); + } + + [TestMethod] + public void OAuthTokens_IsValid_WithNullAccessToken_ShouldReturnFalse() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = null, + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = "test-client-id" + }; + + // Act + var isValid = tokens.IsValid; + + // Assert + Assert.IsFalse(isValid); + } + + [TestMethod] + public void OAuthTokens_IsValid_WithEmptyAccessToken_ShouldReturnFalse() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "", + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = "test-client-id" + }; + + // Act + var isValid = tokens.IsValid; + + // Assert + Assert.IsFalse(isValid); + } + + [TestMethod] + public void OAuthTokens_IsValid_WithExpiredToken_ShouldReturnFalse() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), + ClientId = "test-client-id" + }; + + // Act + var isValid = tokens.IsValid; + + // Assert + Assert.IsFalse(isValid); + } + + [TestMethod] + public void OAuthTokens_IsValid_WithDefaultExpiryTime_ShouldReturnFalse() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = default(DateTime), + ClientId = "test-client-id" + }; + + // Act + var isValid = tokens.IsValid; + + // Assert + Assert.IsFalse(isValid); + } + + [TestMethod] + public void OAuthTokens_IsExpired_WithFutureExpiryTime_ShouldReturnFalse() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + ClientId = "test-client-id" + }; + + // Act + var isExpired = tokens.IsExpired; + + // Assert + Assert.IsFalse(isExpired); + } + + [TestMethod] + public void OAuthTokens_IsExpired_WithPastExpiryTime_ShouldReturnTrue() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), + ClientId = "test-client-id" + }; + + // Act + var isExpired = tokens.IsExpired; + + // Assert + Assert.IsTrue(isExpired); + } + + [TestMethod] + public void OAuthTokens_IsExpired_WithDefaultExpiryTime_ShouldReturnTrue() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = default(DateTime), + ClientId = "test-client-id" + }; + + // Act + var isExpired = tokens.IsExpired; + + // Assert + Assert.IsTrue(isExpired); + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithTokenExpiringSoon_ShouldReturnTrue() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(2), // Less than 5 minutes + ClientId = "test-client-id" + }; + + // Act + var needsRefresh = tokens.NeedsRefresh; + + // Assert + Assert.IsTrue(needsRefresh); + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithTokenNotExpiringSoon_ShouldReturnFalse() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(10), // More than 5 minutes + ClientId = "test-client-id" + }; + + // Act + var needsRefresh = tokens.NeedsRefresh; + + // Assert + Assert.IsFalse(needsRefresh); + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithExpiredToken_ShouldReturnTrue() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(-5), + ClientId = "test-client-id" + }; + + // Act + var needsRefresh = tokens.NeedsRefresh; + + // Assert + Assert.IsTrue(needsRefresh); + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithNoRefreshToken_ShouldReturnTrue() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(2), + RefreshToken = null, + ClientId = "test-client-id" + }; + + // Act + var needsRefresh = tokens.NeedsRefresh; + + // Assert + Assert.IsTrue(needsRefresh); // NeedsRefresh is based on expiry time, not refresh token presence + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithEmptyRefreshToken_ShouldReturnTrue() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(2), + RefreshToken = "", + ClientId = "test-client-id" + }; + + // Act + var needsRefresh = tokens.NeedsRefresh; + + // Assert + Assert.IsTrue(needsRefresh); // NeedsRefresh is based on expiry time, not refresh token presence + } + + [TestMethod] + public void OAuthTokens_NeedsRefresh_WithValidRefreshToken_ShouldReturnTrue() + { + // Arrange + var tokens = new OAuthTokens + { + AccessToken = "test-access-token", + ExpiresAt = DateTime.UtcNow.AddMinutes(2), + RefreshToken = "test-refresh-token", + ClientId = "test-client-id" + }; + + // Act + var needsRefresh = tokens.NeedsRefresh; + + // Assert + Assert.IsTrue(needsRefresh); + } + + [TestMethod] + public void OAuthTokens_ToString_ShouldReturnTypeName() + { + // Arrange + 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) + }; + + // Act + var result = tokens.ToString(); + + // Assert + Assert.IsTrue(result.Contains("OAuthTokens")); // Default ToString returns type name + } + + [TestMethod] + public void OAuthTokens_WithAllProperties_ShouldSetCorrectly() + { + // Arrange + 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); + + // Act + var tokens = new OAuthTokens + { + AccessToken = accessToken, + RefreshToken = refreshToken, + OrganizationUid = organizationUid, + UserUid = userUid, + ClientId = clientId, + AppId = appId, + ExpiresAt = expiresAt + }; + + // Assert + 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 index 4da0c92..0aa75e3 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs @@ -1,5 +1,4 @@ using System; -using System.Text.RegularExpressions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Contentstack.Management.Core.Utils; @@ -9,16 +8,16 @@ namespace Contentstack.Management.Core.Unit.Tests.OAuth public class PkceHelperTest { [TestMethod] - public void PkceHelper_GenerateCodeVerifier_ShouldReturnValidVerifier() + public void PkceHelper_GenerateCodeVerifier_ShouldReturnValidCodeVerifier() { // Act var codeVerifier = PkceHelper.GenerateCodeVerifier(); // Assert Assert.IsNotNull(codeVerifier); + Assert.IsTrue(PkceHelper.IsValidCodeVerifier(codeVerifier)); Assert.IsTrue(codeVerifier.Length >= 43); Assert.IsTrue(codeVerifier.Length <= 128); - Assert.IsTrue(PkceHelper.IsValidCodeVerifier(codeVerifier)); } [TestMethod] @@ -33,7 +32,7 @@ public void PkceHelper_GenerateCodeVerifier_MultipleCalls_ShouldReturnDifferentV } [TestMethod] - public void PkceHelper_GenerateCodeChallenge_ShouldReturnValidChallenge() + public void PkceHelper_GenerateCodeChallenge_WithValidCodeVerifier_ShouldReturnValidChallenge() { // Arrange var codeVerifier = PkceHelper.GenerateCodeVerifier(); @@ -43,12 +42,12 @@ public void PkceHelper_GenerateCodeChallenge_ShouldReturnValidChallenge() // Assert Assert.IsNotNull(codeChallenge); - Assert.AreEqual(43, codeChallenge.Length); Assert.IsTrue(PkceHelper.IsValidCodeChallenge(codeChallenge)); + Assert.AreEqual(43, codeChallenge.Length); // Base64URL encoded SHA256 hash is always 43 characters } [TestMethod] - public void PkceHelper_GenerateCodeChallenge_SameVerifier_ShouldReturnSameChallenge() + public void PkceHelper_GenerateCodeChallenge_WithSameCodeVerifier_ShouldReturnSameChallenge() { // Arrange var codeVerifier = PkceHelper.GenerateCodeVerifier(); @@ -62,15 +61,15 @@ public void PkceHelper_GenerateCodeChallenge_SameVerifier_ShouldReturnSameChalle } [TestMethod] - public void PkceHelper_GenerateCodeChallenge_DifferentVerifiers_ShouldReturnDifferentChallenges() + public void PkceHelper_GenerateCodeChallenge_WithDifferentCodeVerifiers_ShouldReturnDifferentChallenges() { // Arrange - var verifier1 = PkceHelper.GenerateCodeVerifier(); - var verifier2 = PkceHelper.GenerateCodeVerifier(); + var codeVerifier1 = PkceHelper.GenerateCodeVerifier(); + var codeVerifier2 = PkceHelper.GenerateCodeVerifier(); // Act - var challenge1 = PkceHelper.GenerateCodeChallenge(verifier1); - var challenge2 = PkceHelper.GenerateCodeChallenge(verifier2); + var challenge1 = PkceHelper.GenerateCodeChallenge(codeVerifier1); + var challenge2 = PkceHelper.GenerateCodeChallenge(codeVerifier2); // Assert Assert.AreNotEqual(challenge1, challenge2); @@ -83,130 +82,189 @@ public void PkceHelper_VerifyCodeChallenge_WithValidPair_ShouldReturnTrue() var codeVerifier = PkceHelper.GenerateCodeVerifier(); var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); - // Act & Assert - Assert.IsTrue(PkceHelper.VerifyCodeChallenge(codeVerifier, codeChallenge)); + // Act + var isValid = PkceHelper.VerifyCodeChallenge(codeVerifier, codeChallenge); + + // Assert + Assert.IsTrue(isValid); } [TestMethod] - public void PkceHelper_VerifyCodeChallenge_WithInvalidChallenge_ShouldReturnFalse() + public void PkceHelper_VerifyCodeChallenge_WithInvalidPair_ShouldReturnFalse() { // Arrange - var codeVerifier = PkceHelper.GenerateCodeVerifier(); - var invalidChallenge = "invalid-challenge"; + var codeVerifier1 = PkceHelper.GenerateCodeVerifier(); + var codeVerifier2 = PkceHelper.GenerateCodeVerifier(); + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier1); + + // Act + var isValid = PkceHelper.VerifyCodeChallenge(codeVerifier2, codeChallenge); - // Act & Assert - Assert.IsFalse(PkceHelper.VerifyCodeChallenge(codeVerifier, invalidChallenge)); + // Assert + Assert.IsFalse(isValid); } [TestMethod] - public void PkceHelper_VerifyCodeChallenge_WithInvalidVerifier_ShouldReturnFalse() + public void PkceHelper_GeneratePkcePair_ShouldReturnValidPair() + { + // Act + var pkcePair = PkceHelper.GeneratePkcePair(); + + // Assert + 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() { // Arrange - var invalidVerifier = "invalid-verifier"; - var codeChallenge = PkceHelper.GenerateCodeChallenge(PkceHelper.GenerateCodeVerifier()); + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + + // Act + var isValid = PkceHelper.IsValidCodeVerifier(codeVerifier); - // Act & Assert - Assert.IsFalse(PkceHelper.VerifyCodeChallenge(invalidVerifier, codeChallenge)); + // Assert + Assert.IsTrue(isValid); } [TestMethod] - public void PkceHelper_GeneratePkcePair_ShouldReturnValidPair() + public void PkceHelper_IsValidCodeVerifier_WithNullVerifier_ShouldReturnFalse() { // Act - var (verifier, challenge) = PkceHelper.GeneratePkcePair(); + var isValid = PkceHelper.IsValidCodeVerifier(null); // Assert - Assert.IsNotNull(verifier); - Assert.IsNotNull(challenge); - Assert.IsTrue(PkceHelper.IsValidCodeVerifier(verifier)); - Assert.IsTrue(PkceHelper.IsValidCodeChallenge(challenge)); - Assert.IsTrue(PkceHelper.VerifyCodeChallenge(verifier, challenge)); + Assert.IsFalse(isValid); } [TestMethod] - public void PkceHelper_IsValidCodeVerifier_WithValidVerifier_ShouldReturnTrue() + public void PkceHelper_IsValidCodeVerifier_WithEmptyVerifier_ShouldReturnFalse() { - // Arrange - var codeVerifier = PkceHelper.GenerateCodeVerifier(); + // Act + var isValid = PkceHelper.IsValidCodeVerifier(""); - // Act & Assert - Assert.IsTrue(PkceHelper.IsValidCodeVerifier(codeVerifier)); + // Assert + Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeVerifier_WithTooShortVerifier_ShouldReturnFalse() { // Arrange - var shortVerifier = "short"; + var shortVerifier = "short"; // Less than 43 characters - // Act & Assert - Assert.IsFalse(PkceHelper.IsValidCodeVerifier(shortVerifier)); + // Act + var isValid = PkceHelper.IsValidCodeVerifier(shortVerifier); + + // Assert + Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeVerifier_WithTooLongVerifier_ShouldReturnFalse() { // Arrange - var longVerifier = new string('a', 129); // 129 characters + var longVerifier = new string('a', 129); // More than 128 characters - // Act & Assert - Assert.IsFalse(PkceHelper.IsValidCodeVerifier(longVerifier)); + // Act + var isValid = PkceHelper.IsValidCodeVerifier(longVerifier); + + // Assert + Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeVerifier_WithInvalidCharacters_ShouldReturnFalse() { // Arrange - var invalidVerifier = "invalid+characters!@#"; + var invalidVerifier = "invalid-characters!@#$%^&*()"; // Contains invalid characters - // Act & Assert - Assert.IsFalse(PkceHelper.IsValidCodeVerifier(invalidVerifier)); + // Act + var isValid = PkceHelper.IsValidCodeVerifier(invalidVerifier); + + // Assert + Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeChallenge_WithValidChallenge_ShouldReturnTrue() { // Arrange - var codeChallenge = PkceHelper.GenerateCodeChallenge(PkceHelper.GenerateCodeVerifier()); + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); - // Act & Assert - Assert.IsTrue(PkceHelper.IsValidCodeChallenge(codeChallenge)); + // Act + var isValid = PkceHelper.IsValidCodeChallenge(codeChallenge); + + // Assert + Assert.IsTrue(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeChallenge_WithNullChallenge_ShouldReturnFalse() + { + // Act + var isValid = PkceHelper.IsValidCodeChallenge(null); + + // Assert + Assert.IsFalse(isValid); } [TestMethod] - public void PkceHelper_IsValidCodeChallenge_WithWrongLength_ShouldReturnFalse() + public void PkceHelper_IsValidCodeChallenge_WithEmptyChallenge_ShouldReturnFalse() + { + // Act + var isValid = PkceHelper.IsValidCodeChallenge(""); + + // Assert + Assert.IsFalse(isValid); + } + + [TestMethod] + public void PkceHelper_IsValidCodeChallenge_WithWrongLengthChallenge_ShouldReturnFalse() { // Arrange - var wrongLengthChallenge = "wrong-length"; + var wrongLengthChallenge = "wrong-length"; // Should be 43 characters - // Act & Assert - Assert.IsFalse(PkceHelper.IsValidCodeChallenge(wrongLengthChallenge)); + // Act + var isValid = PkceHelper.IsValidCodeChallenge(wrongLengthChallenge); + + // Assert + Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeChallenge_WithInvalidCharacters_ShouldReturnFalse() { // Arrange - var invalidChallenge = "invalid+characters!@#"; + var invalidChallenge = "invalid-characters!@#$%^&*()"; // Contains invalid characters - // Act & Assert - Assert.IsFalse(PkceHelper.IsValidCodeChallenge(invalidChallenge)); + // Act + var isValid = PkceHelper.IsValidCodeChallenge(invalidChallenge); + + // Assert + Assert.IsFalse(isValid); } [TestMethod] - public void PkceHelper_CodeVerifier_ShouldContainOnlyValidCharacters() + public void PkceHelper_CodeVerifier_ShouldBeUrlSafe() { - // Arrange & Act + // Act var codeVerifier = PkceHelper.GenerateCodeVerifier(); - // Assert - Should only contain URL-safe base64 characters - var validPattern = @"^[A-Za-z0-9\-._~]+$"; - Assert.IsTrue(Regex.IsMatch(codeVerifier, validPattern), - $"Code verifier contains invalid characters: {codeVerifier}"); + // Assert + Assert.IsFalse(codeVerifier.Contains("+")); + Assert.IsFalse(codeVerifier.Contains("/")); + Assert.IsFalse(codeVerifier.Contains("=")); } [TestMethod] - public void PkceHelper_CodeChallenge_ShouldContainOnlyValidCharacters() + public void PkceHelper_CodeChallenge_ShouldBeUrlSafe() { // Arrange var codeVerifier = PkceHelper.GenerateCodeVerifier(); @@ -214,29 +272,45 @@ public void PkceHelper_CodeChallenge_ShouldContainOnlyValidCharacters() // Act var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); - // Assert - Should only contain URL-safe base64 characters - var validPattern = @"^[A-Za-z0-9\-._~]+$"; - Assert.IsTrue(Regex.IsMatch(codeChallenge, validPattern), - $"Code challenge contains invalid characters: {codeChallenge}"); + // Assert + Assert.IsFalse(codeChallenge.Contains("+")); + Assert.IsFalse(codeChallenge.Contains("/")); + Assert.IsFalse(codeChallenge.Contains("=")); + } + + [TestMethod] + public void PkceHelper_CodeVerifier_ShouldContainOnlyValidCharacters() + { + // Act + var codeVerifier = PkceHelper.GenerateCodeVerifier(); + + // Assert + 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_GenerateCodeVerifier_ShouldBeCryptographicallySecure() + public void PkceHelper_CodeChallenge_ShouldContainOnlyValidCharacters() { // Arrange - var verifiers = new string[100]; + var codeVerifier = PkceHelper.GenerateCodeVerifier(); // Act - for (int i = 0; i < 100; i++) + var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); + + // Assert + foreach (char c in codeChallenge) { - verifiers[i] = PkceHelper.GenerateCodeVerifier(); + Assert.IsTrue( + char.IsLetterOrDigit(c) || c == '-' || c == '.' || c == '_' || c == '~', + $"Character '{c}' is not valid in code challenge" + ); } - - // Assert - All verifiers should be unique - var uniqueVerifiers = new System.Collections.Generic.HashSet(verifiers); - Assert.AreEqual(100, uniqueVerifiers.Count, "Generated code verifiers should be unique"); } } -} - - +} \ No newline at end of file From 3a77a6406270daf0519f11a903075a6d46c9a59d Mon Sep 17 00:00:00 2001 From: raj pandey Date: Sun, 14 Sep 2025 14:44:38 +0530 Subject: [PATCH 5/8] Fixed the OAuth Flow --- .../ContentstackClient.cs | 13 ++++++++ .../Models/OAuthTokens.cs | 26 +++++++++++++-- Contentstack.Management.Core/OAuthHandler.cs | 33 ++++++++++++++++--- .../Services/ContentstackService.cs | 15 ++++----- .../Services/OAuth/OAuthTokenService.cs | 2 -- 5 files changed, 71 insertions(+), 18 deletions(-) diff --git a/Contentstack.Management.Core/ContentstackClient.cs b/Contentstack.Management.Core/ContentstackClient.cs index 5a3c7b2..d63d782 100644 --- a/Contentstack.Management.Core/ContentstackClient.cs +++ b/Contentstack.Management.Core/ContentstackClient.cs @@ -219,6 +219,11 @@ internal ContentstackResponse InvokeSync(TRequest request, bool addAcc { ThrowIfDisposed(); + if (contentstackOptions.IsOAuthToken && !string.IsNullOrEmpty(contentstackOptions.Authtoken)) + { + EnsureOAuthTokenIsValid(); + } + ExecutionContext context = new ExecutionContext( new RequestContext() { @@ -636,6 +641,14 @@ internal void ClearStoredOAuthTokens(string clientId) _oauthTokens.Remove(clientId); } + + /// + /// Clears all OAuth tokens (useful for cleanup). + /// + internal void ClearAllOAuthTokens() + { + _oauthTokens.Clear(); + } #endregion /// diff --git a/Contentstack.Management.Core/Models/OAuthTokens.cs b/Contentstack.Management.Core/Models/OAuthTokens.cs index a111ec8..4b83b37 100644 --- a/Contentstack.Management.Core/Models/OAuthTokens.cs +++ b/Contentstack.Management.Core/Models/OAuthTokens.cs @@ -23,9 +23,29 @@ public class OAuthTokens public string AppId { get; set; } - public bool IsExpired => DateTime.UtcNow >= ExpiresAt; - - public bool NeedsRefresh => DateTime.UtcNow >= ExpiresAt.AddMinutes(-5) || IsExpired; + 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 index aea4111..f5994a6 100644 --- a/Contentstack.Management.Core/OAuthHandler.cs +++ b/Contentstack.Management.Core/OAuthHandler.cs @@ -254,10 +254,13 @@ public async Task AuthorizeAsync() 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 @@ -310,10 +313,21 @@ public async Task ExchangeCodeForTokenAsync(string authorizationCod try { + // Create the OAuth token service for authorization code exchange OAuthTokenService tokenService; - if (_options.UsePkce && !string.IsNullOrEmpty(this.codeVerifier) ) + 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, @@ -557,9 +571,18 @@ private static string GetOAuthHost(string baseHost) if (string.IsNullOrEmpty(baseHost)) return baseHost; - // Transform api.contentstack.io -> app.contentstack.com + // 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")) { @@ -653,9 +676,9 @@ private async Task RevokeOauthAppAuthorizationAsync(string authorizationId) // Make the API call to revoke authorization var response = await GetClient().InvokeAsync(service); } - catch + catch (Exception ex) { - throw; + throw ex; } finally { diff --git a/Contentstack.Management.Core/Services/ContentstackService.cs b/Contentstack.Management.Core/Services/ContentstackService.cs index 1b58170..dcffbe4 100644 --- a/Contentstack.Management.Core/Services/ContentstackService.cs +++ b/Contentstack.Management.Core/Services/ContentstackService.cs @@ -168,18 +168,17 @@ public virtual IHttpRequest CreateHttpRequest(HttpClient httpClient, Contentstac { Headers["authorization"] = this.ManagementToken; } - else if (!string.IsNullOrEmpty(config.Authtoken)) + else if (config.IsOAuthToken) { - if (config.IsOAuthToken) + if (!string.IsNullOrEmpty(config.Authtoken)) { - // OAuth Bearer token format + Headers["authorization"] = $"Bearer {config.Authtoken}"; } - else - { - // Traditional authtoken format - Headers["authtoken"] = config.Authtoken; - } + } + else if (!string.IsNullOrEmpty(config.Authtoken)) + { + Headers["authtoken"] = config.Authtoken; } if (!string.IsNullOrEmpty(apiVersion)) diff --git a/Contentstack.Management.Core/Services/OAuth/OAuthTokenService.cs b/Contentstack.Management.Core/Services/OAuth/OAuthTokenService.cs index 1aed33f..533870b 100644 --- a/Contentstack.Management.Core/Services/OAuth/OAuthTokenService.cs +++ b/Contentstack.Management.Core/Services/OAuth/OAuthTokenService.cs @@ -78,8 +78,6 @@ public override IHttpRequest CreateHttpRequest(System.Net.Http.HttpClient httpCl Host = GetDeveloperHubHostname(config.Host), Port = config.Port, Version = "", // OAuth endpoints don't use versioning - Authtoken = config.Authtoken, - IsOAuthToken = config.IsOAuthToken }; var request = base.CreateHttpRequest(httpClient, devHubConfig, addAcceptMediaHeader, apiVersion); From debd7f6885594962e10d72ddb883827b15f64788 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Mon, 15 Sep 2025 13:02:58 +0530 Subject: [PATCH 6/8] Remove the OAuthToken Valid method --- Contentstack.Management.Core/ContentstackClient.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Contentstack.Management.Core/ContentstackClient.cs b/Contentstack.Management.Core/ContentstackClient.cs index d63d782..f22beba 100644 --- a/Contentstack.Management.Core/ContentstackClient.cs +++ b/Contentstack.Management.Core/ContentstackClient.cs @@ -219,10 +219,7 @@ internal ContentstackResponse InvokeSync(TRequest request, bool addAcc { ThrowIfDisposed(); - if (contentstackOptions.IsOAuthToken && !string.IsNullOrEmpty(contentstackOptions.Authtoken)) - { - EnsureOAuthTokenIsValid(); - } + // OAuth token validation is handled in the async method ExecutionContext context = new ExecutionContext( new RequestContext() From 0fcf38a37b00507b6006065162e512e2d26adaf6 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Mon, 15 Sep 2025 13:10:55 +0530 Subject: [PATCH 7/8] Version bump --- CHANGELOG.md | 4 ++++ Contentstack.Management.Core/ContentstackClient.cs | 2 +- Directory.Build.props | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b35a7a..d78a494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Changelog +## [v0.4.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.4.0) + - Feature + - Added Support for OAuth + ## [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/ContentstackClient.cs b/Contentstack.Management.Core/ContentstackClient.cs index f22beba..2f8a1b4 100644 --- a/Contentstack.Management.Core/ContentstackClient.cs +++ b/Contentstack.Management.Core/ContentstackClient.cs @@ -35,7 +35,7 @@ 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 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 From b2c114b87a8b83b8dc50a04be0c17e8bfc238829 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Mon, 15 Sep 2025 13:36:30 +0530 Subject: [PATCH 8/8] Removed Comments --- .../OAuth/OAuthExceptionTest.cs | 101 ++---- .../OAuth/OAuthHandlerTest.cs | 305 +++++------------- .../OAuth/OAuthOptionsTest.cs | 90 +----- .../OAuth/OAuthTokenRefreshExceptionTest.cs | 30 +- .../OAuth/OAuthTokenStorageTest.cs | 48 +-- .../OAuth/OAuthTokenTest.cs | 100 +----- .../OAuth/PkceHelperTest.cs | 120 ++----- 7 files changed, 187 insertions(+), 607 deletions(-) diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthExceptionTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthExceptionTest.cs index 883419d..dd44bc2 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthExceptionTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthExceptionTest.cs @@ -10,10 +10,8 @@ public class OAuthExceptionTest [TestMethod] public void OAuthException_DefaultConstructor_ShouldUseDefaultMessage() { - // Act - var exception = new OAuthException(); - // Assert + var exception = new OAuthException(); Assert.AreEqual("OAuth operation failed.", exception.Message); Assert.IsNull(exception.InnerException); } @@ -21,13 +19,9 @@ public void OAuthException_DefaultConstructor_ShouldUseDefaultMessage() [TestMethod] public void OAuthException_WithMessage_ShouldUseProvidedMessage() { - // Arrange + var message = "Custom OAuth error message"; - - // Act var exception = new OAuthException(message); - - // Assert Assert.AreEqual(message, exception.Message); Assert.IsNull(exception.InnerException); } @@ -35,14 +29,10 @@ public void OAuthException_WithMessage_ShouldUseProvidedMessage() [TestMethod] public void OAuthException_WithMessageAndInnerException_ShouldUseBoth() { - // Arrange + var message = "Custom OAuth error message"; var innerException = new InvalidOperationException("Inner exception"); - - // Act var exception = new OAuthException(message, innerException); - - // Assert Assert.AreEqual(message, exception.Message); Assert.AreEqual(innerException, exception.InnerException); } @@ -50,10 +40,8 @@ public void OAuthException_WithMessageAndInnerException_ShouldUseBoth() [TestMethod] public void OAuthConfigurationException_DefaultConstructor_ShouldUseDefaultMessage() { - // Act - var exception = new OAuthConfigurationException(); - // Assert + var exception = new OAuthConfigurationException(); Assert.AreEqual("OAuth configuration is invalid.", exception.Message); Assert.IsNull(exception.InnerException); } @@ -61,13 +49,9 @@ public void OAuthConfigurationException_DefaultConstructor_ShouldUseDefaultMessa [TestMethod] public void OAuthConfigurationException_WithMessage_ShouldUseProvidedMessage() { - // Arrange + var message = "Custom configuration error message"; - - // Act var exception = new OAuthConfigurationException(message); - - // Assert Assert.AreEqual(message, exception.Message); Assert.IsNull(exception.InnerException); } @@ -75,14 +59,10 @@ public void OAuthConfigurationException_WithMessage_ShouldUseProvidedMessage() [TestMethod] public void OAuthConfigurationException_WithMessageAndInnerException_ShouldUseBoth() { - // Arrange + var message = "Custom configuration error message"; var innerException = new ArgumentException("Inner exception"); - - // Act var exception = new OAuthConfigurationException(message, innerException); - - // Assert Assert.AreEqual(message, exception.Message); Assert.AreEqual(innerException, exception.InnerException); } @@ -90,10 +70,8 @@ public void OAuthConfigurationException_WithMessageAndInnerException_ShouldUseBo [TestMethod] public void OAuthTokenException_DefaultConstructor_ShouldUseDefaultMessage() { - // Act - var exception = new OAuthTokenException(); - // Assert + var exception = new OAuthTokenException(); Assert.AreEqual("OAuth token operation failed.", exception.Message); Assert.IsNull(exception.InnerException); } @@ -101,13 +79,9 @@ public void OAuthTokenException_DefaultConstructor_ShouldUseDefaultMessage() [TestMethod] public void OAuthTokenException_WithMessage_ShouldUseProvidedMessage() { - // Arrange + var message = "Custom token error message"; - - // Act var exception = new OAuthTokenException(message); - - // Assert Assert.AreEqual(message, exception.Message); Assert.IsNull(exception.InnerException); } @@ -115,14 +89,10 @@ public void OAuthTokenException_WithMessage_ShouldUseProvidedMessage() [TestMethod] public void OAuthTokenException_WithMessageAndInnerException_ShouldUseBoth() { - // Arrange + var message = "Custom token error message"; var innerException = new InvalidOperationException("Inner exception"); - - // Act var exception = new OAuthTokenException(message, innerException); - - // Assert Assert.AreEqual(message, exception.Message); Assert.AreEqual(innerException, exception.InnerException); } @@ -130,10 +100,8 @@ public void OAuthTokenException_WithMessageAndInnerException_ShouldUseBoth() [TestMethod] public void OAuthAuthorizationException_DefaultConstructor_ShouldUseDefaultMessage() { - // Act - var exception = new OAuthAuthorizationException(); - // Assert + var exception = new OAuthAuthorizationException(); Assert.AreEqual("OAuth authorization failed.", exception.Message); Assert.IsNull(exception.InnerException); } @@ -141,13 +109,9 @@ public void OAuthAuthorizationException_DefaultConstructor_ShouldUseDefaultMessa [TestMethod] public void OAuthAuthorizationException_WithMessage_ShouldUseProvidedMessage() { - // Arrange + var message = "Custom authorization error message"; - - // Act var exception = new OAuthAuthorizationException(message); - - // Assert Assert.AreEqual(message, exception.Message); Assert.IsNull(exception.InnerException); } @@ -155,14 +119,10 @@ public void OAuthAuthorizationException_WithMessage_ShouldUseProvidedMessage() [TestMethod] public void OAuthAuthorizationException_WithMessageAndInnerException_ShouldUseBoth() { - // Arrange + var message = "Custom authorization error message"; var innerException = new InvalidOperationException("Inner exception"); - - // Act var exception = new OAuthAuthorizationException(message, innerException); - - // Assert Assert.AreEqual(message, exception.Message); Assert.AreEqual(innerException, exception.InnerException); } @@ -170,10 +130,8 @@ public void OAuthAuthorizationException_WithMessageAndInnerException_ShouldUseBo [TestMethod] public void OAuthTokenRefreshException_DefaultConstructor_ShouldUseDefaultMessage() { - // Act - var exception = new OAuthTokenRefreshException(); - // Assert + var exception = new OAuthTokenRefreshException(); Assert.AreEqual("OAuth token refresh failed.", exception.Message); Assert.IsNull(exception.InnerException); } @@ -181,13 +139,9 @@ public void OAuthTokenRefreshException_DefaultConstructor_ShouldUseDefaultMessag [TestMethod] public void OAuthTokenRefreshException_WithMessage_ShouldUseProvidedMessage() { - // Arrange + var message = "Custom refresh error message"; - - // Act var exception = new OAuthTokenRefreshException(message); - - // Assert Assert.AreEqual(message, exception.Message); Assert.IsNull(exception.InnerException); } @@ -195,14 +149,10 @@ public void OAuthTokenRefreshException_WithMessage_ShouldUseProvidedMessage() [TestMethod] public void OAuthTokenRefreshException_WithMessageAndInnerException_ShouldUseBoth() { - // Arrange + var message = "Custom refresh error message"; var innerException = new InvalidOperationException("Inner exception"); - - // Act var exception = new OAuthTokenRefreshException(message, innerException); - - // Assert Assert.AreEqual(message, exception.Message); Assert.AreEqual(innerException, exception.InnerException); } @@ -210,14 +160,12 @@ public void OAuthTokenRefreshException_WithMessageAndInnerException_ShouldUseBot [TestMethod] public void OAuthException_Inheritance_ShouldBeCorrect() { - // Act + var oauthException = new OAuthException(); var configException = new OAuthConfigurationException(); var tokenException = new OAuthTokenException(); var authException = new OAuthAuthorizationException(); var refreshException = new OAuthTokenRefreshException(); - - // Assert Assert.IsInstanceOfType(oauthException, typeof(Exception)); Assert.IsInstanceOfType(configException, typeof(OAuthException)); Assert.IsInstanceOfType(tokenException, typeof(OAuthException)); @@ -228,10 +176,8 @@ public void OAuthException_Inheritance_ShouldBeCorrect() [TestMethod] public void OAuthException_Serialization_ShouldWork() { - // Arrange + var originalException = new OAuthException("Test message", new InvalidOperationException("Inner")); - - Assert.IsNotNull(originalException); Assert.AreEqual("Test message", originalException.Message); Assert.IsNotNull(originalException.InnerException); @@ -240,14 +186,10 @@ public void OAuthException_Serialization_ShouldWork() [TestMethod] public void OAuthException_ToString_ShouldIncludeMessage() { - // Arrange + var message = "Test OAuth error message"; var exception = new OAuthException(message); - - // Act var result = exception.ToString(); - - // Assert Assert.IsTrue(result.Contains(message)); Assert.IsTrue(result.Contains("OAuthException")); } @@ -255,16 +197,12 @@ public void OAuthException_ToString_ShouldIncludeMessage() [TestMethod] public void OAuthException_WithInnerException_ToString_ShouldIncludeBoth() { - // Arrange + var message = "Test OAuth error message"; var innerMessage = "Inner exception message"; var innerException = new InvalidOperationException(innerMessage); var exception = new OAuthException(message, innerException); - - // Act var result = exception.ToString(); - - // Assert Assert.IsTrue(result.Contains(message)); Assert.IsTrue(result.Contains(innerMessage)); Assert.IsTrue(result.Contains("OAuthException")); @@ -272,4 +210,3 @@ public void OAuthException_WithInnerException_ToString_ShouldIncludeBoth() } } } - diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs index 3473043..9d1bb13 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthHandlerTest.cs @@ -30,17 +30,15 @@ public void Setup() [TestCleanup] public void Cleanup() { - // Clear any test tokens + _client.ClearOAuthTokens(_options.ClientId); } [TestMethod] public void OAuthHandler_Constructor_WithValidParameters_ShouldCreateInstance() { - // Act - var handler = new OAuthHandler(_client, _options); - // Assert + var handler = new OAuthHandler(_client, _options); Assert.IsNotNull(handler); Assert.AreEqual(_options.ClientId, handler.ClientId); Assert.AreEqual(_options.AppId, handler.AppId); @@ -52,7 +50,7 @@ public void OAuthHandler_Constructor_WithValidParameters_ShouldCreateInstance() [ExpectedException(typeof(ArgumentNullException))] public void OAuthHandler_Constructor_WithNullClient_ShouldThrowException() { - // Act & Assert + new OAuthHandler(null, _options); } @@ -60,7 +58,7 @@ public void OAuthHandler_Constructor_WithNullClient_ShouldThrowException() [ExpectedException(typeof(ArgumentNullException))] public void OAuthHandler_Constructor_WithNullOptions_ShouldThrowException() { - // Act & Assert + new OAuthHandler(_client, null); } @@ -68,7 +66,7 @@ public void OAuthHandler_Constructor_WithNullOptions_ShouldThrowException() [ExpectedException(typeof(OAuthConfigurationException))] public void OAuthHandler_Constructor_WithInvalidOptions_ShouldThrowException() { - // Arrange + var invalidOptions = new OAuthOptions { AppId = "", // Invalid @@ -77,27 +75,23 @@ public void OAuthHandler_Constructor_WithInvalidOptions_ShouldThrowException() ResponseType = "code" }; - // Act & Assert + new OAuthHandler(_client, invalidOptions); } [TestMethod] public void OAuthHandler_GetCurrentTokens_WithNoTokens_ShouldReturnNull() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act var tokens = handler.GetCurrentTokens(); - - // Assert Assert.IsNull(tokens); } [TestMethod] public void OAuthHandler_GetCurrentTokens_WithStoredTokens_ShouldReturnTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); var expectedTokens = new OAuthTokens { @@ -105,11 +99,7 @@ public void OAuthHandler_GetCurrentTokens_WithStoredTokens_ShouldReturnTokens() ClientId = _options.ClientId }; _client.StoreOAuthTokens(_options.ClientId, expectedTokens); - - // Act var tokens = handler.GetCurrentTokens(); - - // Assert Assert.IsNotNull(tokens); Assert.AreEqual("test-token", tokens.AccessToken); } @@ -117,17 +107,17 @@ public void OAuthHandler_GetCurrentTokens_WithStoredTokens_ShouldReturnTokens() [TestMethod] public void OAuthHandler_HasValidTokens_WithNoTokens_ShouldReturnFalse() { - // Arrange + var handler = new OAuthHandler(_client, _options); - // Act & Assert + Assert.IsFalse(handler.HasValidTokens()); } [TestMethod] public void OAuthHandler_HasValidTokens_WithValidTokens_ShouldReturnTrue() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -137,14 +127,14 @@ public void OAuthHandler_HasValidTokens_WithValidTokens_ShouldReturnTrue() }; _client.StoreOAuthTokens(_options.ClientId, tokens); - // Act & Assert + Assert.IsTrue(handler.HasValidTokens()); } [TestMethod] public void OAuthHandler_HasValidTokens_WithExpiredTokens_ShouldReturnFalse() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -154,24 +144,24 @@ public void OAuthHandler_HasValidTokens_WithExpiredTokens_ShouldReturnFalse() }; _client.StoreOAuthTokens(_options.ClientId, tokens); - // Act & Assert + Assert.IsFalse(handler.HasValidTokens()); } [TestMethod] public void OAuthHandler_HasTokens_WithNoTokens_ShouldReturnFalse() { - // Arrange + var handler = new OAuthHandler(_client, _options); - // Act & Assert + Assert.IsFalse(handler.HasTokens()); } [TestMethod] public void OAuthHandler_HasTokens_WithTokens_ShouldReturnTrue() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -180,14 +170,14 @@ public void OAuthHandler_HasTokens_WithTokens_ShouldReturnTrue() }; _client.StoreOAuthTokens(_options.ClientId, tokens); - // Act & Assert + Assert.IsTrue(handler.HasTokens()); } [TestMethod] public void OAuthHandler_ClearTokens_ShouldRemoveTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -196,11 +186,7 @@ public void OAuthHandler_ClearTokens_ShouldRemoveTokens() }; _client.StoreOAuthTokens(_options.ClientId, tokens); Assert.IsTrue(handler.HasTokens()); - - // Act handler.ClearTokens(); - - // Assert Assert.IsFalse(handler.HasTokens()); Assert.IsNull(handler.GetCurrentTokens()); } @@ -208,13 +194,9 @@ public void OAuthHandler_ClearTokens_ShouldRemoveTokens() [TestMethod] public void OAuthHandler_AuthorizeAsync_WithPKCE_ShouldReturnValidUrl() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act var authUrl = handler.AuthorizeAsync().Result; - - // Assert Assert.IsNotNull(authUrl); Assert.IsTrue(authUrl.Contains("response_type=code")); Assert.IsTrue(authUrl.Contains($"client_id={_options.ClientId}")); @@ -226,7 +208,7 @@ public void OAuthHandler_AuthorizeAsync_WithPKCE_ShouldReturnValidUrl() [TestMethod] public void OAuthHandler_AuthorizeAsync_WithTraditionalOAuth_ShouldReturnValidUrl() { - // Arrange + var traditionalOptions = new OAuthOptions { AppId = "test-app-id", @@ -236,11 +218,7 @@ public void OAuthHandler_AuthorizeAsync_WithTraditionalOAuth_ShouldReturnValidUr ClientSecret = "test-secret" }; var handler = new OAuthHandler(_client, traditionalOptions); - - // Act var authUrl = handler.AuthorizeAsync().Result; - - // Assert Assert.IsNotNull(authUrl); Assert.IsTrue(authUrl.Contains("response_type=code")); Assert.IsTrue(authUrl.Contains($"client_id={traditionalOptions.ClientId}")); @@ -251,27 +229,19 @@ public void OAuthHandler_AuthorizeAsync_WithTraditionalOAuth_ShouldReturnValidUr [TestMethod] public void OAuthHandler_AuthorizeAsync_WithScopes_ShouldIncludeScopes() { - // Arrange + _options.Scope = new[] { "read", "write" }; var handler = new OAuthHandler(_client, _options); - - // Act var authUrl = handler.AuthorizeAsync().Result; - - // Assert Assert.IsTrue(authUrl.Contains("scope=read%20write")); } [TestMethod] public void OAuthHandler_AuthorizeAsync_ShouldGenerateCodeVerifierForPKCE() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act var authUrl = handler.AuthorizeAsync().Result; - - // Assert Assert.IsNotNull(authUrl); Assert.IsTrue(authUrl.Contains("code_challenge=")); Assert.IsTrue(authUrl.Contains("code_challenge_method=S256")); @@ -281,13 +251,9 @@ public void OAuthHandler_AuthorizeAsync_ShouldGenerateCodeVerifierForPKCE() [TestMethod] public void OAuthHandler_ToString_ShouldReturnFormattedString() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act var result = handler.ToString(); - - // Assert Assert.IsTrue(result.Contains(_options.ClientId)); Assert.IsTrue(result.Contains(_options.AppId)); Assert.IsTrue(result.Contains("True")); // UsePkce @@ -297,10 +263,9 @@ public void OAuthHandler_ToString_ShouldReturnFormattedString() [TestMethod] public void OAuthHandler_ExchangeCodeForTokenAsync_WithEmptyCode_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - // Act & Assert try { handler.ExchangeCodeForTokenAsync("").Wait(); @@ -308,17 +273,17 @@ public void OAuthHandler_ExchangeCodeForTokenAsync_WithEmptyCode_ShouldThrowExce } catch (AggregateException ex) when (ex.InnerException is ArgumentException) { - // Expected + } } [TestMethod] public void OAuthHandler_ExchangeCodeForTokenAsync_WithNullCode_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - // Act & Assert + try { handler.ExchangeCodeForTokenAsync(null).Wait(); @@ -326,17 +291,17 @@ public void OAuthHandler_ExchangeCodeForTokenAsync_WithNullCode_ShouldThrowExcep } catch (AggregateException ex) when (ex.InnerException is ArgumentException) { - // Expected + } } [TestMethod] public void OAuthHandler_ExchangeCodeForTokenAsync_WithoutCodeVerifier_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - // Act & Assert + try { handler.ExchangeCodeForTokenAsync("test-code").Wait(); @@ -351,10 +316,10 @@ public void OAuthHandler_ExchangeCodeForTokenAsync_WithoutCodeVerifier_ShouldThr [TestMethod] public void OAuthHandler_RefreshTokenAsync_WithNoTokens_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - // Act & Assert + try { handler.RefreshTokenAsync().Wait(); @@ -362,17 +327,17 @@ public void OAuthHandler_RefreshTokenAsync_WithNoTokens_ShouldThrowException() } catch (AggregateException ex) when (ex.InnerException is OAuthTokenRefreshException) { - // Expected + } } [TestMethod] public void OAuthHandler_RefreshTokenAsync_WithEmptyRefreshToken_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - // Act & Assert + try { handler.RefreshTokenAsync("").Wait(); @@ -380,17 +345,17 @@ public void OAuthHandler_RefreshTokenAsync_WithEmptyRefreshToken_ShouldThrowExce } catch (AggregateException ex) when (ex.InnerException is OAuthTokenRefreshException) { - // Expected + } } [TestMethod] public void OAuthHandler_LogoutAsync_WithNoTokens_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - // Act & Assert + try { handler.LogoutAsync().Wait(); @@ -406,7 +371,7 @@ public void OAuthHandler_LogoutAsync_WithNoTokens_ShouldThrowException() [TestMethod] public void OAuthHandler_LogoutAsync_WithTokens_ShouldReturnSuccessMessage() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -415,7 +380,7 @@ public void OAuthHandler_LogoutAsync_WithTokens_ShouldReturnSuccessMessage() }; _client.StoreOAuthTokens(_options.ClientId, tokens); - // Act & Assert + try { var result = handler.LogoutAsync().Result; @@ -436,7 +401,7 @@ public void OAuthHandler_LogoutAsync_WithTokens_ShouldReturnSuccessMessage() [TestMethod] public void OAuthHandler_GetAccessToken_WithValidTokens_ShouldReturnAccessToken() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -444,31 +409,23 @@ public void OAuthHandler_GetAccessToken_WithValidTokens_ShouldReturnAccessToken( ClientId = _options.ClientId }; _client.StoreOAuthTokens(_options.ClientId, tokens); - - // Act var result = handler.GetAccessToken(); - - // Assert Assert.AreEqual("test-access-token", result); } [TestMethod] public void OAuthHandler_GetAccessToken_WithNoTokens_ShouldReturnNull() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act var result = handler.GetAccessToken(); - - // Assert Assert.IsNull(result); } [TestMethod] public void OAuthHandler_GetRefreshToken_WithValidTokens_ShouldReturnRefreshToken() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -476,31 +433,23 @@ public void OAuthHandler_GetRefreshToken_WithValidTokens_ShouldReturnRefreshToke ClientId = _options.ClientId }; _client.StoreOAuthTokens(_options.ClientId, tokens); - - // Act var result = handler.GetRefreshToken(); - - // Assert Assert.AreEqual("test-refresh-token", result); } [TestMethod] public void OAuthHandler_GetRefreshToken_WithNoTokens_ShouldReturnNull() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act var result = handler.GetRefreshToken(); - - // Assert Assert.IsNull(result); } [TestMethod] public void OAuthHandler_GetOrganizationUID_WithValidTokens_ShouldReturnOrganizationUID() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -508,31 +457,23 @@ public void OAuthHandler_GetOrganizationUID_WithValidTokens_ShouldReturnOrganiza ClientId = _options.ClientId }; _client.StoreOAuthTokens(_options.ClientId, tokens); - - // Act var result = handler.GetOrganizationUID(); - - // Assert Assert.AreEqual("test-org-uid", result); } [TestMethod] public void OAuthHandler_GetOrganizationUID_WithNoTokens_ShouldReturnNull() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act var result = handler.GetOrganizationUID(); - - // Assert Assert.IsNull(result); } [TestMethod] public void OAuthHandler_GetUserUID_WithValidTokens_ShouldReturnUserUID() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -540,31 +481,23 @@ public void OAuthHandler_GetUserUID_WithValidTokens_ShouldReturnUserUID() ClientId = _options.ClientId }; _client.StoreOAuthTokens(_options.ClientId, tokens); - - // Act var result = handler.GetUserUID(); - - // Assert Assert.AreEqual("test-user-uid", result); } [TestMethod] public void OAuthHandler_GetUserUID_WithNoTokens_ShouldReturnNull() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act var result = handler.GetUserUID(); - - // Assert Assert.IsNull(result); } [TestMethod] public void OAuthHandler_GetTokenExpiryTime_WithValidTokens_ShouldReturnExpiryTime() { - // Arrange + var handler = new OAuthHandler(_client, _options); var expiryTime = DateTime.UtcNow.AddHours(1); var tokens = new OAuthTokens @@ -573,24 +506,16 @@ public void OAuthHandler_GetTokenExpiryTime_WithValidTokens_ShouldReturnExpiryTi ClientId = _options.ClientId }; _client.StoreOAuthTokens(_options.ClientId, tokens); - - // Act var result = handler.GetTokenExpiryTime(); - - // Assert Assert.AreEqual(expiryTime, result); } [TestMethod] public void OAuthHandler_GetTokenExpiryTime_WithNoTokens_ShouldReturnNull() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act var result = handler.GetTokenExpiryTime(); - - // Assert Assert.IsNull(result); } #endregion @@ -599,18 +524,14 @@ public void OAuthHandler_GetTokenExpiryTime_WithNoTokens_ShouldReturnNull() [TestMethod] public void OAuthHandler_SetAccessToken_WithValidToken_ShouldUpdateTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { ClientId = _options.ClientId }; _client.StoreOAuthTokens(_options.ClientId, tokens); - - // Act handler.SetAccessToken("new-access-token"); - - // Assert var updatedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.AreEqual("new-access-token", updatedTokens.AccessToken); } @@ -618,13 +539,9 @@ public void OAuthHandler_SetAccessToken_WithValidToken_ShouldUpdateTokens() [TestMethod] public void OAuthHandler_SetAccessToken_WithNoExistingTokens_ShouldCreateNewTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetAccessToken("new-access-token"); - - // Assert var tokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(tokens); Assert.AreEqual("new-access-token", tokens.AccessToken); @@ -635,10 +552,8 @@ public void OAuthHandler_SetAccessToken_WithNoExistingTokens_ShouldCreateNewToke [ExpectedException(typeof(ArgumentException))] public void OAuthHandler_SetAccessToken_WithNullToken_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetAccessToken(null); } @@ -646,28 +561,22 @@ public void OAuthHandler_SetAccessToken_WithNullToken_ShouldThrowException() [ExpectedException(typeof(ArgumentException))] public void OAuthHandler_SetAccessToken_WithEmptyToken_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetAccessToken(""); } [TestMethod] public void OAuthHandler_SetRefreshToken_WithValidToken_ShouldUpdateTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { ClientId = _options.ClientId }; _client.StoreOAuthTokens(_options.ClientId, tokens); - - // Act handler.SetRefreshToken("new-refresh-token"); - - // Assert var updatedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.AreEqual("new-refresh-token", updatedTokens.RefreshToken); } @@ -675,13 +584,9 @@ public void OAuthHandler_SetRefreshToken_WithValidToken_ShouldUpdateTokens() [TestMethod] public void OAuthHandler_SetRefreshToken_WithNoExistingTokens_ShouldCreateNewTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetRefreshToken("new-refresh-token"); - - // Assert var tokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(tokens); Assert.AreEqual("new-refresh-token", tokens.RefreshToken); @@ -692,10 +597,8 @@ public void OAuthHandler_SetRefreshToken_WithNoExistingTokens_ShouldCreateNewTok [ExpectedException(typeof(ArgumentException))] public void OAuthHandler_SetRefreshToken_WithNullToken_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetRefreshToken(null); } @@ -703,28 +606,22 @@ public void OAuthHandler_SetRefreshToken_WithNullToken_ShouldThrowException() [ExpectedException(typeof(ArgumentException))] public void OAuthHandler_SetRefreshToken_WithEmptyToken_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetRefreshToken(""); } [TestMethod] public void OAuthHandler_SetOrganizationUID_WithValidUID_ShouldUpdateTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { ClientId = _options.ClientId }; _client.StoreOAuthTokens(_options.ClientId, tokens); - - // Act handler.SetOrganizationUID("new-org-uid"); - - // Assert var updatedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.AreEqual("new-org-uid", updatedTokens.OrganizationUid); } @@ -732,13 +629,9 @@ public void OAuthHandler_SetOrganizationUID_WithValidUID_ShouldUpdateTokens() [TestMethod] public void OAuthHandler_SetOrganizationUID_WithNoExistingTokens_ShouldCreateNewTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetOrganizationUID("new-org-uid"); - - // Assert var tokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(tokens); Assert.AreEqual("new-org-uid", tokens.OrganizationUid); @@ -749,10 +642,8 @@ public void OAuthHandler_SetOrganizationUID_WithNoExistingTokens_ShouldCreateNew [ExpectedException(typeof(ArgumentException))] public void OAuthHandler_SetOrganizationUID_WithNullUID_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetOrganizationUID(null); } @@ -760,28 +651,22 @@ public void OAuthHandler_SetOrganizationUID_WithNullUID_ShouldThrowException() [ExpectedException(typeof(ArgumentException))] public void OAuthHandler_SetOrganizationUID_WithEmptyUID_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetOrganizationUID(""); } [TestMethod] public void OAuthHandler_SetUserUID_WithValidUID_ShouldUpdateTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { ClientId = _options.ClientId }; _client.StoreOAuthTokens(_options.ClientId, tokens); - - // Act handler.SetUserUID("new-user-uid"); - - // Assert var updatedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.AreEqual("new-user-uid", updatedTokens.UserUid); } @@ -789,13 +674,9 @@ public void OAuthHandler_SetUserUID_WithValidUID_ShouldUpdateTokens() [TestMethod] public void OAuthHandler_SetUserUID_WithNoExistingTokens_ShouldCreateNewTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetUserUID("new-user-uid"); - - // Assert var tokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(tokens); Assert.AreEqual("new-user-uid", tokens.UserUid); @@ -806,10 +687,8 @@ public void OAuthHandler_SetUserUID_WithNoExistingTokens_ShouldCreateNewTokens() [ExpectedException(typeof(ArgumentException))] public void OAuthHandler_SetUserUID_WithNullUID_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetUserUID(null); } @@ -817,17 +696,15 @@ public void OAuthHandler_SetUserUID_WithNullUID_ShouldThrowException() [ExpectedException(typeof(ArgumentException))] public void OAuthHandler_SetUserUID_WithEmptyUID_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act handler.SetUserUID(""); } [TestMethod] public void OAuthHandler_SetTokenExpiryTime_WithValidTime_ShouldUpdateTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -835,11 +712,7 @@ public void OAuthHandler_SetTokenExpiryTime_WithValidTime_ShouldUpdateTokens() }; _client.StoreOAuthTokens(_options.ClientId, tokens); var newExpiryTime = DateTime.UtcNow.AddHours(2); - - // Act handler.SetTokenExpiryTime(newExpiryTime); - - // Assert var updatedTokens = _client.GetOAuthTokens(_options.ClientId); Assert.AreEqual(newExpiryTime, updatedTokens.ExpiresAt); } @@ -847,14 +720,10 @@ public void OAuthHandler_SetTokenExpiryTime_WithValidTime_ShouldUpdateTokens() [TestMethod] public void OAuthHandler_SetTokenExpiryTime_WithNoExistingTokens_ShouldCreateNewTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); var newExpiryTime = DateTime.UtcNow.AddHours(2); - - // Act handler.SetTokenExpiryTime(newExpiryTime); - - // Assert var tokens = _client.GetOAuthTokens(_options.ClientId); Assert.IsNotNull(tokens); Assert.AreEqual(newExpiryTime, tokens.ExpiresAt); @@ -866,11 +735,11 @@ public void OAuthHandler_SetTokenExpiryTime_WithNoExistingTokens_ShouldCreateNew [TestMethod] public async Task OAuthHandler_HandleRedirectAsync_WithValidUrl_ShouldExchangeCodeForTokens() { - // Arrange + var handler = new OAuthHandler(_client, _options); var redirectUrl = "https://example.com/callback?code=test-auth-code&state=test-state"; - // Act & Assert + try { await handler.HandleRedirectAsync(redirectUrl); @@ -888,10 +757,8 @@ public async Task OAuthHandler_HandleRedirectAsync_WithValidUrl_ShouldExchangeCo [ExpectedException(typeof(ArgumentException))] public async Task OAuthHandler_HandleRedirectAsync_WithNullUrl_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act await handler.HandleRedirectAsync(null); } @@ -899,10 +766,8 @@ public async Task OAuthHandler_HandleRedirectAsync_WithNullUrl_ShouldThrowExcept [ExpectedException(typeof(ArgumentException))] public async Task OAuthHandler_HandleRedirectAsync_WithEmptyUrl_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act await handler.HandleRedirectAsync(""); } @@ -910,11 +775,9 @@ public async Task OAuthHandler_HandleRedirectAsync_WithEmptyUrl_ShouldThrowExcep [ExpectedException(typeof(Exceptions.OAuthException))] public async Task OAuthHandler_HandleRedirectAsync_WithUrlMissingCode_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); var redirectUrl = "https://example.com/callback?state=test-state"; - - // Act await handler.HandleRedirectAsync(redirectUrl); } @@ -922,22 +785,20 @@ public async Task OAuthHandler_HandleRedirectAsync_WithUrlMissingCode_ShouldThro [ExpectedException(typeof(Exceptions.OAuthException))] public async Task OAuthHandler_HandleRedirectAsync_WithUrlContainingEmptyCode_ShouldThrowException() { - // Arrange + var handler = new OAuthHandler(_client, _options); var redirectUrl = "https://example.com/callback?code=&state=test-state"; - - // Act await handler.HandleRedirectAsync(redirectUrl); } [TestMethod] public async Task OAuthHandler_HandleRedirectAsync_WithComplexUrl_ShouldParseCorrectly() { - // Arrange + var handler = new OAuthHandler(_client, _options); var redirectUrl = "https://example.com/callback?code=test-auth-code&state=test-state&other=value"; - // Act & Assert + try { await handler.HandleRedirectAsync(redirectUrl); @@ -953,7 +814,7 @@ public async Task OAuthHandler_HandleRedirectAsync_WithComplexUrl_ShouldParseCor [TestMethod] public async Task OAuthHandler_LogoutAsync_WithValidTokens_ShouldCallRevocationAPI() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -963,7 +824,7 @@ public async Task OAuthHandler_LogoutAsync_WithValidTokens_ShouldCallRevocationA }; _client.StoreOAuthTokens(_options.ClientId, tokens); - // Act & Assert + try { var result = await handler.LogoutAsync(); @@ -980,17 +841,15 @@ public async Task OAuthHandler_LogoutAsync_WithValidTokens_ShouldCallRevocationA [ExpectedException(typeof(Exceptions.OAuthException))] public async Task OAuthHandler_LogoutAsync_WithNoTokens_ShouldThrowOAuthException() { - // Arrange + var handler = new OAuthHandler(_client, _options); - - // Act await handler.LogoutAsync(); } [TestMethod] public async Task OAuthHandler_LogoutAsync_ShouldClearTokensAfterSuccessfulRevocation() { - // Arrange + var handler = new OAuthHandler(_client, _options); var tokens = new OAuthTokens { @@ -1000,7 +859,7 @@ public async Task OAuthHandler_LogoutAsync_ShouldClearTokensAfterSuccessfulRevoc }; _client.StoreOAuthTokens(_options.ClientId, tokens); - // Act & Assert + try { await handler.LogoutAsync(); diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthOptionsTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthOptionsTest.cs index 03f7f0d..9e75640 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthOptionsTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthOptionsTest.cs @@ -11,10 +11,8 @@ public class OAuthOptionsTest [TestMethod] public void OAuthOptions_DefaultValues_ShouldBeCorrect() { - // Act - var options = new OAuthOptions(); - // Assert + var options = new OAuthOptions(); Assert.IsTrue(options.UsePkce); Assert.AreEqual("code", options.ResponseType); Assert.AreEqual("6400aa06db64de001a31c8a9", options.AppId); @@ -27,7 +25,7 @@ public void OAuthOptions_DefaultValues_ShouldBeCorrect() [TestMethod] public void OAuthOptions_IsValid_WithValidPKCEOptions_ShouldReturnTrue() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -36,11 +34,7 @@ public void OAuthOptions_IsValid_WithValidPKCEOptions_ShouldReturnTrue() ResponseType = "code" // UsePkce is automatically true when ClientSecret is null/empty }; - - // Act var isValid = options.IsValid(); - - // Assert Assert.IsTrue(isValid); Assert.IsTrue(options.UsePkce); } @@ -48,7 +42,7 @@ public void OAuthOptions_IsValid_WithValidPKCEOptions_ShouldReturnTrue() [TestMethod] public void OAuthOptions_IsValid_WithValidTraditionalOAuthOptions_ShouldReturnTrue() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -58,11 +52,7 @@ public void OAuthOptions_IsValid_WithValidTraditionalOAuthOptions_ShouldReturnTr ClientSecret = "test-secret" // UsePkce is automatically false when ClientSecret is provided }; - - // Act var isValid = options.IsValid(); - - // Assert Assert.IsTrue(isValid); Assert.IsFalse(options.UsePkce); } @@ -70,7 +60,7 @@ public void OAuthOptions_IsValid_WithValidTraditionalOAuthOptions_ShouldReturnTr [TestMethod] public void OAuthOptions_IsValid_WithMissingAppId_ShouldReturnFalse() { - // Arrange + var options = new OAuthOptions { AppId = "", @@ -78,11 +68,7 @@ public void OAuthOptions_IsValid_WithMissingAppId_ShouldReturnFalse() RedirectUri = "https://example.com/callback", ResponseType = "code" }; - - // Act var isValid = options.IsValid(out var errorMessage); - - // Assert Assert.IsFalse(isValid); Assert.AreEqual("AppId is required for OAuth configuration.", errorMessage); } @@ -90,7 +76,7 @@ public void OAuthOptions_IsValid_WithMissingAppId_ShouldReturnFalse() [TestMethod] public void OAuthOptions_IsValid_WithMissingClientId_ShouldReturnFalse() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -98,11 +84,7 @@ public void OAuthOptions_IsValid_WithMissingClientId_ShouldReturnFalse() RedirectUri = "https://example.com/callback", ResponseType = "code" }; - - // Act var isValid = options.IsValid(out var errorMessage); - - // Assert Assert.IsFalse(isValid); Assert.AreEqual("ClientId is required for OAuth configuration.", errorMessage); } @@ -110,7 +92,7 @@ public void OAuthOptions_IsValid_WithMissingClientId_ShouldReturnFalse() [TestMethod] public void OAuthOptions_IsValid_WithMissingRedirectUri_ShouldReturnFalse() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -118,11 +100,7 @@ public void OAuthOptions_IsValid_WithMissingRedirectUri_ShouldReturnFalse() RedirectUri = "", ResponseType = "code" }; - - // Act var isValid = options.IsValid(out var errorMessage); - - // Assert Assert.IsFalse(isValid); Assert.AreEqual("RedirectUri is required for OAuth configuration.", errorMessage); } @@ -130,7 +108,7 @@ public void OAuthOptions_IsValid_WithMissingRedirectUri_ShouldReturnFalse() [TestMethod] public void OAuthOptions_IsValid_WithInvalidRedirectUri_ShouldReturnFalse() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -138,11 +116,7 @@ public void OAuthOptions_IsValid_WithInvalidRedirectUri_ShouldReturnFalse() RedirectUri = "not-a-valid-uri", ResponseType = "code" }; - - // Act var isValid = options.IsValid(out var errorMessage); - - // Assert Assert.IsFalse(isValid); Assert.AreEqual("RedirectUri must be a valid absolute URI.", errorMessage); } @@ -150,7 +124,7 @@ public void OAuthOptions_IsValid_WithInvalidRedirectUri_ShouldReturnFalse() [TestMethod] public void OAuthOptions_IsValid_WithNonHttpRedirectUri_ShouldReturnFalse() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -158,11 +132,7 @@ public void OAuthOptions_IsValid_WithNonHttpRedirectUri_ShouldReturnFalse() RedirectUri = "ftp://example.com/callback", ResponseType = "code" }; - - // Act var isValid = options.IsValid(out var errorMessage); - - // Assert Assert.IsFalse(isValid); Assert.AreEqual("RedirectUri must use http or https scheme.", errorMessage); } @@ -170,7 +140,7 @@ public void OAuthOptions_IsValid_WithNonHttpRedirectUri_ShouldReturnFalse() [TestMethod] public void OAuthOptions_IsValid_WithMissingResponseType_ShouldReturnFalse() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -178,11 +148,7 @@ public void OAuthOptions_IsValid_WithMissingResponseType_ShouldReturnFalse() RedirectUri = "https://example.com/callback", ResponseType = "" }; - - // Act var isValid = options.IsValid(out var errorMessage); - - // Assert Assert.IsFalse(isValid); Assert.AreEqual("ResponseType is required for OAuth configuration.", errorMessage); } @@ -190,7 +156,7 @@ public void OAuthOptions_IsValid_WithMissingResponseType_ShouldReturnFalse() [TestMethod] public void OAuthOptions_IsValid_WithInvalidResponseType_ShouldReturnFalse() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -198,11 +164,7 @@ public void OAuthOptions_IsValid_WithInvalidResponseType_ShouldReturnFalse() RedirectUri = "https://example.com/callback", ResponseType = "token" }; - - // Act var isValid = options.IsValid(out var errorMessage); - - // Assert Assert.IsFalse(isValid); Assert.AreEqual("ResponseType must be 'code' for authorization code flow.", errorMessage); } @@ -210,7 +172,7 @@ public void OAuthOptions_IsValid_WithInvalidResponseType_ShouldReturnFalse() [TestMethod] public void OAuthOptions_IsValid_WithTraditionalOAuthMissingClientSecret_ShouldReturnFalse() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -219,11 +181,7 @@ public void OAuthOptions_IsValid_WithTraditionalOAuthMissingClientSecret_ShouldR ResponseType = "code" // UsePkce will be true because ClientSecret is null }; - - // Act var isValid = options.IsValid(out var errorMessage); - - // Assert Assert.IsTrue(isValid); // This will actually be valid because UsePkce is true Assert.IsTrue(options.UsePkce); } @@ -231,7 +189,7 @@ public void OAuthOptions_IsValid_WithTraditionalOAuthMissingClientSecret_ShouldR [TestMethod] public void OAuthOptions_Validate_WithValidOptions_ShouldNotThrow() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -240,7 +198,7 @@ public void OAuthOptions_Validate_WithValidOptions_ShouldNotThrow() ResponseType = "code" }; - // Act & Assert - Should not throw + // Should not throw options.Validate(); } @@ -248,7 +206,7 @@ public void OAuthOptions_Validate_WithValidOptions_ShouldNotThrow() [ExpectedException(typeof(OAuthConfigurationException))] public void OAuthOptions_Validate_WithInvalidOptions_ShouldThrowException() { - // Arrange + var options = new OAuthOptions { AppId = "", @@ -256,15 +214,13 @@ public void OAuthOptions_Validate_WithInvalidOptions_ShouldThrowException() RedirectUri = "https://example.com/callback", ResponseType = "code" }; - - // Act options.Validate(); } [TestMethod] public void OAuthOptions_WithScopes_ShouldBeValid() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -273,18 +229,14 @@ public void OAuthOptions_WithScopes_ShouldBeValid() ResponseType = "code", Scope = new[] { "read", "write", "admin" } }; - - // Act var isValid = options.IsValid(); - - // Assert Assert.IsTrue(isValid); } [TestMethod] public void OAuthOptions_WithEmptyScopes_ShouldBeValid() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -293,18 +245,14 @@ public void OAuthOptions_WithEmptyScopes_ShouldBeValid() ResponseType = "code", Scope = new string[0] }; - - // Act var isValid = options.IsValid(); - - // Assert Assert.IsTrue(isValid); } [TestMethod] public void OAuthOptions_WithNullScopes_ShouldBeValid() { - // Arrange + var options = new OAuthOptions { AppId = "test-app-id", @@ -313,11 +261,7 @@ public void OAuthOptions_WithNullScopes_ShouldBeValid() ResponseType = "code", Scope = null }; - - // Act var isValid = options.IsValid(); - - // Assert Assert.IsTrue(isValid); } } diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenRefreshExceptionTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenRefreshExceptionTest.cs index 22092bd..32b65f4 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenRefreshExceptionTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenRefreshExceptionTest.cs @@ -29,14 +29,14 @@ public void Setup() [TestCleanup] public void Cleanup() { - // Clear any test tokens + _client.ClearOAuthTokens(_options.ClientId); } [TestMethod] public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithExpiredToken_ShouldRefreshSuccessfully() { - // Arrange + var expiredTokens = new OAuthTokens { AccessToken = "expired-token", @@ -51,7 +51,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithExpiredToken_Sho _client.contentstackOptions.Authtoken = expiredTokens.AccessToken; _client.contentstackOptions.IsOAuthToken = true; - // Act & Assert + try { var result = _client.GetUserAsync().Result; @@ -66,7 +66,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithExpiredToken_Sho [TestMethod] public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithInvalidRefreshToken_ShouldThrowOAuthTokenRefreshException() { - // Arrange + var tokensWithInvalidRefresh = new OAuthTokens { AccessToken = "expired-token", @@ -81,7 +81,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithInvalidRefreshTo _client.contentstackOptions.Authtoken = tokensWithInvalidRefresh.AccessToken; _client.contentstackOptions.IsOAuthToken = true; - // Act & Assert + try { var result = _client.GetUserAsync().Result; @@ -97,7 +97,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithInvalidRefreshTo [TestMethod] public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithNullRefreshToken_ShouldThrowOAuthTokenRefreshException() { - // Arrange + var tokensWithNullRefresh = new OAuthTokens { AccessToken = "expired-token", @@ -111,7 +111,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithNullRefreshToken _client.contentstackOptions.Authtoken = tokensWithNullRefresh.AccessToken; _client.contentstackOptions.IsOAuthToken = true; - // Act & Assert + try { var result = _client.GetUserAsync().Result; @@ -128,7 +128,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithNullRefreshToken [TestMethod] public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithValidToken_ShouldNotAttemptRefresh() { - // Arrange + var validTokens = new OAuthTokens { AccessToken = "valid-token", @@ -142,7 +142,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithValidToken_Shoul _client.contentstackOptions.Authtoken = validTokens.AccessToken; _client.contentstackOptions.IsOAuthToken = true; - // Act & Assert + try { var result = _client.GetUserAsync().Result; @@ -162,7 +162,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithValidToken_Shoul [TestMethod] public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithTokenNeedingRefresh_ShouldAttemptRefresh() { - // Arrange + var tokensNeedingRefresh = new OAuthTokens { AccessToken = "token-needing-refresh", @@ -176,7 +176,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithTokenNeedingRefr _client.contentstackOptions.Authtoken = tokensNeedingRefresh.AccessToken; _client.contentstackOptions.IsOAuthToken = true; - // Act & Assert + try { var result = _client.GetUserAsync().Result; @@ -192,7 +192,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithTokenNeedingRefr [TestMethod] public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithMultipleClients_ShouldHandleCorrectClient() { - // Arrange + var client1Tokens = new OAuthTokens { AccessToken = "client1-token", @@ -216,7 +216,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithMultipleClients_ _client.contentstackOptions.Authtoken = "client1-token"; // Use client1's expired token _client.contentstackOptions.IsOAuthToken = true; - // Act & Assert + try { var result = _client.GetUserAsync().Result; @@ -233,7 +233,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithMultipleClients_ [TestMethod] public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithNoMatchingTokens_ShouldNotThrow() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "some-other-token", @@ -247,7 +247,7 @@ public void ContentstackClient_EnsureOAuthTokenIsValidAsync_WithNoMatchingTokens _client.contentstackOptions.Authtoken = "different-token"; // Different token _client.contentstackOptions.IsOAuthToken = true; - // Act & Assert + try { var result = _client.GetUserAsync().Result; diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenStorageTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenStorageTest.cs index adaac06..58d6803 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenStorageTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenStorageTest.cs @@ -28,7 +28,7 @@ public void Cleanup() [TestMethod] public void OAuthTokenStorage_SetAndGetTokens_ShouldWork() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", @@ -38,12 +38,8 @@ public void OAuthTokenStorage_SetAndGetTokens_ShouldWork() UserUid = "test-user-uid", ClientId = TestClientId }; - - // Act _client.StoreOAuthTokens(TestClientId, tokens); var retrievedTokens = _client.GetOAuthTokens(TestClientId); - - // Assert Assert.IsNotNull(retrievedTokens); Assert.AreEqual("test-access-token", retrievedTokens.AccessToken); Assert.AreEqual("test-refresh-token", retrievedTokens.RefreshToken); @@ -55,17 +51,15 @@ public void OAuthTokenStorage_SetAndGetTokens_ShouldWork() [TestMethod] public void OAuthTokenStorage_GetTokens_WithNonExistentClientId_ShouldReturnNull() { - // Act - var tokens = _client.GetOAuthTokens("non-existent-client-id"); - // Assert + var tokens = _client.GetOAuthTokens("non-existent-client-id"); Assert.IsNull(tokens); } [TestMethod] public void OAuthTokenStorage_HasTokens_WithExistingTokens_ShouldReturnTrue() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-token", @@ -73,21 +67,21 @@ public void OAuthTokenStorage_HasTokens_WithExistingTokens_ShouldReturnTrue() }; _client.StoreOAuthTokens(TestClientId, tokens); - // Act & Assert + Assert.IsTrue(_client.HasOAuthTokens(TestClientId)); } [TestMethod] public void OAuthTokenStorage_HasTokens_WithNoTokens_ShouldReturnFalse() { - // Act & Assert + Assert.IsFalse(_client.HasOAuthTokens(TestClientId)); } [TestMethod] public void OAuthTokenStorage_HasValidTokens_WithValidTokens_ShouldReturnTrue() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-token", @@ -96,14 +90,14 @@ public void OAuthTokenStorage_HasValidTokens_WithValidTokens_ShouldReturnTrue() }; _client.StoreOAuthTokens(TestClientId, tokens); - // Act & Assert + Assert.IsTrue(_client.HasValidOAuthTokens(TestClientId)); } [TestMethod] public void OAuthTokenStorage_HasValidTokens_WithExpiredTokens_ShouldReturnFalse() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-token", @@ -112,21 +106,21 @@ public void OAuthTokenStorage_HasValidTokens_WithExpiredTokens_ShouldReturnFalse }; _client.StoreOAuthTokens(TestClientId, tokens); - // Act & Assert + Assert.IsFalse(_client.HasValidOAuthTokens(TestClientId)); } [TestMethod] public void OAuthTokenStorage_HasValidTokens_WithNoTokens_ShouldReturnFalse() { - // Act & Assert + Assert.IsFalse(_client.HasValidOAuthTokens(TestClientId)); } [TestMethod] public void OAuthTokenStorage_ClearTokens_ShouldRemoveTokens() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-token", @@ -134,11 +128,7 @@ public void OAuthTokenStorage_ClearTokens_ShouldRemoveTokens() }; _client.StoreOAuthTokens(TestClientId, tokens); Assert.IsTrue(_client.HasOAuthTokens(TestClientId)); - - // Act _client.ClearOAuthTokens(TestClientId); - - // Assert Assert.IsNull(_client.GetOAuthTokens(TestClientId)); Assert.IsFalse(_client.HasOAuthTokens(TestClientId)); Assert.IsFalse(_client.HasOAuthTokens(TestClientId)); @@ -147,15 +137,13 @@ public void OAuthTokenStorage_ClearTokens_ShouldRemoveTokens() [TestMethod] public void OAuthTokenStorage_ClearTokens_WithNonExistentClientId_ShouldNotThrow() { - // Act & Assert - Should not throw + // Should not throw _client.ClearOAuthTokens("non-existent-client-id"); } - - [TestMethod] public void OAuthTokenStorage_ThreadSafety_ShouldHandleConcurrentAccess() { - // Arrange + var tokens1 = new OAuthTokens { AccessToken = "token-1", @@ -181,8 +169,6 @@ public void OAuthTokenStorage_ThreadSafety_ShouldHandleConcurrentAccess() }); Task.WaitAll(task1, task2); - - // Assert Assert.AreEqual("token-1", task1.Result.AccessToken); Assert.AreEqual("token-2", task2.Result.AccessToken); } @@ -190,7 +176,7 @@ public void OAuthTokenStorage_ThreadSafety_ShouldHandleConcurrentAccess() [TestMethod] public void OAuthTokenStorage_UpdateTokens_ShouldReplaceExistingTokens() { - // Arrange + var originalTokens = new OAuthTokens { AccessToken = "original-token", @@ -204,16 +190,10 @@ public void OAuthTokenStorage_UpdateTokens_ShouldReplaceExistingTokens() RefreshToken = "new-refresh-token", ClientId = TestClientId }; - - // Act _client.StoreOAuthTokens(TestClientId, updatedTokens); var retrievedTokens = _client.GetOAuthTokens(TestClientId); - - // Assert 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 index 10657cf..8a7ce25 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/OAuthTokenTest.cs @@ -10,10 +10,8 @@ public class OAuthTokenTest [TestMethod] public void OAuthTokens_DefaultValues_ShouldBeCorrect() { - // Act - var tokens = new OAuthTokens(); - // Assert + var tokens = new OAuthTokens(); Assert.IsNull(tokens.AccessToken); Assert.IsNull(tokens.RefreshToken); Assert.IsNull(tokens.OrganizationUid); @@ -26,205 +24,161 @@ public void OAuthTokens_DefaultValues_ShouldBeCorrect() [TestMethod] public void OAuthTokens_IsValid_WithValidToken_ShouldReturnTrue() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", ExpiresAt = DateTime.UtcNow.AddHours(1), ClientId = "test-client-id" }; - - // Act var isValid = tokens.IsValid; - - // Assert Assert.IsTrue(isValid); } [TestMethod] public void OAuthTokens_IsValid_WithNullAccessToken_ShouldReturnFalse() { - // Arrange + var tokens = new OAuthTokens { AccessToken = null, ExpiresAt = DateTime.UtcNow.AddHours(1), ClientId = "test-client-id" }; - - // Act var isValid = tokens.IsValid; - - // Assert Assert.IsFalse(isValid); } [TestMethod] public void OAuthTokens_IsValid_WithEmptyAccessToken_ShouldReturnFalse() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "", ExpiresAt = DateTime.UtcNow.AddHours(1), ClientId = "test-client-id" }; - - // Act var isValid = tokens.IsValid; - - // Assert Assert.IsFalse(isValid); } [TestMethod] public void OAuthTokens_IsValid_WithExpiredToken_ShouldReturnFalse() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", ExpiresAt = DateTime.UtcNow.AddMinutes(-5), ClientId = "test-client-id" }; - - // Act var isValid = tokens.IsValid; - - // Assert Assert.IsFalse(isValid); } [TestMethod] public void OAuthTokens_IsValid_WithDefaultExpiryTime_ShouldReturnFalse() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", ExpiresAt = default(DateTime), ClientId = "test-client-id" }; - - // Act var isValid = tokens.IsValid; - - // Assert Assert.IsFalse(isValid); } [TestMethod] public void OAuthTokens_IsExpired_WithFutureExpiryTime_ShouldReturnFalse() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", ExpiresAt = DateTime.UtcNow.AddHours(1), ClientId = "test-client-id" }; - - // Act var isExpired = tokens.IsExpired; - - // Assert Assert.IsFalse(isExpired); } [TestMethod] public void OAuthTokens_IsExpired_WithPastExpiryTime_ShouldReturnTrue() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", ExpiresAt = DateTime.UtcNow.AddMinutes(-5), ClientId = "test-client-id" }; - - // Act var isExpired = tokens.IsExpired; - - // Assert Assert.IsTrue(isExpired); } [TestMethod] public void OAuthTokens_IsExpired_WithDefaultExpiryTime_ShouldReturnTrue() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", ExpiresAt = default(DateTime), ClientId = "test-client-id" }; - - // Act var isExpired = tokens.IsExpired; - - // Assert Assert.IsTrue(isExpired); } [TestMethod] public void OAuthTokens_NeedsRefresh_WithTokenExpiringSoon_ShouldReturnTrue() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", ExpiresAt = DateTime.UtcNow.AddMinutes(2), // Less than 5 minutes ClientId = "test-client-id" }; - - // Act var needsRefresh = tokens.NeedsRefresh; - - // Assert Assert.IsTrue(needsRefresh); } [TestMethod] public void OAuthTokens_NeedsRefresh_WithTokenNotExpiringSoon_ShouldReturnFalse() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", ExpiresAt = DateTime.UtcNow.AddMinutes(10), // More than 5 minutes ClientId = "test-client-id" }; - - // Act var needsRefresh = tokens.NeedsRefresh; - - // Assert Assert.IsFalse(needsRefresh); } [TestMethod] public void OAuthTokens_NeedsRefresh_WithExpiredToken_ShouldReturnTrue() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", ExpiresAt = DateTime.UtcNow.AddMinutes(-5), ClientId = "test-client-id" }; - - // Act var needsRefresh = tokens.NeedsRefresh; - - // Assert Assert.IsTrue(needsRefresh); } [TestMethod] public void OAuthTokens_NeedsRefresh_WithNoRefreshToken_ShouldReturnTrue() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", @@ -232,18 +186,14 @@ public void OAuthTokens_NeedsRefresh_WithNoRefreshToken_ShouldReturnTrue() RefreshToken = null, ClientId = "test-client-id" }; - - // Act var needsRefresh = tokens.NeedsRefresh; - - // Assert Assert.IsTrue(needsRefresh); // NeedsRefresh is based on expiry time, not refresh token presence } [TestMethod] public void OAuthTokens_NeedsRefresh_WithEmptyRefreshToken_ShouldReturnTrue() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", @@ -251,18 +201,14 @@ public void OAuthTokens_NeedsRefresh_WithEmptyRefreshToken_ShouldReturnTrue() RefreshToken = "", ClientId = "test-client-id" }; - - // Act var needsRefresh = tokens.NeedsRefresh; - - // Assert Assert.IsTrue(needsRefresh); // NeedsRefresh is based on expiry time, not refresh token presence } [TestMethod] public void OAuthTokens_NeedsRefresh_WithValidRefreshToken_ShouldReturnTrue() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", @@ -270,18 +216,14 @@ public void OAuthTokens_NeedsRefresh_WithValidRefreshToken_ShouldReturnTrue() RefreshToken = "test-refresh-token", ClientId = "test-client-id" }; - - // Act var needsRefresh = tokens.NeedsRefresh; - - // Assert Assert.IsTrue(needsRefresh); } [TestMethod] public void OAuthTokens_ToString_ShouldReturnTypeName() { - // Arrange + var tokens = new OAuthTokens { AccessToken = "test-access-token", @@ -292,18 +234,14 @@ public void OAuthTokens_ToString_ShouldReturnTypeName() AppId = "test-app-id", ExpiresAt = DateTime.UtcNow.AddHours(1) }; - - // Act var result = tokens.ToString(); - - // Assert Assert.IsTrue(result.Contains("OAuthTokens")); // Default ToString returns type name } [TestMethod] public void OAuthTokens_WithAllProperties_ShouldSetCorrectly() { - // Arrange + var accessToken = "test-access-token"; var refreshToken = "test-refresh-token"; var organizationUid = "test-org-uid"; @@ -311,8 +249,6 @@ public void OAuthTokens_WithAllProperties_ShouldSetCorrectly() var clientId = "test-client-id"; var appId = "test-app-id"; var expiresAt = DateTime.UtcNow.AddHours(1); - - // Act var tokens = new OAuthTokens { AccessToken = accessToken, @@ -323,8 +259,6 @@ public void OAuthTokens_WithAllProperties_ShouldSetCorrectly() AppId = appId, ExpiresAt = expiresAt }; - - // Assert Assert.AreEqual(accessToken, tokens.AccessToken); Assert.AreEqual(refreshToken, tokens.RefreshToken); Assert.AreEqual(organizationUid, tokens.OrganizationUid); diff --git a/Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs b/Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs index 0aa75e3..085ede1 100644 --- a/Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/OAuth/PkceHelperTest.cs @@ -10,10 +10,8 @@ public class PkceHelperTest [TestMethod] public void PkceHelper_GenerateCodeVerifier_ShouldReturnValidCodeVerifier() { - // Act - var codeVerifier = PkceHelper.GenerateCodeVerifier(); - // Assert + var codeVerifier = PkceHelper.GenerateCodeVerifier(); Assert.IsNotNull(codeVerifier); Assert.IsTrue(PkceHelper.IsValidCodeVerifier(codeVerifier)); Assert.IsTrue(codeVerifier.Length >= 43); @@ -23,24 +21,18 @@ public void PkceHelper_GenerateCodeVerifier_ShouldReturnValidCodeVerifier() [TestMethod] public void PkceHelper_GenerateCodeVerifier_MultipleCalls_ShouldReturnDifferentValues() { - // Act + var verifier1 = PkceHelper.GenerateCodeVerifier(); var verifier2 = PkceHelper.GenerateCodeVerifier(); - - // Assert Assert.AreNotEqual(verifier1, verifier2); } [TestMethod] public void PkceHelper_GenerateCodeChallenge_WithValidCodeVerifier_ShouldReturnValidChallenge() { - // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); - - // Act var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); - - // Assert Assert.IsNotNull(codeChallenge); Assert.IsTrue(PkceHelper.IsValidCodeChallenge(codeChallenge)); Assert.AreEqual(43, codeChallenge.Length); // Base64URL encoded SHA256 hash is always 43 characters @@ -49,68 +41,50 @@ public void PkceHelper_GenerateCodeChallenge_WithValidCodeVerifier_ShouldReturnV [TestMethod] public void PkceHelper_GenerateCodeChallenge_WithSameCodeVerifier_ShouldReturnSameChallenge() { - // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); - - // Act var challenge1 = PkceHelper.GenerateCodeChallenge(codeVerifier); var challenge2 = PkceHelper.GenerateCodeChallenge(codeVerifier); - - // Assert Assert.AreEqual(challenge1, challenge2); } [TestMethod] public void PkceHelper_GenerateCodeChallenge_WithDifferentCodeVerifiers_ShouldReturnDifferentChallenges() { - // Arrange + var codeVerifier1 = PkceHelper.GenerateCodeVerifier(); var codeVerifier2 = PkceHelper.GenerateCodeVerifier(); - - // Act var challenge1 = PkceHelper.GenerateCodeChallenge(codeVerifier1); var challenge2 = PkceHelper.GenerateCodeChallenge(codeVerifier2); - - // Assert Assert.AreNotEqual(challenge1, challenge2); } [TestMethod] public void PkceHelper_VerifyCodeChallenge_WithValidPair_ShouldReturnTrue() { - // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); - - // Act var isValid = PkceHelper.VerifyCodeChallenge(codeVerifier, codeChallenge); - - // Assert Assert.IsTrue(isValid); } [TestMethod] public void PkceHelper_VerifyCodeChallenge_WithInvalidPair_ShouldReturnFalse() { - // Arrange + var codeVerifier1 = PkceHelper.GenerateCodeVerifier(); var codeVerifier2 = PkceHelper.GenerateCodeVerifier(); var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier1); - - // Act var isValid = PkceHelper.VerifyCodeChallenge(codeVerifier2, codeChallenge); - - // Assert Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_GeneratePkcePair_ShouldReturnValidPair() { - // Act - var pkcePair = PkceHelper.GeneratePkcePair(); - // Assert + var pkcePair = PkceHelper.GeneratePkcePair(); Assert.IsNotNull(pkcePair); Assert.IsNotNull(pkcePair.CodeVerifier); Assert.IsNotNull(pkcePair.CodeChallenge); @@ -122,142 +96,104 @@ public void PkceHelper_GeneratePkcePair_ShouldReturnValidPair() [TestMethod] public void PkceHelper_IsValidCodeVerifier_WithValidVerifier_ShouldReturnTrue() { - // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); - - // Act var isValid = PkceHelper.IsValidCodeVerifier(codeVerifier); - - // Assert Assert.IsTrue(isValid); } [TestMethod] public void PkceHelper_IsValidCodeVerifier_WithNullVerifier_ShouldReturnFalse() { - // Act - var isValid = PkceHelper.IsValidCodeVerifier(null); - // Assert + var isValid = PkceHelper.IsValidCodeVerifier(null); Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeVerifier_WithEmptyVerifier_ShouldReturnFalse() { - // Act - var isValid = PkceHelper.IsValidCodeVerifier(""); - // Assert + var isValid = PkceHelper.IsValidCodeVerifier(""); Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeVerifier_WithTooShortVerifier_ShouldReturnFalse() { - // Arrange + var shortVerifier = "short"; // Less than 43 characters - - // Act var isValid = PkceHelper.IsValidCodeVerifier(shortVerifier); - - // Assert Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeVerifier_WithTooLongVerifier_ShouldReturnFalse() { - // Arrange + var longVerifier = new string('a', 129); // More than 128 characters - - // Act var isValid = PkceHelper.IsValidCodeVerifier(longVerifier); - - // Assert Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeVerifier_WithInvalidCharacters_ShouldReturnFalse() { - // Arrange + var invalidVerifier = "invalid-characters!@#$%^&*()"; // Contains invalid characters - - // Act var isValid = PkceHelper.IsValidCodeVerifier(invalidVerifier); - - // Assert Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeChallenge_WithValidChallenge_ShouldReturnTrue() { - // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); - - // Act var isValid = PkceHelper.IsValidCodeChallenge(codeChallenge); - - // Assert Assert.IsTrue(isValid); } [TestMethod] public void PkceHelper_IsValidCodeChallenge_WithNullChallenge_ShouldReturnFalse() { - // Act - var isValid = PkceHelper.IsValidCodeChallenge(null); - // Assert + var isValid = PkceHelper.IsValidCodeChallenge(null); Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeChallenge_WithEmptyChallenge_ShouldReturnFalse() { - // Act - var isValid = PkceHelper.IsValidCodeChallenge(""); - // Assert + var isValid = PkceHelper.IsValidCodeChallenge(""); Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeChallenge_WithWrongLengthChallenge_ShouldReturnFalse() { - // Arrange + var wrongLengthChallenge = "wrong-length"; // Should be 43 characters - - // Act var isValid = PkceHelper.IsValidCodeChallenge(wrongLengthChallenge); - - // Assert Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_IsValidCodeChallenge_WithInvalidCharacters_ShouldReturnFalse() { - // Arrange + var invalidChallenge = "invalid-characters!@#$%^&*()"; // Contains invalid characters - - // Act var isValid = PkceHelper.IsValidCodeChallenge(invalidChallenge); - - // Assert Assert.IsFalse(isValid); } [TestMethod] public void PkceHelper_CodeVerifier_ShouldBeUrlSafe() { - // Act - var codeVerifier = PkceHelper.GenerateCodeVerifier(); - // Assert + var codeVerifier = PkceHelper.GenerateCodeVerifier(); Assert.IsFalse(codeVerifier.Contains("+")); Assert.IsFalse(codeVerifier.Contains("/")); Assert.IsFalse(codeVerifier.Contains("=")); @@ -266,13 +202,9 @@ public void PkceHelper_CodeVerifier_ShouldBeUrlSafe() [TestMethod] public void PkceHelper_CodeChallenge_ShouldBeUrlSafe() { - // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); - - // Act var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); - - // Assert Assert.IsFalse(codeChallenge.Contains("+")); Assert.IsFalse(codeChallenge.Contains("/")); Assert.IsFalse(codeChallenge.Contains("=")); @@ -281,10 +213,8 @@ public void PkceHelper_CodeChallenge_ShouldBeUrlSafe() [TestMethod] public void PkceHelper_CodeVerifier_ShouldContainOnlyValidCharacters() { - // Act - var codeVerifier = PkceHelper.GenerateCodeVerifier(); - // Assert + var codeVerifier = PkceHelper.GenerateCodeVerifier(); foreach (char c in codeVerifier) { Assert.IsTrue( @@ -297,13 +227,9 @@ public void PkceHelper_CodeVerifier_ShouldContainOnlyValidCharacters() [TestMethod] public void PkceHelper_CodeChallenge_ShouldContainOnlyValidCharacters() { - // Arrange + var codeVerifier = PkceHelper.GenerateCodeVerifier(); - - // Act var codeChallenge = PkceHelper.GenerateCodeChallenge(codeVerifier); - - // Assert foreach (char c in codeChallenge) { Assert.IsTrue(