diff --git a/10.0/MauiBlazorWebIdentity/.gitignore b/10.0/MauiBlazorWebIdentity/.gitignore index d07a3d256..5f58d94c7 100644 --- a/10.0/MauiBlazorWebIdentity/.gitignore +++ b/10.0/MauiBlazorWebIdentity/.gitignore @@ -366,3 +366,4 @@ FodyWeavers.xsd *.db *.db-shm *.db-wal +scripts/.devflow-state-*/ diff --git a/10.0/MauiBlazorWebIdentity/DEVFLOW_TESTING.md b/10.0/MauiBlazorWebIdentity/DEVFLOW_TESTING.md new file mode 100644 index 000000000..24c9c38ca --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/DEVFLOW_TESTING.md @@ -0,0 +1,87 @@ +# DevFlow live testing + +The live suite complements—not replaces—the server API suite. Run both with: + +```bash +./scripts/run-devflow-live-tests.sh +``` + +The runner executes `MauiBlazorWeb.IdentityApi.Tests`, builds the web, +Playwright, and `net10.0-maccatalyst` projects, then starts an isolated Development SQLite +database, the real web server, the DevFlow broker when needed, and the +production-bundle-id Mac Catalyst app. The already-built web DLL is launched +directly so the recorded server PID is the application itself, not a transient +`dotnet run` wrapper. It waits for the agent and CDP before running the hosted +Playwright and `MauiBlazorWeb.DevFlow.Tests` suites. It only stops PIDs it started; it stops +the broker only when it started a previously stopped broker. Existing occupied +ports fail explicitly rather than being terminated. +The Development/Testing-only `/health` endpoint gates server readiness. + +## Prerequisites + +Install the .NET 10 MAUI workload and the matching experimental `maui` CLI +release `0.1.0-preview.12.26421.1`, and Xcode with Mac Catalyst support. The MAUI project references the matching +`Microsoft.Maui.DevFlow.Agent` and `Microsoft.Maui.DevFlow.Blazor` +`0.1.0-preview.12.26421.1` packages from the configured `dotnet10` NuGet +source. An unsigned local Mac Catalyst signing warning is expected. +Install the cached browser once for the Microsoft Playwright hosted-page tests: + +```bash +pwsh MauiBlazorWeb.WebUi.Tests/bin/Debug/net10.0/playwright.ps1 install chromium +``` + +Optional runner overrides are `DEVFLOW_SERVER_PORT`, `DEVFLOW_SERVER_URL`, +`DEVFLOW_AGENT_PORT` (or `DEVFLOW_TEST_PORT`). The runner never sources signing identities or +profiles. The temporary state lives under `scripts/.devflow-state-*` and is +removed on exit. + +## What is covered + +* `MauiBlazorWeb.IdentityApi.Tests` owns server API behavior. +* `MauiBlazorWeb.WebUi.Tests` uses Microsoft Playwright against the hosted + Razor account pages. +* The official `Microsoft.Maui.DevFlow.Client` `AgentClient` validates the + native app host through the `IdentityBlazorWebView` AutomationId and uses CDP + for the Blazor DOM: registration, Development-only + confirmation, failed and valid login, profile phone/password update, personal + data visibility, logout-all, deletion, and anonymous navigation. + +Live tests are intentionally opt-in and separately discoverable: + +```bash +DEVFLOW_LIVE_TESTS=1 DEVFLOW_SERVER_URL=https://localhost:7157 \ +DEVFLOW_AGENT_PORT=10223 \ +dotnet test MauiBlazorWeb.DevFlow.Tests +``` + +Hosted page tests are likewise opt-in: + +```bash +WEB_UI_TESTS=1 DEVFLOW_SERVER_URL=https://localhost:7157 \ +dotnet test MauiBlazorWeb.WebUi.Tests +``` + +Missing or unreachable required infrastructure produces an explicit MSTest +inconclusive result. The runner uses `maui devflow agent wait`, `list`, `agent +status`, and the `webview` readiness commands; the tests use the matching +`AgentClient` API to retain a single DevFlow mutation lease throughout the +stateful flow. Test diagnostics never print access/refresh/bearer tokens, +confirmation codes, or full response bodies. The test-only `HttpClient` +accepts a certificate only for a loopback Development server. + +The agent, Blazor tools, and Apple web inspector mapper are compiled and +registered only in `DEBUG`; Release has no DevFlow registration. The current +CLI surface used here is `webview` (including `webviews`, `status`, and +`source`), `ui`, `agent`, `broker`, and `batch`. +Do not use obsolete `agent interact` commands. + +Debug Mac Catalyst uses a Debug-only entitlement file with app sandbox disabled +so the in-app DevFlow agent can bind locally. Release continues to use the +normal sandbox entitlement file. + +Experimental limitations: Mac Catalyst launch/signing remains host-dependent, +and WebKit/CDP availability requires supported iOS or Mac Catalyst versions. +The hosted Microsoft Playwright suite covers registration/confirmation/login +and forgot/reset-password/login browser ceremonies. Next coverage should add +deterministic 2FA, real passkey ceremonies, and an external-provider browser +callback once real provider/authenticator fixtures are available. diff --git a/10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md b/10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md new file mode 100644 index 000000000..d316e0502 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/IDENTITY_API_CAPABILITIES.md @@ -0,0 +1,110 @@ +# ASP.NET Core Identity REST capability matrix + +This sample keeps the framework's `MapIdentityApi` contract at `/identity`. +It adds contribution-oriented, first-party REST contracts using only public +`UserManager` and `SignInManager` APIs. **Stock**, **override**, +and **new** below describe the route source; support is assessed for the +complete hosted Identity feature, including its MAUI experience. + +`MauiBlazorWeb.IdentityApi.Tests/IdentityApiTests.cs` is the Microsoft-only +integration suite. It uses MSTest, `WebApplicationFactory`, and SQLite. +`MauiBlazorWeb.DevFlow.Tests` is a separately discoverable, opt-in live MSTest +suite: the official DevFlow `AgentClient` verifies the native MAUI host and +BlazorWebView AutomationId, then drives the MAUI Blazor DOM through CDP. It +does not replace API coverage; see [DEVFLOW_TESTING.md](DEVFLOW_TESTING.md) for +infrastructure and experimental limitations. + +## Capability inventory + +| Hosted Account feature | Classification | Route and implementation | MAUI use | Coverage / limitation | +| --- | --- | --- | --- | --- | +| Register | Fully supported | **Stock:** `POST /identity/register` | `Register.razor` through `AccountClient.RegisterAsync` | Registration is exercised by the typed client; Development supplies an in-memory notification only. | +| Confirm and resend email | Fully supported | **Stock:** `GET /identity/confirmEmail`, `POST /identity/resendConfirmationEmail` | Registration and Account screens | Confirmation remains the stock encoded-token flow; account-existence disclosure remains framework behavior. | +| Forgot and reset password | Fully supported | **Stock:** `POST /identity/forgotPassword`, `POST /identity/resetPassword` | `PasswordHelp.razor` | The development notification page provides links only in Development; production must provide a real `IEmailSender`. | +| Password login and opaque tokens | Fully supported | **Override:** `POST /identity-overrides/login`; **Stock comparison:** `POST /identity/login?useCookies=false`, `POST /identity/refresh` | `Login.razor`, `MauiAuthenticationStateProvider` | Test covers a stable `invalid_credentials` response. The override emits the stock bearer-token response on success. | +| Lockout, unconfirmed-account, authenticator, and recovery-code login outcomes | Partially supported | **Override:** `POST /identity-overrides/login` accepts `twoFactorCode` or `twoFactorRecoveryCode` and returns stable `locked_out`, `not_allowed`, or `requires_two_factor` codes | Login page has authenticator/recovery inputs after `requires_two_factor` | No deterministic integration test: meaningful TOTP/recovery tests require clock/authenticator or recovery-code setup that would obscure this public-API sample. | +| Profile and pending email change | Fully supported | **Override:** `GET/POST /identity-overrides/manage/info` | `Account.razor` displays and changes email | Update requires exactly one operation and sends the stock confirmation link. | +| Phone, change password, and first local password | Fully supported | **Override:** `POST /identity-overrides/manage/info` | Account editor | Test verifies exactly-one mutation plus required/valid current-password behavior. A passwordless account may add its first password. | +| Two-factor status | Fully supported | **Override:** `GET /identity-overrides/manage/2fa` | Account editor | Read-only status never exposes the authenticator key. | +| Configure authenticator, recovery codes, and remembered browser | Fully supported | **Stock:** `POST /identity/manage/2fa` | Account editor | Uses the framework's mutation endpoint. Successful TOTP ceremony coverage is intentionally not synthesized with private internals. | +| Passkey list, rename, and remove | Partially supported | **New:** `GET/PATCH/DELETE /identity/manage/passkeys` | Account screen lists/removes; the typed client also supports rename | Server route is complete, but MAUI's current UI does not offer rename and has no native ceremony yet. | +| Passkey register and sign-in ceremony | Partially supported | **New:** `POST /identity/passkeys/register/begin`, `/register/finish`, `/login/begin`, `/login/finish` | Account screen previews registration-begin JSON only | Begin writes the official temporary Identity cookie; tests cover its presence and finish-without-cookie failure. A real success assertion needs an authenticator. Native `Microsoft.Maui.Authentication.Passkeys` create/assert calls are the .NET 11 client seam. | +| View filtered personal data | Fully supported | **New:** `GET /identity/manage/personal-data` | Account screen | Explicit DTO excludes password hashes, tokens, provider keys, passkey public-key data, and authenticator secrets. | +| Delete account | Partially supported | **New:** `DELETE /identity/manage/account` | Account screen | Test proves wrong-password rejection and confirmed deletion. Passwordless deletion is deliberately blocked pending a real recent interactive reauthentication contract. | +| List and unlink linked providers | Partially supported | **New:** `GET/DELETE /identity/manage/external-logins/{provider}` | Account screen | Test proves a final sign-in method cannot be unlinked. Browser provider challenge, callback, and link handoff are deferred until a real provider is configured. | +| Local logout and logout all devices | Fully supported | Local MAUI token cleanup; **New:** `POST /identity/manage/logout-all` | `Logout.razor` and Account screen | Test proves security-stamp logout invalidates the refresh token but leaves the presented access token usable until expiry. | + +## Route-contract coverage + +The route rows below identify the suite and test that exercise each public +Identity endpoint. "Failure" is a deliberate public validation or +authentication assertion; "happy path" makes a successful request against a +fresh SQLite database. Stock endpoints remain framework-owned; the sample only +tests their documented HTTP contracts. + +| Route | Contract source | Coverage suite and test | Assertion | Remaining gap | +| --- | --- | --- | --- | --- | +| `POST /identity/register` | Stock | API: `Development_notification_confirms_a_stock_registration`; Playwright: `Register_confirm_and_login_complete_through_hosted_pages` | Registers and confirms an account | None | +| `GET /identity/confirmEmail` | Stock | API: `Development_notification_confirms_a_stock_registration`; Playwright: `Register_confirm_and_login_complete_through_hosted_pages` | Completes a generated confirmation action | None | +| `POST /identity/resendConfirmationEmail` | Stock | API: `Stock_resend_forgot_reset_login_refresh_and_manage_routes_have_happy_paths` | Queues a Development confirmation action for an unconfirmed account | Non-disclosure response is framework behavior | +| `POST /identity/forgotPassword` | Stock | API: `Stock_resend_forgot_reset_login_refresh_and_manage_routes_have_happy_paths`; Playwright: `Forgot_and_reset_password_complete_through_hosted_pages` | Queues reset action without disclosing account details | None | +| `POST /identity/resetPassword` | Stock | API: `Stock_resend_forgot_reset_login_refresh_and_manage_routes_have_happy_paths`; Playwright: `Forgot_and_reset_password_complete_through_hosted_pages` | Resets with a public `UserManager`-issued token | Invalid-token matrix remains framework-owned | +| `POST /identity/login` | Stock | API: `Stock_resend_forgot_reset_login_refresh_and_manage_routes_have_happy_paths`; Playwright: both hosted ceremony tests | Issues bearer tokens and accepts reset password | Browser cookie details remain framework-owned | +| `POST /identity/refresh` | Stock | API: `Stock_resend_forgot_reset_login_refresh_and_manage_routes_have_happy_paths`; `Logout_all_invalidates_refresh_tokens_but_not_the_current_access_token` | Refreshes valid token and rejects after logout-all | None | +| `GET/POST /identity/manage/info` | Stock | API: `Stock_resend_forgot_reset_login_refresh_and_manage_routes_have_happy_paths` | Reads profile and changes password | Pending-email confirmation ceremony is covered by stock confirmation route | +| `POST /identity/manage/2fa` | Stock | API: `Stock_resend_forgot_reset_login_refresh_and_manage_routes_have_happy_paths` | Generates a new authenticator shared key | Real TOTP enable/disable and recovery-code ceremony | +| `POST /identity-overrides/login` | Override | API: `Override_login_returns_stable_invalid_credentials_code`; DevFlow: `Incorrect_login_displays_a_failure_through_the_Maui_DOM` | Maps invalid credentials to stable code and client alert | Deterministic TOTP/recovery branches | +| `GET/POST /identity-overrides/manage/info` | Override | API: `Override_profile_and_two_factor_status_return_safe_extended_data`; `Override_account_update_requires_exactly_one_operation_and_confirms_the_current_password`; DevFlow: `Register_confirm_login_update_phone_and_logout_through_the_Maui_DOM` | Reads safe profile; validates and applies phone/password changes | Email-change confirmation flow | +| `GET /identity-overrides/manage/2fa` | Override | API: `Override_profile_and_two_factor_status_return_safe_extended_data` | Returns status without shared key | State after a real TOTP ceremony | +| `GET /identity/manage/passkeys` | New | API: `Passkey_management_and_registration_validate_public_failure_paths` | Returns an empty passkey collection for a new account | Populated-list assertion requires a real passkey | +| `PATCH/DELETE /identity/manage/passkeys/{credentialId}` | New | API: `Passkey_management_and_registration_validate_public_failure_paths` | Validates malformed IDs for rename/delete | Rename/delete happy paths require a real passkey | +| `POST /identity/passkeys/register/begin` | New | API: `Passkey_management_and_registration_validate_public_failure_paths` | Produces creation options for a bearer-authenticated user | Real platform attestation | +| `POST /identity/passkeys/register/finish` | New | API: `Passkey_management_and_registration_validate_public_failure_paths` | Reports invalid attestation deterministically | Real platform attestation | +| `POST /identity/passkeys/login/begin` | New | API: `Passkey_login_begin_writes_temporary_identity_cookie` | Writes official ceremony cookie | None | +| `POST /identity/passkeys/login/finish` | New | API: `Passkey_login_finish_without_the_begin_cookie_returns_a_stable_ceremony_failure` | Maps missing ceremony to stable failure | Real platform assertion | +| `GET /identity/manage/personal-data` | New | API: `Personal_data_and_external_login_management_exclude_sensitive_data`; DevFlow: `Register_confirm_login_update_phone_and_logout_through_the_Maui_DOM` | Returns profile while excluding secrets | None | +| `DELETE /identity/manage/account` | New | API: `Account_deletion_requires_the_current_password_and_deletes_only_after_confirmation`; DevFlow: `Logout_all_and_account_deletion_leave_the_Maui_client_anonymous` | Requires password and removes user | Passwordless recent-reauthentication contract | +| `POST /identity/manage/logout-all` | New | API: `Logout_all_invalidates_refresh_tokens_but_not_the_current_access_token`; DevFlow: `Logout_all_and_account_deletion_leave_the_Maui_client_anonymous` | Invalidates refresh state and returns client to anonymous navigation | Access token naturally remains valid to expiration | +| `GET /identity/manage/external-logins` | New | API: `Personal_data_and_external_login_management_exclude_sensitive_data`; `External_login_unlink_cannot_remove_the_last_sign_in_method` | Lists no providers for a new account, then the linked provider | Real provider challenge and callback | +| `DELETE /identity/manage/external-logins/{provider}` | New | API: `Personal_data_and_external_login_management_exclude_sensitive_data`; `External_login_unlink_cannot_remove_the_last_sign_in_method` | Unlinks a provider and rejects last-method removal | Real provider challenge and callback | +| `GET /development/notifications` | Development only | API: `Development_notifications_are_not_mapped_in_production`; `Development_notification_confirms_a_stock_registration`; Playwright: both hosted ceremony tests | Production-gated and executes generated actions only in Development | Not a production email transport | + +## Route coexistence and framework proposal + +The generic endpoint library never maps an existing method/path pair below +`/identity`. Framework routes remain directly inspectable and comparable. +Enhanced behavior is isolated at `/identity-overrides`; new routes under +`/identity` exist only where `MapIdentityApi` has no counterpart. + +The sample contracts marked **Override** or **New** are proposed ASP.NET Core +framework additions, not stock framework APIs: + +* a machine-readable login-outcome contract; +* extended, unambiguous account read/update and read-only 2FA status; +* passkey management and JSON ceremony endpoints; +* safe personal-data projection, password-confirmed deletion, provider + management, and security-stamp logout-all. + +They are intentionally generic and only invoke public Identity APIs, so they +can be lifted into an ASP.NET Core contribution without inheriting MAUI types. + +## Token, notification, and deployment constraints + +The in-box bearer tokens are Data Protection-protected opaque tickets, **not +JWTs** or an OAuth/OIDC service. Access tokens default to one hour; refresh +tokens default to 14 days and are reusable. There is no device registry, +replay detection, or individual-token revocation. Updating the security stamp +invalidates refresh credentials, while access tickets already issued remain +valid until expiry. Production deployments must persist and share Data +Protection keys across instances. + +`/development/notifications` is mapped only in Development, stores a bounded +in-memory list, and is test-covered as absent in Production. It is not an +email implementation and must never be enabled as a production delivery +mechanism. + +The server passkey routes use Identity's official temporary ceremony cookie. +The net10 MAUI client preserves a narrow JSON seam and only previews +registration options; its real `CreateAsync`/`AssertAsync` implementation is +deferred to .NET 11 MAUI passkey APIs. External providers similarly require a +browser handoff and callback, so their flow is deferred rather than simulated. diff --git a/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md new file mode 100644 index 000000000..284a14fc2 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/IDENTITY_API_IMPLEMENTATION.md @@ -0,0 +1,132 @@ +# 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`) | +| Starting commit for this increment | `94b3e0da570f36addc395be53c709edb96b98283` | +| Current implementation commit | `fbb447d08fce0e35c655aa8fc5ba51ddacab7c1c` (authenticated fixtures) | +| Current documentation baseline | `c04d9fde79c3d7683fb47616d60f8f0d8ff6d894` (OpenAPI URL correction) | +| Current completion status | Endpoint implementation, authenticated fixtures, documentation, and requested builds are complete; this status update records fixture artifact cleanup | +| 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 | Complete | Tracker, capability ledger, project inventory | +| 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 | Complete | Development notification UI, complete Microsoft-only integration suite, documentation reconciliation, and platform validation | +| 5 | Complete | Debug-only DevFlow Mac Catalyst live-test increment with isolated Development state | +| 6 | Complete | Route-contract test matrix, stock/override/new endpoint coverage, hosted Playwright ceremonies, and expanded DevFlow account flows | +| 7 | Complete | Runner launches the built server DLL directly so cleanup owns the real server PID | + +## Endpoint ledger + +| Endpoint group | Endpoint | Status | Notes | +| --- | --- | --- | --- | +| Stock | `/identity/*` | Existing | MapIdentityApi remains the direct comparison surface | +| 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 + +| Feature | Status | Intended client surface | +| --- | --- | --- | +| 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 + +Final validation was run after the DevFlow live-test increment and before the +route-contract coverage commit. + +| Command | Result | +| --- | --- | +| `dotnet --info` | SDK 11 preview and .NET 10 SDK/runtime installed | +| `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 | +| `dotnet build MauiBlazorWeb/MauiBlazorWeb.csproj -f net10.0-maccatalyst --no-restore` | Passed; existing unsigned local development entitlement warning | +| `dotnet build MauiBlazorWeb/MauiBlazorWeb.csproj -f net10.0-ios --no-restore` | Passed | +| `dotnet build MauiBlazorWeb/MauiBlazorWeb.csproj -f net10.0-android --no-restore` | Passed | +| `dotnet build MauiBlazorWeb.sln --no-restore` | Passed for web, Mac Catalyst, iOS, and Android; existing upstream package and unsigned local development entitlement warnings remain | +| 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 | +| `dotnet test MauiBlazorWeb.IdentityApi.Tests/MauiBlazorWeb.IdentityApi.Tests.csproj --no-restore` | Passed: 15 tests covering each stock, override, and new route with a public success, authorization, or validation assertion; per-factory SQLite files and WAL/SHM sidecars are cleaned | +| `WEB_UI_TESTS=1 DEVFLOW_SERVER_URL=https://localhost:7157 dotnet test MauiBlazorWeb.WebUi.Tests` | Passed: hosted Playwright registration/confirmation/login and forgot/reset-password/login ceremonies | +| `DEVFLOW_LIVE_TESTS=1 DEVFLOW_SERVER_URL=https://localhost:7157 DEVFLOW_AGENT_PORT=10223 dotnet test MauiBlazorWeb.DevFlow.Tests` | Passed: 4 live Mac Catalyst DevFlow tests, including profile password/personal-data, logout-all, and deletion | + +The separately discoverable `MauiBlazorWeb.DevFlow.Tests` project is opt-in +live coverage. It uses Microsoft test infrastructure and the official +`Microsoft.Maui.DevFlow.Client` `AgentClient` API for coordinated CDP +automation; the runner also exercises the current `maui devflow webview`, +`agent`, and `broker` CLI commands for readiness diagnostics. It validates the +real Debug Mac Catalyst host/`IdentityBlazorWebView`, then drives registration, +Development-only confirmation, login, phone update, and logout through CDP. +See [DEVFLOW_TESTING.md](DEVFLOW_TESTING.md); no token, confirmation-code, or +full-response diagnostics are emitted. + +## Known blockers and deferred work + +* The MAUI client uses typed REST account methods and an operation/auth epoch. + The Microsoft-only integration suite covers stable public account invariants. + A full passkey attestation/assertion cannot be manufactured without a platform + authenticator; tests instead cover the official begin-cookie contract and + finish-without-cookie failure. This avoids test-only protocol internals. +* Native passkey ceremony execution is deferred behind a small client seam until + the .NET 11 MAUI passkey APIs are used. The net10 UI only previews the server + begin JSON. +* Browser external-provider login/link completion remains deferred until a real + provider is configured. + +## Resume instructions + +## Completion and resume status + +**Completion target:** all implemented routes and the hosted-feature ledger are +complete. The final validation run is recorded below before this documentation +increment is committed. + +**Deliberate limits:** lockout, TOTP, recovery-code, and successful +passkey-finish integration tests are not synthesized: realistic inputs require +clock control, an authenticator, or private protocol fixtures. The suite covers +the closest stable public behavior instead. Passwordless deletion requires a +future recent-interactive-reauthentication contract, and external-provider +browser handoff is deferred until a provider is configured. diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.DevFlow.Tests/DevFlowLiveTests.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.DevFlow.Tests/DevFlowLiveTests.cs new file mode 100644 index 000000000..5565b00e8 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.DevFlow.Tests/DevFlowLiveTests.cs @@ -0,0 +1,446 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Microsoft.Maui.DevFlow.Driver; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MauiBlazorWeb.DevFlow.Tests; + +[TestClass] +public sealed class DevFlowLiveTests +{ + [TestMethod] + [TestCategory("Live")] + public async Task Server_agent_and_cdp_are_ready() + { + using var environment = await LiveEnvironment.RequireServerAndDevFlowAsync(); + + await environment.DevFlow.RequireSuccessAsync("agent status", "agent", "status"); + await environment.DevFlow.RequireSuccessAsync("CDP WebView list", "webview", "webviews"); + await environment.DevFlow.RequireSuccessAsync("CDP status", "webview", "status"); + await environment.DevFlow.RequireSuccessAsync("CDP source", "webview", "source"); + } + + [TestMethod] + [TestCategory("Live")] + public async Task Incorrect_login_displays_a_failure_through_the_Maui_DOM() + { + using var environment = await LiveEnvironment.RequireServerAndDevFlowAsync(); + try + { + await environment.DevFlow.ClickAsync("[data-test='nav-login']"); + await environment.DevFlow.WaitForSelectorAsync("[data-test='login-email']"); + await environment.DevFlow.FillAsync("[data-test='login-email']", $"missing-{Guid.NewGuid():N}@example.test"); + await environment.DevFlow.FillAsync("[data-test='login-password']", "incorrect-password"); + await environment.DevFlow.ClickAsync("[data-test='login-submit']"); + await environment.DevFlow.WaitForConditionAsync( + "document.querySelector(\"[data-test='login-failure']\")?.hidden === false", + "invalid-login alert"); + } + finally + { + await environment.DevFlow.ReleaseMutationLeaseAsync(); + } + } + + [TestMethod] + [TestCategory("Live")] + public async Task Register_confirm_login_update_phone_and_logout_through_the_Maui_DOM() + { + using var environment = await LiveEnvironment.RequireServerAndDevFlowAsync(); + try + { + var email = $"devflow-{Guid.NewGuid():N}@example.test"; + const string password = "DevFlow!Test-Password42"; + var phone = $"555{Random.Shared.Next(1000000, 9999999)}"; + + await environment.DevFlow.ClickAsync("[data-test='nav-register']"); + await environment.DevFlow.WaitForSelectorAsync("[data-test='register-email']"); + await environment.DevFlow.FillAsync("[data-test='register-email']", email); + await environment.DevFlow.FillAsync("[data-test='register-password']", password); + await environment.DevFlow.ClickAsync("[data-test='register-submit']"); + await environment.DevFlow.WaitForConditionAsync( + "document.querySelector(\"[data-test='registration-result']\")?.classList.contains('alert-success') === true", + "registration success"); + + var confirmationUrl = await environment.GetConfirmationUrlAsync(email); + using var confirmation = await environment.HttpClient.GetAsync(confirmationUrl); + Assert.IsTrue( + confirmation.IsSuccessStatusCode, + $"The development confirmation action returned {(int)confirmation.StatusCode} ({confirmation.StatusCode})."); + Assert.IsTrue( + await environment.CanLogInAsync(email, password), + "The confirmed account was not accepted by the server login endpoint."); + + await environment.DevFlow.ClickAsync("[data-test='nav-login']"); + await environment.DevFlow.WaitForSelectorAsync("[data-test='login-email']"); + await environment.DevFlow.FillAsync("[data-test='login-email']", email); + await environment.DevFlow.FillAsync("[data-test='login-password']", password); + await environment.DevFlow.WaitForConditionAsync( + $"document.querySelector(\"[data-test='login-password']\")?.value === '{password}'", + "replacement login password"); + await environment.DevFlow.ClickAsync("[data-test='login-submit']"); + await environment.DevFlow.WaitForSelectorAsync("[data-test='nav-account']"); + await environment.DevFlow.ClickAsync("[data-test='nav-account']"); + await environment.DevFlow.WaitForConditionAsync( + $"document.querySelector(\"[data-test='account-email']\")?.textContent.includes('{email}') === true", + "confirmed account email"); + + await environment.DevFlow.FillAsync("[data-test='phone-input']", phone); + await environment.DevFlow.ClickAsync("[data-test='phone-save']"); + await environment.DevFlow.WaitForConditionAsync( + "document.querySelector(\"[data-test='account-result']\")?.classList.contains('alert-success') === true", + "phone-save success"); + await environment.DevFlow.WaitForConditionAsync( + $"document.querySelector(\"[data-test='phone-input']\")?.value === '{phone}'", + "phone round trip"); + + const string changedPassword = "DevFlow!Changed-Password42"; + await environment.DevFlow.FillAsync("[data-test='password-current-input']", password); + await environment.DevFlow.FillAsync("[data-test='password-new-input']", changedPassword); + await environment.DevFlow.ClickAsync("[data-test='password-save']"); + await environment.DevFlow.WaitForConditionAsync( + "document.querySelector(\"[data-test='account-result']\")?.classList.contains('alert-success') === true", + "password-change success"); + + await environment.DevFlow.ClickAsync("[data-test='personal-data-load']"); + await environment.DevFlow.WaitForConditionAsync( + $"document.querySelector(\"[data-test='personal-data']\")?.textContent.includes('{email}') === true", + "personal data visibility"); + + await environment.DevFlow.ClickAsync("[data-test='nav-logout']"); + await environment.DevFlow.WaitForSelectorAsync("[data-test='nav-login']"); + await environment.DevFlow.WaitForConditionAsync( + "document.querySelector(\"[data-test='nav-account']\") === null", + "anonymous navigation state"); + } + finally + { + await environment.DevFlow.ReleaseMutationLeaseAsync(); + } + } + + [TestMethod] + [TestCategory("Live")] + public async Task Logout_all_and_account_deletion_leave_the_Maui_client_anonymous() + { + using var environment = await LiveEnvironment.RequireServerAndDevFlowAsync(); + try + { + var credentials = await environment.RegisterAndConfirmAsync(); + await environment.DevFlow.SignInAsync(credentials.Email, credentials.Password); + + await environment.DevFlow.ClickAsync("[data-test='nav-account']"); + await environment.DevFlow.WaitForSelectorAsync("[data-test='logout-all']"); + await environment.DevFlow.ClickAsync("[data-test='logout-all']"); + await environment.DevFlow.WaitForSelectorAsync("[data-test='nav-login']"); + await environment.DevFlow.WaitForConditionAsync( + "document.querySelector(\"[data-test='nav-account']\") === null", + "logout-all anonymous navigation state"); + + await environment.DevFlow.SignInAsync(credentials.Email, credentials.Password); + await environment.DevFlow.ClickAsync("[data-test='nav-account']"); + await environment.DevFlow.WaitForSelectorAsync("[data-test='delete-password-input']"); + await environment.DevFlow.FillAsync("[data-test='delete-password-input']", credentials.Password); + await environment.DevFlow.ClickAsync("[data-test='delete-account']"); + await environment.DevFlow.WaitForSelectorAsync("[data-test='nav-register']"); + Assert.IsFalse( + await environment.CanLogInAsync(credentials.Email, credentials.Password), + "The deleted account was still accepted by the server login endpoint."); + } + finally + { + await environment.DevFlow.ReleaseMutationLeaseAsync(); + } + } + +} + +internal sealed class LiveEnvironment : IDisposable +{ + private const int DefaultAgentPort = 10223; + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(15); + + private LiveEnvironment(Uri serverUrl, int agentPort) + { + ServerUrl = serverUrl; + HttpClient = CreateHttpClient(); + DevFlow = new DevFlowClient(agentPort); + } + + internal Uri ServerUrl { get; } + internal HttpClient HttpClient { get; } + internal DevFlowClient DevFlow { get; } + + internal static async Task RequireServerAndDevFlowAsync() + { + var environment = Create(); + try + { + using var response = await environment.HttpClient.GetAsync(new Uri(environment.ServerUrl, "/health")); + Assert.IsTrue(response.IsSuccessStatusCode, "DEVFLOW_SERVER_URL is reachable but did not return a successful response."); + await environment.DevFlow.WaitForAgentAsync(); + await environment.DevFlow.WaitForCdpAsync(); + return environment; + } + catch (HttpRequestException) + { + environment.HttpClient.Dispose(); + Assert.Inconclusive("DEVFLOW_SERVER_URL is not reachable. Start the Development server before running live tests."); + throw; + } + } + + internal async Task GetConfirmationUrlAsync(string email) + { + using var response = await HttpClient.GetAsync(new Uri(ServerUrl, "/development/notifications")); + Assert.IsTrue(response.IsSuccessStatusCode, "The Development notification endpoint is unavailable."); + var notifications = await response.Content.ReadAsStringAsync(); + var match = Regex.Match( + notifications, + $"Confirm email.*?for\\s+{Regex.Escape(email)}.*?href=\\\"(?[^\\\"]+)\\\"", + RegexOptions.Singleline | RegexOptions.CultureInvariant); + Assert.IsTrue(match.Success, "No confirmation notification was found for the generated test account."); + return new Uri(ServerUrl, WebUtility.HtmlDecode(match.Groups["href"].Value)); + } + + internal async Task CanLogInAsync(string email, string password) + { + using var response = await HttpClient.PostAsJsonAsync( + new Uri(ServerUrl, "/identity-overrides/login"), + new { email, password }); + return response.IsSuccessStatusCode; + } + + internal async Task RegisterAndConfirmAsync() + { + var email = $"devflow-{Guid.NewGuid():N}@example.test"; + const string password = "DevFlow!Test-Password42"; + await DevFlow.ClickAsync("[data-test='nav-register']"); + await DevFlow.WaitForSelectorAsync("[data-test='register-email']"); + await DevFlow.FillAsync("[data-test='register-email']", email); + await DevFlow.FillAsync("[data-test='register-password']", password); + await DevFlow.ClickAsync("[data-test='register-submit']"); + await DevFlow.WaitForConditionAsync( + "document.querySelector(\"[data-test='registration-result']\")?.classList.contains('alert-success') === true", + "registration success"); + + var confirmationUrl = await GetConfirmationUrlAsync(email); + using var confirmation = await HttpClient.GetAsync(confirmationUrl); + Assert.IsTrue(confirmation.IsSuccessStatusCode, "The development confirmation action did not succeed."); + return new Credentials(email, password); + } + + private static LiveEnvironment Create() + { + if (!string.Equals(Environment.GetEnvironmentVariable("DEVFLOW_LIVE_TESTS"), "1", StringComparison.Ordinal)) + { + Assert.Inconclusive("Set DEVFLOW_LIVE_TESTS=1 to opt into tests that control a live Development app."); + } + + var serverValue = Environment.GetEnvironmentVariable("DEVFLOW_SERVER_URL"); + if (!Uri.TryCreate(serverValue, UriKind.Absolute, out var serverUrl) || + (serverUrl.Scheme != Uri.UriSchemeHttp && serverUrl.Scheme != Uri.UriSchemeHttps)) + { + Assert.Inconclusive("Set DEVFLOW_SERVER_URL to the reachable Development server URL."); + } + + var agentPortValue = Environment.GetEnvironmentVariable("DEVFLOW_AGENT_PORT"); + if (!int.TryParse(agentPortValue, out var agentPort) || agentPort is < 1 or > 65535) + { + Assert.Inconclusive($"Set DEVFLOW_AGENT_PORT to the reachable DevFlow agent port (for example, {DefaultAgentPort})."); + } + + return new LiveEnvironment(serverUrl!, agentPort); + } + + private static HttpClient CreateHttpClient() + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = static (request, _, _, _) => + request.RequestUri?.IsLoopback == true + }; + return new HttpClient(handler) { Timeout = RequestTimeout }; + } + + public void Dispose() + { + DevFlow.Dispose(); + HttpClient.Dispose(); + } + + internal sealed record Credentials(string Email, string Password); +} + +internal sealed class DevFlowClient : IDisposable +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(30); + private readonly AgentClient client; + + internal DevFlowClient(int agentPort) + { + client = new AgentClient("127.0.0.1", agentPort) + { + MutationLeaseHolderKind = "mstest", + MutationLeaseLabel = "MauiBlazorWeb.DevFlow.Tests", + }; + } + + internal async Task FillAsync(string selector, string text) + { + Assert.IsTrue(await client.FillWebViewAsync(selector, text), "DevFlow could not fill a DOM form field."); + Assert.IsTrue( + await EvaluateBooleanAsync( + $"(() => {{ const element = document.querySelector({JsonSerializer.Serialize(selector)}); if (!element) return false; element.dispatchEvent(new Event('change', {{ bubbles: true }})); return true; }})()"), + "DevFlow could not commit a DOM form field change."); + } + + internal async Task ClickAsync(string selector) + { + if (await client.ClickWebViewAsync(selector)) + { + return; + } + + var selectorLiteral = JsonSerializer.Serialize(selector); + await EvaluateBooleanAsync( + $"(() => {{ const element = document.querySelector({selectorLiteral}); if (!element) return false; element.click(); return true; }})()"); + } + + internal async Task WaitForSelectorAsync(string selector) + { + await WaitForConditionAsync($"document.querySelector(\"{selector}\") !== null", $"selector {selector}"); + } + + internal async Task SignInAsync(string email, string password) + { + await ClickAsync("[data-test='nav-login']"); + await WaitForSelectorAsync("[data-test='login-email']"); + await FillAsync("[data-test='login-email']", email); + await FillAsync("[data-test='login-password']", password); + await ClickAsync("[data-test='login-submit']"); + await WaitForSelectorAsync("[data-test='nav-account']"); + } + + internal async Task WaitForConditionAsync(string expression, string description) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try + { + while (!timeout.IsCancellationRequested) + { + if (await EvaluateBooleanAsync(expression)) + { + return; + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), timeout.Token); + } + } + catch (OperationCanceledException) + { + } + + Assert.Fail($"Timed out waiting for {description} in the Blazor WebView."); + } + + internal async Task RequireSuccessAsync(string operation, params string[] command) + { + if (command is ["agent", "status"]) + { + Assert.IsNotNull(await client.GetStatusAsync(), $"{operation} did not return an agent status."); + return; + } + + if (command is ["webview", "webviews"]) + { + Assert.IsTrue((await client.GetCdpWebViewsAsync()).ToString().Contains("IdentityBlazorWebView", StringComparison.Ordinal), + $"{operation} did not return the registered WebView."); + return; + } + + if (command is ["webview", "status"]) + { + await WaitForCdpAsync(); + return; + } + + if (command is ["webview", "source"]) + { + Assert.IsFalse(string.IsNullOrWhiteSpace(await client.GetCdpSourceAsync()), $"{operation} returned no document."); + return; + } + + Assert.Fail($"Unsupported DevFlow readiness command: {string.Join(' ', command)}."); + } + + internal async Task WaitForAgentAsync() + { + try + { + Assert.IsNotNull(await client.GetStatusAsync(), "The DevFlow agent did not return a status."); + } + catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or InvalidOperationException) + { + Assert.Inconclusive("The DevFlow agent is unavailable. Launch the Debug Mac Catalyst app and wait for its agent."); + } + } + + internal async Task WaitForCdpAsync() + { + using var timeout = new CancellationTokenSource(CommandTimeout); + try + { + while (!timeout.IsCancellationRequested) + { + var webViews = await client.GetCdpWebViewsAsync(); + if (webViews.TryGetProperty("webviews", out var items) && + items.ValueKind == JsonValueKind.Array && + items.EnumerateArray().Any(item => + item.TryGetProperty("ready", out var ready) && + ready.ValueKind == JsonValueKind.True)) + { + return; + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), timeout.Token); + } + } + catch (OperationCanceledException) + { + } + + Assert.Fail("The DevFlow agent connected, but its Blazor CDP bridge did not become ready."); + } + + internal async Task ReleaseMutationLeaseAsync() + { + var result = await client.ControlMutationLeaseAsync("release"); + Assert.IsTrue(result.Ok || string.Equals(result.Authority, "unsupported", StringComparison.Ordinal), + "The live test could not release its DevFlow mutation lease."); + } + + private async Task EvaluateBooleanAsync(string expression) + { + var result = await client.SendCdpCommandAsync( + "Runtime.evaluate", + new JsonObject + { + ["expression"] = expression, + ["returnByValue"] = true, + }); + + return result.TryGetProperty("result", out var outer) && + outer.TryGetProperty("result", out var inner) && + inner.TryGetProperty("value", out var value) && + value.ValueKind is JsonValueKind.True; + } + + public void Dispose() + { + client.Dispose(); + } +} diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.DevFlow.Tests/MauiBlazorWeb.DevFlow.Tests.csproj b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.DevFlow.Tests/MauiBlazorWeb.DevFlow.Tests.csproj new file mode 100644 index 000000000..e3f9571c7 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.DevFlow.Tests/MauiBlazorWeb.DevFlow.Tests.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi.Tests/IdentityApiTests.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi.Tests/IdentityApiTests.cs new file mode 100644 index 000000000..9db0d4d89 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi.Tests/IdentityApiTests.cs @@ -0,0 +1,525 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using MauiBlazorWeb.Web.Data; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MauiBlazorWeb.IdentityApi.Tests; + +[TestClass] +public sealed class IdentityApiTests +{ + [TestMethod] + public async Task Stock_override_and_new_routes_are_distinct() + { + await using var factory = new IdentityApiFactory(); + using var client = factory.CreateInitializedClient(); + + var document = await client.GetStringAsync("/openapi/v1.json"); + + StringAssert.Contains(document, "\"/identity/login\""); + StringAssert.Contains(document, "\"/identity-overrides/login\""); + StringAssert.Contains(document, "\"/identity/manage/passkeys\""); + } + + [TestMethod] + public async Task Override_login_returns_stable_invalid_credentials_code() + { + await using var factory = new IdentityApiFactory(); + using var client = factory.CreateInitializedClient(); + + var response = await client.PostAsJsonAsync("/identity-overrides/login", new + { + email = "missing@example.test", + password = "Password1!", + }); + + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + using var body = JsonDocument.Parse(await response.Content.ReadAsStreamAsync()); + Assert.AreEqual("invalid_credentials", body.RootElement.GetProperty("code").GetString()); + } + + [TestMethod] + public async Task Native_routes_do_not_accept_the_application_cookie() + { + await using var factory = new IdentityApiFactory(); + using var client = factory.CreateInitializedClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true, + }); + + var response = await client.GetAsync("/identity/manage/passkeys"); + + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [TestMethod] + public async Task Passkey_login_begin_writes_temporary_identity_cookie() + { + await using var factory = new IdentityApiFactory(); + using var client = factory.CreateInitializedClient(new WebApplicationFactoryClientOptions + { + HandleCookies = false, + }); + + var response = await client.PostAsync("/identity/passkeys/login/begin", null); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.IsTrue(response.Headers.TryGetValues("Set-Cookie", out var cookies)); + StringAssert.Contains(string.Join(Environment.NewLine, cookies), "Identity.TwoFactorUserId"); + } + + [TestMethod] + public async Task Passkey_login_finish_without_the_begin_cookie_returns_a_stable_ceremony_failure() + { + await using var factory = new IdentityApiFactory(); + using var client = factory.CreateInitializedClient(new WebApplicationFactoryClientOptions + { + HandleCookies = false, + }); + + var response = await client.PostAsJsonAsync("/identity/passkeys/login/finish", new { }); + + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + using var body = JsonDocument.Parse(await response.Content.ReadAsStreamAsync()); + Assert.AreEqual("passkey_ceremony_not_found", body.RootElement.GetProperty("code").GetString()); + } + + [TestMethod] + public async Task Development_notifications_are_not_mapped_in_production() + { + await using var factory = new IdentityApiFactory(Environments.Production); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/development/notifications"); + + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + } + + [TestMethod] + public async Task Development_notification_confirms_a_stock_registration() + { + await using var factory = new IdentityApiFactory(Environments.Development); + using var client = factory.CreateInitializedClient(); + var email = $"development-{Guid.NewGuid():N}@example.test"; + const string password = "Password1!"; + + var registration = await client.PostAsJsonAsync("/identity/register", new { email, password }); + Assert.AreEqual(HttpStatusCode.OK, registration.StatusCode); + + var confirmationUrl = await factory.GetDevelopmentNotificationActionAsync(client, "Confirm email", email); + using var confirmation = await client.GetAsync(confirmationUrl); + Assert.AreEqual(HttpStatusCode.OK, confirmation.StatusCode); + + var login = await client.PostAsJsonAsync("/identity/login?useCookies=false", new { email, password }); + Assert.AreEqual(HttpStatusCode.OK, login.StatusCode); + } + + [TestMethod] + public async Task Stock_resend_forgot_reset_login_refresh_and_manage_routes_have_happy_paths() + { + await using var factory = new IdentityApiFactory(Environments.Development); + using var client = factory.CreateInitializedClient(); + var unconfirmedEmail = $"unconfirmed-{Guid.NewGuid():N}@example.test"; + const string originalPassword = "Password1!"; + const string resetPassword = "ResetPassword1!"; + await factory.CreateUserAsync(unconfirmedEmail, originalPassword, emailConfirmed: false); + + var resend = await client.PostAsJsonAsync("/identity/resendConfirmationEmail", new { email = unconfirmedEmail }); + Assert.AreEqual(HttpStatusCode.OK, resend.StatusCode); + _ = await factory.GetDevelopmentNotificationActionAsync(client, "Confirm email", unconfirmedEmail); + + var authenticated = await factory.CreateAuthenticatedClientAsync(); + var forgot = await client.PostAsJsonAsync("/identity/forgotPassword", new { email = authenticated.Email }); + Assert.AreEqual(HttpStatusCode.OK, forgot.StatusCode); + _ = await factory.GetDevelopmentNotificationActionAsync(client, "Reset password", authenticated.Email); + + var resetCode = await factory.GeneratePasswordResetCodeAsync(authenticated.Email); + var reset = await client.PostAsJsonAsync("/identity/resetPassword", new + { + email = authenticated.Email, + resetCode, + newPassword = resetPassword, + }); + Assert.AreEqual(HttpStatusCode.OK, reset.StatusCode); + + var login = await client.PostAsJsonAsync("/identity/login?useCookies=false", new + { + email = authenticated.Email, + password = resetPassword, + }); + Assert.AreEqual(HttpStatusCode.OK, login.StatusCode); + using var loginBody = JsonDocument.Parse(await login.Content.ReadAsStreamAsync()); + var refreshToken = loginBody.RootElement.GetProperty("refreshToken").GetString(); + Assert.IsFalse(string.IsNullOrWhiteSpace(refreshToken)); + + var refresh = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }); + Assert.AreEqual(HttpStatusCode.OK, refresh.StatusCode); + + using var stockManage = await authenticated.Client.GetAsync("/identity/manage/info"); + Assert.AreEqual(HttpStatusCode.OK, stockManage.StatusCode); + using var stockManageBody = JsonDocument.Parse(await stockManage.Content.ReadAsStreamAsync()); + Assert.AreEqual(authenticated.Email, stockManageBody.RootElement.GetProperty("email").GetString()); + + var stockManageUpdate = await authenticated.Client.PostAsJsonAsync("/identity/manage/info", new + { + oldPassword = resetPassword, + newPassword = "ManagedPassword1!", + }); + Assert.AreEqual(HttpStatusCode.OK, stockManageUpdate.StatusCode); + + var stockTwoFactor = await authenticated.Client.PostAsJsonAsync("/identity/manage/2fa", new { resetSharedKey = true }); + Assert.AreEqual(HttpStatusCode.OK, stockTwoFactor.StatusCode); + } + + [TestMethod] + public async Task Override_profile_and_two_factor_status_return_safe_extended_data() + { + await using var factory = new IdentityApiFactory(); + using var authenticated = await factory.CreateAuthenticatedClientAsync(); + + using var info = await authenticated.Client.GetAsync("/identity-overrides/manage/info"); + Assert.AreEqual(HttpStatusCode.OK, info.StatusCode); + using var infoBody = JsonDocument.Parse(await info.Content.ReadAsStreamAsync()); + Assert.AreEqual(authenticated.Email, infoBody.RootElement.GetProperty("email").GetString()); + Assert.IsTrue(infoBody.RootElement.GetProperty("isEmailConfirmed").GetBoolean()); + Assert.IsFalse(infoBody.RootElement.TryGetProperty("accessToken", out _)); + + var updated = await authenticated.Client.PostAsJsonAsync("/identity-overrides/manage/info", new { phoneNumber = "+15551234567" }); + Assert.AreEqual(HttpStatusCode.OK, updated.StatusCode); + using var updatedBody = JsonDocument.Parse(await updated.Content.ReadAsStreamAsync()); + Assert.AreEqual("+15551234567", updatedBody.RootElement.GetProperty("phoneNumber").GetString()); + + using var twoFactor = await authenticated.Client.GetAsync("/identity-overrides/manage/2fa"); + Assert.AreEqual(HttpStatusCode.OK, twoFactor.StatusCode); + using var twoFactorBody = JsonDocument.Parse(await twoFactor.Content.ReadAsStreamAsync()); + Assert.IsFalse(twoFactorBody.RootElement.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.IsFalse(twoFactorBody.RootElement.TryGetProperty("sharedKey", out _)); + } + + [TestMethod] + public async Task Passkey_management_and_registration_validate_public_failure_paths() + { + await using var factory = new IdentityApiFactory(); + using var authenticated = await factory.CreateAuthenticatedClientAsync(); + + var list = await authenticated.Client.GetFromJsonAsync("/identity/manage/passkeys"); + Assert.IsNotNull(list); + Assert.AreEqual(0, list.Length); + + var malformedRename = await authenticated.Client.PatchAsJsonAsync("/identity/manage/passkeys/not-base64!", new { name = "Laptop" }); + Assert.AreEqual(HttpStatusCode.BadRequest, malformedRename.StatusCode); + await AssertValidationErrorAsync(malformedRename, "credentialId"); + + var malformedDelete = await authenticated.Client.DeleteAsync("/identity/manage/passkeys/not-base64!"); + Assert.AreEqual(HttpStatusCode.BadRequest, malformedDelete.StatusCode); + await AssertValidationErrorAsync(malformedDelete, "credentialId"); + + var begin = await authenticated.Client.PostAsync("/identity/passkeys/register/begin", null); + Assert.AreEqual(HttpStatusCode.OK, begin.StatusCode); + Assert.AreEqual("application/json", begin.Content.Headers.ContentType?.MediaType); + + var finish = await authenticated.Client.PostAsJsonAsync("/identity/passkeys/register/finish", new { }); + Assert.AreEqual(HttpStatusCode.BadRequest, finish.StatusCode); + await AssertFailureCodeAsync(finish, "passkey_attestation_failed"); + } + + [TestMethod] + public async Task Personal_data_and_external_login_management_exclude_sensitive_data() + { + await using var factory = new IdentityApiFactory(); + using var authenticated = await factory.CreateAuthenticatedClientAsync(); + + using var personalData = await authenticated.Client.GetAsync("/identity/manage/personal-data"); + Assert.AreEqual(HttpStatusCode.OK, personalData.StatusCode); + using var personalDataBody = JsonDocument.Parse(await personalData.Content.ReadAsStreamAsync()); + Assert.AreEqual(authenticated.Email, personalDataBody.RootElement.GetProperty("email").GetString()); + Assert.IsFalse(personalDataBody.RootElement.TryGetProperty("passwordHash", out _)); + Assert.IsFalse(personalDataBody.RootElement.TryGetProperty("authenticatorKey", out _)); + + var emptyLogins = await authenticated.Client.GetFromJsonAsync("/identity/manage/external-logins"); + Assert.IsNotNull(emptyLogins); + Assert.AreEqual(0, emptyLogins.Length); + + await factory.AddExternalLoginAsync(authenticated.Email); + var deleted = await authenticated.Client.DeleteAsync("/identity/manage/external-logins/GitHub"); + Assert.AreEqual(HttpStatusCode.NoContent, deleted.StatusCode); + } + + [TestMethod] + public async Task Override_account_update_requires_exactly_one_operation_and_confirms_the_current_password() + { + await using var factory = new IdentityApiFactory(); + using var authenticated = await factory.CreateAuthenticatedClientAsync(); + + var ambiguous = await authenticated.Client.PostAsJsonAsync("/identity-overrides/manage/info", new + { + phoneNumber = "+15551234567", + newPassword = "ChangedPassword1!", + }); + Assert.AreEqual(HttpStatusCode.BadRequest, ambiguous.StatusCode); + await AssertValidationErrorAsync(ambiguous, "AmbiguousOperation"); + + var missingCurrentPassword = await authenticated.Client.PostAsJsonAsync("/identity-overrides/manage/info", new + { + newPassword = "ChangedPassword1!", + }); + Assert.AreEqual(HttpStatusCode.BadRequest, missingCurrentPassword.StatusCode); + await AssertValidationErrorAsync(missingCurrentPassword, "OldPasswordRequired"); + + var wrongCurrentPassword = await authenticated.Client.PostAsJsonAsync("/identity-overrides/manage/info", new + { + oldPassword = "WrongPassword1!", + newPassword = "ChangedPassword1!", + }); + Assert.AreEqual(HttpStatusCode.BadRequest, wrongCurrentPassword.StatusCode); + + var changed = await authenticated.Client.PostAsJsonAsync("/identity-overrides/manage/info", new + { + oldPassword = authenticated.Password, + newPassword = "ChangedPassword1!", + }); + Assert.AreEqual(HttpStatusCode.OK, changed.StatusCode); + + var login = await authenticated.Client.PostAsJsonAsync("/identity-overrides/login", new + { + email = authenticated.Email, + password = "ChangedPassword1!", + }); + Assert.AreEqual(HttpStatusCode.OK, login.StatusCode); + } + + [TestMethod] + public async Task Logout_all_invalidates_refresh_tokens_but_not_the_current_access_token() + { + await using var factory = new IdentityApiFactory(); + using var authenticated = await factory.CreateAuthenticatedClientAsync(); + + var beforeLogoutAll = await authenticated.Client.GetAsync("/identity/manage/personal-data"); + Assert.AreEqual(HttpStatusCode.OK, beforeLogoutAll.StatusCode); + + var logoutAll = await authenticated.Client.PostAsync("/identity/manage/logout-all", null); + Assert.AreEqual(HttpStatusCode.NoContent, logoutAll.StatusCode); + + var currentAccessToken = await authenticated.Client.GetAsync("/identity/manage/personal-data"); + Assert.AreEqual(HttpStatusCode.OK, currentAccessToken.StatusCode); + + var refresh = await authenticated.Client.PostAsJsonAsync("/identity/refresh", new + { + refreshToken = authenticated.RefreshToken, + }); + Assert.AreEqual(HttpStatusCode.BadRequest, refresh.StatusCode); + } + + [TestMethod] + public async Task Account_deletion_requires_the_current_password_and_deletes_only_after_confirmation() + { + await using var factory = new IdentityApiFactory(); + using var authenticated = await factory.CreateAuthenticatedClientAsync(); + + var rejected = await authenticated.Client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/identity/manage/account") + { + Content = JsonContent.Create(new { currentPassword = "WrongPassword1!" }), + }); + Assert.AreEqual(HttpStatusCode.BadRequest, rejected.StatusCode); + await AssertValidationErrorAsync(rejected, "InvalidCurrentPassword"); + Assert.IsNotNull(await factory.FindByEmailAsync(authenticated.Email)); + + var deleted = await authenticated.Client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/identity/manage/account") + { + Content = JsonContent.Create(new { currentPassword = authenticated.Password }), + }); + Assert.AreEqual(HttpStatusCode.NoContent, deleted.StatusCode); + Assert.IsNull(await factory.FindByEmailAsync(authenticated.Email)); + } + + [TestMethod] + public async Task External_login_unlink_cannot_remove_the_last_sign_in_method() + { + await using var factory = new IdentityApiFactory(); + using var authenticated = await factory.CreateAuthenticatedClientAsync(); + + await factory.MakeExternalLoginTheOnlySignInMethodAsync(authenticated.Email); + + var rejected = await authenticated.Client.DeleteAsync("/identity/manage/external-logins/GitHub"); + Assert.AreEqual(HttpStatusCode.BadRequest, rejected.StatusCode); + await AssertValidationErrorAsync(rejected, "LastSignInMethod"); + + var logins = await authenticated.Client.GetFromJsonAsync("/identity/manage/external-logins"); + Assert.IsNotNull(logins); + Assert.AreEqual(1, logins.Length); + Assert.AreEqual("GitHub", logins[0].GetProperty("provider").GetString()); + } + + private static async Task AssertValidationErrorAsync(HttpResponseMessage response, string errorCode) + { + using var body = JsonDocument.Parse(await response.Content.ReadAsStreamAsync()); + Assert.IsTrue(body.RootElement.GetProperty("errors").TryGetProperty(errorCode, out _), + $"Expected validation error '{errorCode}'."); + } + + private static async Task AssertFailureCodeAsync(HttpResponseMessage response, string expectedCode) + { + using var body = JsonDocument.Parse(await response.Content.ReadAsStreamAsync()); + Assert.AreEqual(expectedCode, body.RootElement.GetProperty("code").GetString()); + } + + private sealed class IdentityApiFactory(string environment = "Testing") + : WebApplicationFactory + { + private readonly string _databasePath = Path.Combine(Directory.GetCurrentDirectory(), $".identity-api-{Guid.NewGuid():N}.db"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment(environment); + builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = $"Data Source={_databasePath}", + })); + builder.ConfigureServices(services => + { + services.RemoveAll>(); + services.AddDbContext(options => options.UseSqlite($"Data Source={_databasePath}")); + }); + } + + public HttpClient CreateInitializedClient(WebApplicationFactoryClientOptions? options = null) + { + var client = options is null ? CreateClient() : CreateClient(options); + using var scope = Services.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.Migrate(); + return client; + } + + public async Task CreateAuthenticatedClientAsync() + { + var email = $"user-{Guid.NewGuid():N}@example.test"; + const string password = "Password1!"; + var client = CreateInitializedClient(); + await CreateUserAsync(email, password, emailConfirmed: true); + + var response = await client.PostAsJsonAsync("/identity-overrides/login", new + { + email, + password, + }); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + using var body = JsonDocument.Parse(await response.Content.ReadAsStreamAsync()); + var accessToken = body.RootElement.GetProperty("accessToken").GetString(); + var refreshToken = body.RootElement.GetProperty("refreshToken").GetString(); + Assert.IsFalse(string.IsNullOrWhiteSpace(accessToken)); + Assert.IsFalse(string.IsNullOrWhiteSpace(refreshToken)); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return new AuthenticatedIdentityClient(client, email, password, refreshToken!); + } + + public async Task FindByEmailAsync(string email) + { + using var scope = Services.CreateScope(); + return await scope.ServiceProvider.GetRequiredService>().FindByEmailAsync(email); + } + + public async Task CreateUserAsync(string email, string password, bool emailConfirmed) + { + using var scope = Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var result = await userManager.CreateAsync(new ApplicationUser + { + UserName = email, + Email = email, + EmailConfirmed = emailConfirmed, + }, password); + Assert.IsTrue(result.Succeeded, string.Join(", ", result.Errors.Select(error => error.Code))); + } + + public async Task GeneratePasswordResetCodeAsync(string email) + { + using var scope = Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(email); + Assert.IsNotNull(user); + var token = await userManager.GeneratePasswordResetTokenAsync(user); + return WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); + } + + public async Task GetDevelopmentNotificationActionAsync(HttpClient client, string kind, string email) + { + var page = await client.GetStringAsync("/development/notifications"); + var match = Regex.Match( + page, + $"{Regex.Escape(kind)}.*?for\\s+{Regex.Escape(email)}.*?href=\\\"(?[^\\\"]+)\\\"", + RegexOptions.Singleline | RegexOptions.CultureInvariant); + Assert.IsTrue(match.Success, $"No {kind} notification was found for the test account."); + return WebUtility.HtmlDecode(match.Groups["href"].Value); + } + + public async Task AddExternalLoginAsync(string email) + { + using var scope = Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(email); + Assert.IsNotNull(user); + Assert.IsTrue((await userManager.AddLoginAsync(user, new UserLoginInfo("GitHub", "github-user", "GitHub"))).Succeeded); + } + + public async Task MakeExternalLoginTheOnlySignInMethodAsync(string email) + { + using var scope = Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(email); + Assert.IsNotNull(user); + Assert.IsTrue((await userManager.AddLoginAsync(user, new UserLoginInfo("GitHub", "github-user", "GitHub"))).Succeeded); + Assert.IsTrue((await userManager.RemovePasswordAsync(user)).Succeeded); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + SqliteConnection.ClearAllPools(); + var directory = Path.GetDirectoryName(_databasePath)!; + var fileName = Path.GetFileName(_databasePath); + foreach (var path in Directory.GetFiles(directory, $"{fileName}*")) + { + File.Delete(path); + } + } + } + + public sealed class AuthenticatedIdentityClient(HttpClient client, string email, string password, string refreshToken) + : IDisposable + { + public HttpClient Client { get; } = client; + + public string Email { get; } = email; + + public string Password { get; } = password; + + public string RefreshToken { get; } = refreshToken; + + public void Dispose() => Client.Dispose(); + } + } +} diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi.Tests/MauiBlazorWeb.IdentityApi.Tests.csproj b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi.Tests/MauiBlazorWeb.IdentityApi.Tests.csproj new file mode 100644 index 000000000..eb817571b --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi.Tests/MauiBlazorWeb.IdentityApi.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + false + true + + + + + + + + + + + diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..203ac7bd2 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.IdentityApi/IdentityApiEndpointRouteBuilderExtensions.cs @@ -0,0 +1,729 @@ +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; +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; +using Microsoft.Extensions.Options; + +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 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); + } + } + 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); + + 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) => + 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 stockIdentityPrefix, string userId, string code, string changedEmail) + { + var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + 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) => + 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], + }); + + 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. +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"; +} + +/// 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); + +/// 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); + +/// 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.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/Components/Account/DevelopmentEmailSender.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Components/Account/DevelopmentEmailSender.cs new file mode 100644 index 000000000..5f591ee67 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.Web/Components/Account/DevelopmentEmailSender.cs @@ -0,0 +1,105 @@ +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/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..9f1a77769 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; @@ -29,11 +31,23 @@ options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme; }); -var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? (builder.Environment.IsEnvironment("Testing") + ? $"Data Source={Path.Combine(Directory.GetCurrentDirectory(), "MauiBlazorWebIdentity.Tests.db")}" + : throw new InvalidOperationException("Connection string 'DefaultConnection' not found.")); builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); 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"]; + options.ValidateOrigin = context => ValueTask.FromResult( + !context.CrossOrigin && passkeyOrigins.Contains(context.Origin, StringComparer.Ordinal)); +}); + // Needed for external clients to log in builder.Services.AddIdentityApiEndpoints(options => { @@ -42,26 +56,45 @@ }) .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 // https://learn.microsoft.com/aspnet/core/fundamentals/openapi/overview builder.Services.AddOpenApi(); +builder.Services.AddHealthChecks(); var app = builder.Build(); // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) +if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing")) { - // Apply migrations & create database if needed at startup - using (var scope = app.Services.CreateScope()) + if (app.Environment.IsDevelopment()) { + // Apply migrations & create database if needed at startup + using var scope = app.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.Database.Migrate(); } + app.UseMigrationsEndPoint(); app.MapOpenApi(); + if (app.Environment.IsDevelopment()) + { + app.MapGet("/development/notifications", (DevelopmentEmailSender sender) => + Results.Content(DevelopmentNotificationPage.Render(sender), "text/html")); + } + + app.MapHealthChecks("/health"); } else { @@ -81,7 +114,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 +126,11 @@ { var forecasts = await weatherService.GetWeatherForecastsAsync(); return Results.Ok(forecasts); -}).RequireAuthorization(); +}).RequireAuthorization(new AuthorizeAttribute +{ + AuthenticationSchemes = IdentityConstants.BearerScheme, +}); app.Run(); + +public partial class Program; 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/MauiBlazorWeb.WebUi.Tests/HostedPageTests.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.WebUi.Tests/HostedPageTests.cs new file mode 100644 index 000000000..3bc808227 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.WebUi.Tests/HostedPageTests.cs @@ -0,0 +1,143 @@ +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MauiBlazorWeb.WebUi.Tests; + +[TestClass] +public sealed class HostedPageTests +{ + [TestMethod] + [TestCategory("HostedUi")] + public async Task Account_pages_render_their_expected_forms() + { + await using var environment = await HostedPageEnvironment.CreateAsync(); + + await environment.Page.GotoAsync(environment.Url("/Account/Register")); + await Expect(environment.Page.Locator("h1")).ToHaveTextAsync("Register"); + await Expect(environment.Page.Locator("form")).ToBeVisibleAsync(); + await Expect(environment.Page.Locator("input[autocomplete='username']")).ToBeVisibleAsync(); + + await environment.Page.GotoAsync(environment.Url("/Account/Login")); + await Expect(environment.Page.Locator("h1")).ToHaveTextAsync("Log in"); + await Expect(environment.Page.Locator("form")).ToBeVisibleAsync(); + await Expect(environment.Page.Locator("input[autocomplete='current-password']")).ToBeVisibleAsync(); + } + + [TestMethod] + [TestCategory("HostedUi")] + public async Task Register_confirm_and_login_complete_through_hosted_pages() + { + await using var environment = await HostedPageEnvironment.CreateAsync(); + var email = $"browser-{Guid.NewGuid():N}@example.test"; + const string password = "Browser!Password42"; + + await environment.Page.GotoAsync(environment.Url("/Account/Register")); + await environment.Page.Locator("#Input\\.Email").FillAsync(email); + await environment.Page.Locator("#Input\\.Password").FillAsync(password); + await environment.Page.Locator("#Input\\.ConfirmPassword").FillAsync(password); + await environment.Page.GetByRole(AriaRole.Button, new() { Name = "Register", Exact = true }).ClickAsync(); + await Expect(environment.Page.Locator("h1")).ToHaveTextAsync("Register confirmation"); + + await environment.ConfirmNotificationAsync("Confirm email", email); + await environment.Page.GotoAsync(environment.Url("/Account/Login")); + await environment.Page.Locator("#Input\\.Email").FillAsync(email); + await environment.Page.Locator("#Input\\.Password").FillAsync(password); + await environment.Page.GetByRole(AriaRole.Button, new() { Name = "Log in", Exact = true }).ClickAsync(); + await environment.Page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/"); + } + + [TestMethod] + [TestCategory("HostedUi")] + public async Task Forgot_and_reset_password_complete_through_hosted_pages() + { + await using var environment = await HostedPageEnvironment.CreateAsync(); + var email = $"browser-{Guid.NewGuid():N}@example.test"; + const string originalPassword = "Browser!Password42"; + const string resetPassword = "Browser!ResetPassword42"; + + await environment.RegisterAndConfirmAsync(email, originalPassword); + await environment.Page.GotoAsync(environment.Url("/Account/ForgotPassword")); + await environment.Page.Locator("#Input\\.Email").FillAsync(email); + await environment.Page.GetByRole(AriaRole.Button, new() { Name = "Reset password", Exact = true }).ClickAsync(); + await Expect(environment.Page.Locator("h1")).ToHaveTextAsync("Forgot password confirmation"); + + await environment.OpenNotificationAsync("Reset password", email); + await environment.Page.Locator("#Input\\.Email").FillAsync(email); + await environment.Page.Locator("#Input\\.Password").FillAsync(resetPassword); + await environment.Page.Locator("#Input\\.ConfirmPassword").FillAsync(resetPassword); + await environment.Page.GetByRole(AriaRole.Button, new() { Name = "Reset", Exact = true }).ClickAsync(); + await Expect(environment.Page.Locator("h1")).ToHaveTextAsync("Reset password confirmation"); + + await environment.Page.GotoAsync(environment.Url("/Account/Login")); + await environment.Page.Locator("#Input\\.Email").FillAsync(email); + await environment.Page.Locator("#Input\\.Password").FillAsync(resetPassword); + await environment.Page.GetByRole(AriaRole.Button, new() { Name = "Log in", Exact = true }).ClickAsync(); + await environment.Page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/"); + } +} + +internal sealed class HostedPageEnvironment : IAsyncDisposable +{ + private HostedPageEnvironment(Uri serverUrl, IPlaywright playwright, IBrowser browser, IPage page) + { + ServerUrl = serverUrl; + Playwright = playwright; + Browser = browser; + Page = page; + } + + private Uri ServerUrl { get; } + private IPlaywright Playwright { get; } + private IBrowser Browser { get; } + internal IPage Page { get; } + + internal static async Task CreateAsync() + { + if (!string.Equals(Environment.GetEnvironmentVariable("WEB_UI_TESTS"), "1", StringComparison.Ordinal)) + { + Assert.Inconclusive("Set WEB_UI_TESTS=1 to opt into hosted browser tests."); + } + + if (!Uri.TryCreate(Environment.GetEnvironmentVariable("DEVFLOW_SERVER_URL"), UriKind.Absolute, out var serverUrl)) + { + Assert.Inconclusive("Set DEVFLOW_SERVER_URL to the reachable Development server URL."); + } + + var playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); + var page = await browser.NewPageAsync(new BrowserNewPageOptions { IgnoreHTTPSErrors = serverUrl.IsLoopback }); + return new HostedPageEnvironment(serverUrl, playwright, browser, page); + } + + internal string Url(string path) => new Uri(ServerUrl, path).AbsoluteUri; + + internal async Task RegisterAndConfirmAsync(string email, string password) + { + await Page.GotoAsync(Url("/Account/Register")); + await Page.Locator("#Input\\.Email").FillAsync(email); + await Page.Locator("#Input\\.Password").FillAsync(password); + await Page.Locator("#Input\\.ConfirmPassword").FillAsync(password); + await Page.GetByRole(AriaRole.Button, new() { Name = "Register", Exact = true }).ClickAsync(); + await Expect(Page.Locator("h1")).ToHaveTextAsync("Register confirmation"); + await ConfirmNotificationAsync("Confirm email", email); + } + + internal Task ConfirmNotificationAsync(string kind, string email) => OpenNotificationAsync(kind, email); + + internal async Task OpenNotificationAsync(string kind, string email) + { + await Page.GotoAsync(Url("/development/notifications")); + var action = Page.Locator($"li:has-text('{kind}'):has-text('{email}') a"); + await Expect(action).ToBeVisibleAsync(); + var href = await action.GetAttributeAsync("href"); + Assert.IsFalse(string.IsNullOrWhiteSpace(href), "The Development notification did not contain an action link."); + await Page.GotoAsync(new Uri(ServerUrl, href).AbsoluteUri); + } + + public async ValueTask DisposeAsync() + { + await Browser.DisposeAsync(); + Playwright.Dispose(); + } +} diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.WebUi.Tests/MSTestSettings.cs b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.WebUi.Tests/MSTestSettings.cs new file mode 100644 index 000000000..aaf278c84 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.WebUi.Tests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.WebUi.Tests/MauiBlazorWeb.WebUi.Tests.csproj b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.WebUi.Tests/MauiBlazorWeb.WebUi.Tests.csproj new file mode 100644 index 000000000..cb31cc561 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.WebUi.Tests/MauiBlazorWeb.WebUi.Tests.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + latest + enable + enable + + + + + + + + + + + + diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.sln b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.sln index 694fa7885..9590f9b26 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.sln +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb.sln @@ -1,6 +1,7 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.5.11612.153 insiders +VisualStudioVersion = 18.5.11612.153 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWeb", "MauiBlazorWeb\MauiBlazorWeb.csproj", "{47022CF9-CC61-4F4F-808D-044285B07FF6}" EndProject @@ -8,25 +9,109 @@ 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 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MauiBlazorWeb.IdentityApi.Tests", "MauiBlazorWeb.IdentityApi.Tests\MauiBlazorWeb.IdentityApi.Tests.csproj", "{FD7308C2-8F37-45FD-8B97-9A7159E65615}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MauiBlazorWeb.DevFlow.Tests", "MauiBlazorWeb.DevFlow.Tests\MauiBlazorWeb.DevFlow.Tests.csproj", "{2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MauiBlazorWeb.WebUi.Tests", "MauiBlazorWeb.WebUi.Tests\MauiBlazorWeb.WebUi.Tests.csproj", "{2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {47022CF9-CC61-4F4F-808D-044285B07FF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {47022CF9-CC61-4F4F-808D-044285B07FF6}.Debug|Any CPU.Build.0 = Debug|Any CPU {47022CF9-CC61-4F4F-808D-044285B07FF6}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {47022CF9-CC61-4F4F-808D-044285B07FF6}.Debug|x64.ActiveCfg = Debug|Any CPU + {47022CF9-CC61-4F4F-808D-044285B07FF6}.Debug|x64.Build.0 = Debug|Any CPU + {47022CF9-CC61-4F4F-808D-044285B07FF6}.Debug|x86.ActiveCfg = Debug|Any CPU + {47022CF9-CC61-4F4F-808D-044285B07FF6}.Debug|x86.Build.0 = Debug|Any CPU {47022CF9-CC61-4F4F-808D-044285B07FF6}.Release|Any CPU.ActiveCfg = Release|Any CPU {47022CF9-CC61-4F4F-808D-044285B07FF6}.Release|Any CPU.Build.0 = Release|Any CPU + {47022CF9-CC61-4F4F-808D-044285B07FF6}.Release|x64.ActiveCfg = Release|Any CPU + {47022CF9-CC61-4F4F-808D-044285B07FF6}.Release|x64.Build.0 = Release|Any CPU + {47022CF9-CC61-4F4F-808D-044285B07FF6}.Release|x86.ActiveCfg = Release|Any CPU + {47022CF9-CC61-4F4F-808D-044285B07FF6}.Release|x86.Build.0 = Release|Any CPU {2D0B68A7-4946-49D9-882F-550A2690B572}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2D0B68A7-4946-49D9-882F-550A2690B572}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D0B68A7-4946-49D9-882F-550A2690B572}.Debug|x64.ActiveCfg = Debug|Any CPU + {2D0B68A7-4946-49D9-882F-550A2690B572}.Debug|x64.Build.0 = Debug|Any CPU + {2D0B68A7-4946-49D9-882F-550A2690B572}.Debug|x86.ActiveCfg = Debug|Any CPU + {2D0B68A7-4946-49D9-882F-550A2690B572}.Debug|x86.Build.0 = Debug|Any CPU {2D0B68A7-4946-49D9-882F-550A2690B572}.Release|Any CPU.ActiveCfg = Release|Any CPU {2D0B68A7-4946-49D9-882F-550A2690B572}.Release|Any CPU.Build.0 = Release|Any CPU + {2D0B68A7-4946-49D9-882F-550A2690B572}.Release|x64.ActiveCfg = Release|Any CPU + {2D0B68A7-4946-49D9-882F-550A2690B572}.Release|x64.Build.0 = Release|Any CPU + {2D0B68A7-4946-49D9-882F-550A2690B572}.Release|x86.ActiveCfg = Release|Any CPU + {2D0B68A7-4946-49D9-882F-550A2690B572}.Release|x86.Build.0 = Release|Any CPU {602A0A61-85A3-4373-898C-FBE285A5D09A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {602A0A61-85A3-4373-898C-FBE285A5D09A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {602A0A61-85A3-4373-898C-FBE285A5D09A}.Debug|x64.ActiveCfg = Debug|Any CPU + {602A0A61-85A3-4373-898C-FBE285A5D09A}.Debug|x64.Build.0 = Debug|Any CPU + {602A0A61-85A3-4373-898C-FBE285A5D09A}.Debug|x86.ActiveCfg = Debug|Any CPU + {602A0A61-85A3-4373-898C-FBE285A5D09A}.Debug|x86.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 + {602A0A61-85A3-4373-898C-FBE285A5D09A}.Release|x64.ActiveCfg = Release|Any CPU + {602A0A61-85A3-4373-898C-FBE285A5D09A}.Release|x64.Build.0 = Release|Any CPU + {602A0A61-85A3-4373-898C-FBE285A5D09A}.Release|x86.ActiveCfg = Release|Any CPU + {602A0A61-85A3-4373-898C-FBE285A5D09A}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Debug|x64.Build.0 = Debug|Any CPU + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Debug|x86.ActiveCfg = Debug|Any CPU + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Debug|x86.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 + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Release|x64.ActiveCfg = Release|Any CPU + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Release|x64.Build.0 = Release|Any CPU + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Release|x86.ActiveCfg = Release|Any CPU + {CAEEC5D2-BF38-4518-8D3D-E3CB343E904B}.Release|x86.Build.0 = Release|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Debug|x64.Build.0 = Debug|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Debug|x86.Build.0 = Debug|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Release|Any CPU.Build.0 = Release|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Release|x64.ActiveCfg = Release|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Release|x64.Build.0 = Release|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Release|x86.ActiveCfg = Release|Any CPU + {FD7308C2-8F37-45FD-8B97-9A7159E65615}.Release|x86.Build.0 = Release|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Debug|x64.ActiveCfg = Debug|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Debug|x64.Build.0 = Debug|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Debug|x86.ActiveCfg = Debug|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Debug|x86.Build.0 = Debug|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Release|Any CPU.Build.0 = Release|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Release|x64.ActiveCfg = Release|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Release|x64.Build.0 = Release|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Release|x86.ActiveCfg = Release|Any CPU + {2CFA7302-DCAE-4B91-A6D6-F02A4A74C9B1}.Release|x86.Build.0 = Release|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Debug|x64.ActiveCfg = Debug|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Debug|x64.Build.0 = Debug|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Debug|x86.ActiveCfg = Debug|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Debug|x86.Build.0 = Debug|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Release|Any CPU.Build.0 = Release|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Release|x64.ActiveCfg = Release|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Release|x64.Build.0 = Release|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Release|x86.ActiveCfg = Release|Any CPU + {2C8D4B18-9CE7-4C1C-BC3F-B2F4F7C0234A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Layout/NavMenu.razor b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Layout/NavMenu.razor index 019c9c31b..7b3d90f8a 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Layout/NavMenu.razor +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Layout/NavMenu.razor @@ -16,10 +16,15 @@ + + diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Account.razor b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Account.razor new file mode 100644 index 000000000..e61b9ffe4 --- /dev/null +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Account.razor @@ -0,0 +1,69 @@ +@page "/account" +@attribute [Authorize] +@inject AccountClient AccountClient +@inject MauiAuthenticationStateProvider Authentication +@inject NavigationManager Navigation + +Account +

Account

+@if (message is not null) { } +@if (info is null) {

Loading account information…

} +else { +

Profile

+

Email: @info.Email @if (info.IsEmailConfirmed) { (confirmed) } else { }

+
+
+
+
+

Two-factor authentication

+

Enabled: @(twoFactor?.IsTwoFactorEnabled == true ? "Yes" : "No") · Authenticator configured: @(twoFactor?.HasAuthenticator == true ? "Yes" : "No") · Recovery codes remaining: @twoFactor?.RecoveryCodesLeft

+ + @if (sharedKey is not null) {

Authenticator setup key: @sharedKey

} +

To enable two-factor authentication, enter a valid code from the authenticator configured for this account.

+
+ + + + + + @if (recoveryCodes is not null) {

Save these recovery codes now:

@string.Join(Environment.NewLine, recoveryCodes)
} +
+

Passkeys

+

Native WebAuthn ceremony support is coming in .NET 11. The raw server begin response is available for preview only.

+ + @if (passkeyPreview is not null) {
@passkeyPreview
} + @foreach (var passkey in passkeys) {
@passkey.Name (@passkey.CreatedAt.LocalDateTime.ToShortDateString())
} +
+

External sign-in providers

+ @if (externalLogins.Length == 0) {

No external providers are linked.

} + @foreach (var login in externalLogins) {
@login.DisplayName ?? login.Provider
} +
+

Personal data

+ + @if (personalData is not null) {
Email
@personalData.Email
Phone
@personalData.PhoneNumber
} +
+

Sessions and account

+ +
+
+} + +@code { + private AccountInfo? info; private TwoFactorStatus? twoFactor; private Passkey[] passkeys = []; private ExternalLogin[] externalLogins = []; + private PersonalData? personalData; private string? passkeyPreview, message, newEmail, phone, oldPassword, newPassword, deletePassword, twoFactorCode, sharedKey; private string[]? recoveryCodes; private string alertClass = "alert-info"; + protected override async Task OnInitializedAsync() => await ReloadAsync(); + private async Task ReloadAsync() { var a = await AccountClient.GetInfoAsync(); if (!a.Succeeded) { Set(a.WithoutValue()); return; } info = a.Value; phone = info?.PhoneNumber; var t = await AccountClient.GetTwoFactorAsync(); twoFactor = t.Value; var p = await AccountClient.GetPasskeysAsync(); passkeys = p.Value ?? []; var e = await AccountClient.GetExternalLoginsAsync(); externalLogins = e.Value ?? []; } + private async Task ResendAsync() { if (info is not null) Set(await AccountClient.ResendConfirmationAsync(info.Email ?? "")); } + private async Task ChangeEmailAsync() { Set(await AccountClient.UpdateEmailAsync(newEmail ?? "")); await ReloadAsync(); } + private async Task ChangePhoneAsync() { Set(await AccountClient.UpdatePhoneAsync(phone ?? "")); await ReloadAsync(); } + private async Task ChangePasswordAsync() { Set(await AccountClient.UpdatePasswordAsync(oldPassword ?? "", newPassword ?? "")); oldPassword = newPassword = null; await ReloadAsync(); } + private async Task BeginAuthenticatorSetupAsync() { var result = await AccountClient.BeginAuthenticatorSetupAsync(); Set(result.WithoutValue()); if (result.Succeeded) sharedKey = result.Value?.SharedKey; } + private async Task TwoFactorAsync(bool enable, bool resetKey, bool codes, bool forget) { var result = await AccountClient.ConfigureTwoFactorAsync(enable, resetKey, codes, forget, twoFactorCode); Set(result.WithoutValue()); recoveryCodes = result.Value?.RecoveryCodes; twoFactorCode = null; await ReloadAsync(); } + private async Task PreviewPasskeyAsync() { var result = await AccountClient.BeginPasskeyRegistrationAsync(); if (result.Succeeded) passkeyPreview = result.Value; else Set(result.WithoutValue()); } + private async Task DeletePasskeyAsync(string id) { Set(await AccountClient.DeletePasskeyAsync(id)); await ReloadAsync(); } + private async Task DeleteProviderAsync(string provider) { Set(await AccountClient.DeleteExternalLoginAsync(provider)); await ReloadAsync(); } + private async Task LoadPersonalDataAsync() { var result = await AccountClient.GetPersonalDataAsync(); if (result.Succeeded) personalData = result.Value; else Set(result.WithoutValue()); } + private async Task LogoutAllAsync() { var result = await AccountClient.LogoutAllAsync(); Set(result); if (result.Succeeded) { await Authentication.LogoutAsync(); Navigation.NavigateTo("login"); } } + private async Task DeleteAccountAsync() { var result = await AccountClient.DeleteAccountAsync(deletePassword ?? ""); Set(result); if (result.Succeeded) { await Authentication.LogoutAsync(); Navigation.NavigateTo("register"); } } + private void Set(ApiResult result) { alertClass = result.Succeeded ? "alert-success" : "alert-danger"; message = result.Succeeded ? "The account was updated." : result.Error; } +} diff --git a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Login.razor b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Login.razor index 634b8d8f7..96d8514e9 100644 --- a/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Login.razor +++ b/10.0/MauiBlazorWebIdentity/MauiBlazorWeb/Components/Pages/Login.razor @@ -13,19 +13,31 @@

Use a local account to log in.


-