diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor
index d312eb4634..0b4c4811be 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor
@@ -5,7 +5,9 @@
@Localizer[nameof(AppStrings.PasswordlessTitle)]
+
+
@if (isConfigured)
{
@@ -18,6 +20,13 @@
@Localizer[nameof(AppStrings.EnablePasswordless)]
}
+
+ @*
+
+
+ Delete all credentials
+ *@
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs
index f23b7b0826..97a4ef6a5a 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs
@@ -21,7 +21,7 @@ protected override async Task OnParamsSetAsync()
if (User?.UserName is null) return;
- isConfigured = await JSRuntime.IsWebAuthnConfigured(User.UserName);
+ isConfigured = await JSRuntime.IsWebAuthnConfigured(User.Id);
}
@@ -29,6 +29,15 @@ private async Task EnablePasswordless()
{
if (User?.UserName is null) return;
+ // Only on Android this action will replace the current credential registered on the device,
+ // since android won't show the user selection window when there are multiple credentials registered.
+ // So it may be a good idea to show a confirm modal if this behavior is not appropriate for your app (as shown in the following commented lines):
+ //var userIds = await JSRuntime.GetWebAuthnConfiguredUserIds();
+ //if (userIds is not null && userIds.Length > 0)
+ //{
+ // // show a warning or confirm modal
+ //}
+
var options = await userController.GetWebAuthnCredentialOptions(CurrentCancellationToken);
AuthenticatorAttestationRawResponse attestationResponse;
@@ -45,7 +54,7 @@ private async Task EnablePasswordless()
await userController.CreateWebAuthnCredential(attestationResponse, CurrentCancellationToken);
- await JSRuntime.StoreWebAuthnConfigured(User.UserName);
+ await JSRuntime.SetWebAuthnConfiguredUserId(User.Id);
isConfigured = true;
@@ -56,12 +65,12 @@ private async Task DisablePasswordless()
{
if (User?.UserName is null) return;
- var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken);
+ var options = await identityController.GetWebAuthnAssertionOptions(new() { UserIds = [User.Id] }, CurrentCancellationToken);
AuthenticatorAssertionRawResponse assertion;
try
{
- assertion = await JSRuntime.VerifyWebAuthnCredential(options);
+ assertion = await JSRuntime.GetWebAuthnCredential(options);
}
catch (Exception ex)
{
@@ -74,10 +83,20 @@ private async Task DisablePasswordless()
await userController.DeleteWebAuthnCredential(assertion.Id, CurrentCancellationToken);
- await JSRuntime.RemoveWebAuthnConfigured(User.UserName);
+ await JSRuntime.RemoveWebAuthnConfiguredUserId(User.Id);
isConfigured = false;
SnackBarService.Success(Localizer[nameof(AppStrings.DisablePasswordlessSucsessMessage)]);
}
+
+ // Only for debugging purposes, uncomment the following lines and the corresponding lines in the razor file.
+ //private async Task DeleteAll()
+ //{
+ // await userController.DeleteAllWebAuthnCredentials(CurrentCancellationToken);
+
+ // await JSRuntime.RemoveWebAuthnConfigured();
+
+ // isConfigured = false;
+ //}
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor.cs
index d257bf26c0..aeac9f7bbc 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor.cs
@@ -169,11 +169,14 @@ private async Task HandleOnPasswordlessSignIn()
try
{
- var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken);
+ var userIds = await JSRuntime.GetWebAuthnConfiguredUserIds();
+ if (userIds is null) return;
+
+ var options = await identityController.GetWebAuthnAssertionOptions(new() { UserIds = userIds }, CurrentCancellationToken);
try
{
- webAuthnAssertion = await JSRuntime.VerifyWebAuthnCredential(options);
+ webAuthnAssertion = await JSRuntime.GetWebAuthnCredential(options);
}
catch (Exception ex)
{
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs
index 18cda6f79f..f3ac4dde10 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs
@@ -10,19 +10,24 @@ public static ValueTask IsWebAuthnAvailable(this IJSRuntime jsRuntime)
return jsRuntime.InvokeAsync("WebAuthn.isAvailable");
}
- public static ValueTask StoreWebAuthnConfigured(this IJSRuntime jsRuntime, string username)
+ public static ValueTask IsWebAuthnConfigured(this IJSRuntime jsRuntime, Guid? userId = null)
{
- return jsRuntime.InvokeVoidAsync("WebAuthn.storeConfigured", username);
+ return jsRuntime.InvokeAsync("WebAuthn.isConfigured", userId);
}
- public static ValueTask IsWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null)
+ public static ValueTask GetWebAuthnConfiguredUserIds(this IJSRuntime jsRuntime)
{
- return jsRuntime.InvokeAsync("WebAuthn.isConfigured", username);
+ return jsRuntime.InvokeAsync("WebAuthn.getConfiguredUserIds");
}
- public static ValueTask RemoveWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null)
+ public static ValueTask SetWebAuthnConfiguredUserId(this IJSRuntime jsRuntime, Guid userId)
{
- return jsRuntime.InvokeVoidAsync("WebAuthn.removeConfigured", username);
+ return jsRuntime.InvokeVoidAsync("WebAuthn.setConfiguredUserId", userId);
+ }
+
+ public static ValueTask RemoveWebAuthnConfiguredUserId(this IJSRuntime jsRuntime, Guid? userId = null)
+ {
+ return jsRuntime.InvokeVoidAsync("WebAuthn.removeConfiguredUserId", userId);
}
public static ValueTask CreateWebAuthnCredential(this IJSRuntime jsRuntime, CredentialCreateOptions options)
@@ -30,8 +35,8 @@ public static ValueTask CreateWebAuthnCrede
return jsRuntime.InvokeAsync("WebAuthn.createCredential", options);
}
- public static ValueTask VerifyWebAuthnCredential(this IJSRuntime jsRuntime, AssertionOptions options)
+ public static ValueTask GetWebAuthnCredential(this IJSRuntime jsRuntime, AssertionOptions options)
{
- return jsRuntime.InvokeAsync("WebAuthn.verifyCredential", options);
+ return jsRuntime.InvokeAsync("WebAuthn.getCredential", options);
}
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts
index b24b327f50..133c146e8b 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts
@@ -5,20 +5,25 @@ class WebAuthn {
return !!window.PublicKeyCredential;
}
- public static isConfigured(username: string | undefined) {
+ public static isConfigured(userId: string | undefined) {
const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
- return !!username ? storedCredentials.includes(username) : storedCredentials.length > 0;
+ return !!userId ? storedCredentials.includes(userId) : storedCredentials.length > 0;
}
- public static storeConfigured(username: string) {
+ public static getConfiguredUserIds() {
const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
- storedCredentials.push(username);
+ return storedCredentials;
+ }
+
+ public static setConfiguredUserId(userId: string) {
+ const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
+ storedCredentials.push(userId);
localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(storedCredentials));
}
- public static removeConfigured(username: string) {
+ public static removeConfiguredUserId(userId: string) {
const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
- localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(!!username ? storedCredentials.filter(c => c !== username) : []));
+ localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(!!userId ? storedCredentials.filter(c => c !== userId) : []));
}
@@ -63,7 +68,7 @@ class WebAuthn {
return result;
}
- public static async verifyCredential(options: PublicKeyCredentialRequestOptions) {
+ public static async getCredential(options: PublicKeyCredentialRequestOptions) {
options.challenge = WebAuthn.ToArrayBuffer(options.challenge, 'challenge');
options.allowCredentials?.forEach(function (cred) {
@@ -89,11 +94,10 @@ class WebAuthn {
response: {
authenticatorData: WebAuthn.ToBase64Url(authenticatorData),
clientDataJSON: WebAuthn.ToBase64Url(clientDataJSON),
- userHandle: userHandle !== null ? WebAuthn.ToBase64Url(userHandle) : null,
+ userHandle: userHandle ? (WebAuthn.ToBase64Url(userHandle) || null) : null,
signature: WebAuthn.ToBase64Url(signature)
}
}
-
return result;
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs
index 6f7aadb423..59183a9981 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs
@@ -1,10 +1,10 @@
//+:cnd:noEmit
using System.Text;
-using Microsoft.Extensions.Caching.Distributed;
+using Boilerplate.Server.Api.Models.Identity;
+using Boilerplate.Shared.Dtos.Identity;
using Fido2NetLib;
using Fido2NetLib.Objects;
-using Boilerplate.Shared.Dtos.Identity;
-using Boilerplate.Server.Api.Models.Identity;
+using Microsoft.Extensions.Caching.Distributed;
namespace Boilerplate.Server.Api.Controllers.Identity;
@@ -16,10 +16,19 @@ public partial class IdentityController
[HttpGet]
- public async Task GetWebAuthnAssertionOptions(CancellationToken cancellationToken)
+ public async Task GetWebAuthnAssertionOptions(WebAuthnAssertionOptionsRequestDto request, CancellationToken cancellationToken)
{
var existingKeys = new List();
+ if (request.UserIds is not null)
+ {
+ var existingCredentials = await DbContext.WebAuthnCredential.Where(c => request.UserIds.Contains(c.UserId))
+ .OrderByDescending(c => c.RegDate)
+ .Select(c => new { c.Id, c.Transports })
+ .ToArrayAsync(cancellationToken);
+ existingKeys.AddRange(existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, c.Id, c.Transports)));
+ }
+
var extensions = new AuthenticationExtensionsClientInputs
{
Extensions = true,
@@ -28,9 +37,9 @@ public async Task GetWebAuthnAssertionOptions(CancellationToke
var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams
{
- Extensions = extensions,
+ //Extensions = extensions,
AllowedCredentials = existingKeys,
- UserVerification = UserVerificationRequirement.Discouraged,
+ UserVerification = UserVerificationRequirement.Required,
});
var key = new string([.. options.Challenge.Select(b => (char)b)]);
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs
index 910c1db097..55575fbf15 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs
@@ -21,9 +21,7 @@ public async Task GetWebAuthnCredentialOptions(Cancella
?? throw new ResourceNotFoundException();
var existingCredentials = DbContext.WebAuthnCredential.Where(c => c.UserId == userId);
- var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey,
- c.Id,
- c.Transports));
+ var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, c.Id, c.Transports));
var fidoUser = new Fido2User
{
Id = Encoding.UTF8.GetBytes(userId.ToString()),
@@ -33,15 +31,12 @@ public async Task GetWebAuthnCredentialOptions(Cancella
var authenticatorSelection = new AuthenticatorSelection
{
- ResidentKey = ResidentKeyRequirement.Required,
- UserVerification = UserVerificationRequirement.Preferred
+ RequireResidentKey = false,
+ ResidentKey = ResidentKeyRequirement.Discouraged,
+ UserVerification = UserVerificationRequirement.Required,
+ AuthenticatorAttachment = AuthenticatorAttachment.Platform
};
- //var authenticatorSelection = new AuthenticatorSelection
- //{
- // AuthenticatorAttachment = AuthenticatorAttachment.Platform
- //};
-
var extensions = new AuthenticationExtensionsClientInputs
{
CredProps = true,
@@ -55,7 +50,7 @@ public async Task GetWebAuthnCredentialOptions(Cancella
ExcludeCredentials = [], //[.. existingKeys],
AuthenticatorSelection = authenticatorSelection,
AttestationPreference = AttestationConveyancePreference.None,
- Extensions = extensions
+ //Extensions = extensions
});
var key = GetWebAuthnCacheKey(userId);
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs
index 5432c6e3ad..3bd3018a9f 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs
@@ -310,9 +310,9 @@ void AddDbContext(DbContextOptionsBuilder options)
var options = new Fido2Configuration
{
+ ServerDomain = webAppUrl.Host,
TimestampDriftTolerance = 1000,
ServerName = "Boilerplate WebAuthn",
- ServerDomain = webAppUrl.Host,
Origins = new HashSet([webAppUrl.AbsoluteUri]),
ServerIcon = new Uri(webAppUrl, "images/icons/bit-logo.png").ToString()
};
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs
index aa2b7b44b0..ba36ba8f05 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs
@@ -49,7 +49,7 @@ public interface IIdentityController : IAppController
Task GetSocialSignInUri(string provider, string? returnUrl = null, int? localHttpPort = null, CancellationToken cancellationToken = default);
[HttpGet]
- Task GetWebAuthnAssertionOptions(CancellationToken cancellationToken);
+ Task GetWebAuthnAssertionOptions(WebAuthnAssertionOptionsRequestDto request, CancellationToken cancellationToken);
[HttpPost]
Task VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken);
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs
index c28ab09a1e..9c57ab6daa 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs
@@ -57,6 +57,7 @@ namespace Boilerplate.Shared.Dtos;
[JsonSerializable(typeof(CredentialCreateOptions))]
[JsonSerializable(typeof(VerifyAssertionResult))]
[JsonSerializable(typeof(VerifyWebAuthnAndSignInDto))]
+[JsonSerializable(typeof(WebAuthnAssertionOptionsRequestDto))]
public partial class AppJsonContext : JsonSerializerContext
{
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/WebAuthnAssertionOptionsRequestDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/WebAuthnAssertionOptionsRequestDto.cs
new file mode 100644
index 0000000000..dfc53066c5
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/WebAuthnAssertionOptionsRequestDto.cs
@@ -0,0 +1,6 @@
+namespace Boilerplate.Shared.Dtos.Identity;
+
+public partial class WebAuthnAssertionOptionsRequestDto
+{
+ public Guid[]? UserIds { get; set; }
+}