-
Notifications
You must be signed in to change notification settings - Fork 8
Add OAuth grants support (list + revoke) #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; } | ||
|
|
||
| /// <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; } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This adds a public Prompt for AI agents |
||
|
|
||
| /// <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 | ||
|
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!; | ||
| } | ||
| 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 ); | ||
| } | ||
| } |
| 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 ); | ||
| } | ||
| } |
| 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", | ||
| }; | ||
| } | ||
| } |
| 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; | ||
| } | ||
| } |
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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 usesGuid. 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 usingstringfor the OAuth grant id fields and matching revoke parameter unless the API contract is tightened toformat: uuid.Prompt for AI agents