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 @@ -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";
}
}
}
25 changes: 25 additions & 0 deletions Src/Notion.Client/Api/Authentication/BasicAuthParamValidator.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
}
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 @@ -29,5 +29,16 @@ Task RevokeTokenAsync(
RevokeTokenRequest revokeTokenRequest,
CancellationToken cancellationToken = default
);

/// <summary>
/// Get a token's active status, scope, and issued time.
/// </summary>
/// <param name="introspectTokenRequest"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<IntrospectTokenResponse> IntrospectTokenAsync(
IntrospectTokenRequest introspectTokenRequest,
CancellationToken cancellationToken = default
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace Notion.Client
{
public sealed partial class AuthenticationClient
{
public async Task<IntrospectTokenResponse> 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<IntrospectTokenResponse>(
ApiEndpoints.AuthenticationUrls.IntrospectToken(),
body,
basicAuthenticationParameters: basicAuth,
cancellationToken: cancellationToken
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Newtonsoft.Json;

namespace Notion.Client
{
public interface IIntrospectTokenBodyParameters
{
/// <summary>
/// The access token
/// </summary>
[JsonProperty(PropertyName = "token")]
public string Token { get; }
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
}
41 changes: 40 additions & 1 deletion Test/Notion.IntegrationTests/AuthenticationClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
133 changes: 133 additions & 0 deletions Test/Notion.UnitTests/AuthenticationClientTests.cs
Original file line number Diff line number Diff line change
@@ -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<IRestClient> _restClientMock;
private readonly AuthenticationClient _authenticationClient;

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

[Fact]
public async Task IntrospectTokenAsync_ThrowsArgumentNullException_WhenRequestIsNull()
{
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() => _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 = token,
ClientId = "validClientId",
ClientSecret = "validClientSecret"
};

// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentException>(() => _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"
};
Comment on lines +61 to +66
Copy link

Copilot AI Sep 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar issue - the test correctly uses the clientId parameter from InlineData, but the previous test for Token validation doesn't follow the same pattern.

Copilot uses AI. Check for mistakes.


// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentException>(() => _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<ArgumentException>(() => _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<IntrospectTokenResponse>(
It.Is<string>(url => url == ApiEndpoints.AuthenticationUrls.IntrospectToken()),
It.IsAny<IIntrospectTokenBodyParameters>(),
It.IsAny<IEnumerable<KeyValuePair<string, string>>>(),
It.IsAny<IDictionary<string, string>>(),
It.IsAny<JsonSerializerSettings>(),
It.IsAny<IBasicAuthenticationParameters>(),
It.IsAny<CancellationToken>()))
.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();
}
}
1 change: 1 addition & 0 deletions Test/Notion.UnitTests/Notion.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Microsoft.VisualStudio.VsixColorCompiler" Version="17.11.35325.10" />
<PackageReference Include="Moq.AutoMock" Version="3.5.0" />
<PackageReference Include="WireMock.Net" Version="1.4.19" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
Expand Down
Loading