diff --git a/src/Resend/IResend.cs b/src/Resend/IResend.cs index ca00b1e..7065b56 100644 --- a/src/Resend/IResend.cs +++ b/src/Resend/IResend.cs @@ -353,6 +353,39 @@ public interface IResend #endregion + #region OAuth Grants + + /// + /// Lists all OAuth grants. + /// + /// + /// Pagination query. + /// + /// + /// Cancellation token. + /// + /// Paginated list of OAuth grants. + /// + Task>> OAuthGrantListAsync( PaginatedQuery? query = null, CancellationToken cancellationToken = default ); + + /// + /// Revoke an existing OAuth grant. Revoking a grant invalidates every access + /// and refresh token issued under it. + /// + /// + /// OAuth grant identifier. + /// + /// + /// Cancellation token. + /// + /// + /// Outcome of the revocation. + /// + /// + Task> OAuthGrantRevokeAsync( Guid oauthGrantId, CancellationToken cancellationToken = default ); + + #endregion + #region Audiences /// diff --git a/src/Resend/OAuthGrant.cs b/src/Resend/OAuthGrant.cs new file mode 100644 index 0000000..c402bbd --- /dev/null +++ b/src/Resend/OAuthGrant.cs @@ -0,0 +1,114 @@ +using System.Text.Json.Serialization; + +namespace Resend; + +/// +/// OAuth grant, representing access a user has delegated to an OAuth client. +/// +public class OAuthGrant +{ + /// + /// OAuth grant identifier. + /// + [JsonPropertyName( "id" )] + public Guid Id { get; set; } + + /// + /// Identifier of the OAuth client the grant was issued to. + /// + [JsonPropertyName( "client_id" )] + public Guid ClientId { get; set; } + + /// + /// Scopes granted to the OAuth client. + /// + [JsonPropertyName( "scopes" )] + public List Scopes { get; set; } = default!; + + /// + /// Resource the grant is restricted to, if any. + /// + [JsonPropertyName( "resource" )] + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public string? Resource { get; set; } + + /// + /// Moment in which the grant was created. + /// + [JsonPropertyName( "created_at" )] + [JsonConverter( typeof( JsonUtcDateTimeConverter ) )] + public DateTime MomentCreated { get; set; } + + /// + /// Moment in which the grant was revoked, if it has been revoked. + /// + [JsonPropertyName( "revoked_at" )] + [JsonConverter( typeof( JsonUtcDateTimeConverter ) )] + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public DateTime? MomentRevoked { get; set; } + + /// + /// Reason the grant was revoked, if it has been revoked. + /// + [JsonPropertyName( "revoked_reason" )] + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public string? RevokedReason { get; set; } + + /// + /// OAuth client the grant was issued to. + /// + [JsonPropertyName( "client" )] + public OAuthGrantClient Client { get; set; } = default!; +} + + +/// +/// OAuth client associated with a grant. +/// +public class OAuthGrantClient +{ + /// + /// Display name of the OAuth client. + /// + [JsonPropertyName( "name" )] + public string Name { get; set; } = default!; + + /// + /// URI of the OAuth client logo, if any. + /// + [JsonPropertyName( "logo_uri" )] + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public string? LogoUri { get; set; } +} + + +/// +/// Outcome of revoking an OAuth grant. +/// +public class OAuthGrantRevoked +{ + /// + /// Object type discriminator. + /// + [JsonPropertyName( "object" )] + public string Object { get; set; } = default!; + + /// + /// OAuth grant identifier. + /// + [JsonPropertyName( "id" )] + public Guid Id { get; set; } + + /// + /// Moment in which the grant was revoked. + /// + [JsonPropertyName( "revoked_at" )] + [JsonConverter( typeof( JsonUtcDateTimeConverter ) )] + public DateTime MomentRevoked { get; set; } + + /// + /// Reason the grant was revoked. + /// + [JsonPropertyName( "revoked_reason" )] + public string RevokedReason { get; set; } = default!; +} diff --git a/src/Resend/ResendClient.OAuthGrants.cs b/src/Resend/ResendClient.OAuthGrants.cs new file mode 100644 index 0000000..7213d61 --- /dev/null +++ b/src/Resend/ResendClient.OAuthGrants.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.WebUtilities; +using Resend.Payloads; + +namespace Resend; + +public partial class ResendClient +{ + /// + public Task>> OAuthGrantListAsync( PaginatedQuery? query = null, CancellationToken cancellationToken = default ) + { + var baseUrl = "/oauth/grants"; + var url = baseUrl; + + if ( query != null ) + { + var qs = new Dictionary(); + + 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>( req, ( x ) => x, cancellationToken ); + } + + + /// + public Task> OAuthGrantRevokeAsync( Guid oauthGrantId, CancellationToken cancellationToken = default ) + { + var path = $"/oauth/grants/{oauthGrantId}"; + var req = new HttpRequestMessage( HttpMethod.Delete, path ); + + return Execute( req, ( x ) => x, cancellationToken ); + } +} diff --git a/tests/Resend.Tests/ResendClientTests.OAuthGrants.cs b/tests/Resend.Tests/ResendClientTests.OAuthGrants.cs new file mode 100644 index 0000000..8783a77 --- /dev/null +++ b/tests/Resend.Tests/ResendClientTests.OAuthGrants.cs @@ -0,0 +1,38 @@ +namespace Resend.Tests; + +/// +public partial class ResendClientTests +{ + /// + [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 ); + } + + + /// + [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 ); + } +} diff --git a/tools/Resend.ApiServer/Controllers/OAuthGrantController.cs b/tools/Resend.ApiServer/Controllers/OAuthGrantController.cs new file mode 100644 index 0000000..8d6d825 --- /dev/null +++ b/tools/Resend.ApiServer/Controllers/OAuthGrantController.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Mvc; +using Resend.Payloads; + +namespace Resend.ApiServer.Controllers; + +/// +[ApiController] +public class OAuthGrantController : ControllerBase +{ + private readonly ILogger _logger; + + + /// + public OAuthGrantController( ILogger logger ) + { + _logger = logger; + } + + + /// + [HttpGet] + [Route( "oauth/grants" )] + public PaginatedResult OAuthGrantList() + { + _logger.LogDebug( "OAuthGrantList" ); + + var list = new List(); + list.Add( new OAuthGrant() + { + Id = Guid.NewGuid(), + ClientId = Guid.NewGuid(), + Scopes = new List() { "emails:send" }, + MomentCreated = DateTime.UtcNow, + Client = new OAuthGrantClient() { Name = "Resend CLI" }, + } ); + + return new PaginatedResult() { HasMore = false, Data = list }; + } + + + /// + [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", + }; + } +} diff --git a/tools/Resend.Cli/OAuthGrant/OAuthGrantListCommand.cs b/tools/Resend.Cli/OAuthGrant/OAuthGrantListCommand.cs new file mode 100644 index 0000000..7305b67 --- /dev/null +++ b/tools/Resend.Cli/OAuthGrant/OAuthGrantListCommand.cs @@ -0,0 +1,64 @@ +using McMaster.Extensions.CommandLineUtils; +using Spectre.Console; +using System.Text.Json; + +namespace Resend.Cli.OAuthGrant; + +/// +[Command( "list", Description = "Lists OAuth grants" )] +public class OAuthGrantListCommand +{ + private readonly IResend _resend; + + + /// + [Option( "-j|--json", CommandOptionType.NoValue, Description = "Emit output as JSON array" )] + public bool InJson { get; set; } + + + /// + public OAuthGrantListCommand( IResend resend ) + { + _resend = resend; + } + + + /// + public async Task 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; + } +} diff --git a/tools/Resend.Cli/OAuthGrant/OAuthGrantRevokeCommand.cs b/tools/Resend.Cli/OAuthGrant/OAuthGrantRevokeCommand.cs new file mode 100644 index 0000000..d1080ba --- /dev/null +++ b/tools/Resend.Cli/OAuthGrant/OAuthGrantRevokeCommand.cs @@ -0,0 +1,33 @@ +using McMaster.Extensions.CommandLineUtils; +using System.ComponentModel.DataAnnotations; + +namespace Resend.Cli.OAuthGrant; + +/// +[Command( "revoke", Description = "Revoke an OAuth grant" )] +public class OAuthGrantRevokeCommand +{ + private readonly IResend _resend; + + + /// + [Argument( 0, Description = "OAuth grant identifier" )] + [Required] + public Guid? GrantId { get; set; } + + + /// + public OAuthGrantRevokeCommand( IResend resend ) + { + _resend = resend; + } + + + /// + public async Task OnExecuteAsync() + { + await _resend.OAuthGrantRevokeAsync( this.GrantId!.Value ); + + return 0; + } +} diff --git a/tools/Resend.Cli/OAuthGrantCommand.cs b/tools/Resend.Cli/OAuthGrantCommand.cs new file mode 100644 index 0000000..02c0b86 --- /dev/null +++ b/tools/Resend.Cli/OAuthGrantCommand.cs @@ -0,0 +1,17 @@ +using McMaster.Extensions.CommandLineUtils; + +namespace Resend.Cli; + +/// +[Command( "oauth-grant", Description = "OAuth grant management" )] +[Subcommand( typeof( OAuthGrant.OAuthGrantListCommand ) )] +[Subcommand( typeof( OAuthGrant.OAuthGrantRevokeCommand ) )] +public class OAuthGrantCommand +{ + /// + public int OnExecute( CommandLineApplication app ) + { + app.ShowHelp(); + return 1; + } +} diff --git a/tools/Resend.Cli/Program.cs b/tools/Resend.Cli/Program.cs index 21cea20..7e7bfe9 100644 --- a/tools/Resend.Cli/Program.cs +++ b/tools/Resend.Cli/Program.cs @@ -12,6 +12,7 @@ namespace Resend.Cli; [Subcommand( typeof( ContactPropCommand ) )] [Subcommand( typeof( DomainCommand ) )] [Subcommand( typeof( EmailCommand ) )] +[Subcommand( typeof( OAuthGrantCommand ) )] [Subcommand( typeof( ReceiveCommand ) )] [Subcommand( typeof( SegmentCommand ) )] [Subcommand( typeof( TemplateCommand ) )]