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
33 changes: 33 additions & 0 deletions src/Resend/IResend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,39 @@ public interface IResend

#endregion

#region OAuth Grants

/// <summary>
/// Lists all OAuth grants.
/// </summary>
/// <param name="query">
/// Pagination query.
/// </param>
/// <param name="cancellationToken">
/// Cancellation token.
/// </param>
/// <returns>Paginated list of OAuth grants.</returns>
/// <see href="https://resend.com/docs/api-reference/oauth/list-grants"/>
Task<ResendResponse<PaginatedResult<OAuthGrant>>> OAuthGrantListAsync( PaginatedQuery? query = null, CancellationToken cancellationToken = default );

/// <summary>
/// Revoke an existing OAuth grant. Revoking a grant invalidates every access
/// and refresh token issued under it.
/// </summary>
/// <param name="oauthGrantId">
/// OAuth grant identifier.
/// </param>
/// <param name="cancellationToken">
/// Cancellation token.
/// </param>
/// <returns>
/// Outcome of the revocation.
/// </returns>
/// <see href="https://resend.com/docs/api-reference/oauth/revoke-grant"/>
Task<ResendResponse<OAuthGrantRevoked>> OAuthGrantRevokeAsync( Guid oauthGrantId, CancellationToken cancellationToken = default );

#endregion

#region Audiences

/// <summary>
Expand Down
114 changes: 114 additions & 0 deletions src/Resend/OAuthGrant.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Text.Json.Serialization;

namespace Resend;

/// <summary>
/// OAuth grant, representing access a user has delegated to an OAuth client.
/// </summary>
public class OAuthGrant
{
/// <summary>
/// OAuth grant identifier.
/// </summary>
[JsonPropertyName( "id" )]
public Guid Id { get; set; }

/// <summary>
/// Identifier of the OAuth client the grant was issued to.
/// </summary>
[JsonPropertyName( "client_id" )]
public Guid ClientId { get; set; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The OAuth grant identifiers are narrower than the current API contract: the OpenAPI schema defines the grant id, client_id, revoke response id, and path parameter as plain strings, but the model uses Guid. If the API returns or accepts a non-UUID string identifier, list/revoke calls will fail in the SDK before the caller can handle the response. Consider using string for the OAuth grant id fields and matching revoke parameter unless the API contract is tightened to format: uuid.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/Resend/OAuthGrant.cs, line 20:

<comment>The OAuth grant identifiers are narrower than the current API contract: the OpenAPI schema defines the grant id, `client_id`, revoke response id, and path parameter as plain strings, but the model uses `Guid`. If the API returns or accepts a non-UUID string identifier, list/revoke calls will fail in the SDK before the caller can handle the response. Consider using `string` for the OAuth grant id fields and matching revoke parameter unless the API contract is tightened to `format: uuid`.</comment>

<file context>
@@ -0,0 +1,108 @@
+    /// Identifier of the OAuth client the grant was issued to.
+    /// </summary>
+    [JsonPropertyName( "client_id" )]
+    public Guid ClientId { get; set; }
+
+    /// <summary>
</file context>


/// <summary>
/// Scopes granted to the OAuth client.
/// </summary>
[JsonPropertyName( "scopes" )]
public List<string> Scopes { get; set; } = default!;

/// <summary>
/// Resource the grant is restricted to, if any.
/// </summary>
[JsonPropertyName( "resource" )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public string? Resource { get; set; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This adds a public Resource member that is no longer in the current OAuth grant schema. Keeping it in the first release of the SDK shape can mislead callers into depending on a field the API does not return, and it becomes a public API member that is harder to remove later. Consider dropping Resource unless the endpoint contract is restored to include it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/Resend/OAuthGrant.cs, line 33:

<comment>This adds a public `Resource` member that is no longer in the current OAuth grant schema. Keeping it in the first release of the SDK shape can mislead callers into depending on a field the API does not return, and it becomes a public API member that is harder to remove later. Consider dropping `Resource` unless the endpoint contract is restored to include it.</comment>

<file context>
@@ -0,0 +1,108 @@
+    /// </summary>
+    [JsonPropertyName( "resource" )]
+    [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
+    public string? Resource { get; set; }
+
+    /// <summary>
</file context>


/// <summary>
/// Moment in which the grant was created.
/// </summary>
[JsonPropertyName( "created_at" )]
[JsonConverter( typeof( JsonUtcDateTimeConverter ) )]
public DateTime MomentCreated { get; set; }

/// <summary>
/// Moment in which the grant was revoked, if it has been revoked.
/// </summary>
[JsonPropertyName( "revoked_at" )]
[JsonConverter( typeof( JsonUtcDateTimeConverter ) )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public DateTime? MomentRevoked { get; set; }

/// <summary>
/// Reason the grant was revoked, if it has been revoked.
/// </summary>
[JsonPropertyName( "revoked_reason" )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public string? RevokedReason { get; set; }

/// <summary>
/// OAuth client the grant was issued to.
/// </summary>
[JsonPropertyName( "client" )]
public OAuthGrantClient Client { get; set; } = default!;
}


/// <summary>
/// OAuth client associated with a grant.
/// </summary>
public class OAuthGrantClient
{
/// <summary>
/// Display name of the OAuth client.
/// </summary>
[JsonPropertyName( "name" )]
public string Name { get; set; } = default!;

/// <summary>
/// URI of the OAuth client logo, if any.
/// </summary>
[JsonPropertyName( "logo_uri" )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public string? LogoUri { get; set; }
}


/// <summary>
/// Outcome of revoking an OAuth grant.
/// </summary>
public class OAuthGrantRevoked
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
{
/// <summary>
/// Object type discriminator.
/// </summary>
[JsonPropertyName( "object" )]
public string Object { get; set; } = default!;

/// <summary>
/// OAuth grant identifier.
/// </summary>
[JsonPropertyName( "id" )]
public Guid Id { get; set; }

/// <summary>
/// Moment in which the grant was revoked.
/// </summary>
[JsonPropertyName( "revoked_at" )]
[JsonConverter( typeof( JsonUtcDateTimeConverter ) )]
public DateTime MomentRevoked { get; set; }

/// <summary>
/// Reason the grant was revoked.
/// </summary>
[JsonPropertyName( "revoked_reason" )]
public string RevokedReason { get; set; } = default!;
}
44 changes: 44 additions & 0 deletions src/Resend/ResendClient.OAuthGrants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.WebUtilities;
using Resend.Payloads;

namespace Resend;

public partial class ResendClient
{
/// <inheritdoc />
public Task<ResendResponse<PaginatedResult<OAuthGrant>>> OAuthGrantListAsync( PaginatedQuery? query = null, CancellationToken cancellationToken = default )
{
var baseUrl = "/oauth/grants";
var url = baseUrl;

if ( query != null )
{
var qs = new Dictionary<string, string?>();

if ( query.Limit.HasValue == true )
qs.Add( "limit", query.Limit.Value.ToString() );

if ( query.Before != null )
qs.Add( "before", query.Before );

if ( query.After != null )
qs.Add( "after", query.After );

url = QueryHelpers.AddQueryString( baseUrl, qs );
}

var req = new HttpRequestMessage( HttpMethod.Get, url );

return Execute<PaginatedResult<OAuthGrant>, PaginatedResult<OAuthGrant>>( req, ( x ) => x, cancellationToken );
}


/// <inheritdoc />
public Task<ResendResponse<OAuthGrantRevoked>> OAuthGrantRevokeAsync( Guid oauthGrantId, CancellationToken cancellationToken = default )
{
var path = $"/oauth/grants/{oauthGrantId}";
var req = new HttpRequestMessage( HttpMethod.Delete, path );

return Execute<OAuthGrantRevoked, OAuthGrantRevoked>( req, ( x ) => x, cancellationToken );
}
}
38 changes: 38 additions & 0 deletions tests/Resend.Tests/ResendClientTests.OAuthGrants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace Resend.Tests;

/// <summary />
public partial class ResendClientTests
{
/// <summary />
[Fact]
public async Task OAuthGrantList()
{
var resp = await _resend.OAuthGrantListAsync();

Assert.NotNull( resp );
Assert.NotNull( resp.Content );
Assert.NotEmpty( resp.Content.Data );

var grant = resp.Content.Data[ 0 ];

Assert.NotEqual( Guid.Empty, grant.Id );
Assert.Equal( "Resend CLI", grant.Client.Name );
Assert.Contains( "emails:send", grant.Scopes );
}


/// <summary />
[Fact]
public async Task OAuthGrantRevoke()
{
var id = Guid.NewGuid();

var resp = await _resend.OAuthGrantRevokeAsync( id );

Assert.NotNull( resp );
Assert.NotNull( resp.Content );
Assert.Equal( "oauth_grant", resp.Content.Object );
Assert.Equal( id, resp.Content.Id );
Assert.Equal( "revoked_from_api", resp.Content.RevokedReason );
}
}
56 changes: 56 additions & 0 deletions tools/Resend.ApiServer/Controllers/OAuthGrantController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Mvc;
using Resend.Payloads;

namespace Resend.ApiServer.Controllers;

/// <summary />
[ApiController]
public class OAuthGrantController : ControllerBase
{
private readonly ILogger<OAuthGrantController> _logger;


/// <summary />
public OAuthGrantController( ILogger<OAuthGrantController> logger )
{
_logger = logger;
}


/// <summary />
[HttpGet]
[Route( "oauth/grants" )]
public PaginatedResult<OAuthGrant> OAuthGrantList()
{
_logger.LogDebug( "OAuthGrantList" );

var list = new List<OAuthGrant>();
list.Add( new OAuthGrant()
{
Id = Guid.NewGuid(),
ClientId = Guid.NewGuid(),
Scopes = new List<string>() { "emails:send" },
MomentCreated = DateTime.UtcNow,
Client = new OAuthGrantClient() { Name = "Resend CLI" },
} );

return new PaginatedResult<OAuthGrant>() { HasMore = false, Data = list };
}


/// <summary />
[HttpDelete]
[Route( "oauth/grants/{id}" )]
public OAuthGrantRevoked OAuthGrantRevoke( [FromRoute] Guid id )
{
_logger.LogDebug( "OAuthGrantRevoke" );

return new OAuthGrantRevoked()
{
Object = "oauth_grant",
Id = id,
MomentRevoked = DateTime.UtcNow,
RevokedReason = "revoked_from_api",
};
}
}
64 changes: 64 additions & 0 deletions tools/Resend.Cli/OAuthGrant/OAuthGrantListCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using McMaster.Extensions.CommandLineUtils;
using Spectre.Console;
using System.Text.Json;

namespace Resend.Cli.OAuthGrant;

/// <summary />
[Command( "list", Description = "Lists OAuth grants" )]
public class OAuthGrantListCommand
{
private readonly IResend _resend;


/// <summary />
[Option( "-j|--json", CommandOptionType.NoValue, Description = "Emit output as JSON array" )]
public bool InJson { get; set; }


/// <summary />
public OAuthGrantListCommand( IResend resend )
{
_resend = resend;
}


/// <summary />
public async Task<int> OnExecuteAsync()
{
var res = await _resend.OAuthGrantListAsync();
var grants = res.Content.Data;


if ( this.InJson == true )
{
var jso = new JsonSerializerOptions() { WriteIndented = true };
var json = JsonSerializer.Serialize( grants, jso );

Console.WriteLine( json );
}
else
{
var table = new Table();
table.Border = TableBorder.SimpleHeavy;
table.AddColumn( "Grant Id" );
table.AddColumn( "Client" );
table.AddColumn( "Scopes" );
table.AddColumn( "Created" );

foreach ( var g in grants )
{
table.AddRow(
new Markup( g.Id.ToString() ),
new Markup( Markup.Escape( g.Client.Name ) ),
new Markup( Markup.Escape( string.Join( ", ", g.Scopes ) ) ),
new Markup( g.MomentCreated.ToString() )
);
}

AnsiConsole.Write( table );
}

return 0;
}
}
33 changes: 33 additions & 0 deletions tools/Resend.Cli/OAuthGrant/OAuthGrantRevokeCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using McMaster.Extensions.CommandLineUtils;
using System.ComponentModel.DataAnnotations;

namespace Resend.Cli.OAuthGrant;

/// <summary />
[Command( "revoke", Description = "Revoke an OAuth grant" )]
public class OAuthGrantRevokeCommand
{
private readonly IResend _resend;


/// <summary />
[Argument( 0, Description = "OAuth grant identifier" )]
[Required]
public Guid? GrantId { get; set; }


/// <summary />
public OAuthGrantRevokeCommand( IResend resend )
{
_resend = resend;
}


/// <summary />
public async Task<int> OnExecuteAsync()
{
await _resend.OAuthGrantRevokeAsync( this.GrantId!.Value );

return 0;
}
}
Loading
Loading