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"
};