Implement MFA using swiyu, the Swiss E-ID with Duende IdentityServer, ASP.NET Core Identity and .NET Aspire

The post shows how to use the Swiss Digital identity and trust infrastructure, (swiyu) as an MFA method in an ASP.NET Core web application using ASP.NET Core Identity and Duende IdentityServer. The generic containers from swiyu are used to integrate the Swiss E-ID and the OpenID for Verifiable Presentations standards.

Code: https://github.com/swiss-ssi-group/swiyu-idp-mfa-aspire-aspnetcore

Blogs in this series

Setup

The web application is configured to authenticate using OpenID Connect. It uses a confidential client with the Authorization Code Flow and PKCE. The OpenID Connect server is implemented with Duende IdentityServer and ASP.NET Core Identity. For E-ID verification, the server integrates the Swiyu generic containers to support OpenID for Verifiable Presentations. The Swiyu Wallet is used to manage the E-ID.

Integrate using ASP.NET Core Identity

In this setup, a custom Multi-Factor Authentication (MFA) provider is introduced. The SwiyuUserTwoFactorTokenProvider class is responsible for integrating the swiyu MFA. It implements the IUserTwoFactorTokenProvider interface, required for integration with ASP.NET Core Identity. This implementation uses the ApplicationUser class and allows the application to support additional MFA steps and not just the password user authentication.

using Idp.Swiyu.IdentityProvider.Models;
using Microsoft.AspNetCore.Identity;

namespace Idp.Swiyu.IdentityProvider.SwiyuServices;

public class SwiyuUserTwoFactorTokenProvider : IUserTwoFactorTokenProvider<ApplicationUser>
{
    public Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<ApplicationUser> manager, ApplicationUser user)
    {
        return Task.FromResult(true);
    }

    public Task<string> GenerateAsync(string purpose, UserManager<ApplicationUser> manager, ApplicationUser user)
    {
        return Task.FromResult(SwiyuConsts.SWIYU);
    }

    public Task<bool> ValidateAsync(string purpose, string token, UserManager<ApplicationUser> manager, ApplicationUser user)
    {
        return Task.FromResult(true);
    }
}

The interface is used in ASP.NET Core Identity. The MFA provider is added using the AddTokenProvider method.

builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddTokenProvider<SwiyuUserTwoFactorTokenProvider>(SwiyuConsts.SWIYU)
    .AddDefaultTokenProviders();

Setup database

The MFA provider requires data persistence to work. The SwiyuIdentity class is used for this.

public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    public DbSet<SwiyuIdentity> SwiyuIdentity => Set<SwiyuIdentity>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        builder.Entity<SwiyuIdentity>().HasKey(m => m.Id);
        builder.Entity<SwiyuIdentity>().Property(b => b.Id).ValueGeneratedOnAdd();

        base.OnModelCreating(builder);
    }
}

The SwiyuIdentity class persists the data requested from the verifiable credential. The following claims are used for the identity check:

  • name
  • family_name
  • birth_place
  • birth_date

The entity is linked to the user using the UserId.

public class SwiyuIdentity
{
    public int Id { get; set; }

    public string GivenName { get; set; } = null!;
    public string FamilyName { get; set; } = null!;
    public string BirthPlace { get; set; } = null!;
    public string BirthDate { get; set; } = null!;

    public string UserId { get; set; } = null!;
    public string Email { get; set; } = null!;
}

Register swiyu as MFA

Once setup, an authenticated user can register the swiyu as an MFA method using the RegisterSwiyuModel Razor page. This creates a verifiable credential presentation and user can present the credential from the swyiu wallet controlled by the user. Javascript is used to call the handle the response from the wallet.

using Idp.Swiyu.IdentityProvider.SwiyuServices;
using ImageMagick;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Net.Codecrete.QrCodeGenerator;
using System.Text.Json;

namespace Idp.Swiyu.IdentityProvider.Pages.Account.Manage;

[Authorize]
public class RegisterSwiyuModel : PageModel
{
    private readonly VerificationService _verificationService;
    private readonly string? _swiyuOid4vpUrl;

    [BindProperty]
    public string? VerificationId { get; set; }

    [BindProperty]
    public string? QrCodeUrl { get; set; } = string.Empty;

    [BindProperty]
    public byte[] QrCodePng { get; set; } = [];

    public RegisterSwiyuModel(VerificationService verificationService,
        IConfiguration configuration)
    {
        _verificationService = verificationService;
        _swiyuOid4vpUrl = configuration["SwiyuOid4vpUrl"];
        QrCodeUrl = QrCodeUrl.Replace("{OID4VP_URL}", _swiyuOid4vpUrl);
    }

    public void OnGet()
    {
        // default HTTP GET is required
    }

    public async Task OnPostAsync()
    {
        var presentation = await _verificationService
            .CreateBetaIdVerificationPresentationAsync();

        var verificationResponse = JsonSerializer.Deserialize<CreateVerificationPresentationModel>(presentation);
        // verification_url
        QrCodeUrl = verificationResponse!.verification_url;

        var qrCode = QrCode.EncodeText(verificationResponse!.verification_url, QrCode.Ecc.Quartile);
        QrCodePng = qrCode.ToPng(20, 4, MagickColors.Black, MagickColors.White);

        VerificationId = verificationResponse.id;
    }
}

RegisterController webhook

The RegisterController is used to handle the callbacks from the swiyu mobile wallet. The endpoint user the VerificationService for handle the verifiable credential. If the response is correct, the data is persisted in the DB. Each account has only one credential and when implementing this correctly, it needs to be more robust.

using Duende.IdentityModel;
using Idp.Swiyu.IdentityProvider.Data;
using Idp.Swiyu.IdentityProvider.Models;
using Idp.Swiyu.IdentityProvider.SwiyuServices;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Net.Mail;
using System.Security.Claims;

namespace Swiyu.Aspire.Mgmt.Controllers;

[Route("api/[controller]")]
[ApiController]
public class RegisterController : ControllerBase
{
    private readonly VerificationService _verificationService;
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly ApplicationDbContext _applicationDbContext;

    public RegisterController(VerificationService verificationService,
        UserManager<ApplicationUser> userManager,
        ApplicationDbContext applicationDbContext)
    {
        _verificationService = verificationService;
        _userManager = userManager;
        _applicationDbContext = applicationDbContext;
    }

    [HttpGet("verification-response")]
    public async Task<ActionResult> VerificationResponseAsync([FromQuery] string? id)
    {
        try
        {
            if (id == null)
            {
                return BadRequest(new { error = "400", error_description = "Missing argument 'id'" });
            }

            var verificationModel = await _verificationService.GetVerificationStatus(id);

            if (verificationModel != null && verificationModel.state == "SUCCESS")
            {
                // In a business app we can use the data from the verificationModel
                // Verification data:
                // Use: wallet_response/credential_subject_data
                var verificationClaims = _verificationService.GetVerifiedClaims(verificationModel);

                var user = await _userManager.FindByEmailAsync(GetEmail(User.Claims)!);

                var exists = _applicationDbContext.SwiyuIdentity.FirstOrDefault(c =>
                    c.BirthDate == verificationClaims.BirthDate &&
                    c.BirthPlace == verificationClaims.BirthPlace &&
                    c.GivenName == verificationClaims.GivenName &&
                    c.FamilyName == verificationClaims.FamilyName);

                if (exists != null)
                {
                    throw new Exception("swiyu already in use and connected to an account...");
                }

                if (user != null && user != null && (user.SwiyuIdentityId == null || user.SwiyuIdentityId <= 0))
                {
                    var swiyuIdentity = new SwiyuIdentity
                    {
                        UserId = user.Id,
                        BirthDate = verificationClaims.BirthDate,
                        FamilyName = verificationClaims.FamilyName,
                        BirthPlace = verificationClaims.BirthPlace,
                        GivenName = verificationClaims.GivenName,
                        Email = user.Email!
                    };

                    _applicationDbContext.SwiyuIdentity.Add(swiyuIdentity);

                    // Save to DB
                    user.SwiyuIdentityId = swiyuIdentity.Id;

                    await _applicationDbContext.SaveChangesAsync();

                    await _userManager.SetTwoFactorEnabledAsync(user, true);
                }
                else 
                {
                    throw new Exception("swiyu, could not add, unknown error...");
                }
            }

            return Ok(verificationModel);
        }
        catch (Exception ex)
        {
            return BadRequest(new { error = "400", error_description = ex.Message });
        }
    }

    public static string? GetEmail(IEnumerable<Claim> claims)
    {
        var email = claims.FirstOrDefault(t => t.Type == ClaimTypes.Email);

        if (email != null)
        {
            return email.Value;
        }

        email = claims.FirstOrDefault(t => t.Type == JwtClaimTypes.Email);

        if (email != null)
        {
            return email.Value;
        }

        email = claims.FirstOrDefault(t => t.Type == "preferred_username");

        if (email != null)
        {
            var isNameAndEmail = IsEmailValid(email.Value);
            if (isNameAndEmail)
            {
                return email.Value;
            }
        }

        return null;
    }

    public static bool IsEmailValid(string email)
    {
        if (!MailAddress.TryCreate(email, out var mailAddress))
            return false;

        // And if you want to be more strict:
        var hostParts = mailAddress.Host.Split('.');
        if (hostParts.Length == 1)
            return false; // No dot.
        if (hostParts.Any(p => p == string.Empty))
            return false; // Double dot.
        if (hostParts[^1].Length < 2)
            return false; // TLD only one letter.

        if (mailAddress.User.Contains(' '))
            return false;
        if (mailAddress.User.Split('.').Any(p => p == string.Empty))
            return false; // Double dot or dot at end of user part.

        return true;
    }
}

Disable swiyu MFA

The swiyu MFA can be disabled using the DisableSwiyuModel Razor Page. If using this together with other MFA methods, the check for existing MFA needs to be improved.

using Idp.Swiyu.IdentityProvider.Data;
using Idp.Swiyu.IdentityProvider.Models;
using Idp.Swiyu.IdentityProvider.SwiyuServices;
using ImageMagick;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.VisualStudio.Web.CodeGenerators.Mvc.Templates.BlazorIdentity.Shared;
using Net.Codecrete.QrCodeGenerator;
using System.Text.Json;

namespace Idp.Swiyu.IdentityProvider.Pages.Account.Manage;

[Authorize]
public class DisableSwiyuModel : PageModel
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly ILogger<DisableSwiyuModel> _logger;
    private readonly ApplicationDbContext _applicationDbContext;

    /// <summary>
    ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
    ///     directly from your code. This API may change or be removed in future releases.
    /// </summary>
    [TempData]
    public string? StatusMessage { get; set; }

    public DisableSwiyuModel(
        UserManager<ApplicationUser> userManager,
        ApplicationDbContext applicationDbContext,
        ILogger<DisableSwiyuModel> logger)
    {
        _userManager = userManager;
        _logger = logger;
        _applicationDbContext = applicationDbContext;
    }

    public async Task<IActionResult> OnGet()
    {
        var user = await _userManager.GetUserAsync(User);
        if (user == null)
        {
            return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
        }

        if (!await _userManager.GetTwoFactorEnabledAsync(user))
        {
            throw new InvalidOperationException($"Cannot disable 2FA for user as it's not currently enabled.");
        }

        return Page();
    }

    public async Task<IActionResult> OnPostAsync()
    {
        var user = await _userManager.GetUserAsync(User);
        if (user == null)
        {
            return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
        }

        var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false);
        if (!disable2faResult.Succeeded)
        {
            throw new InvalidOperationException($"Unexpected error occurred disabling 2FA.");
        }

        var exists = _applicationDbContext.SwiyuIdentity.FirstOrDefault(c => c.UserId == user!.Id);

        if (exists != null)
        {
            _applicationDbContext.SwiyuIdentity.Remove(exists);
            await _applicationDbContext.SaveChangesAsync();
        }

        _logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User));
        StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app";
        return RedirectToPage("./TwoFactorAuthentication");
    }
}

Verification service

The VerificationService implements the logic to create verifiable credential presentations and also handle the webhook responses. This service is implemented using the documentation from swiyu.

using System.Text;
using System.Text.Json;
using System.Web;

namespace Idp.Swiyu.IdentityProvider.SwiyuServices;

public class VerificationService
{
    private readonly ILogger<VerificationService> _logger;
    private readonly string? _swiyuVerifierMgmtUrl;
    private readonly string? _issuerId;
    private readonly HttpClient _httpClient;

    public VerificationService(IHttpClientFactory httpClientFactory,
        ILoggerFactory loggerFactory, IConfiguration configuration)
    {
        _swiyuVerifierMgmtUrl = configuration["SwiyuVerifierMgmtUrl"];
        _issuerId = configuration["ISSUER_ID"];
        _httpClient = httpClientFactory.CreateClient();
        _logger = loggerFactory.CreateLogger<VerificationService>();
    }

    /// <summary>
    /// curl - X POST http://localhost:8082/api/v1/verifications \
    ///       -H "accept: application/json" \
    ///       -H "Content-Type: application/json" \
    ///       -d '
    /// </summary>
    public async Task<string> CreateBetaIdVerificationPresentationAsync()
    {
        _logger.LogInformation("Creating verification presentation");

        // from "betaid-sdjwt"
        var acceptedIssuerDid = "did:tdw:QmPEZPhDFR4nEYSFK5bMnvECqdpf1tPTPJuWs9QrMjCumw:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:9a5559f0-b81c-4368-a170-e7b4ae424527";

        var inputDescriptorsId = Guid.NewGuid().ToString();
        var presentationDefinitionId = "00000000-0000-0000-0000-000000000000"; // Guid.NewGuid().ToString();

        var json = GetBetaIdVerificationPresentationBody(inputDescriptorsId,
            presentationDefinitionId, acceptedIssuerDid, "betaid-sdjwt");

        return await SendCreateVerificationPostRequest(json);
    }

    public async Task<VerificationManagementModel?> GetVerificationStatus(string verificationId)
    {
        var idEncoded = HttpUtility.UrlEncode(verificationId);
        using HttpResponseMessage response = await _httpClient.GetAsync(
            $"{_swiyuVerifierMgmtUrl}/api/v1/verifications/{idEncoded}");

        if (response.IsSuccessStatusCode)
        {
            var jsonResponse = await response.Content.ReadAsStringAsync();

            if (jsonResponse == null)
            {
                _logger.LogError("GetVerificationStatus no data returned from Swiyu");
                return null;
            }

            //  state: PENDING, SUCCESS, FAILED
            return JsonSerializer.Deserialize<VerificationManagementModel>(jsonResponse);
        }

        var error = await response.Content.ReadAsStringAsync();
        _logger.LogError("Could not create verification presentation {vp}", error);

        throw new ArgumentException(error);
    }

    /// <summary>
    /// In a business app we can use the data from the verificationModel
    /// Verification data:
    /// Use: wallet_response/credential_subject_data
    ///
    /// birth_date, given_name, family_name, birth_place
    /// 
    /// </summary>
    /// <param name="verificationManagementModel"></param>
    /// <returns></returns>
    public VerificationClaims GetVerifiedClaims(VerificationManagementModel verificationManagementModel)
    {
        var json = verificationManagementModel.wallet_response!.credential_subject_data!.ToString();

        var jsonElement = JsonDocument.Parse(json!).RootElement;

        var claims = new VerificationClaims
        {
            BirthDate = jsonElement.GetProperty("birth_date").ToString(),
            BirthPlace = jsonElement.GetProperty("birth_place").ToString(),
            FamilyName = jsonElement.GetProperty("family_name").ToString(),
            GivenName = jsonElement.GetProperty("given_name").ToString()
        };

        return claims;
    }

    private async Task<string> SendCreateVerificationPostRequest(string json)
    {
        var jsonContent = new StringContent(json, Encoding.UTF8, "application/json");
        var response = await _httpClient.PostAsync(
                    $"{_swiyuVerifierMgmtUrl}/api/v1/verifications", jsonContent);
        if (response.IsSuccessStatusCode)
        {
            var jsonResponse = await response.Content.ReadAsStringAsync();

            return jsonResponse;
        }

        var error = await response.Content.ReadAsStringAsync();
        _logger.LogError("Could not create verification presentation {vp}", error);

        throw new ArgumentException(error);
    }

    /// <summary>
    /// There will be private companies having a need to do identification routines (e.g. KYC or before issuing another credential), 
    /// asking for given_name, family_name, birth_date and birth_place.
    /// 
    /// { "path": [ "$.birth_date" ] },
    /// { "path": ["$.given_name"] },
    /// { "path": ["$.family_name"] },
    /// { "path": ["$.birth_place"] },
    /// </summary>
    private static string GetBetaIdVerificationPresentationBody(string inputDescriptorsId, string presentationDefinitionId, string acceptedIssuerDid, string vcType)
    {
        var json = $$"""
             {
                 "accepted_issuer_dids": [ "{{acceptedIssuerDid}}" ],
                 "jwt_secured_authorization_request": true,
                 "presentation_definition": {
                     "id": "{{presentationDefinitionId}}",
                     "name": "Verification",
                     "purpose": "Verify using Beta ID",
                     "input_descriptors": [
                         {
                             "id": "{{inputDescriptorsId}}",
                             "format": {
                                 "vc+sd-jwt": {
                                     "sd-jwt_alg_values": [
                                         "ES256"
                                     ],
                                     "kb-jwt_alg_values": [
                                         "ES256"
                                     ]
                                 }
                             },
                             "constraints": {
             	                "fields": [
             		                {
             			                "path": [
             				                "$.vct"
             			                ],
             			                "filter": {
             				                "type": "string",
             				                "const": "{{vcType}}"
             			                }
             		                },
             		                { "path": [ "$.birth_date" ] },
             		                { "path": [ "$.given_name" ] },
                                    { "path": [ "$.family_name" ] },
                                    { "path": [ "$.birth_place" ] }
             	                ]
                             }
                         }
                     ]
                 }
             }
             """;

        return json;
    }
}

Login swiyu

A user can authenticate using the default login Razor Page. The LoginSwiyuMfaModel razor page is used to start the swiyu verification. Once complete, the user is signed in and the authentication is complete.

using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using Idp.Swiyu.IdentityProvider.Data;
using Idp.Swiyu.IdentityProvider.Models;
using Idp.Swiyu.IdentityProvider.SwiyuServices;
using ImageMagick;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Net.Codecrete.QrCodeGenerator;
using System.Security.Claims;
using System.Text.Json;

namespace Idp.Swiyu.IdentityProvider.Pages.Login;

[AllowAnonymous]
public class LoginSwiyuMfaModel : PageModel
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly SignInManager<ApplicationUser> _signInManager;
    private readonly IIdentityServerInteractionService _interaction;
    private readonly IEventService _events;
    private readonly IAuthenticationSchemeProvider _schemeProvider;
    private readonly IIdentityProviderStore _identityProviderStore;
    private readonly IHttpClientFactory _clientFactory;
    private readonly ApplicationDbContext _applicationDbContext;

    [BindProperty]
    public string? ReturnUrl { get; set; }

    private readonly VerificationService _verificationService;
    private readonly string? _swiyuOid4vpUrl;

    [BindProperty]
    public string? VerificationId { get; set; }

    [BindProperty]
    public string? QrCodeUrl { get; set; } = string.Empty;

    [BindProperty]
    public byte[]? QrCodePng { get; set; } = [];

    public LoginSwiyuMfaModel(
        IIdentityServerInteractionService interaction,
        IAuthenticationSchemeProvider schemeProvider,
        IIdentityProviderStore identityProviderStore,
        IEventService events,
        UserManager<ApplicationUser> userManager,
        SignInManager<ApplicationUser> signInManager,
        VerificationService verificationService,
        IHttpClientFactory clientFactory,
        IConfiguration configuration,
        ApplicationDbContext applicationDbContext)
    {
        _userManager = userManager;
        _signInManager = signInManager;
        _interaction = interaction;
        _schemeProvider = schemeProvider;
        _identityProviderStore = identityProviderStore;
        _events = events;

        _clientFactory = clientFactory;
        _applicationDbContext = applicationDbContext;

        _verificationService = verificationService;
        _swiyuOid4vpUrl = configuration["SwiyuOid4vpUrl"];
        QrCodeUrl = QrCodeUrl.Replace("{OID4VP_URL}", _swiyuOid4vpUrl);
    }

    public async Task<IActionResult> OnGet(string? returnUrl)
    {
        if (returnUrl != null)
        {
            // check if we are in the context of an authorization request
            var context = await _interaction.GetAuthorizationContextAsync(returnUrl);

            ReturnUrl = returnUrl;
        }

        var presentation = await _verificationService
            .CreateBetaIdVerificationPresentationAsync();

        var verificationResponse = JsonSerializer.Deserialize<CreateVerificationPresentationModel>(presentation);
        // verification_url
        QrCodeUrl = verificationResponse!.verification_url;

        var qrCode = QrCode.EncodeText(verificationResponse!.verification_url, QrCode.Ecc.Quartile);
        QrCodePng = qrCode.ToPng(20, 4, MagickColors.Black, MagickColors.White);

        VerificationId = verificationResponse.id;

        return Page();
    }

    public async Task<IActionResult> OnPost()
    {
        VerificationClaims verificationClaims = null;
        try
        {
            if (VerificationId == null)
            {
                return BadRequest(new { error = "400", error_description = "Missing argument 'VerificationId'" });
            }

            var verificationModel = await RequestSwiyuClaimsAsync(1, VerificationId);

            verificationClaims = _verificationService.GetVerifiedClaims(verificationModel);

            // check if we are in the context of an authorization request
            var context = await _interaction.GetAuthorizationContextAsync(ReturnUrl);

            if (ModelState.IsValid)
            {
                var claims = new List<Claim>
                {
                    new Claim("name", verificationClaims.GivenName),
                    new Claim("family_name", verificationClaims.FamilyName),
                    new Claim("birth_place", verificationClaims.BirthPlace),
                    new Claim("birth_date", verificationClaims.BirthDate)
                };

                var exists = _applicationDbContext.SwiyuIdentity.FirstOrDefault(c =>
                    c.BirthDate == verificationClaims.BirthDate &&
                    c.BirthPlace == verificationClaims.BirthPlace &&
                    c.GivenName == verificationClaims.GivenName &&
                    c.FamilyName == verificationClaims.FamilyName);

                if (exists != null)
                {
                    var user = await _userManager.FindByIdAsync(exists.UserId);

                    if (user == null)
                    {
                        // This should return a user message with no info what went wrong.
                        throw new ArgumentNullException("error in authentication");
                    }

                    var result = await _signInManager.TwoFactorSignInAsync(SwiyuConsts.SWIYU, string.Empty, false, false);
                   
                    if (context != null)
                    {
                        if (context.IsNativeClient())
                        {
                            // The client is native, so this change in how to
                            // return the response is for better UX for the end user.
                            return this.LoadingPage(ReturnUrl ?? "~/");
                        }
                    }

                    // request for a local page
                    if (Url.IsLocalUrl(ReturnUrl))
                    {
                        return Redirect(ReturnUrl);
                    }
                    else if (string.IsNullOrEmpty(ReturnUrl))
                    {
                        return Redirect("~/");
                    }
                    else
                    {
                        // user might have clicked on a malicious link - should be logged
                        throw new ArgumentException("invalid return URL");
                    }
                }
            }
        }
        catch (Exception ex)
        {
            return BadRequest(new { error = "400", error_description = ex.Message });
        }

        return Page();
    }

    internal async Task<VerificationManagementModel> RequestSwiyuClaimsAsync(int interval, string verificationId)
    {
        var client = _clientFactory.CreateClient();

        while (true)
        {

            var verificationModel = await _verificationService.GetVerificationStatus(verificationId);

            if (verificationModel != null && verificationModel.state == "SUCCESS")
            {
                return verificationModel;
            }
            else
            {
                await Task.Delay(interval * 1000);
            }
        }
    }
}

Login

The Login Razor Page checks if the user requires MFA and redirects to the correct service.

 if (result.RequiresTwoFactor)
 {
     var user = await _userManager.FindByEmailAsync(Input.Username!);

     var exists = _applicationDbContext.SwiyuIdentity.FirstOrDefault(c => c.UserId == user!.Id);

     if (exists != null)
     {
         return RedirectToPage("../LoginSwiyuMfa", new { Input.ReturnUrl, Input.RememberLogin });
     }

     return RedirectToPage("../LoginWith2fa", new { ReturnUrl = Input.ReturnUrl, Input.RememberLogin });
 }

Notes

The is fairly standard to implement and the user can do an MFA using the swiyu. This is not a phishing resistant MFA but provides a strong identity. Better would be to authenticate using passkeys and do an identity check using swyiu.

Links

https://swiyu-admin-ch.github.io/

https://www.eid.admin.ch/en/public-beta-e

https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview

https://www.npmjs.com/package/ngrok

https://swiyu-admin-ch.github.io/specifications/interoperability-profile/

https://andrewlock.net/converting-a-docker-compose-file-to-aspire/

https://swiyu-admin-ch.github.io/cookbooks/onboarding-generic-verifier/

https://github.com/orgs/swiyu-admin-ch/projects/2/views/2

Standards

https://identity.foundation/trustdidweb/

https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html

https://openid.net/specs/openid-4-verifiable-presentations-1_0.html

https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/

https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/

https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/

https://www.w3.org/TR/vc-data-model-2.0/

4 comments

  1. Unknown's avatar

    […] Implement MFA using swiyu, the Swiss E-ID with Duende IdentityServer, ASP.NET Core Identity and .NET… […]

  2. Unknown's avatar

    […] Implement MFA using swiyu, the Swiss E-ID with Duende IdentityServer, ASP.NET Core Identity and .NET… […]

  3. […] Implement MFA using swiyu, the Swiss E-ID with Duende IdentityServer, ASP.NET Core Identity and .NET… (Damien Bowden) […]

  4. Unknown's avatar

    […] Implement MFA using swiyu, the Swiss E-ID with Duende IdentityServer, ASP.NET Core Identity and .NET… […]

Leave a reply to Dew Drop – November 10, 2025 (#4537) – Morning Dew by Alvin Ashcraft Cancel reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.