Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Src/Notion.Client/Api/ApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
}
11 changes: 11 additions & 0 deletions Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,16 @@ Task<IntrospectTokenResponse> IntrospectTokenAsync(
IntrospectTokenRequest introspectTokenRequest,
CancellationToken cancellationToken = default
);

/// <summary>
/// Refreshes an access token, generating a new access token and new refresh token.
/// </summary>
/// <param name="refreshTokenRequest"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<RefreshTokenResponse> RefreshTokenAsync(
RefreshTokenRequest refreshTokenRequest,
CancellationToken cancellationToken = default
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace Notion.Client
{
public sealed partial class AuthenticationClient
{
public async Task<RefreshTokenResponse> 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<RefreshTokenResponse>(
ApiEndpoints.AuthenticationUrls.RefreshToken(),
body,
basicAuthenticationParameters: basicAuth,
cancellationToken: cancellationToken
);

return response;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Newtonsoft.Json;

namespace Notion.Client
{
public interface IRefreshTokenBodyParameters
{
/// <summary>
/// A constant string: "refresh_token"
/// </summary>
[JsonProperty("grant_type")]
string GrantType { get; set; }

/// <summary>
/// A unique token that Notion generates to refresh your token, generated when a user initiates the OAuth flow.
/// </summary>
[JsonProperty("refresh_token")]
string RefreshToken { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Newtonsoft.Json;

namespace Notion.Client
{
public class Owner
{
[JsonProperty("workspace")]
public bool Workspace { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using Newtonsoft.Json;

namespace Notion.Client
{
public class RefreshTokenResponse
{
/// <summary>
/// A unique token that you can use to authenticate requests to the Notion API.
/// </summary>
[JsonProperty("access_token")]
public string AccessToken { get; set; }

/// <summary>
/// A unique token that you can use to refresh your access token when it expires.
/// </summary>
[JsonProperty("refresh_token")]
public string RefreshToken { get; set; }

/// <summary>
/// The unique identifier for the integration associated with the access token.
/// </summary>
[JsonProperty("bot_id")]
public string BotId { get; set; }

/// <summary>
/// Duplicated template id
/// </summary>
[JsonProperty("duplicated_template_id")]
public string DuplicatedTemplateId { get; set; }

/// <summary>
/// The type of owner for the integration. This will always be "workspace".
/// </summary>
[JsonProperty("owner")]
public Owner Owner { get; set; }

/// <summary>
/// The icon of the workspace the integration is connected to. This will be null if the workspace has no icon.
/// </summary>
[JsonProperty("workspace_icon")]
public string WorkspaceIcon { get; set; }

/// <summary>
/// The name of the workspace the integration is connected to.
/// </summary>
[JsonProperty("workspace_name")]
public string WorkspaceName { get; set; }

/// <summary>
/// The unique identifier of the workspace the integration is connected to.
/// </summary>
[JsonProperty("workspace_id")]
public string WorkspaceId { get; set; }
}
}
42 changes: 42 additions & 0 deletions Test/Notion.IntegrationTests/AuthenticationClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
});
}
}
126 changes: 126 additions & 0 deletions Test/Notion.UnitTests/AuthenticationClientTest/RefreshTokenApiTests.cs
Original file line number Diff line number Diff line change
@@ -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<IRestClient> _restClientMock;
private readonly AuthenticationClient _authenticationClient;

public RefreshTokenApiTests()
{
_restClientMock = _mocker.GetMock<IRestClient>();
_authenticationClient = _mocker.CreateInstance<AuthenticationClient>();
}

[Fact]
public async Task RefreshTokenAsync_ThrowsArgumentNullException_WhenRequestIsNull()
{
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() => _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<ArgumentNullException>(() => _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<ArgumentException>(() => _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<RefreshTokenResponse>(
ApiEndpoints.AuthenticationUrls.RefreshToken(),
It.IsAny<IRefreshTokenBodyParameters>(),
It.IsAny<IEnumerable<KeyValuePair<string, string>>>(),
It.IsAny<Dictionary<string, string>>(),
It.IsAny<JsonSerializerSettings>(),
It.IsAny<IBasicAuthenticationParameters>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(mockResponse);

// Act
var response = await _authenticationClient.RefreshTokenAsync(refreshTokenRequest);

// Assert
Assert.NotNull(response);
Assert.IsType<RefreshTokenResponse>(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);
}
}
Loading