Skip to content

Commit 975a175

Browse files
Merge pull request #485 from notion-dotnet/471-add-support-for-refresh-token-endpoint
Add support for refresh token endpoint
2 parents 05b3ea3 + 8cbb4a3 commit 975a175

File tree

9 files changed

+316
-0
lines changed

9 files changed

+316
-0
lines changed

Src/Notion.Client/Api/ApiEndpoints.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ public static class AuthenticationUrls
137137
public static string CreateToken() => "/v1/oauth/token";
138138
public static string RevokeToken() => "/v1/oauth/revoke";
139139
public static string IntrospectToken() => "/v1/oauth/introspect";
140+
public static string RefreshToken() => "/v1/oauth/token";
140141
}
141142
}
142143
}

Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,16 @@ Task<IntrospectTokenResponse> IntrospectTokenAsync(
4040
IntrospectTokenRequest introspectTokenRequest,
4141
CancellationToken cancellationToken = default
4242
);
43+
44+
/// <summary>
45+
/// Refreshes an access token, generating a new access token and new refresh token.
46+
/// </summary>
47+
/// <param name="refreshTokenRequest"></param>
48+
/// <param name="cancellationToken"></param>
49+
/// <returns></returns>
50+
Task<RefreshTokenResponse> RefreshTokenAsync(
51+
RefreshTokenRequest refreshTokenRequest,
52+
CancellationToken cancellationToken = default
53+
);
4354
}
4455
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
5+
namespace Notion.Client
6+
{
7+
public sealed partial class AuthenticationClient
8+
{
9+
public async Task<RefreshTokenResponse> RefreshTokenAsync(
10+
RefreshTokenRequest refreshTokenRequest,
11+
CancellationToken cancellationToken = default)
12+
{
13+
if (refreshTokenRequest == null)
14+
{
15+
throw new ArgumentNullException(nameof(refreshTokenRequest));
16+
}
17+
18+
IRefreshTokenBodyParameters body = refreshTokenRequest;
19+
20+
if (string.IsNullOrWhiteSpace(body.RefreshToken))
21+
{
22+
throw new ArgumentNullException(nameof(body.RefreshToken), "RefreshToken is required.");
23+
}
24+
25+
IBasicAuthenticationParameters basicAuth = refreshTokenRequest;
26+
27+
BasicAuthParamValidator.Validate(basicAuth);
28+
29+
var response = await _client.PostAsync<RefreshTokenResponse>(
30+
ApiEndpoints.AuthenticationUrls.RefreshToken(),
31+
body,
32+
basicAuthenticationParameters: basicAuth,
33+
cancellationToken: cancellationToken
34+
);
35+
36+
return response;
37+
}
38+
}
39+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using Newtonsoft.Json;
2+
3+
namespace Notion.Client
4+
{
5+
public interface IRefreshTokenBodyParameters
6+
{
7+
/// <summary>
8+
/// A constant string: "refresh_token"
9+
/// </summary>
10+
[JsonProperty("grant_type")]
11+
string GrantType { get; set; }
12+
13+
/// <summary>
14+
/// A unique token that Notion generates to refresh your token, generated when a user initiates the OAuth flow.
15+
/// </summary>
16+
[JsonProperty("refresh_token")]
17+
string RefreshToken { get; set; }
18+
}
19+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Notion.Client
2+
{
3+
public class RefreshTokenRequest : IRefreshTokenBodyParameters, IBasicAuthenticationParameters
4+
{
5+
public string GrantType { get; set; } = "refresh_token";
6+
7+
public string RefreshToken { get; set; }
8+
9+
public string ClientId { get; set; }
10+
11+
public string ClientSecret { get; set; }
12+
}
13+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using Newtonsoft.Json;
2+
3+
namespace Notion.Client
4+
{
5+
public class Owner
6+
{
7+
[JsonProperty("workspace")]
8+
public bool Workspace { get; set; }
9+
}
10+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using Newtonsoft.Json;
2+
3+
namespace Notion.Client
4+
{
5+
public class RefreshTokenResponse
6+
{
7+
/// <summary>
8+
/// A unique token that you can use to authenticate requests to the Notion API.
9+
/// </summary>
10+
[JsonProperty("access_token")]
11+
public string AccessToken { get; set; }
12+
13+
/// <summary>
14+
/// A unique token that you can use to refresh your access token when it expires.
15+
/// </summary>
16+
[JsonProperty("refresh_token")]
17+
public string RefreshToken { get; set; }
18+
19+
/// <summary>
20+
/// The unique identifier for the integration associated with the access token.
21+
/// </summary>
22+
[JsonProperty("bot_id")]
23+
public string BotId { get; set; }
24+
25+
/// <summary>
26+
/// Duplicated template id
27+
/// </summary>
28+
[JsonProperty("duplicated_template_id")]
29+
public string DuplicatedTemplateId { get; set; }
30+
31+
/// <summary>
32+
/// The type of owner for the integration. This will always be "workspace".
33+
/// </summary>
34+
[JsonProperty("owner")]
35+
public Owner Owner { get; set; }
36+
37+
/// <summary>
38+
/// The icon of the workspace the integration is connected to. This will be null if the workspace has no icon.
39+
/// </summary>
40+
[JsonProperty("workspace_icon")]
41+
public string WorkspaceIcon { get; set; }
42+
43+
/// <summary>
44+
/// The name of the workspace the integration is connected to.
45+
/// </summary>
46+
[JsonProperty("workspace_name")]
47+
public string WorkspaceName { get; set; }
48+
49+
/// <summary>
50+
/// The unique identifier of the workspace the integration is connected to.
51+
/// </summary>
52+
[JsonProperty("workspace_id")]
53+
public string WorkspaceId { get; set; }
54+
}
55+
}

Test/Notion.IntegrationTests/AuthenticationClientTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public async Task Introspect_token()
5555
// Assert
5656
Assert.NotNull(response);
5757
Assert.NotNull(response.AccessToken);
58+
Assert.NotNull(response.RefreshToken);
5859

5960
// introspect token
6061
var introspectResponse = await Client.AuthenticationClient.IntrospectTokenAsync(new IntrospectTokenRequest
@@ -75,4 +76,45 @@ await Client.AuthenticationClient.RevokeTokenAsync(new RevokeTokenRequest
7576
ClientSecret = _clientSecret
7677
});
7778
}
79+
80+
[Fact]
81+
public async Task Refresh_token()
82+
{
83+
// Arrange
84+
var createRequest = new CreateTokenRequest
85+
{
86+
Code = "0362126c-6635-4472-8303-c1701a6a0b71",
87+
ClientId = _clientId,
88+
ClientSecret = _clientSecret,
89+
RedirectUri = "https://localhost:5001",
90+
};
91+
92+
// Act
93+
var response = await Client.AuthenticationClient.CreateTokenAsync(createRequest);
94+
95+
// Assert
96+
Assert.NotNull(response);
97+
Assert.NotNull(response.AccessToken);
98+
99+
// refresh token
100+
var refreshResponse = await Client.AuthenticationClient.RefreshTokenAsync(new RefreshTokenRequest
101+
{
102+
RefreshToken = response.RefreshToken,
103+
ClientId = _clientId,
104+
ClientSecret = _clientSecret
105+
});
106+
107+
Assert.NotNull(refreshResponse);
108+
Assert.NotNull(refreshResponse.AccessToken);
109+
Assert.NotNull(refreshResponse.RefreshToken);
110+
Assert.NotEqual(response.AccessToken, refreshResponse.AccessToken);
111+
112+
// revoke token
113+
await Client.AuthenticationClient.RevokeTokenAsync(new RevokeTokenRequest
114+
{
115+
Token = refreshResponse.AccessToken,
116+
ClientId = _clientId,
117+
ClientSecret = _clientSecret
118+
});
119+
}
78120
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Moq;
6+
using Moq.AutoMock;
7+
using Newtonsoft.Json;
8+
using Notion.Client;
9+
using Xunit;
10+
11+
namespace Notion.UnitTests.AuthenticationClientTest;
12+
13+
public class RefreshTokenApiTests
14+
{
15+
private readonly AutoMocker _mocker = new();
16+
private readonly Mock<IRestClient> _restClientMock;
17+
private readonly AuthenticationClient _authenticationClient;
18+
19+
public RefreshTokenApiTests()
20+
{
21+
_restClientMock = _mocker.GetMock<IRestClient>();
22+
_authenticationClient = _mocker.CreateInstance<AuthenticationClient>();
23+
}
24+
25+
[Fact]
26+
public async Task RefreshTokenAsync_ThrowsArgumentNullException_WhenRequestIsNull()
27+
{
28+
// Act & Assert
29+
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() => _authenticationClient.RefreshTokenAsync(null));
30+
Assert.Equal("refreshTokenRequest", exception.ParamName);
31+
Assert.Equal("Value cannot be null. (Parameter 'refreshTokenRequest')", exception.Message);
32+
}
33+
34+
[Theory]
35+
[InlineData(null)]
36+
[InlineData("")]
37+
[InlineData(" ")]
38+
public async Task RefreshTokenAsync_ThrowsArgumentNullException_WhenRefreshTokenIsNullOrEmpty(string refreshToken)
39+
{
40+
// Arrange
41+
var request = new RefreshTokenRequest
42+
{
43+
RefreshToken = refreshToken,
44+
ClientId = "validClientId",
45+
ClientSecret = "validClientSecret"
46+
};
47+
48+
// Act & Assert
49+
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() => _authenticationClient.RefreshTokenAsync(request));
50+
Assert.Equal("RefreshToken", exception.ParamName);
51+
Assert.Equal("RefreshToken is required. (Parameter 'RefreshToken')", exception.Message);
52+
}
53+
54+
[Theory]
55+
[InlineData(null)]
56+
[InlineData("")]
57+
[InlineData(" ")]
58+
public async Task RefreshTokenAsync_ThrowsArgumentException_WhenClientIdIsNullOrEmpty(string clientId)
59+
{
60+
// Arrange
61+
var request = new RefreshTokenRequest
62+
{
63+
RefreshToken = "validRefreshToken",
64+
ClientId = clientId,
65+
ClientSecret = "validClientSecret"
66+
};
67+
68+
// Act & Assert
69+
var exception = await Assert.ThrowsAsync<ArgumentException>(() => _authenticationClient.RefreshTokenAsync(request));
70+
Assert.Equal("ClientId", exception.ParamName);
71+
Assert.Equal("ClientId must be provided. (Parameter 'ClientId')", exception.Message);
72+
}
73+
74+
[Fact]
75+
public async Task RefreshTokenAsync_ReturnsRefreshTokenResponse_WhenRequestIsValid()
76+
{
77+
// Arrange
78+
var refreshTokenRequest = new RefreshTokenRequest
79+
{
80+
RefreshToken = "validRefreshToken",
81+
ClientId = "validClientId",
82+
ClientSecret = "validClientSecret"
83+
};
84+
85+
var mockResponse = new RefreshTokenResponse
86+
{
87+
AccessToken = "mockAccessToken",
88+
RefreshToken = "mockRefreshToken",
89+
BotId = "mockBotId",
90+
DuplicatedTemplateId = "mockDuplicatedTemplateId",
91+
Owner = new Owner
92+
{
93+
Workspace = true
94+
},
95+
WorkspaceIcon = "mockWorkspaceIcon",
96+
WorkspaceName = "mockWorkspaceName",
97+
WorkspaceId = "mockWorkspaceId"
98+
};
99+
100+
_restClientMock
101+
.Setup(client => client.PostAsync<RefreshTokenResponse>(
102+
ApiEndpoints.AuthenticationUrls.RefreshToken(),
103+
It.IsAny<IRefreshTokenBodyParameters>(),
104+
It.IsAny<IEnumerable<KeyValuePair<string, string>>>(),
105+
It.IsAny<Dictionary<string, string>>(),
106+
It.IsAny<JsonSerializerSettings>(),
107+
It.IsAny<IBasicAuthenticationParameters>(),
108+
It.IsAny<CancellationToken>()))
109+
.ReturnsAsync(mockResponse);
110+
111+
// Act
112+
var response = await _authenticationClient.RefreshTokenAsync(refreshTokenRequest);
113+
114+
// Assert
115+
Assert.NotNull(response);
116+
Assert.IsType<RefreshTokenResponse>(response);
117+
Assert.Equal("mockAccessToken", response.AccessToken);
118+
Assert.Equal("mockRefreshToken", response.RefreshToken);
119+
Assert.Equal("mockBotId", response.BotId);
120+
Assert.Equal("mockDuplicatedTemplateId", response.DuplicatedTemplateId);
121+
Assert.NotNull(response.Owner);
122+
Assert.True(response.Owner.Workspace);
123+
Assert.Equal("mockWorkspaceIcon", response.WorkspaceIcon);
124+
Assert.Equal("mockWorkspaceName", response.WorkspaceName);
125+
}
126+
}

0 commit comments

Comments
 (0)