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..58e30207 --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/AuthenticationClient.cs @@ -0,0 +1,39 @@ +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; + } + } +} 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..15b2324a --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/Request/IRefreshTokenBodyParameters.cs @@ -0,0 +1,19 @@ +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..18396ea5 --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/Request/RefreshTokenRequest.cs @@ -0,0 +1,13 @@ +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..87106d6c --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/RefreshToken/Response/RefreshTokenResponse.cs @@ -0,0 +1,55 @@ +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