From 4f05127068c8a6cca0d8bcf16df8517bfc74be58 Mon Sep 17 00:00:00 2001 From: Vedant Koditkar <18693839+KoditkarVedant@users.noreply.github.com> Date: Sun, 21 Sep 2025 14:30:36 +0530 Subject: [PATCH 1/2] Add support for Introspect token api --- Src/Notion.Client/Api/ApiEndpoints.cs | 1 + .../Authentication/BasicAuthParamValidator.cs | 25 ++++ .../Authentication/IAuthenticationClient.cs | 11 ++ .../IntrospectToken/AuthenticationClient.cs | 36 +++++ .../Request/IIntrospectTokenBodyParameters.cs | 13 ++ .../Request/IntrospectTokenRequest.cs | 11 ++ .../Response/IntrospectTokenResponse.cs | 16 +++ .../AuthenticationClientTests.cs | 41 +++++- .../AuthenticationClientTests.cs | 133 ++++++++++++++++++ Test/Notion.UnitTests/Notion.UnitTests.csproj | 1 + 10 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 Src/Notion.Client/Api/Authentication/BasicAuthParamValidator.cs create mode 100644 Src/Notion.Client/Api/Authentication/IntrospectToken/AuthenticationClient.cs create mode 100644 Src/Notion.Client/Api/Authentication/IntrospectToken/Request/IIntrospectTokenBodyParameters.cs create mode 100644 Src/Notion.Client/Api/Authentication/IntrospectToken/Request/IntrospectTokenRequest.cs create mode 100644 Src/Notion.Client/Api/Authentication/IntrospectToken/Response/IntrospectTokenResponse.cs create mode 100644 Test/Notion.UnitTests/AuthenticationClientTests.cs diff --git a/Src/Notion.Client/Api/ApiEndpoints.cs b/Src/Notion.Client/Api/ApiEndpoints.cs index 86d703b..3142514 100644 --- a/Src/Notion.Client/Api/ApiEndpoints.cs +++ b/Src/Notion.Client/Api/ApiEndpoints.cs @@ -136,6 +136,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"; } } } diff --git a/Src/Notion.Client/Api/Authentication/BasicAuthParamValidator.cs b/Src/Notion.Client/Api/Authentication/BasicAuthParamValidator.cs new file mode 100644 index 0000000..ba3295d --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/BasicAuthParamValidator.cs @@ -0,0 +1,25 @@ +using System; + +namespace Notion.Client +{ + public static class BasicAuthParamValidator + { + public static void Validate(IBasicAuthenticationParameters basicAuthParams) + { + if (basicAuthParams == null) + { + throw new ArgumentNullException(nameof(basicAuthParams), "Basic authentication parameters must be provided."); + } + + if (string.IsNullOrWhiteSpace(basicAuthParams.ClientId)) + { + throw new ArgumentException("ClientId must be provided.", nameof(basicAuthParams.ClientId)); + } + + if (string.IsNullOrWhiteSpace(basicAuthParams.ClientSecret)) + { + throw new ArgumentException("ClientSecret must be provided.", nameof(basicAuthParams.ClientSecret)); + } + } + } +} diff --git a/Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs b/Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs index 1973638..8b1e08d 100644 --- a/Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs +++ b/Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs @@ -29,5 +29,16 @@ Task RevokeTokenAsync( RevokeTokenRequest revokeTokenRequest, CancellationToken cancellationToken = default ); + + /// + /// Get a token's active status, scope, and issued time. + /// + /// + /// + /// + Task IntrospectTokenAsync( + IntrospectTokenRequest introspectTokenRequest, + CancellationToken cancellationToken = default + ); } } diff --git a/Src/Notion.Client/Api/Authentication/IntrospectToken/AuthenticationClient.cs b/Src/Notion.Client/Api/Authentication/IntrospectToken/AuthenticationClient.cs new file mode 100644 index 0000000..358b71c --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/IntrospectToken/AuthenticationClient.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Notion.Client +{ + public sealed partial class AuthenticationClient + { + public async Task IntrospectTokenAsync( + IntrospectTokenRequest introspectTokenRequest, + CancellationToken cancellationToken = default) + { + if (introspectTokenRequest is null) + { + throw new ArgumentNullException(nameof(introspectTokenRequest)); + } + + IIntrospectTokenBodyParameters body = introspectTokenRequest; + IBasicAuthenticationParameters basicAuth = introspectTokenRequest; + + if (string.IsNullOrWhiteSpace(body.Token)) + { + throw new ArgumentException("Token must be provided.", nameof(body.Token)); + } + + BasicAuthParamValidator.Validate(basicAuth); + + return await _client.PostAsync( + ApiEndpoints.AuthenticationUrls.IntrospectToken(), + body, + basicAuthenticationParameters: basicAuth, + cancellationToken: cancellationToken + ); + } + } +} diff --git a/Src/Notion.Client/Api/Authentication/IntrospectToken/Request/IIntrospectTokenBodyParameters.cs b/Src/Notion.Client/Api/Authentication/IntrospectToken/Request/IIntrospectTokenBodyParameters.cs new file mode 100644 index 0000000..fe1e91f --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/IntrospectToken/Request/IIntrospectTokenBodyParameters.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Notion.Client +{ + public interface IIntrospectTokenBodyParameters + { + /// + /// The access token + /// + [JsonProperty(PropertyName = "token")] + public string Token { get; } + } +} diff --git a/Src/Notion.Client/Api/Authentication/IntrospectToken/Request/IntrospectTokenRequest.cs b/Src/Notion.Client/Api/Authentication/IntrospectToken/Request/IntrospectTokenRequest.cs new file mode 100644 index 0000000..2c9a19a --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/IntrospectToken/Request/IntrospectTokenRequest.cs @@ -0,0 +1,11 @@ +namespace Notion.Client +{ + public class IntrospectTokenRequest : IIntrospectTokenBodyParameters, IBasicAuthenticationParameters + { + public string Token { get; set; } + + public string ClientId { get; set; } + + public string ClientSecret { get; set; } + } +} diff --git a/Src/Notion.Client/Api/Authentication/IntrospectToken/Response/IntrospectTokenResponse.cs b/Src/Notion.Client/Api/Authentication/IntrospectToken/Response/IntrospectTokenResponse.cs new file mode 100644 index 0000000..ab829db --- /dev/null +++ b/Src/Notion.Client/Api/Authentication/IntrospectToken/Response/IntrospectTokenResponse.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Notion.Client +{ + public class IntrospectTokenResponse + { + [JsonProperty("active")] + public bool IsActive { get; set; } + + [JsonProperty("scope")] + public string Scope { get; set; } + + [JsonProperty("iat")] + public long Iat { get; set; } + } +} diff --git a/Test/Notion.IntegrationTests/AuthenticationClientTests.cs b/Test/Notion.IntegrationTests/AuthenticationClientTests.cs index 9e07c8b..4d96e03 100644 --- a/Test/Notion.IntegrationTests/AuthenticationClientTests.cs +++ b/Test/Notion.IntegrationTests/AuthenticationClientTests.cs @@ -27,7 +27,46 @@ public async Task Create_and_revoke_token() // Assert Assert.NotNull(response); Assert.NotNull(response.AccessToken); - + + // revoke token + await Client.AuthenticationClient.RevokeTokenAsync(new RevokeTokenRequest + { + Token = response.AccessToken, + ClientId = _clientId, + ClientSecret = _clientSecret + }); + } + + [Fact] + public async Task Introspect_token() + { + // Arrange + var createRequest = new CreateTokenRequest + { + Code = "036822b2-62c1-42f4-95ea-0153e69cc20e", + ClientId = _clientId, + ClientSecret = _clientSecret, + RedirectUri = "https://localhost:5001", + }; + + // Act + var response = await Client.AuthenticationClient.CreateTokenAsync(createRequest); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.AccessToken); + + // introspect token + var introspectResponse = await Client.AuthenticationClient.IntrospectTokenAsync(new IntrospectTokenRequest + { + Token = response.AccessToken, + ClientId = _clientId, + ClientSecret = _clientSecret + }); + + Assert.NotNull(introspectResponse); + Assert.True(introspectResponse.IsActive); + // revoke token await Client.AuthenticationClient.RevokeTokenAsync(new RevokeTokenRequest { diff --git a/Test/Notion.UnitTests/AuthenticationClientTests.cs b/Test/Notion.UnitTests/AuthenticationClientTests.cs new file mode 100644 index 0000000..b44b6c0 --- /dev/null +++ b/Test/Notion.UnitTests/AuthenticationClientTests.cs @@ -0,0 +1,133 @@ +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; + +public class AuthenticationClientTests +{ + private readonly AutoMocker _mocker = new(); + private readonly Mock _restClientMock; + private readonly AuthenticationClient _authenticationClient; + + public AuthenticationClientTests() + { + _restClientMock = _mocker.GetMock(); + _authenticationClient = _mocker.CreateInstance(); + } + + [Fact] + public async Task IntrospectTokenAsync_ThrowsArgumentNullException_WhenRequestIsNull() + { + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _authenticationClient.IntrospectTokenAsync(null)); + Assert.Equal("introspectTokenRequest", exception.ParamName); + Assert.Equal("Value cannot be null. (Parameter 'introspectTokenRequest')", exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task IntrospectTokenAsync_ThrowsArgumentException_WhenTokenIsNullOrEmpty(string token) + { + // Arrange + var request = new IntrospectTokenRequest + { + Token = null, + ClientId = "validClientId", + ClientSecret = "validClientSecret" + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _authenticationClient.IntrospectTokenAsync(request)); + Assert.Equal("Token", exception.ParamName); + Assert.Equal("Token must be provided. (Parameter 'Token')", exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task IntrospectTokenAsync_ThrowsArgumentException_WhenClientIdIsNullOrEmpty(string clientId) + { + // Arrange + var request = new IntrospectTokenRequest + { + Token = "validToken", + ClientId = clientId, + ClientSecret = "validClientSecret" + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _authenticationClient.IntrospectTokenAsync(request)); + Assert.Equal("ClientId", exception.ParamName); + Assert.Equal("ClientId must be provided. (Parameter 'ClientId')", exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task IntrospectTokenAsync_ThrowsArgumentException_WhenClientSecretIsNullOrEmpty(string clientSecret) + { + // Arrange + var request = new IntrospectTokenRequest + { + Token = "validToken", + ClientId = "validClientId", + ClientSecret = clientSecret + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _authenticationClient.IntrospectTokenAsync(request)); + Assert.Equal("ClientSecret", exception.ParamName); + Assert.Equal("ClientSecret must be provided. (Parameter 'ClientSecret')", exception.Message); + } + + [Fact] + public async Task IntrospectTokenAsync_CallsPostAsync_WithCorrectParameters() + { + // Arrange + var introspectTokenRequest = new IntrospectTokenRequest + { + Token = "validToken", + ClientId = "validClientId", + ClientSecret = "validClientSecret" + }; + + var expectedResponse = new IntrospectTokenResponse + { + IsActive = true, + Scope = "read write", + Iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + + _restClientMock + .Setup(client => client.PostAsync( + It.Is(url => url == ApiEndpoints.AuthenticationUrls.IntrospectToken()), + It.IsAny(), + It.IsAny>>(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + // Act + var response = await _authenticationClient.IntrospectTokenAsync(introspectTokenRequest); + + // Assert + Assert.NotNull(response); + Assert.Equal(expectedResponse.IsActive, response.IsActive); + Assert.Equal(expectedResponse.Scope, response.Scope); + Assert.Equal(expectedResponse.Iat, response.Iat); + _restClientMock.VerifyAll(); + } +} \ No newline at end of file diff --git a/Test/Notion.UnitTests/Notion.UnitTests.csproj b/Test/Notion.UnitTests/Notion.UnitTests.csproj index 6a33f5c..79529e8 100644 --- a/Test/Notion.UnitTests/Notion.UnitTests.csproj +++ b/Test/Notion.UnitTests/Notion.UnitTests.csproj @@ -10,6 +10,7 @@ + From ae89c33ebbdb62268f8dd9de25ee581a06fd8eac Mon Sep 17 00:00:00 2001 From: Vedant Koditkar <18693839+KoditkarVedant@users.noreply.github.com> Date: Sun, 21 Sep 2025 14:33:33 +0530 Subject: [PATCH 2/2] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Test/Notion.UnitTests/AuthenticationClientTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Test/Notion.UnitTests/AuthenticationClientTests.cs b/Test/Notion.UnitTests/AuthenticationClientTests.cs index b44b6c0..af0c1d7 100644 --- a/Test/Notion.UnitTests/AuthenticationClientTests.cs +++ b/Test/Notion.UnitTests/AuthenticationClientTests.cs @@ -40,7 +40,7 @@ public async Task IntrospectTokenAsync_ThrowsArgumentException_WhenTokenIsNullOr // Arrange var request = new IntrospectTokenRequest { - Token = null, + Token = token, ClientId = "validClientId", ClientSecret = "validClientSecret" };