From 953b2e8301e001b148b6eadf320669df841d91c7 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Sat, 12 Sep 2026 02:40:53 +0200 Subject: [PATCH 01/14] Add Identity REST API tracker Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IDENTITY_API_CAPABILITIES.md | 81 ++++++++++++++++ .../IDENTITY_API_IMPLEMENTATION.md | 92 +++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md create mode 100644 10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md diff --git a/10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md b/10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md new file mode 100644 index 000000000..03c14a420 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md @@ -0,0 +1,81 @@ +# ASP.NET Core Identity REST capability matrix + +This ledger compares the hosted Razor `/Account/*` UI with the stock +`MapIdentityApi` contract and the contribution-oriented REST additions planned +for this sample. The REST routes are intentionally first-party and use only +public `UserManager` and `SignInManager` APIs. + +## Support summary + +### Stock APIs fully consumable by portable clients + +| Capability | Stock route | MAUI usage | Support | +| --- | --- | --- | --- | +| Register | `POST /identity/register` | Account registration | Planned | +| Password login and tokens | `POST /identity/login?useCookies=false` | Typed token client | Planned | +| Refresh | `POST /identity/refresh` | Serialized token refresh | Planned | +| Confirm/resend email | `/identity/confirmEmail`, `POST /identity/resendConfirmationEmail` | Confirmation UI | Planned | +| Forgot/reset password | `POST /identity/forgotPassword`, `POST /identity/resetPassword` | Reset UI | Planned | +| Profile/email/password | `GET/POST /identity/manage/info` | Profile editor | Planned | +| Authenticator and recovery codes | `POST /identity/manage/2fa` | 2FA editor | Planned | + +### Partial stock APIs proposed for override + +| Capability | Stock limitation | Override route | Support | +| --- | --- | --- | --- | +| Login outcomes | Generic unsuccessful response does not expose a stable outcome | `POST /identity-overrides/login` | Pending | +| Manage info | Does not return phone, password, authenticators, passkeys, recovery code count, or external providers | `GET /identity-overrides/manage/info` | Pending | +| Manage info mutations | Cannot set phone or add a first local password; combination semantics are opaque | `POST /identity-overrides/manage/info` | Pending | +| 2FA inspection | Stock handler is mutating/configuration-oriented | `GET /identity-overrides/manage/2fa` | Pending | + +### Missing stock APIs proposed as new routes + +| Capability | New route(s) | Support | +| --- | --- | --- | +| Passkey list/manage | `GET/PATCH/DELETE /identity/manage/passkeys` | Pending | +| Passkey registration and login | `/identity/passkeys/register/*`, `/identity/passkeys/login/*` | Pending | +| Filtered personal data | `GET /identity/manage/personal-data` | Pending | +| Delete account | `DELETE /identity/manage/account` | Pending | +| Invalidate refresh sessions | `POST /identity/manage/logout-all` | Pending | +| List/unlink external logins | `GET/DELETE /identity/manage/external-logins` | Pending | + +## Hosted Account feature inventory + +| Hosted Razor source | UserManager/SignInManager API | Stock endpoint | REST route/action | MAUI usage | Support/security/tests | +| --- | --- | --- | --- | --- | --- | +| `Pages/Register.razor` | `CreateAsync`, `GenerateEmailConfirmationTokenAsync` | `POST /identity/register` | Stock | Registration | Planned; development notifications only, test registration/confirmation | +| `Pages/ConfirmEmail.razor` | `ConfirmEmailAsync` | `GET /identity/confirmEmail` | Stock | Confirmation action | Planned; test valid/invalid token | +| `Pages/ResendEmailConfirmation.razor` | `GenerateEmailConfirmationTokenAsync` | `POST /identity/resendConfirmationEmail` | Stock | Resend action | Planned; do not reveal account existence | +| `Pages/Login.razor` | `PasswordSignInAsync` | `POST /identity/login` | Override login for stable outcomes | Login and continuation | Pending; bearer only, test invalid/lockout/not-allowed | +| `Pages/LoginWith2fa.razor` | `TwoFactorAuthenticatorSignInAsync` | `POST /identity/login` | Override login | Authenticator continuation | Pending; test TOTP | +| `Pages/LoginWithRecoveryCode.razor` | `TwoFactorRecoveryCodeSignInAsync` | `POST /identity/login` | Override login | Recovery continuation | Pending; never log recovery codes | +| `Pages/ForgotPassword.razor` | `GeneratePasswordResetTokenAsync` | `POST /identity/forgotPassword` | Stock | Reset request | Planned; bounded dev notification | +| `Pages/ResetPassword.razor` | `ResetPasswordAsync` | `POST /identity/resetPassword` | Stock | Reset completion | Planned; test reset/login | +| `Pages/Manage/Index.razor` | `GetUserNameAsync` | `GET /identity/manage/info` | Override extended info | Profile summary | Pending; bearer isolation | +| `Pages/Manage/Email.razor` | `SetEmailAsync`, `GenerateChangeEmailTokenAsync` | `POST /identity/manage/info` | Override extended info | Email/phone editing | Pending; test confirmation semantics | +| `Pages/Manage/ChangePassword.razor` | `ChangePasswordAsync` | `POST /identity/manage/info` | Override extended info | Change password | Pending; current password required | +| `Pages/Manage/SetPassword.razor` | `AddPasswordAsync` | None | Override info | Set first password | Pending; reject ambiguous mutations | +| `Pages/Manage/TwoFactorAuthentication.razor` | `GetTwoFactorEnabledAsync`, `CountRecoveryCodesAsync` | `POST /identity/manage/2fa` | `GET /identity-overrides/manage/2fa` | 2FA status | Pending; non-mutating read | +| `Pages/Manage/EnableAuthenticator.razor` | `ResetAuthenticatorKeyAsync`, `VerifyTwoFactorTokenAsync`, `SetTwoFactorEnabledAsync` | `POST /identity/manage/2fa` | Stock | Setup/verify | Planned; do not expose shared key in status API | +| `Pages/Manage/Disable2fa.razor` | `SetTwoFactorEnabledAsync` | `POST /identity/manage/2fa` | Stock | Disable | Planned; test bearer auth | +| `Pages/Manage/ResetAuthenticator.razor` | `SetTwoFactorEnabledAsync`, `ResetAuthenticatorKeyAsync` | `POST /identity/manage/2fa` | Stock | Reset | Planned; test regeneration | +| `Pages/Manage/GenerateRecoveryCodes.razor` | `GenerateNewTwoFactorRecoveryCodesAsync` | `POST /identity/manage/2fa` | Stock | Recovery display | Planned; never persist or log | +| `Pages/Manage/Passkeys.razor`, `RenamePasskey.razor` | Passkey list/update/remove APIs | None | New passkey routes | List/rename/remove | Pending; link `dotnet/maui#36837`, JSON-in/JSON-out prior to .NET 11 native API | +| `Pages/Manage/PersonalData.razor` | `GetUserIdAsync` | None | New personal-data route | Data summary | Pending; explicit DTO excludes secrets and provider keys | +| `Pages/Manage/DeletePersonalData.razor` | `HasPasswordAsync`, `CheckPasswordAsync`, `DeleteAsync` | None | New delete route | Account deletion | Pending; password or recent-auth requirement | +| `Pages/Manage/ExternalLogins.razor` | `GetLoginsAsync`, `RemoveLoginAsync` | None | New external-login routes | List/unlink | Pending; cannot remove final sign-in method | +| `Pages/ExternalLogin.razor` | `ConfigureExternalAuthenticationProperties`, `ExternalLoginSignInAsync` | None | Deferred | Display only | Deferred until a real provider is configured | +| `Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs` | `SignOutAsync` | None | New logout-all route | Local logout/logout all | Pending; access ticket remains valid until expiry | + +## Contribution notes + +The passkey server endpoints follow the official Identity API pattern used by +[dotnet/maui#36837](https://github.com/dotnet/maui/pull/36837). The net10 +client exchanges ceremony options and responses as JSON and clearly reports that +the native `.NET 11 Passkeys API` is forthcoming. It keeps the server contract +stable while a client seam later calls `Microsoft.Maui.Authentication.Passkeys` +for `CreateAsync` and `AssertAsync`. + +The generic endpoint library never maps an existing method/path pair on +`/identity`; enhanced handlers live under `/identity-overrides` so callers can +compare stock and proposed behavior without route collisions. diff --git a/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md new file mode 100644 index 000000000..196426c28 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md @@ -0,0 +1,92 @@ +# Identity REST API implementation tracker + +## Goal and non-goals + +This sample demonstrates a portable, first-party REST client for ASP.NET Core +Identity. MAUI and other native clients use the same JSON contracts while the +server owns identity business rules. Authentication uses the in-box opaque +bearer access and refresh tokens. + +It deliberately does **not** add OpenIddict, OAuth/OIDC flows, third-party +authentication packages, custom JWTs, custom token issuance, or a fake external +provider. + +## Source and architecture decisions + +| Item | Value | +| --- | --- | +| Base commit | `567732e0a78d2c1b2a22c3677f86c673d7527bed` (`origin/main`) | +| Current commit | Uncommitted tracker baseline | +| Stock API | `app.MapGroup("/identity").MapIdentityApi()` | +| New endpoints | `MauiBlazorWeb.IdentityApi.MapNewIdentityApi()`, mapped on `/identity` only for stock-absent routes | +| Overrides | `MapOverrideIdentityApi()`, mapped only under `/identity-overrides` | +| Native authorization | Explicit `IdentityConstants.BearerScheme`; the application cookie remains for `/Account/*` | +| Tokens | ASP.NET Core Data Protection opaque tickets; access defaults to one hour, refresh to 14 days | + +The stock bearer refresh token is reusable. There is no replay detection, device +registry, individual token revocation, or immediate access-token revocation. +`logout-all` updates the security stamp, which invalidates refresh but leaves +issued access tokens valid until their expiry. Production deployments require +shared, persistent Data Protection keys. + +## Phase status + +| Phase | Status | Scope | +| --- | --- | --- | +| 0 | In progress | Tracker, capability ledger, project inventory | +| 1 | Pending | Stock endpoint typed MAUI client, durable token lifecycle, account UI | +| 2 | Pending | Generic override endpoint library and `/identity-overrides` client use | +| 3 | Pending | Generic new endpoint library: passkeys, personal data, deletion, external logins, logout-all | +| 4 | Pending | Development notification UI, integration tests, platform validation | + +## Endpoint ledger + +| Endpoint group | Endpoint | Status | Notes | +| --- | --- | --- | --- | +| Stock | `/identity/*` | Existing | MapIdentityApi remains the direct comparison surface | +| Override | `/identity-overrides/login` | Pending | Stable failure codes | +| Override | `/identity-overrides/manage/info` | Pending | Extended profile and unambiguous mutations | +| Override | `/identity-overrides/manage/2fa` | Pending | Read-only 2FA status | +| New | `/identity/manage/passkeys` | Pending | List, rename, remove | +| New | `/identity/passkeys/*` | Pending | Official Identity ceremony APIs and temporary cookie continuity | +| New | `/identity/manage/personal-data` | Pending | Explicit filtered DTO | +| New | `/identity/manage/account` | Pending | Password/recent-auth deletion protection | +| New | `/identity/manage/logout-all` | Pending | Security-stamp refresh invalidation | +| New | `/identity/manage/external-logins` | Pending | List/unlink with last-method safeguard | + +## Client feature ledger + +| Feature | Status | Intended client surface | +| --- | --- | --- | +| Password register/login/refresh | Existing partial | Replace legacy provider with typed client | +| Email confirmation and password reset | Pending | Account pages | +| Profile, email, phone, passwords | Pending | Account pages | +| Authenticator and recovery codes | Pending | Account pages | +| Passkey management | Pending | JSON preview with `.NET 11 Passkeys API coming soon` | +| Personal data/deletion | Pending | Account pages | +| External logins and logout-all | Pending | Account pages | + +## Validation ledger + +| Command | Result | +| --- | --- | +| `dotnet --info` | SDK 11 preview and .NET 10 SDK/runtime installed | +| `dotnet build MauiBlazorWeb.sln` | Pending | +| Web, Mac Catalyst, iOS, Android builds | Pending | +| Microsoft-only integration tests | Pending project creation | + +## Known blockers and deferred work + +* The current worktree has only the main sample's legacy MAUI login client. No + endpoint extension library or integration-test project exists yet. +* Native passkey ceremony execution is deferred behind a small client seam until + the .NET 11 MAUI passkey APIs are used. The net10 UI will inspect the server's + sanitized options JSON only. +* Browser external-provider login/link completion is designed but deferred until + a real provider is configured. + +## Resume instructions + +**Next task:** create the `MauiBlazorWeb.IdentityApi` net10.0 framework-reference +library, add it to the solution and web project, and implement the generic +`MapOverrideIdentityApi()` login endpoint with typed stable failures. From 3183f04267756ef1ca6ac0160b6f71f3893e36ec Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Sat, 12 Sep 2026 03:16:53 +0200 Subject: [PATCH 02/14] Add Identity API override endpoints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IDENTITY_API_IMPLEMENTATION.md | 11 +- ...entityApiEndpointRouteBuilderExtensions.cs | 304 ++++++++++++++++++ .../MauiBlazorWeb.IdentityApi.csproj | 13 + .../MauiBlazorWeb.Web.csproj | 1 + .../MauiBlazorWeb.Web/Program.cs | 12 +- 10.0/MauiBlazorWebIdentity/MauiBlazorWeb.sln | 6 + 6 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs create mode 100644 10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/MauiBlazorWeb.IdentityApi.csproj diff --git a/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md index 196426c28..94531e6f2 100644 --- a/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md +++ b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md @@ -16,7 +16,7 @@ provider. | Item | Value | | --- | --- | | Base commit | `567732e0a78d2c1b2a22c3677f86c673d7527bed` (`origin/main`) | -| Current commit | Uncommitted tracker baseline | +| Current commit | `953b2e8` tracker baseline; endpoint-library increment in progress | | Stock API | `app.MapGroup("/identity").MapIdentityApi()` | | New endpoints | `MauiBlazorWeb.IdentityApi.MapNewIdentityApi()`, mapped on `/identity` only for stock-absent routes | | Overrides | `MapOverrideIdentityApi()`, mapped only under `/identity-overrides` | @@ -33,9 +33,9 @@ shared, persistent Data Protection keys. | Phase | Status | Scope | | --- | --- | --- | -| 0 | In progress | Tracker, capability ledger, project inventory | +| 0 | Complete | Tracker, capability ledger, project inventory | | 1 | Pending | Stock endpoint typed MAUI client, durable token lifecycle, account UI | -| 2 | Pending | Generic override endpoint library and `/identity-overrides` client use | +| 2 | In progress | Generic override endpoint library and `/identity-overrides` client use | | 3 | Pending | Generic new endpoint library: passkeys, personal data, deletion, external logins, logout-all | | 4 | Pending | Development notification UI, integration tests, platform validation | @@ -87,6 +87,5 @@ shared, persistent Data Protection keys. ## Resume instructions -**Next task:** create the `MauiBlazorWeb.IdentityApi` net10.0 framework-reference -library, add it to the solution and web project, and implement the generic -`MapOverrideIdentityApi()` login endpoint with typed stable failures. +**Next task:** test the new override routes against an in-memory host, then +replace the legacy MAUI authentication provider with the typed stock REST client. diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..0c54fbcb0 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs @@ -0,0 +1,304 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.BearerToken; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; + +namespace MauiBlazorWeb.IdentityApi; + +/// +/// Maps proposed Identity API routes that are intentionally separate from the +/// framework's stock MapIdentityApi routes. +/// +public static class IdentityApiEndpointRouteBuilderExtensions +{ + private static readonly EmailAddressAttribute EmailAddress = new(); + + /// + /// Maps enhanced equivalents of selected stock Identity API routes. + /// Map these endpoints on a separately prefixed route group. + /// + public static IEndpointRouteBuilder MapOverrideIdentityApi(this IEndpointRouteBuilder endpoints) + where TUser : class + { + ArgumentNullException.ThrowIfNull(endpoints); + + var group = endpoints.MapGroup("") + .WithTags("Identity overrides"); + var bearerOnly = new AuthorizeAttribute + { + AuthenticationSchemes = IdentityConstants.BearerScheme, + }; + + group.MapPost("/login", async Task>> + ([FromBody] LoginRequest login, [FromServices] IServiceProvider services) => + { + var signInManager = services.GetRequiredService>(); + signInManager.AuthenticationScheme = IdentityConstants.BearerScheme; + + var result = await signInManager.PasswordSignInAsync( + login.Email, + login.Password, + isPersistent: false, + lockoutOnFailure: true); + + if (result.RequiresTwoFactor) + { + if (!string.IsNullOrWhiteSpace(login.TwoFactorCode)) + { + result = await signInManager.TwoFactorAuthenticatorSignInAsync( + login.TwoFactorCode, + isPersistent: false, + rememberClient: false); + } + else if (!string.IsNullOrWhiteSpace(login.TwoFactorRecoveryCode)) + { + result = await signInManager.TwoFactorRecoveryCodeSignInAsync(login.TwoFactorRecoveryCode); + } + } + + if (result.Succeeded) + { + // The in-box bearer handler writes AccessTokenResponse when this + // request signs in using IdentityConstants.BearerScheme. + return TypedResults.Empty; + } + + return TypedResults.Json(new LoginFailureResponse(GetLoginFailureCode(result)), statusCode: StatusCodes.Status401Unauthorized); + }) + .WithName("IdentityOverridesLogin") + .WithSummary("Signs in with an opaque bearer token and stable failures.") + .Produces() + .Produces(StatusCodes.Status401Unauthorized); + + group.MapGet("/manage/info", async Task, NotFound>> + (ClaimsPrincipal principal, [FromServices] IServiceProvider services) => + { + var userManager = services.GetRequiredService>(); + var user = await userManager.GetUserAsync(principal); + return user is null + ? TypedResults.NotFound() + : TypedResults.Ok(await CreateExtendedInfoResponseAsync(user, userManager)); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityOverridesManageInfo") + .WithSummary("Gets an extended, non-secret account profile.") + .Produces(); + + group.MapPost("/manage/info", async Task, ValidationProblem, NotFound>> + (ClaimsPrincipal principal, [FromBody] ExtendedInfoRequest request, HttpContext context, [FromServices] IServiceProvider services) => + { + var userManager = services.GetRequiredService>(); + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + var changes = new[] + { + !string.IsNullOrWhiteSpace(request.NewEmail), + !string.IsNullOrWhiteSpace(request.NewPassword), + request.PhoneNumber is not null, + }; + + if (changes.Count(change => change) != 1) + { + return CreateValidationProblem( + "AmbiguousOperation", + "Specify exactly one of newEmail, newPassword, or phoneNumber."); + } + + if (!string.IsNullOrWhiteSpace(request.NewEmail)) + { + if (!EmailAddress.IsValid(request.NewEmail)) + { + return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(request.NewEmail))); + } + + var currentEmail = await userManager.GetEmailAsync(user); + if (!string.Equals(currentEmail, request.NewEmail, StringComparison.OrdinalIgnoreCase)) + { + var code = await userManager.GenerateChangeEmailTokenAsync(user, request.NewEmail); + var userId = await userManager.GetUserIdAsync(user); + var confirmationUrl = BuildConfirmationUrl(context, userId, code, request.NewEmail); + var emailSender = services.GetRequiredService>(); + await emailSender.SendConfirmationLinkAsync(user, request.NewEmail, confirmationUrl); + } + } + else if (!string.IsNullOrWhiteSpace(request.NewPassword)) + { + IdentityResult passwordResult; + if (await userManager.HasPasswordAsync(user)) + { + if (string.IsNullOrWhiteSpace(request.OldPassword)) + { + return CreateValidationProblem( + "OldPasswordRequired", + "The old password is required to set a new password. Use password reset if it is unavailable."); + } + + passwordResult = await userManager.ChangePasswordAsync(user, request.OldPassword, request.NewPassword); + } + else + { + passwordResult = await userManager.AddPasswordAsync(user, request.NewPassword); + } + + if (!passwordResult.Succeeded) + { + return CreateValidationProblem(passwordResult); + } + } + else + { + var phoneResult = await userManager.SetPhoneNumberAsync(user, request.PhoneNumber!); + if (!phoneResult.Succeeded) + { + return CreateValidationProblem(phoneResult); + } + } + + return TypedResults.Ok(await CreateExtendedInfoResponseAsync(user, userManager)); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityOverridesUpdateManageInfo") + .WithSummary("Changes exactly one extended account property.") + .Produces() + .ProducesValidationProblem(); + + group.MapGet("/manage/2fa", async Task, NotFound>> + (ClaimsPrincipal principal, [FromServices] IServiceProvider services) => + { + var signInManager = services.GetRequiredService>(); + var user = await signInManager.UserManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + var userManager = signInManager.UserManager; + var authenticatorKey = await userManager.GetAuthenticatorKeyAsync(user); + return TypedResults.Ok(new TwoFactorStatusResponse( + await userManager.GetTwoFactorEnabledAsync(user), + !string.IsNullOrEmpty(authenticatorKey), + await userManager.CountRecoveryCodesAsync(user), + await signInManager.IsTwoFactorClientRememberedAsync(user))); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityOverridesTwoFactorStatus") + .WithSummary("Gets two-factor status without generating or revealing a shared key.") + .Produces(); + + return endpoints; + } + + /// + /// Maps portable account routes that do not duplicate a stock Identity API route. + /// + public static IEndpointRouteBuilder MapNewIdentityApi(this IEndpointRouteBuilder endpoints) + where TUser : class + { + ArgumentNullException.ThrowIfNull(endpoints); + return endpoints.MapGroup("").WithTags("Identity extensions"); + } + + private static string GetLoginFailureCode(Microsoft.AspNetCore.Identity.SignInResult result) => + result.RequiresTwoFactor ? LoginFailureCodes.RequiresTwoFactor : + result.IsLockedOut ? LoginFailureCodes.LockedOut : + result.IsNotAllowed ? LoginFailureCodes.NotAllowed : + LoginFailureCodes.InvalidCredentials; + + private static async Task CreateExtendedInfoResponseAsync( + TUser user, + UserManager userManager) + where TUser : class + { + var passkeys = await userManager.GetPasskeysAsync(user); + var logins = await userManager.GetLoginsAsync(user); + var authenticatorKey = await userManager.GetAuthenticatorKeyAsync(user); + + return new ExtendedInfoResponse( + await userManager.GetEmailAsync(user), + await userManager.IsEmailConfirmedAsync(user), + await userManager.GetPhoneNumberAsync(user), + await userManager.HasPasswordAsync(user), + await userManager.GetTwoFactorEnabledAsync(user), + !string.IsNullOrEmpty(authenticatorKey), + await userManager.CountRecoveryCodesAsync(user), + passkeys.Count, + logins.Select(login => login.LoginProvider).Distinct(StringComparer.Ordinal).ToArray()); + } + + private static string BuildConfirmationUrl(HttpContext context, string userId, string code, string changedEmail) + { + var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + return $"{context.Request.Scheme}://{context.Request.Host}/identity/confirmEmail?userId={Uri.EscapeDataString(userId)}&code={Uri.EscapeDataString(encodedCode)}&changedEmail={Uri.EscapeDataString(changedEmail)}"; + } + + private static ValidationProblem CreateValidationProblem(IdentityResult result) => + TypedResults.ValidationProblem(result.Errors + .GroupBy(error => error.Code) + .ToDictionary( + group => group.Key, + group => group.Select(error => error.Description).ToArray())); + + private static ValidationProblem CreateValidationProblem(string code, string description) => + TypedResults.ValidationProblem(new Dictionary + { + [code] = [description], + }); +} + +/// Stable machine-readable values returned for unsuccessful override login attempts. +public static class LoginFailureCodes +{ + public const string InvalidCredentials = "invalid_credentials"; + public const string RequiresTwoFactor = "requires_two_factor"; + public const string LockedOut = "locked_out"; + public const string NotAllowed = "not_allowed"; +} + +/// Failure response for /identity-overrides/login. +public sealed record LoginFailureResponse(string Code); + +/// Supported extended account mutations. +public sealed class ExtendedInfoRequest +{ + public string? NewEmail { get; init; } + + public string? OldPassword { get; init; } + + public string? NewPassword { get; init; } + + public string? PhoneNumber { get; init; } +} + +/// Non-secret extended account information. +public sealed record ExtendedInfoResponse( + string? Email, + bool IsEmailConfirmed, + string? PhoneNumber, + bool HasPassword, + bool IsTwoFactorEnabled, + bool HasAuthenticator, + int RecoveryCodesLeft, + int PasskeyCount, + string[] ExternalLoginProviders); + +/// Non-mutating two-factor status. +public sealed record TwoFactorStatusResponse( + bool IsTwoFactorEnabled, + bool HasAuthenticator, + int RecoveryCodesLeft, + bool IsMachineRemembered); diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/MauiBlazorWeb.IdentityApi.csproj b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/MauiBlazorWeb.IdentityApi.csproj new file mode 100644 index 000000000..6084832bc --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/MauiBlazorWeb.IdentityApi.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/MauiBlazorWeb.Web.csproj b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/MauiBlazorWeb.Web.csproj index 376d63d6e..6431d0f87 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/MauiBlazorWeb.Web.csproj +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/MauiBlazorWeb.Web.csproj @@ -17,6 +17,7 @@ + diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs index 8304102ae..648111f83 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs @@ -3,7 +3,9 @@ using MauiBlazorWeb.Web.Components.Account; using MauiBlazorWeb.Web.Data; using MauiBlazorWeb.Web.Services; +using MauiBlazorWeb.IdentityApi; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -81,7 +83,10 @@ .AddAdditionalAssemblies(typeof(MauiBlazorWeb.Shared._Imports).Assembly); // Needed for external clients to log in -app.MapGroup("/identity").MapIdentityApi(); +var identity = app.MapGroup("/identity"); +identity.MapIdentityApi(); +identity.MapNewIdentityApi(); +app.MapGroup("/identity-overrides").MapOverrideIdentityApi(); // Needed for Identity Blazor components app.MapAdditionalIdentityEndpoints(); @@ -90,6 +95,9 @@ { var forecasts = await weatherService.GetWeatherForecastsAsync(); return Results.Ok(forecasts); -}).RequireAuthorization(); +}).RequireAuthorization(new AuthorizeAttribute +{ + AuthenticationSchemes = IdentityConstants.BearerScheme, +}); app.Run(); diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.sln b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.sln index 694fa7885..b131e3c5c 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.sln +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.sln @@ -8,6 +8,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWeb.Shared", "Mau EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWeb.Web", "MauiBlazorWeb.Web\MauiBlazorWeb.Web.csproj", "{602A0A61-85A3-4373-898C-FBE285A5D09A}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWeb.IdentityApi", "MauiBlazorWeb.IdentityApi\MauiBlazorWeb.IdentityApi.csproj", "{CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {602A0A61-85A3-4373-898C-FBE285A5D09A}.Debug|Any CPU.Build.0 = Debug|Any CPU {602A0A61-85A3-4373-898C-FBE285A5D09A}.Release|Any CPU.ActiveCfg = Release|Any CPU {602A0A61-85A3-4373-898C-FBE285A5D09A}.Release|Any CPU.Build.0 = Release|Any CPU + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 9786023e31bc043cd050bc7511c9d663a333be86 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Sat, 12 Sep 2026 03:24:49 +0200 Subject: [PATCH 03/14] Add portable Identity account endpoints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IDENTITY_API_IMPLEMENTATION.md | 35 +- ...entityApiEndpointRouteBuilderExtensions.cs | 417 +++++++++++++++++- .../Account/DevelopmentEmailSender.cs | 103 +++++ .../MauiBlazorWeb.Web/Program.cs | 21 +- .../appsettings.Development.json | 6 + 10.0/MauiBlazorWebIdentity/README.md | 23 +- 6 files changed, 585 insertions(+), 20 deletions(-) create mode 100644 10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Components/Account/DevelopmentEmailSender.cs diff --git a/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md index 94531e6f2..020f97799 100644 --- a/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md +++ b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md @@ -16,7 +16,7 @@ provider. | Item | Value | | --- | --- | | Base commit | `567732e0a78d2c1b2a22c3677f86c673d7527bed` (`origin/main`) | -| Current commit | `953b2e8` tracker baseline; endpoint-library increment in progress | +| Current commit | `3183f04` generic override endpoint increment; portable endpoint and development-notification increment in progress | | Stock API | `app.MapGroup("/identity").MapIdentityApi()` | | New endpoints | `MauiBlazorWeb.IdentityApi.MapNewIdentityApi()`, mapped on `/identity` only for stock-absent routes | | Overrides | `MapOverrideIdentityApi()`, mapped only under `/identity-overrides` | @@ -36,23 +36,23 @@ shared, persistent Data Protection keys. | 0 | Complete | Tracker, capability ledger, project inventory | | 1 | Pending | Stock endpoint typed MAUI client, durable token lifecycle, account UI | | 2 | In progress | Generic override endpoint library and `/identity-overrides` client use | -| 3 | Pending | Generic new endpoint library: passkeys, personal data, deletion, external logins, logout-all | -| 4 | Pending | Development notification UI, integration tests, platform validation | +| 3 | In progress | Generic new endpoint library: passkeys, personal data, deletion, external logins, logout-all | +| 4 | In progress | Development notification UI, integration tests, platform validation | ## Endpoint ledger | Endpoint group | Endpoint | Status | Notes | | --- | --- | --- | --- | | Stock | `/identity/*` | Existing | MapIdentityApi remains the direct comparison surface | -| Override | `/identity-overrides/login` | Pending | Stable failure codes | -| Override | `/identity-overrides/manage/info` | Pending | Extended profile and unambiguous mutations | -| Override | `/identity-overrides/manage/2fa` | Pending | Read-only 2FA status | -| New | `/identity/manage/passkeys` | Pending | List, rename, remove | -| New | `/identity/passkeys/*` | Pending | Official Identity ceremony APIs and temporary cookie continuity | -| New | `/identity/manage/personal-data` | Pending | Explicit filtered DTO | -| New | `/identity/manage/account` | Pending | Password/recent-auth deletion protection | -| New | `/identity/manage/logout-all` | Pending | Security-stamp refresh invalidation | -| New | `/identity/manage/external-logins` | Pending | List/unlink with last-method safeguard | +| Override | `/identity-overrides/login` | Complete | Stable failure codes | +| Override | `/identity-overrides/manage/info` | Complete | Extended profile and unambiguous mutations | +| Override | `/identity-overrides/manage/2fa` | Complete | Read-only 2FA status | +| New | `/identity/manage/passkeys` | Complete | List, rename, remove | +| New | `/identity/passkeys/*` | Complete | Official Identity ceremony APIs and temporary cookie continuity | +| New | `/identity/manage/personal-data` | Complete | Explicit filtered DTO | +| New | `/identity/manage/account` | Partial | Password accounts supported; passwordless deletion explicitly requires an unimplemented recent interactive reauthentication flow | +| New | `/identity/manage/logout-all` | Complete | Security-stamp refresh invalidation | +| New | `/identity/manage/external-logins` | Complete | List/unlink with last-method safeguard | ## Client feature ledger @@ -71,14 +71,17 @@ shared, persistent Data Protection keys. | Command | Result | | --- | --- | | `dotnet --info` | SDK 11 preview and .NET 10 SDK/runtime installed | -| `dotnet build MauiBlazorWeb.sln` | Pending | +| `dotnet build MauiBlazorWeb.Web/MauiBlazorWeb.Web.csproj --no-restore` | Passed; known upstream package vulnerability warnings remain | +| HTTPS development smoke test | Passed; stock, override, and new routes present; bearer-only passkey route returns 401 without bearer credential | +| Production development-notification smoke test | Passed; `/development/notifications` returns 404 outside Development | +| Passkey login-begin smoke test | Passed; emits an `Identity.TwoFactorUserId` temporary ceremony cookie | | Web, Mac Catalyst, iOS, Android builds | Pending | | Microsoft-only integration tests | Pending project creation | ## Known blockers and deferred work * The current worktree has only the main sample's legacy MAUI login client. No - endpoint extension library or integration-test project exists yet. + typed REST account client or integration-test project exists yet. * Native passkey ceremony execution is deferred behind a small client seam until the .NET 11 MAUI passkey APIs are used. The net10 UI will inspect the server's sanitized options JSON only. @@ -87,5 +90,5 @@ shared, persistent Data Protection keys. ## Resume instructions -**Next task:** test the new override routes against an in-memory host, then -replace the legacy MAUI authentication provider with the typed stock REST client. +**Next task:** replace the legacy MAUI authentication provider with the typed +stock REST client, including atomic token-pair replacement and auth epochs. diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs index 0c54fbcb0..200b78fb2 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.Security.Claims; using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Authorization; @@ -210,7 +211,375 @@ public static IEndpointRouteBuilder MapNewIdentityApi(this IEndpointRoute where TUser : class { ArgumentNullException.ThrowIfNull(endpoints); - return endpoints.MapGroup("").WithTags("Identity extensions"); + + var group = endpoints.MapGroup("") + .WithTags("Identity extensions"); + var bearerOnly = new AuthorizeAttribute + { + AuthenticationSchemes = IdentityConstants.BearerScheme, + }; + + group.MapGet("/manage/passkeys", async Task (ClaimsPrincipal principal, [FromServices] UserManager userManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + var passkeys = await userManager.GetPasskeysAsync(user); + return TypedResults.Ok(passkeys.Select(passkey => new PasskeyResponse( + WebEncoders.Base64UrlEncode(passkey.CredentialId), + passkey.Name, + passkey.CreatedAt, + passkey.IsUserVerified, + passkey.IsBackedUp)).ToArray()); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityManagePasskeys") + .WithSummary("Lists the authenticated user's passkeys without exposing key material.") + .Produces(); + + group.MapPatch("/manage/passkeys/{credentialId}", async Task ( + string credentialId, + [FromBody] RenamePasskeyRequest request, + ClaimsPrincipal principal, + [FromServices] UserManager userManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + if (!TryDecodeCredentialId(credentialId, out var credentialIdBytes)) + { + return TypedResults.ValidationProblem(new Dictionary + { + ["credentialId"] = ["The credential ID must be base64url encoded."], + }); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return TypedResults.ValidationProblem(new Dictionary + { + ["name"] = ["A passkey name is required."], + }); + } + + var passkey = await userManager.GetPasskeyAsync(user, credentialIdBytes); + if (passkey is null) + { + return TypedResults.NotFound(); + } + + passkey.Name = request.Name.Trim(); + var result = await userManager.AddOrUpdatePasskeyAsync(user, passkey); + return result.Succeeded + ? TypedResults.NoContent() + : CreateValidationProblem(result); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityRenamePasskey") + .WithSummary("Renames one passkey.") + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem(); + + group.MapDelete("/manage/passkeys/{credentialId}", async Task ( + string credentialId, + ClaimsPrincipal principal, + [FromServices] UserManager userManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + if (!TryDecodeCredentialId(credentialId, out var credentialIdBytes)) + { + return TypedResults.ValidationProblem(new Dictionary + { + ["credentialId"] = ["The credential ID must be base64url encoded."], + }); + } + + var result = await userManager.RemovePasskeyAsync(user, credentialIdBytes); + return result.Succeeded + ? TypedResults.NoContent() + : CreateValidationProblem(result); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityDeletePasskey") + .WithSummary("Removes one passkey.") + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem(); + + var passkeyGroup = group.MapGroup("/passkeys").DisableAntiforgery(); + + passkeyGroup.MapPost("/register/begin", async Task ( + ClaimsPrincipal principal, + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + var userId = await userManager.GetUserIdAsync(user); + var userName = await userManager.GetUserNameAsync(user) ?? userId; + var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new PasskeyUserEntity + { + Id = userId, + Name = userName, + DisplayName = userName, + }); + + return TypedResults.Content(optionsJson, "application/json"); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityBeginPasskeyRegistration") + .WithSummary("Begins passkey registration and writes the official temporary Identity ceremony cookie."); + + passkeyGroup.MapPost("/register/finish", async Task ( + [FromBody] JsonElement credential, + [FromQuery] string? name, + ClaimsPrincipal principal, + [FromServices] UserManager userManager, + [FromServices] SignInManager signInManager) => + { + PasskeyAttestationResult attestation; + try + { + attestation = await signInManager.PerformPasskeyAttestationAsync(credential.GetRawText()); + } + catch (InvalidOperationException) + { + return TypedResults.BadRequest(new PasskeyCeremonyFailureResponse( + "passkey_ceremony_not_found", + "No passkey registration is in progress. Begin a new registration and return its temporary Identity cookie.")); + } + + if (!attestation.Succeeded) + { + return TypedResults.BadRequest(new PasskeyCeremonyFailureResponse( + "passkey_attestation_failed", + "The passkey attestation could not be verified.")); + } + + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + var userId = await userManager.GetUserIdAsync(user); + if (!string.Equals(userId, attestation.UserEntity.Id, StringComparison.Ordinal)) + { + return TypedResults.BadRequest(new PasskeyCeremonyFailureResponse( + "passkey_user_mismatch", + "The passkey ceremony belongs to a different account.")); + } + + if (!string.IsNullOrWhiteSpace(name)) + { + attestation.Passkey.Name = name.Trim(); + } + + var result = await userManager.AddOrUpdatePasskeyAsync(user, attestation.Passkey); + return result.Succeeded + ? TypedResults.Ok(new PasskeyRegistrationResponse(true, attestation.Passkey.Name)) + : CreateValidationProblem(result); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityFinishPasskeyRegistration") + .WithSummary("Completes passkey registration using the temporary Identity ceremony cookie."); + + passkeyGroup.MapPost("/login/begin", async Task ([FromServices] SignInManager signInManager) => + { + var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user: null); + return TypedResults.Content(optionsJson, "application/json"); + }) + .WithName("IdentityBeginPasskeyLogin") + .WithSummary("Begins discoverable passkey login and writes the official temporary Identity ceremony cookie."); + + passkeyGroup.MapPost("/login/finish", async Task ( + [FromBody] JsonElement credential, + [FromServices] SignInManager signInManager) => + { + signInManager.AuthenticationScheme = IdentityConstants.BearerScheme; + Microsoft.AspNetCore.Identity.SignInResult result; + try + { + result = await signInManager.PasskeySignInAsync(credential.GetRawText()); + } + catch (InvalidOperationException) + { + return TypedResults.BadRequest(new PasskeyCeremonyFailureResponse( + "passkey_ceremony_not_found", + "No passkey login is in progress. Begin a new login and return its temporary Identity cookie.")); + } + + if (!result.Succeeded) + { + return TypedResults.Json( + new LoginFailureResponse(GetLoginFailureCode(result)), + statusCode: StatusCodes.Status401Unauthorized); + } + + return TypedResults.Empty; + }) + .WithName("IdentityFinishPasskeyLogin") + .WithSummary("Completes passkey login and emits an in-box opaque bearer token response."); + + group.MapGet("/manage/personal-data", async Task (ClaimsPrincipal principal, [FromServices] UserManager userManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(new PersonalDataResponse( + await userManager.GetUserIdAsync(user), + await userManager.GetUserNameAsync(user), + await userManager.GetEmailAsync(user), + await userManager.GetPhoneNumberAsync(user), + await userManager.IsEmailConfirmedAsync(user), + await userManager.GetTwoFactorEnabledAsync(user))); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityPersonalData") + .WithSummary("Returns a fixed safe subset of personal data.") + .Produces(); + + group.MapDelete("/manage/account", async Task ( + [FromBody] DeleteAccountRequest request, + ClaimsPrincipal principal, + [FromServices] UserManager userManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + if (!await userManager.HasPasswordAsync(user)) + { + return CreateValidationProblem( + "PasswordlessRecentAuthenticationRequired", + "Passwordless deletion requires a recent interactive reauthentication flow, which this sample does not implement."); + } + + if (string.IsNullOrWhiteSpace(request.CurrentPassword)) + { + return CreateValidationProblem("CurrentPasswordRequired", "The current password is required to delete this account."); + } + + if (!await userManager.CheckPasswordAsync(user, request.CurrentPassword)) + { + return CreateValidationProblem("InvalidCurrentPassword", "The current password is incorrect."); + } + + var result = await userManager.DeleteAsync(user); + return result.Succeeded + ? TypedResults.NoContent() + : CreateValidationProblem(result); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityDeleteAccount") + .WithSummary("Deletes a password account after validating its current password.") + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem(); + + group.MapPost("/manage/logout-all", async Task (ClaimsPrincipal principal, [FromServices] UserManager userManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + var result = await userManager.UpdateSecurityStampAsync(user); + return result.Succeeded + ? TypedResults.NoContent() + : CreateValidationProblem(result); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityLogoutAll") + .WithSummary("Invalidates refresh tokens by updating the security stamp; access tokens remain valid until expiry.") + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem(); + + group.MapGet("/manage/external-logins", async Task (ClaimsPrincipal principal, [FromServices] UserManager userManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + var logins = await userManager.GetLoginsAsync(user); + return TypedResults.Ok(logins.Select(login => new ExternalLoginResponse( + login.LoginProvider, + login.ProviderDisplayName)).ToArray()); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityExternalLogins") + .WithSummary("Lists linked external login providers without provider keys.") + .Produces(); + + group.MapDelete("/manage/external-logins/{provider}", async Task ( + string provider, + ClaimsPrincipal principal, + [FromServices] UserManager userManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.NotFound(); + } + + var logins = await userManager.GetLoginsAsync(user); + var linkedLogins = logins + .Where(login => string.Equals(login.LoginProvider, provider, StringComparison.Ordinal)) + .ToArray(); + if (linkedLogins.Length == 0) + { + return TypedResults.NotFound(); + } + + var hasOtherLogin = logins.Any(login => !string.Equals(login.LoginProvider, provider, StringComparison.Ordinal)); + var hasPassword = await userManager.HasPasswordAsync(user); + var hasPasskey = (await userManager.GetPasskeysAsync(user)).Count > 0; + if (!hasOtherLogin && !hasPassword && !hasPasskey) + { + return CreateValidationProblem( + "LastSignInMethod", + "Removing this provider would leave the account without a usable sign-in method."); + } + + foreach (var login in linkedLogins) + { + var result = await userManager.RemoveLoginAsync(user, login.LoginProvider, login.ProviderKey); + if (!result.Succeeded) + { + return CreateValidationProblem(result); + } + } + + return TypedResults.NoContent(); + }) + .RequireAuthorization(bearerOnly) + .WithName("IdentityDeleteExternalLogin") + .WithSummary("Unlinks an external provider without exposing provider keys or removing the final sign-in method.") + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem(); + + return endpoints; } private static string GetLoginFailureCode(Microsoft.AspNetCore.Identity.SignInResult result) => @@ -258,6 +627,20 @@ private static ValidationProblem CreateValidationProblem(string code, string des { [code] = [description], }); + + private static bool TryDecodeCredentialId(string credentialId, out byte[] credentialIdBytes) + { + try + { + credentialIdBytes = WebEncoders.Base64UrlDecode(credentialId); + return credentialIdBytes.Length > 0; + } + catch (FormatException) + { + credentialIdBytes = []; + return false; + } + } } /// Stable machine-readable values returned for unsuccessful override login attempts. @@ -302,3 +685,35 @@ public sealed record TwoFactorStatusResponse( bool HasAuthenticator, int RecoveryCodesLeft, bool IsMachineRemembered); + +/// Safe metadata about one passkey. +public sealed record PasskeyResponse( + string CredentialId, + string? Name, + DateTimeOffset CreatedAt, + bool IsUserVerified, + bool IsBackedUp); + +/// Request to rename a passkey. +public sealed record RenamePasskeyRequest(string? Name); + +/// Non-secret passkey registration completion response. +public sealed record PasskeyRegistrationResponse(bool Registered, string? Name); + +/// Machine-readable passkey ceremony failure response. +public sealed record PasskeyCeremonyFailureResponse(string Code, string Message); + +/// A safe explicit personal-data projection. +public sealed record PersonalDataResponse( + string UserId, + string? UserName, + string? Email, + string? PhoneNumber, + bool IsEmailConfirmed, + bool IsTwoFactorEnabled); + +/// Current-password confirmation for deleting an account. +public sealed record DeleteAccountRequest(string? CurrentPassword); + +/// External login metadata without a provider key. +public sealed record ExternalLoginResponse(string Provider, string? DisplayName); diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Components/Account/DevelopmentEmailSender.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Components/Account/DevelopmentEmailSender.cs new file mode 100644 index 000000000..4f54e96aa --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Components/Account/DevelopmentEmailSender.cs @@ -0,0 +1,103 @@ +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Identity; +using MauiBlazorWeb.Web.Data; + +namespace MauiBlazorWeb.Web.Components.Account; + +/// +/// Development-only email sender that retains a bounded, in-memory set of +/// identity actions for local testing. It must never be registered in production. +/// +internal sealed class DevelopmentEmailSender : IEmailSender +{ + private const int MaximumNotifications = 20; + private readonly object _gate = new(); + private readonly Queue _notifications = new(); + + public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) + { + Add("Confirm email", email, confirmationLink, null); + return Task.CompletedTask; + } + + public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) + { + Add("Reset password", email, resetLink, null); + return Task.CompletedTask; + } + + public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) + { + Add("Reset password", email, null, resetCode); + return Task.CompletedTask; + } + + public IReadOnlyList GetNotifications() + { + lock (_gate) + { + return _notifications.Reverse().ToArray(); + } + } + + private void Add(string kind, string email, string? actionLink, string? code) + { + lock (_gate) + { + _notifications.Enqueue(new DevelopmentNotification(kind, email, actionLink, code, DateTimeOffset.UtcNow)); + while (_notifications.Count > MaximumNotifications) + { + _notifications.Dequeue(); + } + } + } +} + +internal sealed record DevelopmentNotification( + string Kind, + string Email, + string? ActionLink, + string? Code, + DateTimeOffset CreatedAt); + +internal static class DevelopmentNotificationPage +{ + public static string Render(DevelopmentEmailSender sender) + { + var body = new StringBuilder(""" + + Development Identity notifications +

Development Identity notifications

+

This page exists only in Development. Do not use it as an email service in production.

+
    + """); + + foreach (var notification in sender.GetNotifications()) + { + body.Append("
  • ") + .Append(WebUtility.HtmlEncode(notification.Kind)) + .Append(" for ") + .Append(WebUtility.HtmlEncode(notification.Email)) + .Append(" at ") + .Append(WebUtility.HtmlEncode(notification.CreatedAt.ToString("O"))); + + if (notification.ActionLink is not null) + { + body.Append(": complete action"); + } + else if (notification.Code is not null) + { + body.Append(": ") + .Append(WebUtility.HtmlEncode(notification.Code)) + .Append(""); + } + + body.Append("
  • "); + } + + return body.Append("
").ToString(); + } +} diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs index 648111f83..b75e3609a 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs @@ -36,6 +36,14 @@ options.UseSqlite(connectionString)); builder.Services.AddDatabaseDeveloperPageExceptionFilter(); +var passkeyOrigins = builder.Configuration.GetSection("Passkeys:AllowedOrigins").Get() ?? []; +builder.Services.Configure(options => +{ + options.ServerDomain = builder.Configuration["Passkeys:ServerDomain"]; + options.ValidateOrigin = context => ValueTask.FromResult( + !context.CrossOrigin && passkeyOrigins.Contains(context.Origin, StringComparer.Ordinal)); +}); + // Needed for external clients to log in builder.Services.AddIdentityApiEndpoints(options => { @@ -44,7 +52,16 @@ }) .AddEntityFrameworkStores(); -builder.Services.AddSingleton, IdentityNoOpEmailSender>(); +if (builder.Environment.IsDevelopment()) +{ + builder.Services.AddSingleton(); + builder.Services.AddSingleton>(services => + services.GetRequiredService()); +} +else +{ + builder.Services.AddSingleton, IdentityNoOpEmailSender>(); +} // For more information on OpenAPI support in ASP.NET Core, // see OpenAPI support in ASP.NET Core API apps at @@ -64,6 +81,8 @@ } app.UseMigrationsEndPoint(); app.MapOpenApi(); + app.MapGet("/development/notifications", (DevelopmentEmailSender sender) => + Results.Content(DevelopmentNotificationPage.Render(sender), "text/html")); } else { diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/appsettings.Development.json b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/appsettings.Development.json index 0c208ae91..7e65d1500 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/appsettings.Development.json +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/appsettings.Development.json @@ -4,5 +4,11 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Passkeys": { + "ServerDomain": "localhost", + "AllowedOrigins": [ + "https://localhost:7157" + ] } } diff --git a/10.0/MauiBlazorWebIdentity/README.md b/10.0/MauiBlazorWebIdentity/README.md index d06e58d28..b536ccdc0 100644 --- a/10.0/MauiBlazorWebIdentity/README.md +++ b/10.0/MauiBlazorWebIdentity/README.md @@ -10,17 +10,36 @@ The sample: * Saves and retrieves tokens in secure device storage. * Calls a secure endpoint (`/api/weather`) from the client. +## Identity REST API exploration + +The sample retains the framework stock API at `/identity` and adds proposed +portable account endpoints under `/identity` only where the framework does not +already provide a route. Enhanced replacements are deliberately isolated under +`/identity-overrides` so their behavior can be compared directly. + +* [Identity REST API implementation tracker](IDENTITY_API_IMPLEMENTATION.md) + records the phased status, endpoint ledger, and validation results. +* [Identity REST API capability matrix](IDENTITY_API_CAPABILITIES.md) maps every + hosted `/Account/*` feature to stock, override, or proposed new REST support. + +The access and refresh tokens are opaque Data Protection tickets, not JWTs. +Access tokens default to one hour and refresh tokens to 14 days. Refresh tokens +are reusable and there is no device registry, replay detection, or individual +token revocation. Updating the security stamp invalidates refresh tokens but +does not invalidate an issued access token before it expires. Production hosts +must share persistent Data Protection keys. + For more information, see [.NET MAUI Blazor Hybrid and Web App with ASP.NET Core Identity](https://learn.microsoft.com/aspnet/core/blazor/hybrid/security/maui-blazor-web-identity). ## Steps to run the sample 1. Clone this repository or download a ZIP archive of the repository. For more information, see [How to download a sample](https://learn.microsoft.com/aspnet/core/introduction-to-aspnet-core#how-to-download-a-sample). -1. Make sure you have [.NET 9 and the MAUI workload installed](https://learn.microsoft.com/dotnet/maui/get-started/installation). +1. Make sure you have [.NET 10 and the MAUI workload installed](https://learn.microsoft.com/dotnet/maui/get-started/installation). 1. Open the solution in Visual Studio 2022 or VS Code with the .NET MAUI extension installed. 1. Set the `MauiBlazorWeb` MAUI project as the startup project. In Visual Studio, right-click the project and select **Set as Startup Project**. 1. Start the `MauiBlazorWeb.Web` project without debugging. In Visual Studio, right-click on the project and select **Debug** > **Start without Debugging**. 1. Inspect the Identity endpoints by navigating to `https://localhost:7157/swagger` in a browser. -1. Navigate to `https://localhost:7157/account/register` to register a user in the Blazor Web App. Immediately after the user is registered, use the **Click here to confirm your account** link in the UI to confirm the user's email address because a real email sender isn't registered for account confirmation. +1. Navigate to `https://localhost:7157/account/register` to register a user in the Blazor Web App. In Development, open `https://localhost:7157/development/notifications` and use the explicit confirmation action. This bounded, in-memory page is not mapped outside Development and is not production email. 1. Start (`F5`) the `MauiBlazorWeb` MAUI project. You can set the debug target to either **Windows** or an Android emulator. 1. Notice you can only see the `Home` and `Login` pages. 1. Log in with the user that you registered. From d390856a9f50f98e885b3fcb192de72047fdd78c Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Sat, 12 Sep 2026 03:27:26 +0200 Subject: [PATCH 04/14] Harden MAUI Identity token lifecycle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IDENTITY_API_IMPLEMENTATION.md | 11 +- ...entityApiEndpointRouteBuilderExtensions.cs | 16 +- .../MauiBlazorWeb.Web/Program.cs | 1 + .../Components/Pages/Logout.razor | 4 +- .../MauiBlazorWeb/Models/LoginRequest.cs | 4 + .../Services/HttpClientHelper.cs | 2 + .../MauiAuthenticationStateProvider.cs | 357 +++++++++++------- .../MauiBlazorWeb/Services/TokenStorage.cs | 54 +-- 8 files changed, 269 insertions(+), 180 deletions(-) diff --git a/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md index 020f97799..3ef75e5d4 100644 --- a/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md +++ b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md @@ -16,7 +16,7 @@ provider. | Item | Value | | --- | --- | | Base commit | `567732e0a78d2c1b2a22c3677f86c673d7527bed` (`origin/main`) | -| Current commit | `3183f04` generic override endpoint increment; portable endpoint and development-notification increment in progress | +| Current commit | `9786023` portable endpoint increment; MAUI typed-client increment in progress | | Stock API | `app.MapGroup("/identity").MapIdentityApi()` | | New endpoints | `MauiBlazorWeb.IdentityApi.MapNewIdentityApi()`, mapped on `/identity` only for stock-absent routes | | Overrides | `MapOverrideIdentityApi()`, mapped only under `/identity-overrides` | @@ -34,9 +34,9 @@ shared, persistent Data Protection keys. | Phase | Status | Scope | | --- | --- | --- | | 0 | Complete | Tracker, capability ledger, project inventory | -| 1 | Pending | Stock endpoint typed MAUI client, durable token lifecycle, account UI | +| 1 | In progress | Stock endpoint typed MAUI client, durable token lifecycle, account UI | | 2 | In progress | Generic override endpoint library and `/identity-overrides` client use | -| 3 | In progress | Generic new endpoint library: passkeys, personal data, deletion, external logins, logout-all | +| 3 | Complete | Generic new endpoint library: passkeys, personal data, deletion, external logins, logout-all | | 4 | In progress | Development notification UI, integration tests, platform validation | ## Endpoint ledger @@ -75,6 +75,7 @@ shared, persistent Data Protection keys. | HTTPS development smoke test | Passed; stock, override, and new routes present; bearer-only passkey route returns 401 without bearer credential | | Production development-notification smoke test | Passed; `/development/notifications` returns 404 outside Development | | Passkey login-begin smoke test | Passed; emits an `Identity.TwoFactorUserId` temporary ceremony cookie | +| `dotnet build MauiBlazorWeb/MauiBlazorWeb.csproj -f net10.0-maccatalyst --no-restore` | Passed; existing unsigned local development entitlement warning | | Web, Mac Catalyst, iOS, Android builds | Pending | | Microsoft-only integration tests | Pending project creation | @@ -90,5 +91,5 @@ shared, persistent Data Protection keys. ## Resume instructions -**Next task:** replace the legacy MAUI authentication provider with the typed -stock REST client, including atomic token-pair replacement and auth epochs. +**Next task:** add the typed REST account client methods and MAUI account pages +for confirmation, reset, profile, passwords, and two-factor operations. diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs index 200b78fb2..203ac7bd2 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace MauiBlazorWeb.IdentityApi; @@ -132,7 +133,8 @@ public static IEndpointRouteBuilder MapOverrideIdentityApi(this IEndpoint { var code = await userManager.GenerateChangeEmailTokenAsync(user, request.NewEmail); var userId = await userManager.GetUserIdAsync(user); - var confirmationUrl = BuildConfirmationUrl(context, userId, code, request.NewEmail); + var routeOptions = services.GetRequiredService>().Value; + var confirmationUrl = BuildConfirmationUrl(context, routeOptions.StockIdentityPrefix, userId, code, request.NewEmail); var emailSender = services.GetRequiredService>(); await emailSender.SendConfirmationLinkAsync(user, request.NewEmail, confirmationUrl); } @@ -609,10 +611,11 @@ await userManager.CountRecoveryCodesAsync(user), logins.Select(login => login.LoginProvider).Distinct(StringComparer.Ordinal).ToArray()); } - private static string BuildConfirmationUrl(HttpContext context, string userId, string code, string changedEmail) + private static string BuildConfirmationUrl(HttpContext context, string stockIdentityPrefix, string userId, string code, string changedEmail) { var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - return $"{context.Request.Scheme}://{context.Request.Host}/identity/confirmEmail?userId={Uri.EscapeDataString(userId)}&code={Uri.EscapeDataString(encodedCode)}&changedEmail={Uri.EscapeDataString(changedEmail)}"; + var prefix = stockIdentityPrefix.Trim('/'); + return $"{context.Request.Scheme}://{context.Request.Host}/{prefix}/confirmEmail?userId={Uri.EscapeDataString(userId)}&code={Uri.EscapeDataString(encodedCode)}&changedEmail={Uri.EscapeDataString(changedEmail)}"; } private static ValidationProblem CreateValidationProblem(IdentityResult result) => @@ -652,6 +655,13 @@ public static class LoginFailureCodes public const string NotAllowed = "not_allowed"; } +/// Configures stock Identity route links used by the override endpoints. +public sealed class IdentityApiRouteOptions +{ + /// The mount path of the application's stock MapIdentityApi endpoints. + public string StockIdentityPrefix { get; set; } = "/identity"; +} + /// Failure response for /identity-overrides/login. public sealed record LoginFailureResponse(string Code); diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs index b75e3609a..10f943b83 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Program.cs @@ -37,6 +37,7 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter(); var passkeyOrigins = builder.Configuration.GetSection("Passkeys:AllowedOrigins").Get() ?? []; +builder.Services.Configure(options => options.StockIdentityPrefix = "/identity"); builder.Services.Configure(options => { options.ServerDomain = builder.Configuration["Passkeys:ServerDomain"]; diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Logout.razor b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Logout.razor index 3c9054b5f..56ded3dd9 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Logout.razor +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Logout.razor @@ -4,9 +4,9 @@
Redirecting...
@code{ - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { - AuthStateProvider.Logout(); + await AuthStateProvider.LogoutAsync(); navigationManager.NavigateTo("/login"); } } diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Models/LoginRequest.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Models/LoginRequest.cs index 606c08dd9..5a07bf82b 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Models/LoginRequest.cs +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Models/LoginRequest.cs @@ -16,5 +16,9 @@ public class LoginRequest [Display(Name = "Remember me?")] public bool RememberMe { get; set; } + + public string? TwoFactorCode { get; set; } + + public string? TwoFactorRecoveryCode { get; set; } } } diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/HttpClientHelper.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/HttpClientHelper.cs index 129af3f8c..85cec1000 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/HttpClientHelper.cs +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/HttpClientHelper.cs @@ -23,7 +23,9 @@ public static string BaseUrl } } public static string LoginUrl => $"{BaseUrl}identity/login"; + public static string OverrideLoginUrl => $"{BaseUrl}identity-overrides/login"; public static string RefreshUrl => $"{BaseUrl}identity/refresh"; + public static string ManageInfoUrl => $"{BaseUrl}identity/manage/info"; public static string WeatherUrl => $"{BaseUrl}api/weather"; public static HttpClient GetHttpClient() diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/MauiAuthenticationStateProvider.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/MauiAuthenticationStateProvider.cs index d55919d7b..36a03d293 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/MauiAuthenticationStateProvider.cs +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/MauiAuthenticationStateProvider.cs @@ -1,203 +1,268 @@ -using MauiBlazorWeb.Models; -using Microsoft.AspNetCore.Components.Authorization; -using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; +using System.Text.Json; +using MauiBlazorWeb.Models; +using Microsoft.AspNetCore.Components.Authorization; -namespace MauiBlazorWeb.Services -{ - /// - /// This class manages the authentication state of the user. - /// The class handles user login, logout, and token validation, including refreshing tokens when they are close to expiration. - /// It uses secure storage to save and retrieve tokens, ensuring that users do not need to log in every time. - /// - public class MauiAuthenticationStateProvider : AuthenticationStateProvider - { - //TODO: Place this in AppSettings or Client config file - private const string AuthenticationType = "Custom authentication"; - private const int TokenExpirationBuffer = 30; //minutes +namespace MauiBlazorWeb.Services; - private static ClaimsPrincipal _defaultUser = new ClaimsPrincipal(new ClaimsIdentity()); - private static Task _defaultAuthState = Task.FromResult(new AuthenticationState(_defaultUser)); +/// +/// Owns the native client's opaque Identity token pair and authentication state. +/// +public sealed class MauiAuthenticationStateProvider : AuthenticationStateProvider +{ + private const int TokenExpirationBufferMinutes = 30; + private const string AuthenticationType = "IdentityBearer"; + private static readonly ClaimsPrincipal DefaultUser = new(new ClaimsIdentity()); + private static readonly Task DefaultAuthState = + Task.FromResult(new AuthenticationState(DefaultUser)); + private readonly SemaphoreSlim _refreshLock = new(1, 1); + private Task _currentAuthState = DefaultAuthState; + private AccessTokenInfo? _accessToken; + private bool _persistToken; + private long _authEpoch; - public LoginStatus LoginStatus { get; set; } = LoginStatus.None; - public string LoginFailureMessage { get; set; } = ""; + public LoginStatus LoginStatus { get; private set; } - private Task _currentAuthState = _defaultAuthState; - private AccessTokenInfo? _accessToken; + public string LoginFailureMessage { get; private set; } = string.Empty; - public override Task GetAuthenticationStateAsync() + public override Task GetAuthenticationStateAsync() + { + if (_currentAuthState != DefaultAuthState) { - if (_currentAuthState != _defaultAuthState) - { - return _currentAuthState; - } - - _currentAuthState = CreateAuthenticationStateFromSecureStorageAsync(); - NotifyAuthenticationStateChanged(_currentAuthState); - return _currentAuthState; } - public async Task GetAccessTokenInfoAsync() - { - if (await UpdateAndValidateAccessTokenAsync()) - { - return _accessToken; - } - - Logout(); - return null; - } + _currentAuthState = RestoreAuthenticationStateAsync(); + NotifyAuthenticationStateChanged(_currentAuthState); + return _currentAuthState; + } - public void Logout() + public async Task GetAccessTokenInfoAsync() + { + if (await UpdateAndValidateAccessTokenAsync()) { - LoginStatus = LoginStatus.None; - _currentAuthState = _defaultAuthState; - _accessToken = null; - TokenStorage.RemoveToken(); - NotifyAuthenticationStateChanged(_defaultAuthState); + return _accessToken; } - public Task LogInAsync(LoginRequest loginModel) - { - _currentAuthState = LogInAsyncCore(loginModel); - NotifyAuthenticationStateChanged(_currentAuthState); + await LogoutAsync(); + return null; + } - return _currentAuthState; + public async Task LogoutAsync() + { + Interlocked.Increment(ref _authEpoch); + LoginStatus = LoginStatus.None; + LoginFailureMessage = string.Empty; + _accessToken = null; + _persistToken = false; + _currentAuthState = DefaultAuthState; + await TokenStorage.RemoveTokenAsync(); + NotifyAuthenticationStateChanged(DefaultAuthState); + } - async Task LogInAsyncCore(LoginRequest loginModel) - { - var user = await LoginWithProviderAsync(loginModel); - return new AuthenticationState(user); - } - } + public Task LogInAsync(LoginRequest login) + { + var epoch = Interlocked.Increment(ref _authEpoch); + _currentAuthState = LogInAsyncCore(login, epoch); + NotifyAuthenticationStateChanged(_currentAuthState); + return _currentAuthState; + } - private async Task LoginWithProviderAsync(LoginRequest loginModel) + private async Task LogInAsyncCore(LoginRequest login, long epoch) + { + LoginStatus = LoginStatus.None; + LoginFailureMessage = string.Empty; + + try { - var authenticatedUser = _defaultUser; - LoginStatus = LoginStatus.None; + using var client = HttpClientHelper.GetHttpClient(); + using var response = await client.PostAsJsonAsync( + HttpClientHelper.OverrideLoginUrl, + new + { + login.Email, + login.Password, + login.TwoFactorCode, + login.TwoFactorRecoveryCode, + }); - try + if (!response.IsSuccessStatusCode) { - //Call the Login endpoint and pass the email and password - var httpClient = HttpClientHelper.GetHttpClient(); - var loginData = new { loginModel.Email, loginModel.Password }; - using var response = await httpClient.PostAsJsonAsync(HttpClientHelper.LoginUrl, loginData); - - LoginStatus = response.IsSuccessStatusCode ? LoginStatus.Success : LoginStatus.Failed; - - if (LoginStatus == LoginStatus.Success) - { - var token = await response.Content.ReadAsStringAsync(); - - if (loginModel.RememberMe) - { - // Save token to secure storage so the user doesn't have to login every time - _accessToken = await TokenStorage.SaveTokenToSecureStorageAsync(token, loginModel.Email); - } - else - { - // Keep token in memory only — cleared when app closes - _accessToken = TokenStorage.DeserializeToken(token, loginModel.Email); - } - - authenticatedUser = CreateAuthenticatedUser(loginModel.Email); - LoginStatus = LoginStatus.Success; - } - else + var failure = await response.Content.ReadFromJsonAsync(); + if (epoch == Volatile.Read(ref _authEpoch)) { - LoginFailureMessage = "Invalid Email or Password. Please try again."; LoginStatus = LoginStatus.Failed; + LoginFailureMessage = GetLoginFailureMessage(failure?.Code); } + + return new AuthenticationState(DefaultUser); } - catch (Exception ex) + + var responseToken = await response.Content.ReadFromJsonAsync(); + if (responseToken is null) { - Debug.WriteLine($"Error logging in: {ex}"); - LoginFailureMessage = "Server error."; - LoginStatus = LoginStatus.Failed; + throw new InvalidOperationException("Identity login returned no token response."); } - return authenticatedUser; - } + var email = await GetAuthoritativeEmailAsync(client, responseToken); + if (string.IsNullOrWhiteSpace(email) || epoch != Volatile.Read(ref _authEpoch)) + { + return new AuthenticationState(DefaultUser); + } - private async Task CreateAuthenticationStateFromSecureStorageAsync() - { - var authenticatedUser = _defaultUser; - LoginStatus = LoginStatus.None; + var token = TokenStorage.DeserializeToken( + JsonSerializer.Serialize(responseToken), + email); + if (token is null) + { + throw new InvalidOperationException("Identity login returned an invalid token response."); + } - if (await UpdateAndValidateAccessTokenAsync()) + _persistToken = login.RememberMe; + _accessToken = login.RememberMe + ? await TokenStorage.SaveTokenToSecureStorageAsync(JsonSerializer.Serialize(responseToken), email) + : token; + if (_accessToken is null || epoch != Volatile.Read(ref _authEpoch)) { - authenticatedUser = CreateAuthenticatedUser(_accessToken!.Email); - LoginStatus = LoginStatus.Success; + return new AuthenticationState(DefaultUser); } - return new AuthenticationState(authenticatedUser); + if (!login.RememberMe) + { + await TokenStorage.RemoveTokenAsync(); + } + + LoginStatus = LoginStatus.Success; + return new AuthenticationState(CreateAuthenticatedUser(email)); } + catch (HttpRequestException) + { + LoginStatus = LoginStatus.Failed; + LoginFailureMessage = "The Identity server could not be reached."; + return new AuthenticationState(DefaultUser); + } + catch (JsonException) + { + LoginStatus = LoginStatus.Failed; + LoginFailureMessage = "The Identity server returned an invalid response."; + return new AuthenticationState(DefaultUser); + } + } - private async Task UpdateAndValidateAccessTokenAsync() + private async Task RestoreAuthenticationStateAsync() + { + _persistToken = true; + if (!await UpdateAndValidateAccessTokenAsync() || _accessToken is null) { - try - { - var now = DateTime.UtcNow; - var thirtyMinutesFromNow = now.AddMinutes(TokenExpirationBuffer); + return new AuthenticationState(DefaultUser); + } - if (_accessToken is null || thirtyMinutesFromNow > _accessToken.AccessTokenExpiration) - { - _accessToken = await TokenStorage.GetTokenFromSecureStorageAsync(); - } + LoginStatus = LoginStatus.Success; + return new AuthenticationState(CreateAuthenticatedUser(_accessToken.Email)); + } - if (_accessToken is null) - { - return false; - } + private async Task UpdateAndValidateAccessTokenAsync() + { + if (_accessToken is null) + { + _accessToken = await TokenStorage.GetTokenFromSecureStorageAsync(); + _persistToken = _accessToken is not null; + } - // The refresh token expiration is unknown, so we always try to refresh even if the access token expires. It defaults to 14 days. - // However, we start trying to refresh the access token 30 minutes before it expires to avoid race conditions. - if (thirtyMinutesFromNow >= _accessToken.AccessTokenExpiration) - { - return await RefreshAccessTokenAsync(_accessToken.LoginResponse.RefreshToken, _accessToken.Email); - } + if (_accessToken is null) + { + return false; + } + + if (DateTime.UtcNow.AddMinutes(TokenExpirationBufferMinutes) < _accessToken.AccessTokenExpiration) + { + return true; + } + + await _refreshLock.WaitAsync(); + try + { + if (_accessToken is null) + { + return false; + } + if (DateTime.UtcNow.AddMinutes(TokenExpirationBufferMinutes) < _accessToken.AccessTokenExpiration) + { return true; } - catch (Exception ex) + + var epoch = Volatile.Read(ref _authEpoch); + using var client = HttpClientHelper.GetHttpClient(); + using var response = await client.PostAsJsonAsync( + HttpClientHelper.RefreshUrl, + new { _accessToken.LoginResponse.RefreshToken }); + if (!response.IsSuccessStatusCode) { - Debug.WriteLine($"Error checking token for validity: {ex}"); return false; } - } - private async Task RefreshAccessTokenAsync(string refreshToken, string email) - { - try + var refreshed = await response.Content.ReadFromJsonAsync(); + if (refreshed is null || epoch != Volatile.Read(ref _authEpoch)) { - if (refreshToken != null) - { - //Call the Refresh endpoint and pass the refresh token - var httpClient = HttpClientHelper.GetHttpClient(); - var refreshData = new { refreshToken }; - using var response = await httpClient.PostAsJsonAsync(HttpClientHelper.RefreshUrl, refreshData); - response.EnsureSuccessStatusCode(); - var token = await response.Content.ReadAsStringAsync(); - _accessToken = await TokenStorage.SaveTokenToSecureStorageAsync(token, email); - return true; - } - return false; } - catch (Exception ex) + + var replacement = TokenStorage.DeserializeToken( + JsonSerializer.Serialize(refreshed), + _accessToken.Email); + if (replacement is null) { - Debug.WriteLine($"Error refreshing access token: {ex}"); - throw; + return false; } + + _accessToken = _persistToken + ? await TokenStorage.SaveTokenToSecureStorageAsync(JsonSerializer.Serialize(refreshed), replacement.Email) + : replacement; + return _accessToken is not null && epoch == Volatile.Read(ref _authEpoch); } + catch (HttpRequestException) + { + return false; + } + catch (JsonException) + { + return false; + } + finally + { + _refreshLock.Release(); + } + } - private ClaimsPrincipal CreateAuthenticatedUser(string email) + private static async Task GetAuthoritativeEmailAsync(HttpClient client, LoginResponse token) + { + using var request = new HttpRequestMessage(HttpMethod.Get, HttpClientHelper.ManageInfoUrl); + request.Headers.Authorization = new AuthenticationHeaderValue(token.TokenType, token.AccessToken); + using var response = await client.SendAsync(request); + if (response.StatusCode != HttpStatusCode.OK) { - var claims = new[] { new Claim(ClaimTypes.Name, email) }; //TODO: Add more claims as needed - var identity = new ClaimsIdentity(claims, AuthenticationType); - return new ClaimsPrincipal(identity); + return null; } + + return (await response.Content.ReadFromJsonAsync())?.Email; } + + private static ClaimsPrincipal CreateAuthenticatedUser(string email) => + new(new ClaimsIdentity([new Claim(ClaimTypes.Name, email)], AuthenticationType)); + + private static string GetLoginFailureMessage(string? code) => code switch + { + "requires_two_factor" => "Two-factor authentication is required.", + "locked_out" => "This account is locked out.", + "not_allowed" => "This account is not allowed to sign in. Confirm its email first.", + _ => "The email address or password is incorrect.", + }; + + private sealed record LoginFailureResponse(string Code); + + private sealed record ManageInfoResponse(string? Email, bool IsEmailConfirmed); } diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/TokenStorage.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/TokenStorage.cs index 0379f6d9d..e01e5ccb1 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/TokenStorage.cs +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/TokenStorage.cs @@ -1,37 +1,40 @@ -using System.Diagnostics; using System.Text.Json; using MauiBlazorWeb.Models; namespace MauiBlazorWeb.Services { - /// - /// This class is used to store and retrieve the access token from the SecureStorage. - /// internal class TokenStorage { private const string StorageKeyName = "access_token"; + private static readonly SemaphoreSlim StorageLock = new(1, 1); - public static void RemoveToken() + public static async Task RemoveTokenAsync() { - SecureStorage.Remove(StorageKeyName); + await StorageLock.WaitAsync(); + try + { + SecureStorage.Remove(StorageKeyName); + } + finally + { + StorageLock.Release(); + } } public static async Task GetTokenFromSecureStorageAsync() { + await StorageLock.WaitAsync(); try { var token = await SecureStorage.GetAsync(StorageKeyName); - - if (!string.IsNullOrEmpty(token)) - { - return JsonSerializer.Deserialize(token); - } + return string.IsNullOrEmpty(token) + ? null + : JsonSerializer.Deserialize(token); } - catch (Exception ex) + finally { - Debug.WriteLine("Unable to retrieve AccessTokenInfo from SecureStorage." + ex); + StorageLock.Release(); } - return null; } public static AccessTokenInfo? DeserializeToken(string token, string email) @@ -57,21 +60,24 @@ public static void RemoveToken() public static async Task SaveTokenToSecureStorageAsync(string token, string email) { - AccessTokenInfo? accessToken = null; + var accessToken = DeserializeToken(token, email); + if (accessToken is null) + { + return null; + } + + await StorageLock.WaitAsync(); try { - accessToken = DeserializeToken(token, email); - if (accessToken != null) - { - await SecureStorage.SetAsync(StorageKeyName, JsonSerializer.Serialize(accessToken)); - } + // The complete access/refresh pair is one serialized value, so a + // successful SecureStorage write cannot expose a mixed pair. + await SecureStorage.SetAsync(StorageKeyName, JsonSerializer.Serialize(accessToken)); + return accessToken; } - catch (Exception ex) + finally { - Debug.WriteLine("Unable to save AccessTokenInfo to SecureStorage." + ex); - accessToken = null; + StorageLock.Release(); } - return accessToken; } } } From f57ec79c0ffc53f125c7e29753f74f3c7b7d8e4e Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Sat, 12 Sep 2026 04:01:03 +0200 Subject: [PATCH 05/14] Add MAUI Identity REST account UI Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../IDENTITY_API_CAPABILITIES.md | 44 ++++--- .../IDENTITY_API_IMPLEMENTATION.md | 30 ++--- .../Components/Layout/NavMenu.razor | 10 ++ .../Components/Pages/Account.razor | 69 +++++++++++ .../Components/Pages/Login.razor | 14 +++ .../Components/Pages/PasswordHelp.razor | 25 ++++ .../Components/Pages/Register.razor | 31 +++++ .../MauiBlazorWeb/Components/_Imports.razor | 1 + .../MauiBlazorWeb/MauiProgram.cs | 1 + .../MauiBlazorWeb/Models/AccountModels.cs | 24 ++++ .../MauiBlazorWeb/Services/AccountClient.cs | 116 ++++++++++++++++++ .../Services/HttpClientHelper.cs | 3 + .../MauiAuthenticationStateProvider.cs | 28 ++++- .../MauiBlazorWeb/Services/TokenStorage.cs | 27 +++- 14 files changed, 383 insertions(+), 40 deletions(-) create mode 100644 10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Account.razor create mode 100644 10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/PasswordHelp.razor create mode 100644 10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Register.razor create mode 100644 10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Models/AccountModels.cs create mode 100644 10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Services/AccountClient.cs diff --git a/10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md b/10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md index 03c14a420..b40fa82f9 100644 --- a/10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md +++ b/10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md @@ -11,33 +11,33 @@ public `UserManager` and `SignInManager` APIs. | Capability | Stock route | MAUI usage | Support | | --- | --- | --- | --- | -| Register | `POST /identity/register` | Account registration | Planned | -| Password login and tokens | `POST /identity/login?useCookies=false` | Typed token client | Planned | -| Refresh | `POST /identity/refresh` | Serialized token refresh | Planned | -| Confirm/resend email | `/identity/confirmEmail`, `POST /identity/resendConfirmationEmail` | Confirmation UI | Planned | -| Forgot/reset password | `POST /identity/forgotPassword`, `POST /identity/resetPassword` | Reset UI | Planned | -| Profile/email/password | `GET/POST /identity/manage/info` | Profile editor | Planned | -| Authenticator and recovery codes | `POST /identity/manage/2fa` | 2FA editor | Planned | +| Register | `POST /identity/register` | Registration page | Implemented | +| Password login and tokens | `POST /identity/login?useCookies=false` | Typed token client | Implemented | +| Refresh | `POST /identity/refresh` | Serialized token refresh | Implemented | +| Confirm/resend email | `/identity/confirmEmail`, `POST /identity/resendConfirmationEmail` | Registration/account pages | Implemented | +| Forgot/reset password | `POST /identity/forgotPassword`, `POST /identity/resetPassword` | Password-help page | Implemented | +| Profile/email/password | `GET/POST /identity/manage/info` | Account editor | Implemented | +| Authenticator and recovery codes | `POST /identity/manage/2fa` | Account editor and login continuation | Implemented | ### Partial stock APIs proposed for override | Capability | Stock limitation | Override route | Support | | --- | --- | --- | --- | -| Login outcomes | Generic unsuccessful response does not expose a stable outcome | `POST /identity-overrides/login` | Pending | -| Manage info | Does not return phone, password, authenticators, passkeys, recovery code count, or external providers | `GET /identity-overrides/manage/info` | Pending | -| Manage info mutations | Cannot set phone or add a first local password; combination semantics are opaque | `POST /identity-overrides/manage/info` | Pending | -| 2FA inspection | Stock handler is mutating/configuration-oriented | `GET /identity-overrides/manage/2fa` | Pending | +| Login outcomes | Generic unsuccessful response does not expose a stable outcome | `POST /identity-overrides/login` | Implemented | +| Manage info | Does not return phone, password, authenticators, passkeys, recovery code count, or external providers | `GET /identity-overrides/manage/info` | Implemented | +| Manage info mutations | Cannot set phone or add a first local password; combination semantics are opaque | `POST /identity-overrides/manage/info` | Implemented | +| 2FA inspection | Stock handler is mutating/configuration-oriented | `GET /identity-overrides/manage/2fa` | Implemented | ### Missing stock APIs proposed as new routes | Capability | New route(s) | Support | | --- | --- | --- | -| Passkey list/manage | `GET/PATCH/DELETE /identity/manage/passkeys` | Pending | -| Passkey registration and login | `/identity/passkeys/register/*`, `/identity/passkeys/login/*` | Pending | -| Filtered personal data | `GET /identity/manage/personal-data` | Pending | -| Delete account | `DELETE /identity/manage/account` | Pending | -| Invalidate refresh sessions | `POST /identity/manage/logout-all` | Pending | -| List/unlink external logins | `GET/DELETE /identity/manage/external-logins` | Pending | +| Passkey list/manage | `GET/PATCH/DELETE /identity/manage/passkeys` | Implemented; MAUI previews native begin JSON pending .NET 11 | +| Passkey registration and login | `/identity/passkeys/register/*`, `/identity/passkeys/login/*` | Server implemented; native MAUI ceremony deferred to .NET 11 | +| Filtered personal data | `GET /identity/manage/personal-data` | Implemented | +| Delete account | `DELETE /identity/manage/account` | Implemented for password accounts; passwordless recent reauth deferred | +| Invalidate refresh sessions | `POST /identity/manage/logout-all` | Implemented | +| List/unlink external logins | `GET/DELETE /identity/manage/external-logins` | Implemented | ## Hosted Account feature inventory @@ -79,3 +79,13 @@ for `CreateAsync` and `AssertAsync`. The generic endpoint library never maps an existing method/path pair on `/identity`; enhanced handlers live under `/identity-overrides` so callers can compare stock and proposed behavior without route collisions. + +## Implementation map + +| Surface | Server implementation | MAUI usage | +| --- | --- | --- | +| Stock and proposed endpoint mappings | `MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs` | `MauiBlazorWeb/Services/AccountClient.cs` | +| Bearer-only host configuration | `MauiBlazorWeb.Web/Program.cs` | `MauiBlazorWeb/Services/MauiAuthenticationStateProvider.cs` | +| Development notifications | `MauiBlazorWeb.Web/Components/Account/DevelopmentEmailSender.cs` | `MauiBlazorWeb/Components/Pages/Register.razor` | +| Account management UI | `MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs` | `MauiBlazorWeb/Components/Pages/Account.razor` | +| Registration and recovery | `MauiBlazorWeb.Web/Program.cs` | `MauiBlazorWeb/Components/Pages/Register.razor`, `PasswordHelp.razor` | diff --git a/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md index 3ef75e5d4..311c64cec 100644 --- a/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md +++ b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md @@ -16,7 +16,7 @@ provider. | Item | Value | | --- | --- | | Base commit | `567732e0a78d2c1b2a22c3677f86c673d7527bed` (`origin/main`) | -| Current commit | `9786023` portable endpoint increment; MAUI typed-client increment in progress | +| Current commit | `HEAD` (MAUI REST account UI and resilient SecureStorage increment) | | Stock API | `app.MapGroup("/identity").MapIdentityApi()` | | New endpoints | `MauiBlazorWeb.IdentityApi.MapNewIdentityApi()`, mapped on `/identity` only for stock-absent routes | | Overrides | `MapOverrideIdentityApi()`, mapped only under `/identity-overrides` | @@ -34,8 +34,8 @@ shared, persistent Data Protection keys. | Phase | Status | Scope | | --- | --- | --- | | 0 | Complete | Tracker, capability ledger, project inventory | -| 1 | In progress | Stock endpoint typed MAUI client, durable token lifecycle, account UI | -| 2 | In progress | Generic override endpoint library and `/identity-overrides` client use | +| 1 | Complete | Stock endpoint typed MAUI client, durable token lifecycle, account UI | +| 2 | Complete | Generic override endpoint library and `/identity-overrides` client use | | 3 | Complete | Generic new endpoint library: passkeys, personal data, deletion, external logins, logout-all | | 4 | In progress | Development notification UI, integration tests, platform validation | @@ -58,13 +58,13 @@ shared, persistent Data Protection keys. | Feature | Status | Intended client surface | | --- | --- | --- | -| Password register/login/refresh | Existing partial | Replace legacy provider with typed client | -| Email confirmation and password reset | Pending | Account pages | -| Profile, email, phone, passwords | Pending | Account pages | -| Authenticator and recovery codes | Pending | Account pages | -| Passkey management | Pending | JSON preview with `.NET 11 Passkeys API coming soon` | -| Personal data/deletion | Pending | Account pages | -| External logins and logout-all | Pending | Account pages | +| Password register/login/refresh | Complete | Typed client with serialized refresh and auth epochs | +| Email confirmation and password reset | Complete | Registration and password-help pages | +| Profile, email, phone, passwords | Complete | Account page | +| Authenticator and recovery codes | Complete | Account page and login continuation | +| Passkey management | Partial | List/remove and JSON preview with `.NET 11 Passkeys API coming soon` | +| Personal data/deletion | Complete | Account page | +| External logins and logout-all | Complete | Account page | ## Validation ledger @@ -76,13 +76,14 @@ shared, persistent Data Protection keys. | Production development-notification smoke test | Passed; `/development/notifications` returns 404 outside Development | | Passkey login-begin smoke test | Passed; emits an `Identity.TwoFactorUserId` temporary ceremony cookie | | `dotnet build MauiBlazorWeb/MauiBlazorWeb.csproj -f net10.0-maccatalyst --no-restore` | Passed; existing unsigned local development entitlement warning | +| SecureStorage boundary validation | Passed by build review: reads/removes fall back to logged-out/best-effort cleanup with diagnostics; failed writes retain the valid in-memory pair and disable restart persistence | | Web, Mac Catalyst, iOS, Android builds | Pending | | Microsoft-only integration tests | Pending project creation | ## Known blockers and deferred work -* The current worktree has only the main sample's legacy MAUI login client. No - typed REST account client or integration-test project exists yet. +* The MAUI client uses typed REST account methods and an operation/auth epoch. + A Microsoft-only integration-test project has not been added yet. * Native passkey ceremony execution is deferred behind a small client seam until the .NET 11 MAUI passkey APIs are used. The net10 UI will inspect the server's sanitized options JSON only. @@ -91,5 +92,6 @@ shared, persistent Data Protection keys. ## Resume instructions -**Next task:** add the typed REST account client methods and MAUI account pages -for confirmation, reset, profile, passwords, and two-factor operations. +**Next task:** add Microsoft-only integration tests for route layout, bearer +isolation, login outcomes, passkey ceremony continuity, and development-route +environment gating. diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Layout/NavMenu.razor b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Layout/NavMenu.razor index 019c9c31b..725af5d7e 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Layout/NavMenu.razor +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Layout/NavMenu.razor @@ -20,6 +20,11 @@ Log In + + + @if (requiresTwoFactor) + { +
+ + +
+
+ + +
+ }
+

Register · Forgot your password?