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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Duende User Management Sample

This sample demonstrates a complete IdentityServer v8 implementation using **Duende User Management** — a user store and authentication platform that ships as a NuGet package and replaces ASP.NET Identity for IdentityServer scenarios.
This sample demonstrates a complete IdentityServer v8 implementation using **Duende User Management** to store users and authenticate them with te below authentication methods.

## What This Sample Shows

Expand All @@ -16,11 +16,11 @@ This sample demonstrates a complete IdentityServer v8 implementation using **Due

### User Profile Management

User Management uses a **schema-driven attribute model** rather than a fixed user table. Attributes like `email`, `name`, `website`, and custom ones like `location` are defined as `AttributeDefinition` entries. Profiles are collections of `AttributeValue` instances tied to a `UserSubjectId`.
User Management uses a schema-driven attribute model rather than a fixed user table. Attributes like `email`, `name`, `website`, and custom ones like `location` are defined as `AttributeDefinition` entries. Profiles are collections of `AttributeValue` instances tied to a `UserSubjectId`.

### Migration from ASP.NET Identity

The Admin → Import page demonstrates bulk-importing users from an existing ASP.NET Identity SQLite database, including:
The IdentityServer `/Admin/Import` page demonstrates bulk-importing users from an existing ASP.NET Identity SQLite database, including:

- Password hash compatibility (imports hashes as-is using a custom `IPasswordHashAlgorithm`)
- Claims-to-attributes mapping (e.g., `given_name` + `family_name` → `name`)
Expand Down Expand Up @@ -50,10 +50,11 @@ This launches:
| Service | URL |
|---------|-----|
| IdentityServer | `https://localhost:5001` |
| Client App | (assigned by Aspire) |
| Mailpit UI | `http://localhost:8025` |
| Mailpit SMTP | `localhost:1025` |
| Aspire Dashboard | `https://localhost:15027` |
| Client App | `https://client.dev.localhost:5002` |
| ASP.NET Identity Source | `https://aspnet-identity-source.dev.localhost:5003` |
| Aspire Dashboard | `https://aspire.dev.localhost:17300` |
| Mailpit UI | `http://mailpit-aspire.localhost:8025` |
| Mailpit SMTP | `tcp://localhost:1025` |

### Test Credentials

Expand All @@ -77,27 +78,6 @@ If you change the hosting URL, update these values in `Program.cs`.

Google authentication is configured but hidden when credentials are not present. To enable it, add your Google OAuth client ID and secret to the app configuration.

## Project Structure

```
UserManagement/
├── UserManagementSample/ # IdentityServer application
│ ├── Program.cs # Service registration and configuration
│ ├── SeedData.cs # Creates test users on startup
│ ├── SecondFactorStateCookie.cs # Encrypted cookie for 2FA interim state
│ ├── OtpCookie.cs # Encrypted cookie for OTP flow state
│ ├── Pages/
│ │ ├── Account/ # Login, OTP, password, passkey, external
│ │ ├── Manage/ # Profile, 2FA setup, passkey management
│ │ └── Admin/ # User search, details, import
│ ├── Import/ # ASP.NET Identity migration logic
│ └── Services/ # SecondFactorResolver for passkey 2FA
├── UserManagementSample.AppHost/ # Aspire orchestrator (Mailpit + services)
├── UserManagementSample.Client/ # OIDC client app (Authorization Code + PKCE)
├── UserManagementSample.AspNetIdentitySource/ # Legacy identity DB for import demo
└── UserManagementSample.ServiceDefaults/ # Shared Aspire configuration
```

## Key APIs Demonstrated

### Configuration (Program.cs)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

var builder = DistributedApplication.CreateBuilder(args);

var mailpit = builder.AddContainer("mailpit", "axllent/mailpit")
.WithContainerName($"user-management-sample-mailpit")
.WithHttpEndpoint(port: 8025, targetPort: 8025, name: "ui", isProxied: false)
.WithEndpoint(port: 1025, targetPort: 1025, name: "smtp", isProxied: false);
var smtpEndpoint = mailpit.GetEndpoint("smtp");

var identityServer = builder.AddProject<Projects.UserManagementSample>("identity-server")
.WaitFor(mailpit)
.WithSmtp(smtpEndpoint);

builder.AddProject<Projects.UserManagementSample_Client>("client")
.WithReference(identityServer);

builder.AddProject<Projects.UserManagementSample_AspNetIdentitySource>("aspnet-identity-source");

builder.Build().Run();

internal static class AspireExtensions
{
/// <summary>
/// Injects SMTP configuration (Smtp:Host, Smtp:Port, Smtp:EnableSsl) from a mailpit endpoint.
/// </summary>
internal static IResourceBuilder<T> WithSmtp<T>(this IResourceBuilder<T> project, EndpointReference smtpEndpoint)
where T : IResourceWithEnvironment =>
project
.WithEnvironment("Smtp__Host", smtpEndpoint.Property(EndpointProperty.Host))
.WithEnvironment("Smtp__Port", smtpEndpoint.Property(EndpointProperty.Port))
.WithEnvironment("Smtp__FromEmail", "no-reply@localhost")
.WithEnvironment("Smtp__FromName", "UserManagement Sample")
.WithEnvironment("Smtp__EnableSsl", "false");
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17300;http://localhost:15300",
"applicationUrl": "https://aspire.dev.localhost:17300",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21300",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
<PackageReference Include="Aspire.Hosting.AppHost" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\UserManagementSample.GettingStarted\UserManagementSample.GettingStarted.csproj" />
<ProjectReference Include="..\UserManagementSample\UserManagementSample.csproj" />
<ProjectReference Include="..\UserManagementSample.Client\UserManagementSample.Client.csproj" />
<ProjectReference Include="..\UserManagementSample.AspNetIdentitySource\UserManagementSample.AspNetIdentitySource.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://aspnet-identity-source.dev.localhost:5003",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7214;http://localhost:5127",
"applicationUrl": "https://client.dev.localhost:5002",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
@page
@model UserManagementSample.Pages.Account.LoginModel

<div class="row">
<h2>Sign In</h2>
<hr />
<button id="passkey-btn" type="button" class="btn btn-success mb-2">
Sign in with Passkey
</button>
<a asp-page="/Account/LoginWithOtp" asp-route-returnUrl="@Model.ReturnUrl" class="btn btn-primary mb-2">
Sign in with OTP
</a>
<a asp-page="/Account/LoginWithPassword" asp-route-returnUrl="@Model.ReturnUrl" class="btn btn-secondary mb-2">
Sign in with Password
</a>
@if (Model.GoogleConfigured)
{
<a href="/Account/ExternalLogin?provider=Google&returnUrl=@Uri.EscapeDataString(Model.ReturnUrl ?? string.Empty)" class="btn btn-danger mb-2">
Sign in with Google
</a>
}
else
{

<div>google authentication is disabled. Configure the key Authentication:Google:ClientId in appsettings.json to enable it</div>
}
</div>

@section Scripts {
<script src="/passkeys/js"></script>
<script>
(function () {
const returnUrl = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.ReturnUrl ?? "/"));

console.log(returnUrl);

document.getElementById('passkey-btn').addEventListener('click', async function () {
const btn = this;
btn.disabled = true;
try {
await authenticateWithDiscoverablePasskey();
window.location.href = returnUrl;
} catch (err) {
alert(err?.message ?? 'Passkey authentication failed.');
btn.disabled = false;
}
});
})();
</script>
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.ComponentModel.DataAnnotations;

using Duende.IdentityModel;
using Duende.Storage.EntityAttributeValue;
using Duende.Storage.Internal.Outbox;
using Duende.UserManagement;
using Duende.UserManagement.Profiles;

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
Expand Down Expand Up @@ -46,19 +49,22 @@ public async Task<IActionResult> OnGetAsync()
return Page();
}

if (profile.Attributes.TryGetValue(OidcStandardAttributes.Name.Code, out var name))
if (profile.Attributes.TryGetValue(OidcStandardAttributes.Name.Code, out var nameAttribute)
&& nameAttribute.TryGetValue<string>(out var name))
{
Name = name.ToString() ?? string.Empty;
Name = name ?? string.Empty;
}

if (profile.Attributes.TryGetValue(OidcStandardAttributes.Email.Code, out var email))
if (profile.Attributes.TryGetValue(OidcStandardAttributes.Email.Code, out var emailAttribute)
&& emailAttribute.TryGetValue<string>(out var email))
{
Email = email.ToString() ?? string.Empty;
Email = email ?? string.Empty;
}

if (profile.Attributes.TryGetValue(OidcStandardAttributes.Website.Code, out var website))
if (profile.Attributes.TryGetValue(OidcStandardAttributes.Website.Code, out var websiteAttribute)
&& websiteAttribute.TryGetValue<string>(out var website))
{
Website = website.ToString();
Website = website;
}

return Page();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

using Duende.IdentityServer;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.UserManagement;
using Duende.Storage.Schema;
using Duende.Storage.Sqlite;
using Duende.UserManagement;
Expand Down Expand Up @@ -52,26 +51,7 @@

// Adds configuration for sending OTP's. In this sample, we're sending OTP's via email,
// but you could also implement a custom delivery mechanism, e.g. for sending OTP's via SMS.
authentication.UseSmtpOtpDispatcher(smtp =>
{
var connectionString = builder.Configuration.GetConnectionString("mailpit");
if (!string.IsNullOrWhiteSpace(connectionString) &&
Uri.TryCreate(connectionString, UriKind.Absolute, out var uri))
{
smtp.Host = uri.Host;
smtp.Port = uri.Port;
smtp.EnableSsl = false;
}
else
{
smtp.Host = "localhost";
smtp.Port = 1025;
smtp.EnableSsl = false;
}

smtp.FromEmail = "no-reply@localhost";
smtp.FromName = "UserManagement Sample";
});
_ = authentication.UseSmtpOtpDispatcher(options => builder.Configuration.GetSection("Smtp").Bind(options));
});

// Store user management data in sql lite
Expand All @@ -85,9 +65,9 @@
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RedirectUris = { "https://localhost:5002/signin-oidc" },
PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc" },
FrontChannelLogoutUri = "https://localhost:5002/signout-oidc",
RedirectUris = { "https://client.dev.localhost:5002/signin-oidc" },
PostLogoutRedirectUris = { "https://client.dev.localhost:5002/signout-callback-oidc" },
FrontChannelLogoutUri = "https://client.dev.localhost:5002/signout-oidc",
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
//Don't use dev.localhost TLD because Google Auth is hard coded to use this endpoint
"applicationUrl": "https://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

using Duende.IdentityServer.Models;

namespace UserManagementSample.GettingStarted;
namespace GettingStarted;

public static class Config
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

using Duende.UserManagement.Authentication.Otp;

namespace UserManagementSample.GettingStarted;
namespace GettingStarted;

public class ConsoleOtpDispatcher : IOtpDispatcher
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,9 @@
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\UserManagementSample.ServiceDefaults\UserManagementSample.ServiceDefaults.csproj"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Duende.IdentityServer"/>
<PackageReference Include="Duende.Storage.Sqlite"/>
<PackageReference Include="Duende.UserManagement.IdentityServer8"/>
<PackageReference Include="Duende.IdentityServer" />
<PackageReference Include="Duende.Storage.Sqlite" />
<PackageReference Include="Duende.UserManagement.IdentityServer8" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<Solution>
<Project Path="GettingStarted.csproj" />
</Solution>
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
@page
@model UserManagementSample.GettingStarted.Pages.Account.EnterOtpModel
@model GettingStarted.Pages.Account.EnterOtpModel

<h2>Enter one-time password</h2>

Expand All @@ -13,6 +13,11 @@
</ul>
}

<div class="alert alert-info small mb-3" role="alert">
<i class="bi bi-info-circle me-1"></i>
<p>The OTP was output to console by `ConsoleOtpDispatcher.cs`</p>
</div>

<form method="post">
<input type="hidden" asp-for="Input.Token" />
<label asp-for="Input.Code">One-time password</label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace UserManagementSample.GettingStarted.Pages.Account;
namespace GettingStarted.Pages.Account;

public class EnterOtpModel(
IOtpAuthenticator otpAuthenticator,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
@page
@model UserManagementSample.GettingStarted.Pages.Account.LoginModel
@model GettingStarted.Pages.Account.LoginModel
@{
ViewData["Title"] = "Log in";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace UserManagementSample.GettingStarted.Pages.Account;
namespace GettingStarted.Pages.Account;

public class LoginModel(IOtpSender otpSender) : PageModel
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@page
@model GettingStarted.Pages.Account.LogoutModel
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace UserManagementSample.GettingStarted.Pages.Account;
namespace GettingStarted.Pages.Account;

public class LogoutModel(IIdentityServerInteractionService interaction) : PageModel
{
Expand Down
Loading
Loading