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
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
<PackageVersion Include="AngleSharp" Version="1.5.2" />
</ItemGroup>

<ItemGroup Label="Core">
<PackageVersion Include="AndreGoepel.Core" Version="1.0.1" />
</ItemGroup>

<ItemGroup Label="Marten">
<PackageVersion Include="Marten" Version="9.19.0" />
<PackageVersion Include="Marten.AspNetCore" Version="9.19.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using AndreGoepel.Core;
using AndreGoepel.Marten.Identity.Users;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Identity;
Expand Down Expand Up @@ -29,10 +30,10 @@ public async Task<IdentityResult> InviteAsync(
)
{
var result = await invitations.InviteAsync(email, roles, cancellationToken);
if (!result.Succeeded)
return result.Result;
if (!result.IsSuccess)
return Failed(result.Error);

await SendAsync(result, email, cancellationToken);
await SendAsync(result.Value!, email, cancellationToken);
return IdentityResult.Success;
}

Expand All @@ -42,31 +43,42 @@ public async Task<IdentityResult> ResendAsync(
)
{
var result = await invitations.ResendAsync(user, cancellationToken);
if (!result.Succeeded)
return result.Result;
if (!result.IsSuccess)
return Failed(result.Error);

await SendAsync(result, user.Email!, cancellationToken);
await SendAsync(result.Value!, user.Email!, cancellationToken);
return IdentityResult.Success;
}

/// <summary>
/// Adapts a <see cref="Result{T}"/> failure back into the <see cref="IdentityResult"/>
/// this class's own callers (the admin dialog and Users page) expect. A generic error
/// code is used because neither caller branches on it today, only on the joined
/// description text.
/// </summary>
private static IdentityResult Failed(string description) =>
IdentityResult.Failed(
new IdentityError { Code = "InvitationFailed", Description = description }
);

private async Task SendAsync(
UserInvitationResult result,
UserInvitationDetails details,
string email,
CancellationToken cancellationToken
)
{
// userId identifies the account; the token rides in the query Base64Url-encoded so
// it survives the URL intact.
var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(result.Token!));
var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(details.Token));
var link = navigation.GetUriWithQueryParameters(
navigation.ToAbsoluteUri("Account/AcceptInvitation").AbsoluteUri,
new Dictionary<string, object?> { ["userId"] = result.User!.Id, ["code"] = encodedCode }
new Dictionary<string, object?> { ["userId"] = details.User.Id, ["code"] = encodedCode }
);

// Pass the raw URL. HTML-encoding is the sender's job, done only if it embeds the
// link in HTML — encoding here turns the query separator into "&amp;", which breaks
// the link for any sender that emits plain text (e.g. a dev logger writing it to a
// console, where "&amp;" is copied verbatim into the browser and splits the query).
await emailSender.SendInvitationLinkAsync(result.User, email, link, cancellationToken);
await emailSender.SendInvitationLinkAsync(details.User, email, link, cancellationToken);
}
}
7 changes: 7 additions & 0 deletions src/AndreGoepel.Marten.Identity.Blazor/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
"andregoepel.marten.identity": {
"type": "Project",
"dependencies": {
"AndreGoepel.Core": "[1.0.1, )",
"AndreGoepel.Marten.Configuration": "[1.0.1, )",
"AndreGoepel.Marten.Identity.Abstractions": "[1.8.0, )",
"Marten": "[9.19.0, )",
Expand All @@ -194,6 +195,12 @@
"andregoepel.marten.identity.abstractions": {
"type": "Project"
},
"AndreGoepel.Core": {
"type": "CentralTransitive",
"requested": "[1.0.1, )",
"resolved": "1.0.1",
"contentHash": "jA27iMianyxUdrCtz5b6KxZOYxXlhZqkxDVKD6sed1sDCHF1upfV28CnczJ6kYPcrqJNj9+cDaxMWPlNvDkjcA=="
},
"Marten.AspNetCore": {
"type": "CentralTransitive",
"requested": "[9.19.0, )",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageReference Include="Marten.AspNetCore" />
<PackageReference Include="Quartz.Extensions.Hosting" />
<PackageReference Include="AndreGoepel.Marten.Configuration" />
<PackageReference Include="AndreGoepel.Core" />
</ItemGroup>

<ItemGroup>
Expand Down
75 changes: 20 additions & 55 deletions src/AndreGoepel.Marten.Identity/Users/UserInvitationService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AndreGoepel.Marten.Identity.Services;
using AndreGoepel.Core;
using AndreGoepel.Marten.Identity.Services;
using Microsoft.AspNetCore.Identity;

namespace AndreGoepel.Marten.Identity.Users;
Expand Down Expand Up @@ -27,38 +28,23 @@ public sealed class UserInvitationService(
IIdentityAuthorizer authorizer
)
{
private static IdentityResult NotAuthorized() =>
IdentityResult.Failed(
new IdentityError
{
Code = IdentityErrorCodes.NotAuthorized,
Description = "Inviting a user requires administrator authority.",
}
);

private static IdentityResult Failure(string code, string description) =>
IdentityResult.Failed(new IdentityError { Code = code, Description = description });
private const string NotAuthorizedMessage = "Inviting a user requires administrator authority.";

/// <summary>
/// Creates a passwordless, unconfirmed account for <paramref name="email"/>, assigns
/// <paramref name="roles"/>, and returns the invitation token to embed in the link.
/// </summary>
public async Task<UserInvitationResult> InviteAsync(
public async Task<Result<UserInvitationDetails>> InviteAsync(
string email,
IEnumerable<string>? roles = null,
CancellationToken cancellationToken = default
)
{
if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken))
return UserInvitationResult.Failed(NotAuthorized());
return Result.Fail<UserInvitationDetails>(NotAuthorizedMessage);

if (await userManager.FindByEmailAsync(email) is not null)
return UserInvitationResult.Failed(
Failure(
IdentityErrorCodes.DuplicateEmail,
$"An account already exists for {email}."
)
);
return Result.Fail<UserInvitationDetails>($"An account already exists for {email}.");

// No password argument: the account is created with a null password hash, so it
// cannot be signed in to until the invitee redeems the link and sets one. Leaving
Expand All @@ -69,43 +55,40 @@ public async Task<UserInvitationResult> InviteAsync(

var createResult = await userManager.CreateAsync(user);
if (!createResult.Succeeded)
return UserInvitationResult.Failed(createResult);
return Result.Fail<UserInvitationDetails>(Describe(createResult));

var roleList = roles?.ToArray() ?? [];
foreach (var role in roleList)
{
var roleResult = await userStore.AddToRoleAsync(user, role, cancellationToken);
if (!roleResult.Succeeded)
return UserInvitationResult.Failed(roleResult);
return Result.Fail<UserInvitationDetails>(Describe(roleResult));
}

return UserInvitationResult.Success(user, await GenerateTokenAsync(user));
return Result.Ok(new UserInvitationDetails(user, await GenerateTokenAsync(user)));
}

/// <summary>
/// Issues a fresh invitation token for an account that was invited but has not been
/// claimed yet, for when the first email is lost or expires.
/// </summary>
public async Task<UserInvitationResult> ResendAsync(
public async Task<Result<UserInvitationDetails>> ResendAsync(
User user,
CancellationToken cancellationToken = default
)
{
if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken))
return UserInvitationResult.Failed(NotAuthorized());
return Result.Fail<UserInvitationDetails>(NotAuthorizedMessage);

// Refuse to re-issue against a claimed account. Without this, "resend invitation"
// would amount to a password reset for an active colleague that skips their
// mailbox check: it would mint a link setting their password on demand.
if (!IsPending(user))
return UserInvitationResult.Failed(
Failure(
IdentityErrorCodes.InvitationAlreadyAccepted,
"This account has already been set up; send a password reset instead."
)
return Result.Fail<UserInvitationDetails>(
"This account has already been set up; send a password reset instead."
);

return UserInvitationResult.Success(user, await GenerateTokenAsync(user));
return Result.Ok(new UserInvitationDetails(user, await GenerateTokenAsync(user)));
}

/// <summary>
Expand All @@ -121,29 +104,11 @@ private Task<string> GenerateTokenAsync(User user) =>
UserInvitationTokenProvider.ProviderName,
UserInvitationTokenProvider.Purpose
);
}

/// <summary>Outcome of an invitation attempt: the created user and its token on success.</summary>
public sealed record UserInvitationResult
{
private UserInvitationResult() { }

public bool Succeeded { get; private init; }
public IdentityResult Result { get; private init; } = IdentityResult.Success;
public User? User { get; private init; }
public string? Token { get; private init; }

/// <summary>The failure descriptions, joined for display.</summary>
public string ErrorMessage => string.Join(", ", Result.Errors.Select(e => e.Description));

internal static UserInvitationResult Success(User user, string token) =>
new()
{
Succeeded = true,
User = user,
Token = token,
};

internal static UserInvitationResult Failed(IdentityResult result) =>
new() { Succeeded = false, Result = result };
/// <summary>Joins an <see cref="IdentityResult"/>'s failure descriptions into one message.</summary>
private static string Describe(IdentityResult result) =>
string.Join(", ", result.Errors.Select(e => e.Description));
}

/// <summary>The created user and its invitation token, on a successful invite or resend.</summary>
public sealed record UserInvitationDetails(User User, string Token);
6 changes: 6 additions & 0 deletions src/AndreGoepel.Marten.Identity/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
"version": 2,
"dependencies": {
"net10.0": {
"AndreGoepel.Core": {
"type": "Direct",
"requested": "[1.0.1, )",
"resolved": "1.0.1",
"contentHash": "jA27iMianyxUdrCtz5b6KxZOYxXlhZqkxDVKD6sed1sDCHF1upfV28CnczJ6kYPcrqJNj9+cDaxMWPlNvDkjcA=="
},
"AndreGoepel.Marten.Configuration": {
"type": "Direct",
"requested": "[1.0.1, )",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@
"andregoepel.marten.identity": {
"type": "Project",
"dependencies": {
"AndreGoepel.Core": "[1.0.1, )",
"AndreGoepel.Marten.Configuration": "[1.0.1, )",
"AndreGoepel.Marten.Identity.Abstractions": "[1.8.0, )",
"Marten": "[9.19.0, )",
Expand All @@ -390,6 +391,12 @@
"Radzen.Blazor": "[11.1.7, )"
}
},
"AndreGoepel.Core": {
"type": "CentralTransitive",
"requested": "[1.0.1, )",
"resolved": "1.0.1",
"contentHash": "jA27iMianyxUdrCtz5b6KxZOYxXlhZqkxDVKD6sed1sDCHF1upfV28CnczJ6kYPcrqJNj9+cDaxMWPlNvDkjcA=="
},
"AndreGoepel.Design.Blazor": {
"type": "CentralTransitive",
"requested": "[1.4.2, )",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ public async Task InviteAsync_AnonymousActor_ReturnsNotAuthorized()
var result = await invitations.InviteAsync("new@example.com", cancellationToken: Ct);

// Assert
Assert.False(result.Succeeded);
Assert.Contains(result.Result.Errors, e => e.Code == "NotAuthorized");
Assert.False(result.IsSuccess);
Assert.Contains("administrator authority", result.Error);
}

[Fact]
Expand All @@ -60,8 +60,8 @@ public async Task InviteAsync_NonAdminActor_ReturnsNotAuthorized()
var result = await invitations.InviteAsync("new@example.com", cancellationToken: Ct);

// Assert
Assert.False(result.Succeeded);
Assert.Contains(result.Result.Errors, e => e.Code == "NotAuthorized");
Assert.False(result.IsSuccess);
Assert.Contains("administrator authority", result.Error);
Assert.Null(await FindAsync("new@example.com"));
}

Expand All @@ -76,8 +76,8 @@ public async Task InviteAsync_AdminActor_CreatesPasswordlessUnconfirmedUserWithT
var result = await invitations.InviteAsync("new@example.com", cancellationToken: Ct);

// Assert
Assert.True(result.Succeeded);
Assert.False(string.IsNullOrEmpty(result.Token));
Assert.True(result.IsSuccess);
Assert.False(string.IsNullOrEmpty(result.Value!.Token));

var created = await FindAsync("new@example.com");
Assert.NotNull(created);
Expand All @@ -102,7 +102,7 @@ public async Task InviteAsync_WithRoles_AssignsThem()
);

// Assert
Assert.True(result.Succeeded);
Assert.True(result.IsSuccess);
var created = await users.FindByEmailAsync("new@example.com");
Assert.Contains("Member", await users.GetRolesAsync(created!));
}
Expand All @@ -119,8 +119,8 @@ public async Task InviteAsync_DuplicateEmail_Fails()
var again = await invitations.InviteAsync("dupe@example.com", cancellationToken: Ct);

// Assert
Assert.False(again.Succeeded);
Assert.Contains(again.Result.Errors, e => e.Code == "DuplicateEmail");
Assert.False(again.IsSuccess);
Assert.Contains("dupe@example.com", again.Error);
}

[Fact]
Expand All @@ -136,8 +136,8 @@ public async Task ResendAsync_PendingInvitation_IssuesFreshToken()
var resend = await invitations.ResendAsync(user!, Ct);

// Assert
Assert.True(resend.Succeeded);
Assert.False(string.IsNullOrEmpty(resend.Token));
Assert.True(resend.IsSuccess);
Assert.False(string.IsNullOrEmpty(resend.Value!.Token));
}

[Fact]
Expand All @@ -158,8 +158,8 @@ public async Task ResendAsync_AlreadyAcceptedAccount_IsRefused()
var resend = await invitations.ResendAsync(claimed!, Ct);

// Assert
Assert.False(resend.Succeeded);
Assert.Contains(resend.Result.Errors, e => e.Code == "InvitationAlreadyAccepted");
Assert.False(resend.IsSuccess);
Assert.Contains("already been set up", resend.Error);
}

#region Harness
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@
"andregoepel.marten.identity": {
"type": "Project",
"dependencies": {
"AndreGoepel.Core": "[1.0.1, )",
"AndreGoepel.Marten.Configuration": "[1.0.1, )",
"AndreGoepel.Marten.Identity.Abstractions": "[1.8.0, )",
"Marten": "[9.19.0, )",
Expand All @@ -437,6 +438,12 @@
"Radzen.Blazor": "[11.1.7, )"
}
},
"AndreGoepel.Core": {
"type": "CentralTransitive",
"requested": "[1.0.1, )",
"resolved": "1.0.1",
"contentHash": "jA27iMianyxUdrCtz5b6KxZOYxXlhZqkxDVKD6sed1sDCHF1upfV28CnczJ6kYPcrqJNj9+cDaxMWPlNvDkjcA=="
},
"AndreGoepel.Design.Blazor": {
"type": "CentralTransitive",
"requested": "[1.4.2, )",
Expand Down
7 changes: 7 additions & 0 deletions tests/AndreGoepel.Marten.Identity.Tests/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@
"andregoepel.marten.identity": {
"type": "Project",
"dependencies": {
"AndreGoepel.Core": "[1.0.1, )",
"AndreGoepel.Marten.Configuration": "[1.0.1, )",
"AndreGoepel.Marten.Identity.Abstractions": "[1.8.0, )",
"Marten": "[9.19.0, )",
Expand All @@ -641,6 +642,12 @@
"andregoepel.marten.identity.abstractions": {
"type": "Project"
},
"AndreGoepel.Core": {
"type": "CentralTransitive",
"requested": "[1.0.1, )",
"resolved": "1.0.1",
"contentHash": "jA27iMianyxUdrCtz5b6KxZOYxXlhZqkxDVKD6sed1sDCHF1upfV28CnczJ6kYPcrqJNj9+cDaxMWPlNvDkjcA=="
},
"AndreGoepel.Marten.Configuration": {
"type": "CentralTransitive",
"requested": "[1.0.1, )",
Expand Down