From 79b4b6ee623b5fd5e470785bef9f065009a3c817 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 4 Apr 2024 10:55:48 -0500 Subject: [PATCH] Adding Magic Links (#116) Added Magic links to the SDK Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Co-authored-by: Jonas Hendrickx --- .../Helpers/PasswordlessSerializerContext.cs | 1 + src/Passwordless/IPasswordlessClient.cs | 8 ++++ src/Passwordless/Models/GetEventLogRequest.cs | 2 - .../Models/SendMagicLinkRequest.cs | 23 +++++++++++ src/Passwordless/PasswordlessClient.cs | 12 ++++++ tests/Passwordless.Tests.Infra/TestApi.cs | 3 +- tests/Passwordless.Tests/MagicLinksTests.cs | 41 +++++++++++++++++++ 7 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 src/Passwordless/Models/SendMagicLinkRequest.cs create mode 100644 tests/Passwordless.Tests/MagicLinksTests.cs diff --git a/src/Passwordless/Helpers/PasswordlessSerializerContext.cs b/src/Passwordless/Helpers/PasswordlessSerializerContext.cs index 3c1b8db..25b0073 100644 --- a/src/Passwordless/Helpers/PasswordlessSerializerContext.cs +++ b/src/Passwordless/Helpers/PasswordlessSerializerContext.cs @@ -26,4 +26,5 @@ namespace Passwordless.Helpers; [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(GetEventLogRequest))] [JsonSerializable(typeof(GetEventLogResponse))] +[JsonSerializable(typeof(SendMagicLinkApiRequest))] internal partial class PasswordlessSerializerContext : JsonSerializerContext; \ No newline at end of file diff --git a/src/Passwordless/IPasswordlessClient.cs b/src/Passwordless/IPasswordlessClient.cs index 13c555b..f2143ac 100644 --- a/src/Passwordless/IPasswordlessClient.cs +++ b/src/Passwordless/IPasswordlessClient.cs @@ -110,4 +110,12 @@ Task DeleteCredentialAsync( /// /// Task GetEventLogAsync(GetEventLogRequest request, CancellationToken cancellationToken = default); + + /// + /// Sends an email containing a magic link template allowing users to login. + /// + /// + /// + /// + Task SendMagicLinkAsync(SendMagicLinkRequest request, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Passwordless/Models/GetEventLogRequest.cs b/src/Passwordless/Models/GetEventLogRequest.cs index ef60157..9886f37 100644 --- a/src/Passwordless/Models/GetEventLogRequest.cs +++ b/src/Passwordless/Models/GetEventLogRequest.cs @@ -1,5 +1,3 @@ -using System.ComponentModel.DataAnnotations; - namespace Passwordless.Models; /// diff --git a/src/Passwordless/Models/SendMagicLinkRequest.cs b/src/Passwordless/Models/SendMagicLinkRequest.cs new file mode 100644 index 0000000..0eda740 --- /dev/null +++ b/src/Passwordless/Models/SendMagicLinkRequest.cs @@ -0,0 +1,23 @@ +using System; +using Passwordless.Helpers.Extensions; + +namespace Passwordless.Models; + +/// +/// Request for sending an email with a link that contains a 1-time-use token to be used for validating signin. +/// +/// Valid email address that will be the recipient of the magic link email +/// Url template that needs to contain the token template, . The token template will be replaced with a valid signin token to be sent to the verify sign in token endpoint (https://v4.passwsordless.dev/signin/verify). +/// Identifier for the user the email is intended for. +/// Length of time the magic link will be active. Default value will be 15 minutes. +public record SendMagicLinkRequest(string EmailAddress, string UrlTemplate, string UserId, TimeSpan? TimeToLive) +{ + internal SendMagicLinkApiRequest ToRequest() => + new( + this.EmailAddress, + this.UrlTemplate, + this.UserId, + this.TimeToLive?.TotalSeconds.Pipe(Convert.ToInt32)); +}; + +internal record SendMagicLinkApiRequest(string EmailAddress, string UrlTemplate, string UserId, int? TimeToLive); \ No newline at end of file diff --git a/src/Passwordless/PasswordlessClient.cs b/src/Passwordless/PasswordlessClient.cs index 7d5023e..40de3a4 100644 --- a/src/Passwordless/PasswordlessClient.cs +++ b/src/Passwordless/PasswordlessClient.cs @@ -115,6 +115,18 @@ public async Task GetEventLogAsync(GetEventLogRequest reque PasswordlessSerializerContext.Default.GetEventLogResponse, cancellationToken))!; + /// + public async Task SendMagicLinkAsync(SendMagicLinkRequest request, CancellationToken cancellationToken = default) + { + using var response = await _http.PostAsJsonAsync( + "magic-links/send", + request.ToRequest(), + PasswordlessSerializerContext.Default.SendMagicLinkApiRequest, + cancellationToken); + + response.EnsureSuccessStatusCode(); + } + /// public async Task GetUsersCountAsync(CancellationToken cancellationToken = default) => (await _http.GetFromJsonAsync( diff --git a/tests/Passwordless.Tests.Infra/TestApi.cs b/tests/Passwordless.Tests.Infra/TestApi.cs index 7a29aec..51a83fb 100644 --- a/tests/Passwordless.Tests.Infra/TestApi.cs +++ b/tests/Passwordless.Tests.Infra/TestApi.cs @@ -96,7 +96,8 @@ public async Task CreateAppAsync() { "adminEmail": "test@passwordless.dev", "eventLoggingIsEnabled": true, - "eventLoggingRetentionPeriod": 7 + "eventLoggingRetentionPeriod": 7, + "magicLinkEmailMonthlyQuota" : 100 } """, Encoding.UTF8, diff --git a/tests/Passwordless.Tests/MagicLinksTests.cs b/tests/Passwordless.Tests/MagicLinksTests.cs new file mode 100644 index 0000000..f888721 --- /dev/null +++ b/tests/Passwordless.Tests/MagicLinksTests.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Passwordless.Models; +using Passwordless.Tests.Infra; +using Xunit; +using Xunit.Abstractions; + +namespace Passwordless.Tests; + +public class MagicLinksTests(TestApiFixture api, ITestOutputHelper testOutput) : ApiTestBase(api, testOutput) +{ + [Fact] + public async Task I_can_send_a_magic_link_with_a_specified_time_to_live() + { + // Arrange + var passwordless = await Api.CreateClientAsync(); + var request = new SendMagicLinkRequest("test@passwordless.dev", "https://www.example.com?token=$TOKEN", "user", new TimeSpan(0, 15, 0)); + + // Act + var action = async () => await passwordless.SendMagicLinkAsync(request, CancellationToken.None); + + // Assert + await action.Should().NotThrowAsync(); + } + + [Fact] + public async Task I_can_send_a_magic_link_without_a_time_to_live() + { + // Arrange + var passwordless = await Api.CreateClientAsync(); + var request = new SendMagicLinkRequest("test@passwordless.dev", "https://www.example.com?token=$TOKEN", "user", null); + + // Act + var action = async () => await passwordless.SendMagicLinkAsync(request, CancellationToken.None); + + // Assert + await action.Should().NotThrowAsync(); + } +} \ No newline at end of file