From dac4ab4cd211f200bb108440d7ef4a8b0249e1cd Mon Sep 17 00:00:00 2001 From: Vedant Koditkar <18693839+KoditkarVedant@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:41:50 +0530 Subject: [PATCH 1/2] Add support for refresh token endpoint --- Src/Notion.Client/Api/ApiEndpoints.cs | 1 + .../Authentication/IAuthenticationClient.cs | 11 ++ .../RefreshToken/AuthenticationClient.cs | 40 ++++++ .../Request/IRefreshTokenBodyParameters.cs | 20 +++ .../Request/RefreshTokenRequest.cs | 14 ++ .../RefreshToken/Response/Owner.cs | 10 ++ .../Response/RefreshTokenResponse.cs | 56 ++++++++ .../AuthenticationClientTests.cs | 42 ++++++ .../RefreshTokenApiTests.cs | 126 ++++++++++++++++++ 9 files changed, 320 insertions(+) create mode 100644 Src/Notion.Client/Api/Authentication/RefreshToken/AuthenticationClient.cs create mode 100644 Src/Notion.Client/Api/Authentication/RefreshToken/Request/IRefreshTokenBodyParameters.cs create mode 100644 Src/Notion.Client/Api/Authentication/RefreshToken/Request/RefreshTokenRequest.cs create mode 100644 Src/Notion.Client/Api/Authentication/RefreshToken/Response/Owner.cs create mode 100644 Src/Notion.Client/Api/Authentication/RefreshToken/Response/RefreshTokenResponse.cs create mode 100644 Test/Notion.UnitTests/AuthenticationClientTest/RefreshTokenApiTests.cs diff --git a/Src/Notion.Client/Api/ApiEndpoints.cs b/Src/Notion.Client/Api/ApiEndpoints.cs index 31425149..068e53db 100644 --- a/Src/Notion.Client/Api/ApiEndpoints.cs +++ b/Src/Notion.Client/Api/ApiEndpoints.cs @@ -137,6 +137,7 @@ public static class AuthenticationUrls public static string CreateToken() => "/v1/oauth/token"; public static string RevokeToken() => "/v1/oauth/revoke"; public static string IntrospectToken() => "/v1/oauth/introspect"; + public static string RefreshToken() => "/v1/oauth/token"; } } } diff --git a/Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs b/Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs index 8b1e08d0..0ce98d9c 100644 --- a/Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs +++ b/Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs @@ -40,5 +40,16 @@ Task IntrospectTokenAsync( IntrospectTokenRequest introspectTokenRequest, CancellationToken cancellationToken = default ); + + /// + /// Refreshes an access token, generating a new access token and new refresh token. + /// + /// + /// + /// + Task RefreshTokenAsync( + RefreshTokenRequest refreshTokenRequest, + CancellationToken cancellationToken = default + ); } } diff --git a/Src/Notion.Client/Api/Authentication/RefreshToken/AuthenticationClient.cs b/Src/Notion.Client/Api/Authentication/RefreshToken/AuthenticationClient.cs new file mode 100644 index 00000000..2e806ca1 --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/AuthenticationClient.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Notion.Client +{ + public sealed partial class AuthenticationClient + { + public async Task RefreshTokenAsync( + RefreshTokenRequest refreshTokenRequest, + CancellationToken cancellationToken = default) + { + if (refreshTokenRequest == null) + { + throw new ArgumentNullException(nameof(refreshTokenRequest)); + } + + IRefreshTokenBodyParameters body = refreshTokenRequest; + + if (string.IsNullOrWhiteSpace(body.RefreshToken)) + { + throw new ArgumentNullException(nameof(body.RefreshToken), "RefreshToken is required."); + } + + IBasicAuthenticationParameters basicAuth = refreshTokenRequest; + + BasicAuthParamValidator.Validate(basicAuth); + + var response = await _client.PostAsync( + ApiEndpoints.AuthenticationUrls.RefreshToken(), + body, + basicAuthenticationParameters: basicAuth, + cancellationToken: cancellationToken + ); + + return response; + } + } + +} \ No newline at end of file diff --git a/Src/Notion.Client/Api/Authentication/RefreshToken/Request/IRefreshTokenBodyParameters.cs b/Src/Notion.Client/Api/Authentication/RefreshToken/Request/IRefreshTokenBodyParameters.cs new file mode 100644 index 00000000..a2afed5b --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/Request/IRefreshTokenBodyParameters.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace Notion.Client +{ + public interface IRefreshTokenBodyParameters + { + /// + /// A constant string: "refresh_token" + /// + [JsonProperty("grant_type")] + string GrantType { get; set; } + + /// + /// A unique token that Notion generates to refresh your token, generated when a user initiates the OAuth flow. + /// + [JsonProperty("refresh_token")] + string RefreshToken { get; set; } + } + +} diff --git a/Src/Notion.Client/Api/Authentication/RefreshToken/Request/RefreshTokenRequest.cs b/Src/Notion.Client/Api/Authentication/RefreshToken/Request/RefreshTokenRequest.cs new file mode 100644 index 00000000..bd236e36 --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/Request/RefreshTokenRequest.cs @@ -0,0 +1,14 @@ +namespace Notion.Client +{ + public class RefreshTokenRequest : IRefreshTokenBodyParameters, IBasicAuthenticationParameters + { + public string GrantType { get; set; } = "refresh_token"; + + public string RefreshToken { get; set; } + + public string ClientId { get; set; } + + public string ClientSecret { get; set; } + } + +} diff --git a/Src/Notion.Client/Api/Authentication/RefreshToken/Response/Owner.cs b/Src/Notion.Client/Api/Authentication/RefreshToken/Response/Owner.cs new file mode 100644 index 00000000..dbf4eb44 --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/Response/Owner.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Notion.Client +{ + public class Owner + { + [JsonProperty("workspace")] + public bool Workspace { get; set; } + } +} diff --git a/Src/Notion.Client/Api/Authentication/RefreshToken/Response/RefreshTokenResponse.cs b/Src/Notion.Client/Api/Authentication/RefreshToken/Response/RefreshTokenResponse.cs new file mode 100644 index 00000000..a2401d67 --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/Response/RefreshTokenResponse.cs @@ -0,0 +1,56 @@ +using Newtonsoft.Json; + +namespace Notion.Client +{ + public class RefreshTokenResponse + { + /// + /// A unique token that you can use to authenticate requests to the Notion API. + /// + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + /// + /// A unique token that you can use to refresh your access token when it expires. + /// + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + + /// + /// The unique identifier for the integration associated with the access token. + /// + [JsonProperty("bot_id")] + public string BotId { get; set; } + + /// + /// Duplicated template id + /// + [JsonProperty("duplicated_template_id")] + public string DuplicatedTemplateId { get; set; } + + /// + /// The type of owner for the integration. This will always be "workspace". + /// + [JsonProperty("owner")] + public Owner Owner { get; set; } + + /// + /// The icon of the workspace the integration is connected to. This will be null if the workspace has no icon. + /// + [JsonProperty("workspace_icon")] + public string WorkspaceIcon { get; set; } + + /// + /// The name of the workspace the integration is connected to. + /// + [JsonProperty("workspace_name")] + public string WorkspaceName { get; set; } + + /// + /// The unique identifier of the workspace the integration is connected to. + /// + [JsonProperty("workspace_id")] + public string WorkspaceId { get; set; } + + } +} diff --git a/Test/Notion.IntegrationTests/AuthenticationClientTests.cs b/Test/Notion.IntegrationTests/AuthenticationClientTests.cs index 4d96e030..299eb0ef 100644 --- a/Test/Notion.IntegrationTests/AuthenticationClientTests.cs +++ b/Test/Notion.IntegrationTests/AuthenticationClientTests.cs @@ -55,6 +55,7 @@ public async Task Introspect_token() // Assert Assert.NotNull(response); Assert.NotNull(response.AccessToken); + Assert.NotNull(response.RefreshToken); // introspect token var introspectResponse = await Client.AuthenticationClient.IntrospectTokenAsync(new IntrospectTokenRequest @@ -75,4 +76,45 @@ await Client.AuthenticationClient.RevokeTokenAsync(new RevokeTokenRequest ClientSecret = _clientSecret }); } + + [Fact] + public async Task Refresh_token() + { + // Arrange + var createRequest = new CreateTokenRequest + { + Code = "0362126c-6635-4472-8303-c1701a6a0b71", + ClientId = _clientId, + ClientSecret = _clientSecret, + RedirectUri = "https://localhost:5001", + }; + + // Act + var response = await Client.AuthenticationClient.CreateTokenAsync(createRequest); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.AccessToken); + + // refresh token + var refreshResponse = await Client.AuthenticationClient.RefreshTokenAsync(new RefreshTokenRequest + { + RefreshToken = response.RefreshToken, + ClientId = _clientId, + ClientSecret = _clientSecret + }); + + Assert.NotNull(refreshResponse); + Assert.NotNull(refreshResponse.AccessToken); + Assert.NotNull(refreshResponse.RefreshToken); + Assert.NotEqual(response.AccessToken, refreshResponse.AccessToken); + + // revoke token + await Client.AuthenticationClient.RevokeTokenAsync(new RevokeTokenRequest + { + Token = refreshResponse.AccessToken, + ClientId = _clientId, + ClientSecret = _clientSecret + }); + } } diff --git a/Test/Notion.UnitTests/AuthenticationClientTest/RefreshTokenApiTests.cs b/Test/Notion.UnitTests/AuthenticationClientTest/RefreshTokenApiTests.cs new file mode 100644 index 00000000..7f88b738 --- /dev/null +++ b/Test/Notion.UnitTests/AuthenticationClientTest/RefreshTokenApiTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.AutoMock; +using Newtonsoft.Json; +using Notion.Client; +using Xunit; + +namespace Notion.UnitTests.AuthenticationClientTest; + +public class RefreshTokenApiTests +{ + private readonly AutoMocker _mocker = new(); + private readonly Mock _restClientMock; + private readonly AuthenticationClient _authenticationClient; + + public RefreshTokenApiTests() + { + _restClientMock = _mocker.GetMock(); + _authenticationClient = _mocker.CreateInstance(); + } + + [Fact] + public async Task RefreshTokenAsync_ThrowsArgumentNullException_WhenRequestIsNull() + { + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _authenticationClient.RefreshTokenAsync(null)); + Assert.Equal("refreshTokenRequest", exception.ParamName); + Assert.Equal("Value cannot be null. (Parameter 'refreshTokenRequest')", exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RefreshTokenAsync_ThrowsArgumentNullException_WhenRefreshTokenIsNullOrEmpty(string refreshToken) + { + // Arrange + var request = new RefreshTokenRequest + { + RefreshToken = refreshToken, + ClientId = "validClientId", + ClientSecret = "validClientSecret" + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _authenticationClient.RefreshTokenAsync(request)); + Assert.Equal("RefreshToken", exception.ParamName); + Assert.Equal("RefreshToken is required. (Parameter 'RefreshToken')", exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RefreshTokenAsync_ThrowsArgumentException_WhenClientIdIsNullOrEmpty(string clientId) + { + // Arrange + var request = new RefreshTokenRequest + { + RefreshToken = "validRefreshToken", + ClientId = clientId, + ClientSecret = "validClientSecret" + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _authenticationClient.RefreshTokenAsync(request)); + Assert.Equal("ClientId", exception.ParamName); + Assert.Equal("ClientId must be provided. (Parameter 'ClientId')", exception.Message); + } + + [Fact] + public async Task RefreshTokenAsync_ReturnsRefreshTokenResponse_WhenRequestIsValid() + { + // Arrange + var refreshTokenRequest = new RefreshTokenRequest + { + RefreshToken = "validRefreshToken", + ClientId = "validClientId", + ClientSecret = "validClientSecret" + }; + + var mockResponse = new RefreshTokenResponse + { + AccessToken = "mockAccessToken", + RefreshToken = "mockRefreshToken", + BotId = "mockBotId", + DuplicatedTemplateId = "mockDuplicatedTemplateId", + Owner = new Owner + { + Workspace = true + }, + WorkspaceIcon = "mockWorkspaceIcon", + WorkspaceName = "mockWorkspaceName", + WorkspaceId = "mockWorkspaceId" + }; + + _restClientMock + .Setup(client => client.PostAsync( + ApiEndpoints.AuthenticationUrls.RefreshToken(), + It.IsAny(), + It.IsAny>>(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(mockResponse); + + // Act + var response = await _authenticationClient.RefreshTokenAsync(refreshTokenRequest); + + // Assert + Assert.NotNull(response); + Assert.IsType(response); + Assert.Equal("mockAccessToken", response.AccessToken); + Assert.Equal("mockRefreshToken", response.RefreshToken); + Assert.Equal("mockBotId", response.BotId); + Assert.Equal("mockDuplicatedTemplateId", response.DuplicatedTemplateId); + Assert.NotNull(response.Owner); + Assert.True(response.Owner.Workspace); + Assert.Equal("mockWorkspaceIcon", response.WorkspaceIcon); + Assert.Equal("mockWorkspaceName", response.WorkspaceName); + } +} \ No newline at end of file From 8cbb4a399b0106f64bded9eda393e5eaf14465c6 Mon Sep 17 00:00:00 2001 From: Vedant Koditkar <18693839+KoditkarVedant@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:48:55 +0530 Subject: [PATCH 2/2] Fix code factor warnings --- .../Api/Authentication/RefreshToken/AuthenticationClient.cs | 3 +-- .../RefreshToken/Request/IRefreshTokenBodyParameters.cs | 1 - .../Authentication/RefreshToken/Request/RefreshTokenRequest.cs | 1 - .../RefreshToken/Response/RefreshTokenResponse.cs | 1 - 4 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Src/Notion.Client/Api/Authentication/RefreshToken/AuthenticationClient.cs b/Src/Notion.Client/Api/Authentication/RefreshToken/AuthenticationClient.cs index 2e806ca1..58e30207 100644 --- a/Src/Notion.Client/Api/Authentication/RefreshToken/AuthenticationClient.cs +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/AuthenticationClient.cs @@ -36,5 +36,4 @@ public async Task RefreshTokenAsync( return response; } } - -} \ No newline at end of file +} diff --git a/Src/Notion.Client/Api/Authentication/RefreshToken/Request/IRefreshTokenBodyParameters.cs b/Src/Notion.Client/Api/Authentication/RefreshToken/Request/IRefreshTokenBodyParameters.cs index a2afed5b..15b2324a 100644 --- a/Src/Notion.Client/Api/Authentication/RefreshToken/Request/IRefreshTokenBodyParameters.cs +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/Request/IRefreshTokenBodyParameters.cs @@ -16,5 +16,4 @@ public interface IRefreshTokenBodyParameters [JsonProperty("refresh_token")] string RefreshToken { get; set; } } - } diff --git a/Src/Notion.Client/Api/Authentication/RefreshToken/Request/RefreshTokenRequest.cs b/Src/Notion.Client/Api/Authentication/RefreshToken/Request/RefreshTokenRequest.cs index bd236e36..18396ea5 100644 --- a/Src/Notion.Client/Api/Authentication/RefreshToken/Request/RefreshTokenRequest.cs +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/Request/RefreshTokenRequest.cs @@ -10,5 +10,4 @@ public class RefreshTokenRequest : IRefreshTokenBodyParameters, IBasicAuthentica public string ClientSecret { get; set; } } - } diff --git a/Src/Notion.Client/Api/Authentication/RefreshToken/Response/RefreshTokenResponse.cs b/Src/Notion.Client/Api/Authentication/RefreshToken/Response/RefreshTokenResponse.cs index a2401d67..87106d6c 100644 --- a/Src/Notion.Client/Api/Authentication/RefreshToken/Response/RefreshTokenResponse.cs +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/Response/RefreshTokenResponse.cs @@ -51,6 +51,5 @@ public class RefreshTokenResponse /// [JsonProperty("workspace_id")] public string WorkspaceId { get; set; } - } }