Skip to content

Commit a451fe8

Browse files
committed
security(auth): document and harden passkey account recovery guidance
1 parent d32edb3 commit a451fe8

4 files changed

Lines changed: 362 additions & 32 deletions

File tree

docs/guides/authentication-guide.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,32 @@ The application supports WebAuthn/FIDO2 for passwordless login. This flow is ful
327327

328328
See [Passkey Guide](passkey-guide.md) for implementation details.
329329

330+
### Passkey Account Recovery
331+
332+
The API enforces a hard invariant: an account must always keep at least one sign-in factor available (password or passkey).
333+
334+
Supported recovery flows:
335+
336+
1. **User is still signed in on a trusted device**
337+
- Add a password using `POST /account/add-password`.
338+
- Register a replacement passkey.
339+
- Remove the old passkey only after a replacement factor exists.
340+
341+
2. **User has another passkey**
342+
- Sign in with the backup passkey.
343+
- Register any needed replacement passkey.
344+
- Remove the old passkey only after confirming another factor is available.
345+
346+
3. **User lost all passkeys and has no password**
347+
- Self-service recovery is not available.
348+
- Admin-assisted recovery is required.
349+
- There is currently no dedicated admin API endpoint for passkey re-enrollment recovery.
350+
- Operations/support must perform manual recovery and keep the account locked until the user re-enrolls at least one authentication factor.
351+
352+
For safety, two endpoint guards return actionable RFC 7807 responses:
353+
- `POST /account/remove-password` is blocked when the user has zero passkeys.
354+
- `DELETE /account/passkeys/{id}` is blocked when deleting the last passkey on an account with no password.
355+
330356
## State Management & Re-Authentication
331357

332358
### In-Memory Token Storage

src/BookStore.ApiService/Endpoints/JwtAuthenticationEndpoints.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,14 @@ static async Task<IResult> RemovePasswordAsync(
536536
var passkeys = await passkeyStore.GetPasskeysAsync(appUser, cancellationToken);
537537
if (passkeys.Count == 0)
538538
{
539-
return Result.Failure(Error.Validation(ErrorCodes.Auth.InvalidRequest, "You must have at least one passkey registered to remove your password.")).ToProblemDetails();
539+
return Results.Problem(
540+
statusCode: StatusCodes.Status400BadRequest,
541+
title: "Cannot Remove Password Without a Passkey",
542+
detail: "To keep account recovery available, register at least one passkey before removing your password. If you cannot register a passkey right now, keep your password and contact an administrator for recovery assistance.",
543+
extensions: new Dictionary<string, object?>
544+
{
545+
{ "error", ErrorCodes.Auth.InvalidRequest }
546+
});
540547
}
541548

542549
var result = await userManager.RemovePasswordAsync(appUser);

src/BookStore.ApiService/Endpoints/PasskeyEndpoints.cs

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -476,47 +476,70 @@ public static IEndpointRouteBuilder MapPasskeyEndpoints(this IEndpointRouteBuild
476476
HybridCache cache,
477477
ITenantContext tenantContext,
478478
IUserStore<ApplicationUser> userStore,
479-
CancellationToken cancellationToken) =>
479+
CancellationToken cancellationToken) => await DeletePasskeyAsync(
480+
id,
481+
context.User,
482+
userManager,
483+
cache,
484+
tenantContext,
485+
userStore,
486+
cancellationToken)).RequireAuthorization().RequireRateLimiting("AuthPolicy");
487+
488+
return endpoints;
489+
}
490+
491+
static async Task<IResult> DeletePasskeyAsync(
492+
string id,
493+
ClaimsPrincipal principal,
494+
UserManager<ApplicationUser> userManager,
495+
HybridCache cache,
496+
ITenantContext tenantContext,
497+
IUserStore<ApplicationUser> userStore,
498+
CancellationToken cancellationToken)
499+
{
500+
var user = await userManager.GetUserAsync(principal);
501+
if (user is null)
480502
{
481-
var user = await userManager.GetUserAsync(context.User);
482-
if (user is null)
483-
{
484-
return Result.Failure(Error.Unauthorized(ErrorCodes.Auth.InvalidToken, "User not found.")).ToProblemDetails();
485-
}
503+
return Result.Failure(Error.Unauthorized(ErrorCodes.Auth.InvalidToken, "User not found.")).ToProblemDetails();
504+
}
486505

487-
try
506+
try
507+
{
508+
var credentialId = Base64UrlTextEncoder.Decode(id);
509+
if (userStore is IUserPasskeyStore<ApplicationUser> passkeyStore)
488510
{
489-
var credentialId = Base64UrlTextEncoder.Decode(id);
490-
if (userStore is IUserPasskeyStore<ApplicationUser> passkeyStore)
511+
// Prevent deleting last passkey if user has no password.
512+
var passkeys = await passkeyStore.GetPasskeysAsync(user, cancellationToken);
513+
if (passkeys.Count <= 1)
491514
{
492-
// Prevent deleting last passkey if user has no password
493-
var passkeys = await passkeyStore.GetPasskeysAsync(user, cancellationToken);
494-
if (passkeys.Count <= 1)
515+
var hasPassword = await userManager.HasPasswordAsync(user);
516+
if (!hasPassword)
495517
{
496-
var hasPassword = await userManager.HasPasswordAsync(user);
497-
if (!hasPassword)
498-
{
499-
return Result.Failure(Error.Validation(ErrorCodes.Passkey.LastPasskey, "Cannot delete your only passkey. You would be locked out of your account. Set a password first.")).ToProblemDetails();
500-
}
518+
return Results.Problem(
519+
statusCode: StatusCodes.Status400BadRequest,
520+
title: "Cannot Remove Your Last Passkey",
521+
detail: "This account has no password. Add a password while signed in, then register a replacement passkey before removing this one. If you have already lost access to all factors, contact an administrator for account recovery and re-enrollment.",
522+
extensions: new Dictionary<string, object?>
523+
{
524+
{ "error", ErrorCodes.Passkey.LastPasskey }
525+
});
501526
}
502-
503-
await passkeyStore.RemovePasskeyAsync(user, credentialId, cancellationToken);
504-
// UpdateSecurityStampAsync persists the passkey removal AND rotates the
505-
// security stamp, which immediately invalidates any existing JWTs.
506-
_ = await userManager.UpdateSecurityStampAsync(user);
507-
await SecurityStampCache.InvalidateAsync(cache, tenantContext.TenantId, user.Id, cancellationToken);
508-
return Results.Ok(new { Message = "Passkey deleted." });
509527
}
510528

511-
return Result.Failure(Error.Validation(ErrorCodes.Passkey.StoreNotAvailable, "Passkey store not available.")).ToProblemDetails();
512-
}
513-
catch (FormatException)
514-
{
515-
return Result.Failure(Error.Validation(ErrorCodes.Passkey.InvalidFormat, "Invalid passkey ID format.")).ToProblemDetails();
529+
await passkeyStore.RemovePasskeyAsync(user, credentialId, cancellationToken);
530+
// UpdateSecurityStampAsync persists the passkey removal AND rotates the
531+
// security stamp, which immediately invalidates any existing JWTs.
532+
_ = await userManager.UpdateSecurityStampAsync(user);
533+
await SecurityStampCache.InvalidateAsync(cache, tenantContext.TenantId, user.Id, cancellationToken);
534+
return Results.Ok(new { Message = "Passkey deleted." });
516535
}
517-
}).RequireAuthorization().RequireRateLimiting("AuthPolicy");
518536

519-
return endpoints;
537+
return Result.Failure(Error.Validation(ErrorCodes.Passkey.StoreNotAvailable, "Passkey store not available.")).ToProblemDetails();
538+
}
539+
catch (FormatException)
540+
{
541+
return Result.Failure(Error.Validation(ErrorCodes.Passkey.InvalidFormat, "Invalid passkey ID format.")).ToProblemDetails();
542+
}
520543
}
521544

522545
/// <summary>

0 commit comments

Comments
 (0)