From d9a428b826a001294c6f181dfbf6d8a53029a080 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 13:03:17 +0200 Subject: [PATCH 01/22] Fix sm CLI dev workflow and generator diagnostics for user projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sm new project: scaffold vite.dev.config.ts so sm dev works out of the box - sm dev: fix vite invocation (vite dev → vite) and add --configLoader runner - sm doctor: fix PagesKeyPattern regex to match single-quoted Pages/index.ts keys - moduleHmrPlugin: fall back to src/modules/ path for scaffolded user projects - SM0052/SM0053 diagnostics: scope to SimpleModule.* host projects only so user projects with their own naming conventions are not falsely flagged - AgentExtensionsEmitter: remove early return that broke empty projects - SimpleModuleHostExtensions: register IHttpContextAccessor for EntityInterceptor - Directory.Build.props: guard PackageReadmeFile with Exists() to fix pack --- .../Commands/Dev/DevCommand.cs | 2 +- .../Doctor/Checks/PagesRegistryCheck.cs | 2 +- .../Commands/New/NewProjectCommand.cs | 5 + cli/SimpleModule.Cli/SimpleModule.Cli.csproj | 4 + .../Templates/HostTemplates.cs | 8 ++ framework/Directory.Build.props | 4 +- .../Emitters/AgentExtensionsEmitter.cs | 3 - .../Emitters/DiagnosticEmitter.cs | 119 ++++++++++-------- .../SimpleModuleHostExtensions.cs | 3 + modules/Directory.Build.props | 2 +- .../src/vite-plugin-module-hmr.ts | 5 +- 11 files changed, 93 insertions(+), 64 deletions(-) diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs index 4e75bde3..cfe3cfbe 100644 --- a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs @@ -122,7 +122,7 @@ string viteConfigPath ); var npx = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "npx.cmd" : "npx"; var viteArgs = - $"vite dev --config \"{viteConfigPath}\" --port {settings.VitePort} --strictPort"; + $"vite --config \"{viteConfigPath}\" --port {settings.VitePort} --strictPort --configLoader runner"; StartProcess(npx, viteArgs, solution.RootPath, "vite"); } else if (!settings.NoVite && !File.Exists(viteConfigPath)) diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs index 6c26fe0a..f37332b5 100644 --- a/cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/PagesRegistryCheck.cs @@ -8,7 +8,7 @@ public sealed partial class PagesRegistryCheck : IDoctorCheck [GeneratedRegex(@"Inertia\.Render\s*\(\s*""([^""]+)""")] private static partial Regex InertiaRenderPattern(); - [GeneratedRegex(@"""([^""]+)""\s*:\s*(?:\(\s*\)|(?:async\s*)?\(\s*\)\s*=>|import)")] + [GeneratedRegex(@"[""']([^""']+)[""']\s*:\s*(?:\(\s*\)|(?:async\s*)?\(\s*\)\s*=>|import)")] private static partial Regex PagesKeyPattern(); public IEnumerable Run(SolutionContext solution) diff --git a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs index b86e1adc..6c64eec9 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs @@ -148,6 +148,10 @@ string frameworkVersion Path.Combine(hostDir, "ClientApp", "vite.config.ts"), HostTemplates.ViteConfig() ); + File.WriteAllText( + Path.Combine(hostDir, "ClientApp", "vite.dev.config.ts"), + HostTemplates.ViteDevConfig() + ); File.WriteAllText( Path.Combine(hostDir, "ClientApp", "validate-pages.mjs"), HostTemplates.ValidatePages() @@ -293,6 +297,7 @@ string rootDir Plan(Path.Combine(hostDir, "wwwroot", "index.html")); Plan(Path.Combine(hostDir, "ClientApp", "app.tsx")); Plan(Path.Combine(hostDir, "ClientApp", "vite.config.ts")); + Plan(Path.Combine(hostDir, "ClientApp", "vite.dev.config.ts")); Plan(Path.Combine(hostDir, "ClientApp", "validate-pages.mjs")); Plan(Path.Combine(hostDir, "ClientApp", "package.json")); Plan(Path.Combine(hostDir, "Styles", "app.css")); diff --git a/cli/SimpleModule.Cli/SimpleModule.Cli.csproj b/cli/SimpleModule.Cli/SimpleModule.Cli.csproj index 253d1d55..ff873fee 100644 --- a/cli/SimpleModule.Cli/SimpleModule.Cli.csproj +++ b/cli/SimpleModule.Cli/SimpleModule.Cli.csproj @@ -40,6 +40,10 @@ Include="..\..\template\SimpleModule.Host\ClientApp\vite.config.ts" LogicalName="Templates.Host.ClientApp.vite.config.ts" /> + + /// Copy vite.dev.config.ts as-is. + /// + public static string ViteDevConfig() + { + return EmbeddedResourceReader.ReadTemplate("Templates.Host.ClientApp.vite.dev.config.ts"); + } + /// /// Copy validate-pages.mjs as-is. /// diff --git a/framework/Directory.Build.props b/framework/Directory.Build.props index cdf0bfc4..8c68480b 100644 --- a/framework/Directory.Build.props +++ b/framework/Directory.Build.props @@ -2,9 +2,9 @@ true - README.md + README.md - + diff --git a/framework/SimpleModule.Generator/Emitters/AgentExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/AgentExtensionsEmitter.cs index 341ac846..b97462a1 100644 --- a/framework/SimpleModule.Generator/Emitters/AgentExtensionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/AgentExtensionsEmitter.cs @@ -10,9 +10,6 @@ internal sealed class AgentExtensionsEmitter : IEmitter { public void Emit(SourceProductionContext context, DiscoveryData data) { - if (!data.HasAnyAgentContent) - return; - var sb = new StringBuilder(); sb.AppendLine("// "); sb.AppendLine("#nullable enable"); diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 577b29c6..35d19958 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -1084,68 +1084,77 @@ public void Emit(SourceProductionContext context, DiscoveryData data) // SM0052: Module assembly name must follow SimpleModule.{ModuleName} convention // SM0053: Module must have matching Contracts assembly - var contractsSet = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var name in data.ContractsAssemblyNames) - contractsSet.Add(name); + // These checks only apply when the host project itself is a SimpleModule.* project. + // User projects (e.g. TestApp.Host) use their own naming conventions. + var hostIsFramework = + data.HostAssemblyName?.StartsWith("SimpleModule.", System.StringComparison.Ordinal) + == true; - foreach (var module in data.Modules) + if (hostIsFramework) { - if (string.IsNullOrEmpty(module.ModuleName)) - continue; + var contractsSet = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var name in data.ContractsAssemblyNames) + contractsSet.Add(name); - // SM0052: Assembly naming convention - // Accepted patterns: SimpleModule.{ModuleName} or SimpleModule.{ModuleName}.Module - // The .Module suffix is allowed when a framework assembly with the same base name exists. - var expectedAssemblyName = "SimpleModule." + module.ModuleName; - var expectedModuleSuffix = expectedAssemblyName + ".Module"; - if ( - !string.IsNullOrEmpty(module.AssemblyName) - && !string.Equals( - module.AssemblyName, - expectedAssemblyName, - System.StringComparison.Ordinal - ) - && !string.Equals( - module.AssemblyName, - expectedModuleSuffix, - System.StringComparison.Ordinal - ) - && !string.Equals( - module.AssemblyName, - data.HostAssemblyName, - System.StringComparison.Ordinal - ) - ) + foreach (var module in data.Modules) { - context.ReportDiagnostic( - Diagnostic.Create( - ModuleAssemblyNamingViolation, - LocationHelper.ToLocation(module.Location), - module.ModuleName, - module.AssemblyName - ) - ); - } + if (string.IsNullOrEmpty(module.ModuleName)) + continue; - // SM0053: Missing contracts assembly - var expectedContractsName = "SimpleModule." + module.ModuleName + ".Contracts"; - if ( - !contractsSet.Contains(expectedContractsName) - && !string.Equals( - module.AssemblyName, - data.HostAssemblyName, - System.StringComparison.Ordinal + // SM0052: Assembly naming convention + // Accepted patterns: SimpleModule.{ModuleName} or SimpleModule.{ModuleName}.Module + // The .Module suffix is allowed when a framework assembly with the same base name exists. + var expectedAssemblyName = "SimpleModule." + module.ModuleName; + var expectedModuleSuffix = expectedAssemblyName + ".Module"; + if ( + !string.IsNullOrEmpty(module.AssemblyName) + && !string.Equals( + module.AssemblyName, + expectedAssemblyName, + System.StringComparison.Ordinal + ) + && !string.Equals( + module.AssemblyName, + expectedModuleSuffix, + System.StringComparison.Ordinal + ) + && !string.Equals( + module.AssemblyName, + data.HostAssemblyName, + System.StringComparison.Ordinal + ) ) - ) - { - context.ReportDiagnostic( - Diagnostic.Create( - MissingContractsAssembly, - LocationHelper.ToLocation(module.Location), - module.ModuleName, - module.AssemblyName + { + context.ReportDiagnostic( + Diagnostic.Create( + ModuleAssemblyNamingViolation, + LocationHelper.ToLocation(module.Location), + module.ModuleName, + module.AssemblyName + ) + ); + } + + // SM0053: Missing contracts assembly + var expectedContractsName = "SimpleModule." + module.ModuleName + ".Contracts"; + if ( + !contractsSet.Contains(expectedContractsName) + && !string.Equals( + module.AssemblyName, + data.HostAssemblyName, + System.StringComparison.Ordinal ) - ); + ) + { + context.ReportDiagnostic( + Diagnostic.Create( + MissingContractsAssembly, + LocationHelper.ToLocation(module.Location), + module.ModuleName, + module.AssemblyName + ) + ); + } } } } diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index bec11c48..cdc92b9b 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -74,6 +74,9 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( builder.Services.AddScoped(); builder.Services.AddScoped(); + // Required by EntityInterceptor to access the current HTTP context + builder.Services.AddHttpContextAccessor(); + // Entity framework interceptors for automatic entity field population builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/modules/Directory.Build.props b/modules/Directory.Build.props index d20d12e1..d5a5b735 100644 --- a/modules/Directory.Build.props +++ b/modules/Directory.Build.props @@ -3,7 +3,7 @@ true $(PackageTags);simplemodule-module - README.md + README.md diff --git a/packages/SimpleModule.Client/src/vite-plugin-module-hmr.ts b/packages/SimpleModule.Client/src/vite-plugin-module-hmr.ts index c779fda5..912aa346 100644 --- a/packages/SimpleModule.Client/src/vite-plugin-module-hmr.ts +++ b/packages/SimpleModule.Client/src/vite-plugin-module-hmr.ts @@ -17,7 +17,10 @@ interface ModuleEntry { * Discovers all SimpleModule modules that have a Pages/index.ts entry. */ function discoverModules(repoRoot: string): ModuleEntry[] { - const modulesDir = resolve(repoRoot, 'modules'); + // Support both `modules/` (framework repo) and `src/modules/` (scaffolded projects) + const modulesDir = existsSync(resolve(repoRoot, 'modules')) + ? resolve(repoRoot, 'modules') + : resolve(repoRoot, 'src', 'modules'); const entries: ModuleEntry[] = []; if (!existsSync(modulesDir)) return entries; From dbbababa23e92bf224cd281641cf773d44db6f40 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 14:09:13 +0200 Subject: [PATCH 02/22] Add passkey login design spec --- .../specs/2026-04-06-passkey-login-design.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-06-passkey-login-design.md diff --git a/docs/superpowers/specs/2026-04-06-passkey-login-design.md b/docs/superpowers/specs/2026-04-06-passkey-login-design.md new file mode 100644 index 00000000..ba84c5d7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-passkey-login-design.md @@ -0,0 +1,199 @@ +# Passkey Login — Design Spec + +**Date:** 2026-04-06 +**Status:** Approved +**Scope:** Add optional passkey authentication to the existing Users module + +--- + +## Overview + +Add WebAuthn passkey support to SimpleModule as an optional authentication method alongside existing password login. Users can register one or more passkeys from their account security settings and use them as an alternative to entering a password. This uses the built-in .NET 10 passkey support (`Microsoft.AspNetCore.Authentication.WebAuthn`) with no third-party library dependencies. + +--- + +## Goals + +- Users can register passkeys (Touch ID, Face ID, Windows Hello, hardware keys) from their account settings +- Users can sign in with a passkey instead of a password +- Users can view and delete their registered passkeys +- Passkeys are stored in the existing database alongside Identity data +- No changes to existing password login flow + +--- + +## Non-Goals + +- Passkey-only or passkey-first login (passwords remain the primary flow) +- Attestation validation +- Enterprise FIDO2 policy enforcement +- Replacing OpenIddict or the existing OAuth flows with passkeys + +--- + +## Architecture + +All changes live inside `modules/Users/`. No new module is created. + +### Backend Components + +**`UsersModule.ConfigureServices`** — calls `.AddPasskeys()` on the Identity builder and binds `PasskeyOptions` from configuration (RP domain, server name). + +**`UsersDbContext`** — bumps Identity schema to `IdentitySchemaVersions.Version3`, which provisions the `AspNetUserPasskeys` table via a new EF Core migration. + +**6 new `IEndpoint` classes** under `Endpoints/Passkeys/`: + +| Class | Route | Auth | Purpose | +|---|---|---|---| +| `PasskeyRegisterBeginEndpoint` | `POST /api/passkeys/register/begin` | Required | Returns `PublicKeyCredentialCreationOptions` from `UserManager.MakePasskeyCreationOptionsAsync()` | +| `PasskeyRegisterCompleteEndpoint` | `POST /api/passkeys/register/complete` | Required | Receives browser credential, calls `UserManager.AddOrUpdatePasskeyAsync()` | +| `PasskeyLoginBeginEndpoint` | `POST /api/passkeys/login/begin` | Anonymous | Returns `PublicKeyCredentialRequestOptions` from `SignInManager.MakePasskeyRequestOptionsAsync()` | +| `PasskeyLoginCompleteEndpoint` | `POST /api/passkeys/login/complete` | Anonymous | Calls `SignInManager.PasskeySignInAsync()`, sets auth cookie, returns redirect | +| `GetPasskeysEndpoint` | `GET /api/passkeys` | Required | Returns list of registered passkeys for current user | +| `DeletePasskeyEndpoint` | `DELETE /api/passkeys/{credentialId}` | Required | Calls `UserManager.RemovePasskeyAsync()` | + +**1 new `IViewEndpoint`** — `ManagePasskeysEndpoint` at `GET /Identity/Account/Manage/Passkeys` + +**1 updated `IViewEndpoint`** — existing `LoginEndpoint` passes a flag indicating passkeys are supported so the frontend can render the passkey button. + +### Frontend Components + +**`passkey.ts`** — shared utility handling: +- Base64url encoding/decoding (required by WebAuthn API) +- `startPasskeyRegistration(options)` — wraps `navigator.credentials.create()` +- `startPasskeyAssertion(options)` — wraps `navigator.credentials.get()` + +**`ManagePasskeys.tsx`** — new Inertia page at `Identity/Account/Manage/Passkeys`: +- Table of registered passkeys: name, device type hint, registered date +- "Add passkey" button — triggers registration flow +- Delete button per passkey with confirmation dialog + +**`Login.tsx`** — updated to add a "Sign in with passkey" button below the existing password form: +1. POST `/api/passkeys/login/begin` → receive options +2. `navigator.credentials.get({ publicKey: options })` +3. POST `/api/passkeys/login/complete` → redirect on success + +**`Pages/index.ts`** — add entry: +```ts +"Identity/Account/Manage/Passkeys": () => import("./ManagePasskeys"), +``` + +**Menu** — add "Passkeys" link to the Security section of the account management sidebar (alongside "Two-factor authentication"). + +--- + +## Data Model + +Identity Schema Version 3 adds `AspNetUserPasskeys` automatically: + +| Column | Type | Notes | +|---|---|---| +| `UserId` | string | FK → `AspNetUsers.Id` | +| `CredentialId` | byte[] | PK | +| `Name` | string | User-assigned label | +| `PublicKey` | byte[] | COSE-encoded public key | +| `SignCount` | long | Replay attack counter | +| `Transports` | string[] | Browser transport hints | +| `CreatedAt` | DateTimeOffset | Registration timestamp | +| `LastUsedAt` | DateTimeOffset | Last successful assertion | + +One EF Core migration generated after the schema version bump. No manual entity definitions needed. + +--- + +## Configuration + +Add to `appsettings.json`: + +```json +"Passkeys": { + "ServerDomain": "localhost", + "ServerName": "SimpleModule" +} +``` + +`ServerDomain` is the WebAuthn Relying Party ID — must exactly match the origin domain. Passkeys registered on one domain cannot be used on another. + +- Development: `localhost` +- Production: the actual domain (e.g. `yourdomain.com`) + +--- + +## Request Flows + +### Registration + +``` +ManagePasskeys page + → POST /api/passkeys/register/begin + ← PublicKeyCredentialCreationOptions (challenge stored in session) + → navigator.credentials.create({ publicKey: options }) + ← AuthenticatorAttestationResponse (from device) + → POST /api/passkeys/register/complete (credential JSON) + ← 200 OK + updated passkey list +``` + +### Authentication + +``` +Login page + → POST /api/passkeys/login/begin + ← PublicKeyCredentialRequestOptions (challenge stored in session) + → navigator.credentials.get({ publicKey: options }) + ← AuthenticatorAssertionResponse (from device) + → POST /api/passkeys/login/complete (credential JSON) + ← 200 OK + redirect to returnUrl (auth cookie set) +``` + +--- + +## Error Handling + +- **Browser doesn't support WebAuthn**: `window.PublicKeyCredential` check — hide passkey button if unsupported +- **User cancels biometric prompt**: `navigator.credentials.get()` rejects — show "Passkey sign-in cancelled" message, do not redirect +- **Challenge mismatch / signature failure**: `PasskeySignInAsync` returns `SignInResult.Failed` — return 401, show error on login page +- **No passkeys registered**: `GetPasskeysEndpoint` returns empty list — manage page shows "No passkeys registered yet" with an add button + +--- + +## Security Considerations + +- Challenge stored server-side in session (not in cookie) — prevents replay attacks +- `SignCount` incremented and validated on each assertion — detects cloned credentials +- `ServerDomain` (RP ID) scoped to origin domain — prevents cross-site credential phishing +- Delete endpoint requires authentication and validates passkey belongs to the requesting user + +--- + +## Testing + +- Unit tests for each new endpoint (using existing `SimpleModuleWebApplicationFactory`) +- Registration flow: begin → browser mock → complete → verify passkey in DB +- Login flow: begin → browser mock → complete → verify auth cookie set +- Delete: verify only owner can delete their own passkeys +- Error cases: invalid credential, wrong user, missing session challenge + +--- + +## Files to Create / Modify + +### New files +- `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterBeginEndpoint.cs` +- `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs` +- `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginBeginEndpoint.cs` +- `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginCompleteEndpoint.cs` +- `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs` +- `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/DeletePasskeyEndpoint.cs` +- `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs` +- `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx` +- `modules/Users/src/SimpleModule.Users/Pages/passkey.ts` +- EF Core migration for schema version 3 + +### Modified files +- `modules/Users/src/SimpleModule.Users/UsersModule.cs` — add `.AddPasskeys()` and `PasskeyOptions` binding +- `modules/Users/src/SimpleModule.Users/UsersDbContext.cs` — set `IdentitySchemaVersions.Version3` +- `modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx` — add passkey sign-in button +- `modules/Users/src/SimpleModule.Users/Pages/index.ts` — register `ManagePasskeys` page +- `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageLayout.tsx` (or equivalent sidebar) — add Passkeys nav link +- `template/SimpleModule.Host/appsettings.json` — add `Passkeys` config section +- `template/SimpleModule.Host/appsettings.Development.json` — add dev passkey config From bf990cc664b36d1e083b661c3431667f4ac9617b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 14:16:36 +0200 Subject: [PATCH 03/22] Update passkey spec: fix API names, schema override, challenge storage --- .../specs/2026-04-06-passkey-login-design.md | 75 ++++++++++++------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-04-06-passkey-login-design.md b/docs/superpowers/specs/2026-04-06-passkey-login-design.md index ba84c5d7..765c9b4d 100644 --- a/docs/superpowers/specs/2026-04-06-passkey-login-design.md +++ b/docs/superpowers/specs/2026-04-06-passkey-login-design.md @@ -8,7 +8,7 @@ ## Overview -Add WebAuthn passkey support to SimpleModule as an optional authentication method alongside existing password login. Users can register one or more passkeys from their account security settings and use them as an alternative to entering a password. This uses the built-in .NET 10 passkey support (`Microsoft.AspNetCore.Authentication.WebAuthn`) with no third-party library dependencies. +Add WebAuthn passkey support to SimpleModule as an optional authentication method alongside existing password login. Users can register one or more passkeys from their account security settings and use them as an alternative to entering a password. This uses the built-in .NET 10 passkey support with no third-party library dependencies. --- @@ -37,24 +37,38 @@ All changes live inside `modules/Users/`. No new module is created. ### Backend Components -**`UsersModule.ConfigureServices`** — calls `.AddPasskeys()` on the Identity builder and binds `PasskeyOptions` from configuration (RP domain, server name). +**`UsersModule.ConfigureServices`** — configure `IdentityPasskeyOptions` via: +```csharp +services.Configure(configuration.GetSection("Passkeys")); +``` +No `.AddPasskeys()` call is needed — passkey store support is included automatically when `AddEntityFrameworkStores()` is used with Identity Schema Version 3. -**`UsersDbContext`** — bumps Identity schema to `IdentitySchemaVersions.Version3`, which provisions the `AspNetUserPasskeys` table via a new EF Core migration. +**`UsersDbContext`** — override the `SchemaVersion` property to provision the `AspNetUserPasskeys` table: +```csharp +protected override Version SchemaVersion => IdentitySchemaVersions.Version3; +``` +Then generate a new EF Core migration. **6 new `IEndpoint` classes** under `Endpoints/Passkeys/`: | Class | Route | Auth | Purpose | |---|---|---|---| -| `PasskeyRegisterBeginEndpoint` | `POST /api/passkeys/register/begin` | Required | Returns `PublicKeyCredentialCreationOptions` from `UserManager.MakePasskeyCreationOptionsAsync()` | -| `PasskeyRegisterCompleteEndpoint` | `POST /api/passkeys/register/complete` | Required | Receives browser credential, calls `UserManager.AddOrUpdatePasskeyAsync()` | -| `PasskeyLoginBeginEndpoint` | `POST /api/passkeys/login/begin` | Anonymous | Returns `PublicKeyCredentialRequestOptions` from `SignInManager.MakePasskeyRequestOptionsAsync()` | -| `PasskeyLoginCompleteEndpoint` | `POST /api/passkeys/login/complete` | Anonymous | Calls `SignInManager.PasskeySignInAsync()`, sets auth cookie, returns redirect | -| `GetPasskeysEndpoint` | `GET /api/passkeys` | Required | Returns list of registered passkeys for current user | -| `DeletePasskeyEndpoint` | `DELETE /api/passkeys/{credentialId}` | Required | Calls `UserManager.RemovePasskeyAsync()` | +| `PasskeyRegisterBeginEndpoint` | `POST /api/passkeys/register/begin` | Required | Calls `SignInManager.MakePasskeyCreationOptionsAsync()`, returns JSON options to browser. Call `.DisableAntiforgery()`. | +| `PasskeyRegisterCompleteEndpoint` | `POST /api/passkeys/register/complete` | Required | Calls `SignInManager.PerformPasskeyAttestationAsync(credentialJson)`, then `UserManager.AddOrUpdatePasskeyAsync(user, result.Passkey)` on success. Call `.DisableAntiforgery()`. | +| `PasskeyLoginBeginEndpoint` | `POST /api/passkeys/login/begin` | Anonymous | Calls `SignInManager.MakePasskeyRequestOptionsAsync()`, returns JSON options to browser. Call `.DisableAntiforgery()`. | +| `PasskeyLoginCompleteEndpoint` | `POST /api/passkeys/login/complete` | Anonymous | Calls `SignInManager.PasskeySignInAsync(credentialJson)`, sets auth cookie on success, returns redirect to `returnUrl`. Call `.DisableAntiforgery()`. | +| `GetPasskeysEndpoint` | `GET /api/passkeys` | Required | Returns list of registered passkeys via `UserManager.GetPasskeysAsync()` | +| `DeletePasskeyEndpoint` | `DELETE /api/passkeys/{credentialId}` | Required | Calls `UserManager.RemovePasskeyAsync()`, validates passkey belongs to requesting user | + +> **Note on antiforgery:** All four POST passkey endpoints receive JSON via `fetch()` (not Inertia form submissions) and must call `.DisableAntiforgery()` on their routes to avoid HTTP 400 rejections from the antiforgery middleware. -**1 new `IViewEndpoint`** — `ManagePasskeysEndpoint` at `GET /Identity/Account/Manage/Passkeys` +> **Note on API ownership:** `MakePasskeyCreationOptionsAsync`, `MakePasskeyRequestOptionsAsync`, `PerformPasskeyAttestationAsync`, and `PasskeySignInAsync` are on `SignInManager`. `AddOrUpdatePasskeyAsync`, `GetPasskeysAsync`, and `RemovePasskeyAsync` are on `UserManager`. -**1 updated `IViewEndpoint`** — existing `LoginEndpoint` passes a flag indicating passkeys are supported so the frontend can render the passkey button. +> **Note on challenge storage:** The .NET 10 implementation stores the challenge in an encrypted/signed authentication cookie, not in ASP.NET Core Session. No `AddSession()` / `UseSession()` call is required. + +**1 new `IViewEndpoint`** — `ManagePasskeysEndpoint` at `GET /Users/Account/Manage/Passkeys`, renders Inertia component `"Users/Account/Manage/Passkeys"`. + +**1 updated `IViewEndpoint`** — existing `LoginEndpoint` passes a `passkeySupported` flag (derived from whether `IdentityPasskeyOptions.ServerDomain` is configured, not per-user passkey presence — the user is not yet identified at login time). ### Frontend Components @@ -63,22 +77,24 @@ All changes live inside `modules/Users/`. No new module is created. - `startPasskeyRegistration(options)` — wraps `navigator.credentials.create()` - `startPasskeyAssertion(options)` — wraps `navigator.credentials.get()` -**`ManagePasskeys.tsx`** — new Inertia page at `Identity/Account/Manage/Passkeys`: +> **CSP note:** WebAuthn does not make network requests itself, so the existing `connect-src 'self'` CSP does not block the passkey flow. The `fetch()` calls in `passkey.ts` go to `'self'` endpoints and are also unaffected. + +**`ManagePasskeys.tsx`** — new Inertia page at route `Users/Account/Manage/Passkeys`: - Table of registered passkeys: name, device type hint, registered date - "Add passkey" button — triggers registration flow - Delete button per passkey with confirmation dialog -**`Login.tsx`** — updated to add a "Sign in with passkey" button below the existing password form: +**`Login.tsx`** — updated to add a "Sign in with passkey" button below the existing password form (shown only when `passkeySupported` prop is true): 1. POST `/api/passkeys/login/begin` → receive options 2. `navigator.credentials.get({ publicKey: options })` 3. POST `/api/passkeys/login/complete` → redirect on success -**`Pages/index.ts`** — add entry: +**`Pages/index.ts`** — add entry (key must match the string passed to `Inertia.Render(...)` in the C# endpoint): ```ts -"Identity/Account/Manage/Passkeys": () => import("./ManagePasskeys"), +"Users/Account/Manage/Passkeys": () => import("./ManagePasskeys"), ``` -**Menu** — add "Passkeys" link to the Security section of the account management sidebar (alongside "Two-factor authentication"). +**Menu** — add "Passkeys" link to the Security section of the account management sidebar (alongside "Two-factor authentication"). The sidebar nav lives in `modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx`. --- @@ -107,12 +123,11 @@ Add to `appsettings.json`: ```json "Passkeys": { - "ServerDomain": "localhost", - "ServerName": "SimpleModule" + "ServerDomain": "localhost" } ``` -`ServerDomain` is the WebAuthn Relying Party ID — must exactly match the origin domain. Passkeys registered on one domain cannot be used on another. +`ServerDomain` maps to `IdentityPasskeyOptions.ServerDomain` — the WebAuthn Relying Party ID. Must exactly match the origin domain. Passkeys registered on one domain cannot be used on another. - Development: `localhost` - Production: the actual domain (e.g. `yourdomain.com`) @@ -126,7 +141,7 @@ Add to `appsettings.json`: ``` ManagePasskeys page → POST /api/passkeys/register/begin - ← PublicKeyCredentialCreationOptions (challenge stored in session) + ← PublicKeyCredentialCreationOptions (challenge stored in encrypted auth cookie) → navigator.credentials.create({ publicKey: options }) ← AuthenticatorAttestationResponse (from device) → POST /api/passkeys/register/complete (credential JSON) @@ -138,7 +153,7 @@ ManagePasskeys page ``` Login page → POST /api/passkeys/login/begin - ← PublicKeyCredentialRequestOptions (challenge stored in session) + ← PublicKeyCredentialRequestOptions (challenge stored in encrypted auth cookie) → navigator.credentials.get({ publicKey: options }) ← AuthenticatorAssertionResponse (from device) → POST /api/passkeys/login/complete (credential JSON) @@ -158,10 +173,12 @@ Login page ## Security Considerations -- Challenge stored server-side in session (not in cookie) — prevents replay attacks +- Challenge stored in an encrypted/signed auth cookie by the .NET 10 passkey implementation — prevents replay attacks - `SignCount` incremented and validated on each assertion — detects cloned credentials - `ServerDomain` (RP ID) scoped to origin domain — prevents cross-site credential phishing +- All POST passkey endpoints call `.DisableAntiforgery()` — they are JSON API endpoints receiving `fetch()` requests, not form submissions - Delete endpoint requires authentication and validates passkey belongs to the requesting user +- `passkeySupported` flag on login page reflects server-level config only — no per-user passkey lookup on anonymous login GET --- @@ -187,13 +204,13 @@ Login page - `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs` - `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx` - `modules/Users/src/SimpleModule.Users/Pages/passkey.ts` -- EF Core migration for schema version 3 +- EF Core migration for Identity Schema Version 3 ### Modified files -- `modules/Users/src/SimpleModule.Users/UsersModule.cs` — add `.AddPasskeys()` and `PasskeyOptions` binding -- `modules/Users/src/SimpleModule.Users/UsersDbContext.cs` — set `IdentitySchemaVersions.Version3` -- `modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx` — add passkey sign-in button -- `modules/Users/src/SimpleModule.Users/Pages/index.ts` — register `ManagePasskeys` page -- `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageLayout.tsx` (or equivalent sidebar) — add Passkeys nav link +- `modules/Users/src/SimpleModule.Users/UsersModule.cs` — configure `IdentityPasskeyOptions` via `services.Configure()` +- `modules/Users/src/SimpleModule.Users/UsersDbContext.cs` — set Identity schema to Version 3 +- `modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx` — add passkey sign-in button (conditional on `passkeySupported` prop) +- `modules/Users/src/SimpleModule.Users/Pages/index.ts` — register `"Users/Account/Manage/Passkeys"` page +- `modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx` — add Passkeys nav link in Security section - `template/SimpleModule.Host/appsettings.json` — add `Passkeys` config section -- `template/SimpleModule.Host/appsettings.Development.json` — add dev passkey config +- `template/SimpleModule.Host/appsettings.Development.json` — add dev passkey config (`ServerDomain: "localhost"`) From 3dc7a9a4298aadef3f36a2cc91aba6c1884eafc5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 15:47:27 +0200 Subject: [PATCH 04/22] Add passkey login implementation plan --- .../plans/2026-04-06-passkey-login.md | 1674 +++++++++++++++++ 1 file changed, 1674 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-passkey-login.md diff --git a/docs/superpowers/plans/2026-04-06-passkey-login.md b/docs/superpowers/plans/2026-04-06-passkey-login.md new file mode 100644 index 00000000..40e513a9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-passkey-login.md @@ -0,0 +1,1674 @@ +# Passkey Login Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add optional WebAuthn passkey authentication to the Users module so users can register, use, and manage passkeys alongside existing password login. + +**Architecture:** Adds a hand-written `partial class HostDbContext` to override `SchemaVersion` to `IdentitySchemaVersions.Version3` (auto-provisions `AspNetUserPasskeys` table via EF Core migration against `HostDbContext`, which is the generated aggregate context all migrations target), adds 6 JSON API endpoints + 1 view endpoint using the built-in .NET 10 `SignInManager`/`UserManager` passkey APIs, and updates the React login page and account management UI with TypeScript WebAuthn browser API helpers. + +**Tech Stack:** .NET 10 ASP.NET Core Identity passkey APIs (no third-party library), EF Core migrations, xUnit.v3 + FluentAssertions + NSubstitute, React 19 + Inertia.js, TypeScript WebAuthn browser API (`navigator.credentials`) + +> **API reference:** Verify exact method signatures for `MakePasskeyCreationOptionsAsync`, `PerformPasskeyAttestationAsync`, `MakePasskeyRequestOptionsAsync`, `PasskeySignInAsync`, `AddOrUpdatePasskeyAsync`, `GetPasskeysAsync`, and `RemovePasskeyAsync` at: +> https://learn.microsoft.com/en-us/aspnet/core/security/authentication/passkeys/?view=aspnetcore-10.0 + +--- + +## File Map + +### New files +| File | Purpose | +|---|---| +| `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterBeginEndpoint.cs` | `POST /api/passkeys/register/begin` — returns WebAuthn creation options | +| `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs` | `POST /api/passkeys/register/complete` — validates and stores passkey | +| `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginBeginEndpoint.cs` | `POST /api/passkeys/login/begin` — returns WebAuthn request options (anonymous) | +| `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginCompleteEndpoint.cs` | `POST /api/passkeys/login/complete` — authenticates via passkey (anonymous) | +| `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs` | `GET /api/passkeys` — list user's registered passkeys | +| `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/DeletePasskeyEndpoint.cs` | `DELETE /api/passkeys/{credentialId}` — remove a passkey | +| `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs` | `GET /Manage/Passkeys` — Inertia view endpoint | +| `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx` | React page for listing/adding/deleting passkeys | +| `modules/Users/src/SimpleModule.Users/Pages/passkey.ts` | WebAuthn browser API helpers (base64url, credentials.create/get) | +| `template/SimpleModule.Host/HostDbContextPasskeys.cs` | Hand-written `partial class HostDbContext` that overrides `SchemaVersion` | +| EF Core migration | Adds `AspNetUserPasskeys` table via Schema Version 3 (targets `HostDbContext`) | +| `modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs` | Integration tests for the 6 API endpoints | +| `modules/Users/tests/SimpleModule.Users.Tests/Integration/ManagePasskeysEndpointTests.cs` | Integration test for the view endpoint | + +### Modified files +| File | Change | +|---|---| +| `template/SimpleModule.Host/HostDbContextPasskeys.cs` | **New** — hand-written partial class overriding `SchemaVersion` | +| `modules/Users/src/SimpleModule.Users/UsersModule.cs` | Add `services.Configure(...)` | +| `modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs` | Pass `passkeyEnabled` prop from config | +| `modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx` | Add "Sign in with passkey" button | +| `modules/Users/src/SimpleModule.Users/Pages/index.ts` | Register `"Users/Account/Manage/Passkeys"` page | +| `modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx` | Add Passkeys nav item | +| `template/SimpleModule.Host/appsettings.json` | Add `"Passkeys": { "ServerDomain": "yourdomain.com" }` | +| `template/SimpleModule.Host/appsettings.Development.json` | Add `"Passkeys": { "ServerDomain": "localhost" }` | + +--- + +## Chunk 1: Infrastructure Setup + +### Task 1: Add HostDbContext partial, configure passkey options, and run migration + +> **Architecture note:** This project uses a Roslyn source generator that synthesizes `HostDbContext` as a `partial class` combining all module DbContexts. All EF Core migrations run against `HostDbContext` (in `template/SimpleModule.Host/Migrations/`), NOT against `UsersDbContext`. To opt into Identity Schema Version 3, we add a hand-written `partial class HostDbContext` file that overrides `SchemaVersion`. + +**Files:** +- Create: `template/SimpleModule.Host/HostDbContextPasskeys.cs` +- Modify: `modules/Users/src/SimpleModule.Users/UsersModule.cs` +- Modify: `template/SimpleModule.Host/appsettings.json` +- Modify: `template/SimpleModule.Host/appsettings.Development.json` +- Create: EF Core migration (auto-generated in `template/SimpleModule.Host/Migrations/`) + +- [ ] **Step 1: Create hand-written HostDbContext partial to override SchemaVersion** + +Create `template/SimpleModule.Host/HostDbContextPasskeys.cs`: + +```csharp +using Microsoft.AspNetCore.Identity; + +namespace SimpleModule.Host; + +// Extends the source-generated HostDbContext to opt into Identity Schema Version 3. +// This adds the AspNetUserPasskeys table for WebAuthn passkey support. +public partial class HostDbContext +{ + protected override Version SchemaVersion => IdentitySchemaVersions.Version3; +} +``` + +> **Why a separate file:** `HostDbContext` is generated by the Roslyn source generator and declared as `partial`. This hand-written partial adds only the `SchemaVersion` override. The generated partial handles everything else (user types, module DbSets, schema mapping). + +- [ ] **Step 2: Configure IdentityPasskeyOptions in UsersModule.cs** + +In `modules/Users/src/SimpleModule.Users/UsersModule.cs`, add this line immediately after the `.AddDefaultTokenProviders()` chain in `ConfigureServices`: + +```csharp +services.Configure(configuration.GetSection("Passkeys")); +``` + +The full `ConfigureServices` method should look like: + +```csharp +public void ConfigureServices(IServiceCollection services, IConfiguration configuration) +{ + services.AddModuleDbContext(configuration, UsersConstants.ModuleName); + + services + .AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.Configure(configuration.GetSection("Passkeys")); + + services.ConfigureApplicationCookie(options => + { + options.LoginPath = "/Identity/Account/Login"; + options.LogoutPath = "/Identity/Account/Logout"; + options.AccessDeniedPath = "/Identity/Account/AccessDenied"; + }); + + services.AddSingleton, ApplyUsersModuleOptions>(); + services.AddHostedService(); + services.AddSingleton, ConsoleEmailSender>(); +} +``` + +Add using at the top of `UsersModule.cs` if not already present: `using Microsoft.AspNetCore.Identity;` + +- [ ] **Step 3: Add Passkeys config to appsettings.json** + +In `template/SimpleModule.Host/appsettings.json`, add a `"Passkeys"` section (replace `yourdomain.com` with your actual domain in production): + +```json +"Passkeys": { + "ServerDomain": "yourdomain.com" +} +``` + +- [ ] **Step 4: Add Passkeys config to appsettings.Development.json** + +In `template/SimpleModule.Host/appsettings.Development.json`, add: + +```json +"Passkeys": { + "ServerDomain": "localhost" +} +``` + +- [ ] **Step 5: Build to verify the partial compiles** + +```bash +dotnet build +``` + +Expected: 0 errors. Fix any namespace mismatches (check the generated `HostDbContext.g.cs` namespace if needed — look in `template/SimpleModule.Host/obj/Debug/net10.0/generated/`). + +- [ ] **Step 6: Generate the EF Core migration** + +Run from the solution root (all migrations target `HostDbContext` in the Host project): + +```bash +dotnet ef migrations add AddPasskeySupport --project template/SimpleModule.Host --startup-project template/SimpleModule.Host +``` + +Inspect the generated migration file in `template/SimpleModule.Host/Migrations/` and confirm it creates an `AspNetUserPasskeys` table (or prefixed equivalent for the Users schema, e.g., `Users_AspNetUserPasskeys`). + +- [ ] **Step 7: Build again after migration** + +```bash +dotnet build +``` + +Expected: 0 errors. + +- [ ] **Step 8: Commit** + +```bash +git add template/SimpleModule.Host/HostDbContextPasskeys.cs +git add modules/Users/src/SimpleModule.Users/UsersModule.cs +git add template/SimpleModule.Host/appsettings.json +git add template/SimpleModule.Host/appsettings.Development.json +git add -A -- "template/SimpleModule.Host/Migrations/*AddPasskeySupport*" +git commit -m "feat: add Identity Schema V3 partial and IdentityPasskeyOptions for passkey support" +``` + +--- + +## Chunk 2: Registration API Endpoints + +### Task 2: PasskeyRegisterBeginEndpoint + +**Files:** +- Create: `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterBeginEndpoint.cs` +- Create (or add to): `modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs` + +- [ ] **Step 1: Write the failing integration test** + +Create `modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs`: + +```csharp +using System.Net; +using System.Security.Claims; +using FluentAssertions; +using SimpleModule.Tests.Shared.Fixtures; + +namespace Users.Tests.Integration; + +[Collection(TestCollections.Integration)] +public class PasskeyApiEndpointTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + private readonly HttpClient _unauthenticated; + + public PasskeyApiEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + _unauthenticated = factory.CreateClient(); + } + + // ── Register Begin ────────────────────────────────────────────── + + [Fact] + public async Task RegisterBegin_WhenAuthenticated_Returns200WithJson() + { + var client = _factory.CreateAuthenticatedClient(); + + var response = await client.PostAsync("/api/passkeys/register/begin", null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + var body = await response.Content.ReadAsStringAsync(); + body.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task RegisterBegin_WhenUnauthenticated_Returns401() + { + var response = await _unauthenticated.PostAsync("/api/passkeys/register/begin", null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~RegisterBegin" -v +``` + +Expected: FAIL (endpoint does not exist). + +- [ ] **Step 3: Create the endpoint** + +Create `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterBeginEndpoint.cs`: + +```csharp +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class PasskeyRegisterBeginEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + "/api/passkeys/register/begin", + async ( + ClaimsPrincipal principal, + UserManager userManager, + SignInManager signInManager + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return Results.Unauthorized(); + + // MakePasskeyCreationOptionsAsync takes a PasskeyUserEntity (NOT ApplicationUser directly). + // It stores the challenge in an encrypted auth cookie and returns a JSON string + // of PublicKeyCredentialCreationOptions for the browser. + // Verify exact PasskeyUserEntity properties at: + // https://learn.microsoft.com/en-us/aspnet/core/security/authentication/passkeys + var userEntity = new PasskeyUserEntity + { + Id = await userManager.GetUserIdAsync(user), + Name = await userManager.GetUserNameAsync(user) ?? user.Email ?? user.Id, + DisplayName = user.DisplayName.Length > 0 + ? user.DisplayName + : (await userManager.GetUserNameAsync(user) ?? user.Email ?? user.Id), + }; + + var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(userEntity); + return Results.Content(optionsJson, "application/json"); + } + ) + .RequireAuthorization() + .DisableAntiforgery() + .WithTags("Passkeys"); + } +} +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~RegisterBegin" -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterBeginEndpoint.cs +git add modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs +git commit -m "feat: add PasskeyRegisterBeginEndpoint" +``` + +--- + +### Task 3: PasskeyRegisterCompleteEndpoint + +**Files:** +- Create: `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs` +- Modify: `modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs` + +- [ ] **Step 1: Add the failing tests to PasskeyApiEndpointTests.cs** + +Append to `PasskeyApiEndpointTests.cs`: + +```csharp + // ── Register Complete ───────────────────────────────────────────── + + [Fact] + public async Task RegisterComplete_WhenUnauthenticated_Returns401() + { + var content = new StringContent( + """{"id":"test"}""", + System.Text.Encoding.UTF8, + "application/json" + ); + + var response = await _unauthenticated.PostAsync( + "/api/passkeys/register/complete", + content + ); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task RegisterComplete_WithInvalidCredential_ReturnsBadRequest() + { + var client = _factory.CreateAuthenticatedClient(); + var content = new StringContent( + """{"invalid":"data"}""", + System.Text.Encoding.UTF8, + "application/json" + ); + + var response = await client.PostAsync("/api/passkeys/register/complete", content); + + // Invalid attestation should be rejected + response.StatusCode.Should().BeOneOf( + HttpStatusCode.BadRequest, + HttpStatusCode.UnprocessableEntity + ); + } +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~RegisterComplete" -v +``` + +Expected: FAIL + +- [ ] **Step 3: Create the endpoint** + +Create `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs`: + +```csharp +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class PasskeyRegisterCompleteEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + "/api/passkeys/register/complete", + async ( + HttpRequest request, + ClaimsPrincipal principal, + UserManager userManager, + SignInManager signInManager + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return Results.Unauthorized(); + + var credentialJson = await new StreamReader(request.Body).ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(credentialJson)) + return Results.BadRequest("Credential JSON is required."); + + // PerformPasskeyAttestationAsync validates the WebAuthn attestation. + // Returns a result with .Succeeded and .Passkey (the passkey to store). + // Verify exact return type at: + // https://learn.microsoft.com/en-us/aspnet/core/security/authentication/passkeys + var result = await signInManager.PerformPasskeyAttestationAsync(credentialJson); + if (!result.Succeeded) + return Results.BadRequest("Passkey registration failed."); + + await userManager.AddOrUpdatePasskeyAsync(user, result.Passkey); + return Results.Ok(); + } + ) + .RequireAuthorization() + .DisableAntiforgery() + .WithTags("Passkeys"); + } +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~RegisterComplete" -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs +git add modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs +git commit -m "feat: add PasskeyRegisterCompleteEndpoint" +``` + +--- + +## Chunk 3: Auth + Management API Endpoints + +### Task 4: PasskeyLoginBeginEndpoint + +**Files:** +- Create: `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginBeginEndpoint.cs` +- Modify: `modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs` + +- [ ] **Step 1: Add the failing test** + +Append to `PasskeyApiEndpointTests.cs`: + +```csharp + // ── Login Begin ─────────────────────────────────────────────────── + + [Fact] + public async Task LoginBegin_WhenAnonymous_Returns200WithJson() + { + var response = await _unauthenticated.PostAsync("/api/passkeys/login/begin", null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + var body = await response.Content.ReadAsStringAsync(); + body.Should().NotBeNullOrEmpty(); + } +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~LoginBegin" -v +``` + +- [ ] **Step 3: Create the endpoint** + +Create `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginBeginEndpoint.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class PasskeyLoginBeginEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + "/api/passkeys/login/begin", + async (SignInManager signInManager) => + { + // MakePasskeyRequestOptionsAsync stores challenge in encrypted auth cookie. + // Pass null for user to allow any registered passkey (discoverable credentials). + var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(null); + return Results.Content(optionsJson, "application/json"); + } + ) + .AllowAnonymous() + .DisableAntiforgery() + .WithTags("Passkeys"); + } +} +``` + +- [ ] **Step 4: Run the test** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~LoginBegin" -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginBeginEndpoint.cs +git add modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs +git commit -m "feat: add PasskeyLoginBeginEndpoint" +``` + +--- + +### Task 5: PasskeyLoginCompleteEndpoint + +**Files:** +- Create: `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginCompleteEndpoint.cs` +- Modify: `modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs` + +- [ ] **Step 1: Add the failing tests** + +Append to `PasskeyApiEndpointTests.cs`: + +```csharp + // ── Login Complete ──────────────────────────────────────────────── + + [Fact] + public async Task LoginComplete_WithInvalidCredential_ReturnsUnauthorized() + { + var content = new StringContent( + """{"id":"invalid","type":"public-key"}""", + System.Text.Encoding.UTF8, + "application/json" + ); + + var response = await _unauthenticated.PostAsync("/api/passkeys/login/complete", content); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task LoginComplete_WithEmptyBody_ReturnsBadRequest() + { + var response = await _unauthenticated.PostAsync("/api/passkeys/login/complete", null); + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.BadRequest, + HttpStatusCode.Unauthorized + ); + } +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~LoginComplete" -v +``` + +- [ ] **Step 3: Create the endpoint** + +Create `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginCompleteEndpoint.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class PasskeyLoginCompleteEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + "/api/passkeys/login/complete", + async ( + HttpRequest request, + SignInManager signInManager, + [FromQuery] string? returnUrl = null + ) => + { + var credentialJson = await new StreamReader(request.Body).ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(credentialJson)) + return Results.BadRequest("Credential JSON is required."); + + // PasskeySignInAsync validates the assertion, signs in, and sets the auth cookie. + // Verify exact signature at: + // https://learn.microsoft.com/en-us/aspnet/core/security/authentication/passkeys + var result = await signInManager.PasskeySignInAsync( + credentialJson, + isPersistent: false, + lockoutOnFailure: true + ); + + if (result.Succeeded) + { + var redirectUrl = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl; + return Results.Ok(new { redirectUrl }); + } + + if (result.IsLockedOut) + return Results.Problem("Account is locked out.", statusCode: 423); + + return Results.Unauthorized(); + } + ) + .AllowAnonymous() + .DisableAntiforgery() + .WithTags("Passkeys"); + } +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~LoginComplete" -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginCompleteEndpoint.cs +git add modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs +git commit -m "feat: add PasskeyLoginCompleteEndpoint" +``` + +--- + +### Task 6: GetPasskeysEndpoint + +**Files:** +- Create: `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs` +- Modify: `modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs` + +- [ ] **Step 1: Add the failing tests** + +Append to `PasskeyApiEndpointTests.cs`: + +```csharp + // ── Get Passkeys ────────────────────────────────────────────────── + + [Fact] + public async Task GetPasskeys_WhenUnauthenticated_Returns401() + { + var response = await _unauthenticated.GetAsync("/api/passkeys"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetPasskeys_WhenAuthenticated_ReturnsOkWithList() + { + var client = _factory.CreateAuthenticatedClient(); + + var response = await client.GetAsync("/api/passkeys"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + body.Should().NotBeNull(); + // New users have no passkeys — should return empty array + body.Should().Be("[]"); + } +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~GetPasskeys" -v +``` + +- [ ] **Step 3: Create the endpoint** + +Create `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs`: + +```csharp +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class GetPasskeysEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/api/passkeys", + async ( + ClaimsPrincipal principal, + UserManager userManager + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return Results.Unauthorized(); + + var passkeys = await userManager.GetPasskeysAsync(user); + + // IdentityUserPasskey properties: CredentialId (byte[]), Name, CreatedAt. + // Verify exact property names at: + // https://learn.microsoft.com/en-us/aspnet/core/security/authentication/passkeys + var result = passkeys.Select(p => new + { + credentialId = ToBase64Url(p.CredentialId), + name = p.Name, + createdAt = p.CreatedAt, + }); + + return Results.Ok(result); + } + ) + .RequireAuthorization() + .WithTags("Passkeys"); + } + + private static string ToBase64Url(byte[] bytes) => + Convert.ToBase64String(bytes) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~GetPasskeys" -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs +git add modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs +git commit -m "feat: add GetPasskeysEndpoint" +``` + +--- + +### Task 7: DeletePasskeyEndpoint + +**Files:** +- Create: `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/DeletePasskeyEndpoint.cs` +- Modify: `modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs` + +- [ ] **Step 1: Add the failing tests** + +Append to `PasskeyApiEndpointTests.cs`, then close the class with `}`: + +```csharp + // ── Delete Passkey ──────────────────────────────────────────────── + + [Fact] + public async Task DeletePasskey_WhenUnauthenticated_Returns401() + { + var response = await _unauthenticated.DeleteAsync("/api/passkeys/someCredentialId"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task DeletePasskey_WithNonExistentCredential_ReturnsNotFound() + { + var client = _factory.CreateAuthenticatedClient(); + + var response = await client.DeleteAsync("/api/passkeys/nonexistent-credential-id"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~DeletePasskey" -v +``` + +- [ ] **Step 3: Create the endpoint** + +Create `modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/DeletePasskeyEndpoint.cs`: + +```csharp +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class DeletePasskeyEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapDelete( + "/api/passkeys/{credentialId}", + async ( + string credentialId, + ClaimsPrincipal principal, + UserManager userManager + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return Results.Unauthorized(); + + byte[] credentialIdBytes; + try + { + // Decode base64url (URL-safe base64 without padding) + var base64 = credentialId.Replace('-', '+').Replace('_', '/'); + var padding = (4 - (base64.Length % 4)) % 4; + base64 = base64.PadRight(base64.Length + padding, '='); + credentialIdBytes = Convert.FromBase64String(base64); + } + catch + { + return Results.BadRequest("Invalid credential ID format."); + } + + // Verify the passkey belongs to this user before deleting + var passkeys = await userManager.GetPasskeysAsync(user); + var exists = passkeys.Any(p => p.CredentialId.SequenceEqual(credentialIdBytes)); + if (!exists) + return Results.NotFound(); + + await userManager.RemovePasskeyAsync(user, credentialIdBytes); + return Results.NoContent(); + } + ) + .RequireAuthorization() + .WithTags("Passkeys"); + } +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~DeletePasskey" -v +``` + +Expected: PASS + +- [ ] **Step 5: Run all passkey API tests together** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~PasskeyApiEndpointTests" -v +``` + +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/DeletePasskeyEndpoint.cs +git add modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs +git commit -m "feat: add DeletePasskeyEndpoint" +``` + +--- + +## Chunk 4: View Endpoint + Frontend + +### Task 8: ManagePasskeysEndpoint (Inertia view) + +**Files:** +- Create: `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs` +- Create: `modules/Users/tests/SimpleModule.Users.Tests/Integration/ManagePasskeysEndpointTests.cs` + +- [ ] **Step 1: Write the failing integration test** + +Create `modules/Users/tests/SimpleModule.Users.Tests/Integration/ManagePasskeysEndpointTests.cs`: + +```csharp +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using SimpleModule.Tests.Shared.Fixtures; + +namespace Users.Tests.Integration; + +[Collection(TestCollections.Integration)] +public class ManagePasskeysEndpointTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public ManagePasskeysEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Get_WhenAuthenticated_Returns200() + { + var client = _factory.CreateAuthenticatedClient(); + + var response = await client.GetAsync("/Identity/Account/Manage/Passkeys"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Get_WhenUnauthenticated_RedirectsToLogin() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + + var response = await client.GetAsync("/Identity/Account/Manage/Passkeys"); + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.Redirect, + HttpStatusCode.Found, + HttpStatusCode.Unauthorized + ); + } +} +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~ManagePasskeysEndpointTests" -v +``` + +- [ ] **Step 3: Create the view endpoint** + +Create `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs`: + +```csharp +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Pages.Account.Manage; + +public class ManagePasskeysEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/Manage/Passkeys", + async ( + ClaimsPrincipal principal, + UserManager userManager + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return TypedResults.Redirect("/Identity/Account/Login"); + + var passkeys = await userManager.GetPasskeysAsync(user); + + var passkeysDto = passkeys.Select(p => new + { + credentialId = ToBase64Url(p.CredentialId), + name = p.Name, + createdAt = p.CreatedAt, + }); + + return Inertia.Render( + "Users/Account/Manage/Passkeys", + new { passkeys = passkeysDto } + ); + } + ) + .RequireAuthorization(); + } + + private static string ToBase64Url(byte[] bytes) => + Convert.ToBase64String(bytes) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +dotnet test modules/Users/tests/SimpleModule.Users.Tests \ + --filter "FullyQualifiedName~ManagePasskeysEndpointTests" -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs +git add modules/Users/tests/SimpleModule.Users.Tests/Integration/ManagePasskeysEndpointTests.cs +git commit -m "feat: add ManagePasskeysEndpoint (Inertia view)" +``` + +--- + +### Task 9: passkey.ts — WebAuthn Browser API Utility + +**Files:** +- Create: `modules/Users/src/SimpleModule.Users/Pages/passkey.ts` + +- [ ] **Step 1: Create the utility** + +Create `modules/Users/src/SimpleModule.Users/Pages/passkey.ts`: + +```typescript +// WebAuthn uses base64url encoding for all binary data. +// These helpers convert between ArrayBuffer (required by browser API) and base64url strings. + +function base64urlToArrayBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const binary = atob(padded); + const buffer = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + buffer[i] = binary.charCodeAt(i); + } + return buffer.buffer; +} + +function arrayBufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +// Convert the server's JSON options to the format the browser API expects. +// The server sends base64url strings; the browser API needs ArrayBuffers. +function prepareCreationOptions(json: Record): PublicKeyCredentialCreationOptions { + const opts = json as Record; + return { + ...opts, + challenge: base64urlToArrayBuffer(opts['challenge'] as string), + user: { + ...(opts['user'] as Record), + id: base64urlToArrayBuffer((opts['user'] as Record)['id'] as string), + }, + excludeCredentials: ((opts['excludeCredentials'] as unknown[]) ?? []).map( + (c: unknown) => { + const cred = c as Record; + return { ...cred, id: base64urlToArrayBuffer(cred['id'] as string) }; + } + ), + } as unknown as PublicKeyCredentialCreationOptions; +} + +function prepareRequestOptions(json: Record): PublicKeyCredentialRequestOptions { + const opts = json as Record; + return { + ...opts, + challenge: base64urlToArrayBuffer(opts['challenge'] as string), + allowCredentials: ((opts['allowCredentials'] as unknown[]) ?? []).map( + (c: unknown) => { + const cred = c as Record; + return { ...cred, id: base64urlToArrayBuffer(cred['id'] as string) }; + } + ), + } as unknown as PublicKeyCredentialRequestOptions; +} + +// Serialize the browser's attestation response back to a JSON-compatible object for the server. +function serializeAttestation(credential: PublicKeyCredential): Record { + const r = credential.response as AuthenticatorAttestationResponse; + return { + id: credential.id, + rawId: arrayBufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: arrayBufferToBase64url(r.clientDataJSON), + attestationObject: arrayBufferToBase64url(r.attestationObject), + transports: r.getTransports?.() ?? [], + }, + clientExtensionResults: credential.getClientExtensionResults(), + }; +} + +// Serialize the browser's assertion response back to a JSON-compatible object for the server. +function serializeAssertion(credential: PublicKeyCredential): Record { + const r = credential.response as AuthenticatorAssertionResponse; + return { + id: credential.id, + rawId: arrayBufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: arrayBufferToBase64url(r.clientDataJSON), + authenticatorData: arrayBufferToBase64url(r.authenticatorData), + signature: arrayBufferToBase64url(r.signature), + userHandle: r.userHandle ? arrayBufferToBase64url(r.userHandle) : null, + }, + clientExtensionResults: credential.getClientExtensionResults(), + }; +} + +/** + * Full passkey registration flow: + * 1. Fetches creation options from the server + * 2. Prompts the user's device for biometric/PIN confirmation + * 3. Returns the serialized credential to be posted to /api/passkeys/register/complete + */ +export async function startPasskeyRegistration(): Promise> { + const beginRes = await fetch('/api/passkeys/register/begin', { method: 'POST' }); + if (!beginRes.ok) { + throw new Error('Failed to start passkey registration'); + } + const optionsJson = (await beginRes.json()) as Record; + const options = prepareCreationOptions(optionsJson); + + const credential = await navigator.credentials.create({ publicKey: options }); + if (!credential) { + throw new Error('No credential returned from device'); + } + return serializeAttestation(credential as PublicKeyCredential); +} + +/** + * Full passkey authentication flow: + * 1. Fetches request options from the server + * 2. Prompts the user's device for biometric/PIN confirmation + * 3. Returns the serialized credential to be posted to /api/passkeys/login/complete + */ +export async function startPasskeyAssertion(): Promise> { + const beginRes = await fetch('/api/passkeys/login/begin', { method: 'POST' }); + if (!beginRes.ok) { + throw new Error('Failed to start passkey sign-in'); + } + const optionsJson = (await beginRes.json()) as Record; + const options = prepareRequestOptions(optionsJson); + + const credential = await navigator.credentials.get({ publicKey: options }); + if (!credential) { + throw new Error('No credential returned from device'); + } + return serializeAssertion(credential as PublicKeyCredential); +} +``` + +- [ ] **Step 2: Run biome lint check** + +```bash +npm run check +``` + +Fix any issues automatically: + +```bash +npm run check:fix +``` + +- [ ] **Step 3: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Pages/passkey.ts +git commit -m "feat: add WebAuthn browser API utility (passkey.ts)" +``` + +--- + +### Task 10: ManagePasskeys.tsx React Page + +**Files:** +- Create: `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx` + +- [ ] **Step 1: Create the page** + +Create `modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx`: + +```tsx +import { router } from '@inertiajs/react'; +import { Button, CardContent, CardHeader, CardTitle } from '@simplemodule/ui'; +import { useState } from 'react'; +import ManageLayout from '@/components/ManageLayout'; // '@/' alias resolves to module src root +import { startPasskeyRegistration } from '../../passkey'; + +interface Passkey { + credentialId: string; + name: string; + createdAt: string; +} + +interface Props { + passkeys: Passkey[]; +} + +export default function ManagePasskeys({ passkeys }: Props) { + const [registering, setRegistering] = useState(false); + const [error, setError] = useState(null); + + async function handleAddPasskey() { + if (!window.PublicKeyCredential) { + setError('Your browser does not support passkeys.'); + return; + } + setRegistering(true); + setError(null); + try { + const credential = await startPasskeyRegistration(); + const res = await fetch('/api/passkeys/register/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credential), + }); + if (!res.ok) { + setError('Passkey registration failed. Please try again.'); + return; + } + router.reload(); + } catch (err) { + if (err instanceof Error && err.name === 'NotAllowedError') { + setError('Registration was cancelled.'); + } else { + setError('An unexpected error occurred. Please try again.'); + } + } finally { + setRegistering(false); + } + } + + async function handleDeletePasskey(credentialId: string) { + if (!confirm('Remove this passkey?')) return; + const res = await fetch(`/api/passkeys/${encodeURIComponent(credentialId)}`, { + method: 'DELETE', + }); + if (res.ok) { + router.reload(); + } else { + setError('Failed to remove passkey. Please try again.'); + } + } + + return ( + + + Passkeys + + +

+ Passkeys let you sign in with your fingerprint, face, or device PIN — no password needed. +

+ + {error && ( +
+ {error} +
+ )} + + {passkeys.length === 0 ? ( +

No passkeys registered yet.

+ ) : ( +
    + {passkeys.map((passkey) => ( +
  • +
    +

    {passkey.name || 'Passkey'}

    +

    + Added {new Date(passkey.createdAt).toLocaleDateString()} +

    +
    + +
  • + ))} +
+ )} + + +
+
+ ); +} +``` + +- [ ] **Step 2: Run biome lint check** + +```bash +npm run check +``` + +Fix automatically if needed: + +```bash +npm run check:fix +``` + +- [ ] **Step 3: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx +git commit -m "feat: add ManagePasskeys React page" +``` + +--- + +### Task 11: Update Login.tsx with passkey sign-in button + +**Files:** +- Modify: `modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx` + +- [ ] **Step 1: Update the Props interface and add imports** + +At the top of `Login.tsx`, add: + +```tsx +import { useState } from 'react'; +import { startPasskeyAssertion } from '../passkey'; +``` + +Update the Props interface to add `passkeyEnabled`: + +```tsx +interface Props { + returnUrl: string; + showTestAccounts: boolean; + passkeyEnabled: boolean; + errors?: { email?: string }; +} +``` + +- [ ] **Step 2: Add state and handler inside the component** + +Add to the component body (after the existing `quickLogin` function). `startPasskeyAssertion` is imported from `'../passkey'` (i.e., `Pages/passkey.ts` relative to `Pages/Account/Login.tsx`): + +```tsx +export default function Login({ returnUrl, showTestAccounts, passkeyEnabled, errors }: Props) { + const [passkeyError, setPasskeyError] = useState(null); + const [passkeyLoading, setPasskeyLoading] = useState(false); + + // ... keep existing handleSubmit and quickLogin ... + + async function handlePasskeySignIn() { + if (!window.PublicKeyCredential) { + setPasskeyError('Your browser does not support passkeys.'); + return; + } + setPasskeyLoading(true); + setPasskeyError(null); + try { + const credential = await startPasskeyAssertion(); + const res = await fetch( + `/api/passkeys/login/complete?returnUrl=${encodeURIComponent(returnUrl)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credential), + } + ); + if (res.ok) { + const data = (await res.json()) as { redirectUrl: string }; + window.location.href = data.redirectUrl; + } else if (res.status === 423) { + setPasskeyError('Your account is locked. Please try again later.'); + } else { + setPasskeyError('Passkey sign-in failed. Use your password instead.'); + } + } catch (err) { + if (err instanceof Error && err.name === 'NotAllowedError') { + setPasskeyError('Passkey sign-in was cancelled.'); + } else { + setPasskeyError('An unexpected error occurred.'); + } + } finally { + setPasskeyLoading(false); + } + } +``` + +- [ ] **Step 3: Add the passkey button to the JSX** + +Inside ``, after the closing `` tag and before the test accounts section, add: + +```tsx +{passkeyEnabled && ( + <> +
+
+ + or + +
+ {passkeyError && ( +
+ {passkeyError} +
+ )} + + +)} +``` + +- [ ] **Step 4: Run biome lint check and fix** + +```bash +npm run check:fix +``` + +- [ ] **Step 5: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx +git commit -m "feat: add passkey sign-in button to login page" +``` + +--- + +### Task 12: Update LoginEndpoint.cs to pass passkeyEnabled prop + +**Files:** +- Modify: `modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs` + +- [ ] **Step 1: Inject IOptions and pass the flag** + +In the GET handler of `LoginEndpoint.cs`, add `IOptions passkeyOptions` as a parameter and pass `passkeyEnabled` to `Inertia.Render`. + +Add the using at the top: +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +``` + +Update the GET handler signature: + +```csharp +app.MapGet( + "/Login", + async ( + HttpContext context, + ISettingsContracts settingsService, + ISettingsDefinitionRegistry settingsDefinitions, + IOptions passkeyOptions, + [FromQuery] string? returnUrl + ) => + { + await context.SignOutAsync(IdentityConstants.ExternalScheme); + + var showTestAccounts = await settingsService.GetSettingAsync( + ConfigKeys.ShowTestAccounts, + SettingScope.System + ); + showTestAccounts ??= settingsDefinitions + .GetDefinition(ConfigKeys.ShowTestAccounts) + ?.DefaultValue; + + return Inertia.Render( + "Users/Account/Login", + new + { + returnUrl = returnUrl ?? "/", + showTestAccounts = showTestAccounts == "true", + passkeyEnabled = !string.IsNullOrEmpty(passkeyOptions.Value.ServerDomain), + } + ); + } +) +.AllowAnonymous(); +``` + +- [ ] **Step 2: Build to verify no compilation errors** + +```bash +dotnet build +``` + +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs +git commit -m "feat: pass passkeyEnabled prop from IdentityPasskeyOptions to login page" +``` + +--- + +### Task 13: Update ManageLayout.tsx and Pages/index.ts + +**Files:** +- Modify: `modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx` +- Modify: `modules/Users/src/SimpleModule.Users/Pages/index.ts` + +- [ ] **Step 1: Add Passkeys to the navItems array in ManageLayout.tsx** + +In `components/ManageLayout.tsx`, add this entry to the `navItems` array after the `TwoFactorAuthentication` entry: + +```typescript +{ + href: '/Identity/Account/Manage/Passkeys', + page: 'Passkeys', + label: 'Passkeys', + icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', +}, +``` + +- [ ] **Step 2: Register the page in Pages/index.ts** + +In `modules/Users/src/SimpleModule.Users/Pages/index.ts`, add to the `pages` object: + +```typescript +'Users/Account/Manage/Passkeys': () => import('./Account/Manage/ManagePasskeys'), +``` + +Place it after the `'Users/Account/Manage/ExternalLogins'` entry. + +- [ ] **Step 3: Run biome lint check** + +```bash +npm run check:fix +``` + +- [ ] **Step 4: Validate page registrations** + +```bash +npm run validate-pages +``` + +Expected: No mismatches. The key `"Users/Account/Manage/Passkeys"` in `Pages/index.ts` must match the string in `Inertia.Render(...)` in `ManagePasskeysEndpoint.cs`. + +- [ ] **Step 5: Commit** + +```bash +git add modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx +git add modules/Users/src/SimpleModule.Users/Pages/index.ts +git commit -m "feat: register ManagePasskeys page and add Passkeys nav item to account sidebar" +``` + +--- + +### Task 14: Final verification + +- [ ] **Step 1: Full build** + +```bash +dotnet build +``` + +Expected: 0 errors. + +- [ ] **Step 2: Run all tests** + +```bash +dotnet test +``` + +Expected: All tests pass. + +- [ ] **Step 3: Biome lint + format check** + +```bash +npm run check +``` + +Fix automatically if needed: + +```bash +npm run check:fix +``` + +Expected: No lint errors across `passkey.ts`, `ManagePasskeys.tsx`, and updated `Login.tsx`. + +- [ ] **Step 4: Build frontend** + +```bash +npm run dev:build +``` + +Expected: All module builds succeed. + +- [ ] **Step 5: Validate page registrations** + +```bash +npm run validate-pages +``` + +Expected: No mismatches. + +- [ ] **Step 6: Smoke test (manual)** + +Run the dev server and verify: +1. Login page shows "Sign in with passkey" button (when `ServerDomain` is configured) +2. Account Settings → Passkeys page loads +3. "Add passkey" triggers the browser's passkey UI (requires HTTPS or localhost) +4. Registered passkeys appear in the list with a Remove button + +```bash +npm run dev +``` + +Navigate to `https://localhost:5001/Identity/Account/Login` From a65b1fac2797c415c3e8f31310e61bfa755cf590 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:03:09 +0200 Subject: [PATCH 05/22] feat: add Identity Schema V3 and IdentityPasskeyOptions for passkey support - Configure IdentityOptions.Stores.SchemaVersion = Version3 in UsersModule to opt into the AspNetUserPasskeys table (WebAuthn passkey support) - Configure IdentityPasskeyOptions from appsettings "Passkeys" section in UsersModule - Add Passkeys config sections to appsettings.json and appsettings.Development.json - Fix HostDbContextFactory to expose a minimal service provider with IdentityOptions via UseApplicationServiceProvider so EF design-time model creation picks up SchemaVersion - Add UseApplicationServiceProvider to AddModuleDbContext for runtime parity - Add migration AddPasskeySupport creating Users_AspNetUserPasskeys table - Add HostDbContextPasskeys.cs as a seam partial for future passkey customizations --- .../ModuleDbContextOptionsBuilder.cs | 4 + .../src/SimpleModule.Users/UsersModule.cs | 7 + .../SimpleModule.Host/HostDbContextFactory.cs | 12 + .../HostDbContextPasskeys.cs | 11 + ...260406140223_AddPasskeySupport.Designer.cs | 1903 +++++++++++++++++ .../20260406140223_AddPasskeySupport.cs | 315 +++ .../Migrations/HostDbContextModelSnapshot.cs | 77 +- .../appsettings.Development.json | 3 + template/SimpleModule.Host/appsettings.json | 3 + 9 files changed, 2333 insertions(+), 2 deletions(-) create mode 100644 template/SimpleModule.Host/HostDbContextPasskeys.cs create mode 100644 template/SimpleModule.Host/Migrations/20260406140223_AddPasskeySupport.Designer.cs create mode 100644 template/SimpleModule.Host/Migrations/20260406140223_AddPasskeySupport.cs diff --git a/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs b/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs index 7d46f153..fc09d4f0 100644 --- a/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs +++ b/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs @@ -34,6 +34,10 @@ public static IServiceCollection AddModuleDbContext( services.AddDbContext( (sp, options) => { + // Expose the application service provider so that EF model creation can + // read IOptions values (e.g. IdentityOptions.Stores.SchemaVersion). + options.UseApplicationServiceProvider(sp); + switch (provider) { case DatabaseProvider.PostgreSql: diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index 011dfca3..8f738639 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -24,6 +24,13 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config .AddEntityFrameworkStores() .AddDefaultTokenProviders(); + services.Configure(configuration.GetSection("Passkeys")); + + // Opt into Identity Schema Version 3 to enable the AspNetUserPasskeys table + services.Configure(options => + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3 + ); + services.ConfigureApplicationCookie(options => { options.LoginPath = "/Identity/Account/Login"; diff --git a/template/SimpleModule.Host/HostDbContextFactory.cs b/template/SimpleModule.Host/HostDbContextFactory.cs index 66c74c9c..0481e066 100644 --- a/template/SimpleModule.Host/HostDbContextFactory.cs +++ b/template/SimpleModule.Host/HostDbContextFactory.cs @@ -1,6 +1,8 @@ +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using SimpleModule.Database; @@ -20,6 +22,15 @@ public HostDbContext CreateDbContext(string[] args) var dbOptions = config.GetSection("Database").Get() ?? new DatabaseOptions(); + // Build a minimal service provider that includes IdentityOptions with SchemaVersion 3. + // This allows HostDbContext (which inherits from IdentityDbContext) to discover + // the passkeys schema (AspNetUserPasskeys table) during model creation. + var services = new ServiceCollection(); + services.Configure(options => + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3 + ); + var sp = services.BuildServiceProvider(); + var optionsBuilder = new DbContextOptionsBuilder(); var provider = DatabaseProviderDetector.Detect( dbOptions.DefaultConnection, @@ -40,6 +51,7 @@ public HostDbContext CreateDbContext(string[] args) } optionsBuilder.UseOpenIddict(); + optionsBuilder.UseApplicationServiceProvider(sp); return new HostDbContext(optionsBuilder.Options, Options.Create(dbOptions)); } diff --git a/template/SimpleModule.Host/HostDbContextPasskeys.cs b/template/SimpleModule.Host/HostDbContextPasskeys.cs new file mode 100644 index 00000000..4a4eee9e --- /dev/null +++ b/template/SimpleModule.Host/HostDbContextPasskeys.cs @@ -0,0 +1,11 @@ +// Passkey support is enabled by configuring IdentityOptions.Stores.SchemaVersion +// in UsersModule.ConfigureServices. The source-generated HostDbContext inherits from +// IdentityDbContext, which reads SchemaVersion from IdentityOptions at model creation time +// and adds the AspNetUserPasskeys table when Version3 is active. +// +// This file is a placeholder for any future hand-written HostDbContext extensions +// related to passkey infrastructure (e.g. custom entity configurations). + +namespace SimpleModule.Host; + +public partial class HostDbContext; diff --git a/template/SimpleModule.Host/Migrations/20260406140223_AddPasskeySupport.Designer.cs b/template/SimpleModule.Host/Migrations/20260406140223_AddPasskeySupport.Designer.cs new file mode 100644 index 00000000..c1bea49b --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260406140223_AddPasskeySupport.Designer.cs @@ -0,0 +1,1903 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SimpleModule.Host; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + [DbContext(typeof(HostDbContext))] + [Migration("20260406140223_AddPasskeySupport")] + partial class AddPasskeySupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("BLOB"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserPasskeys", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("Users_AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ClientSecret") + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("JsonWebKeySet") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedirectUris") + .HasColumnType("TEXT"); + + b.Property("Requirements") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Scopes") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Descriptions") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Resources") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("AuthorizationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Payload") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedemptionDate") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Agents.Module.AgentMessage", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TokenCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.HasIndex("SessionId", "Timestamp"); + + b.ToTable("Agents_Messages", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Agents.Module.AgentSession", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastMessageAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentName"); + + b.HasIndex("UserId"); + + b.ToTable("Agents_Sessions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.AuditLogs.Contracts.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Changes") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("EntityId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("HttpMethod") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("QueryString") + .HasColumnType("TEXT"); + + b.Property("RequestBody") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("StatusCode") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("Path"); + + b.HasIndex("Source"); + + b.HasIndex("StatusCode"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.HasIndex("EntityType", "EntityId"); + + b.HasIndex("Module", "Timestamp") + .IsDescending(false, true); + + b.HasIndex("UserId", "Timestamp") + .IsDescending(false, true); + + b.ToTable("AuditLogs_AuditEntries", (string)null); + }); + + modelBuilder.Entity("SimpleModule.BackgroundJobs.Entities.JobProgress", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("JobTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Logs") + .HasColumnType("TEXT"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProgressMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ProgressPercentage") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleName"); + + b.ToTable("BackgroundJobs_JobProgress", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Email.Contracts.EmailMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bcc") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Cc") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsHtml") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ReplyTo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TemplateSlug") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("Email_EmailMessages", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Email.Contracts.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultReplyTo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsHtml") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Email_EmailTemplates", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Entities.FeatureFlagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeprecated") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlags", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Entities.FeatureFlagOverrideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FlagName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("OverrideType") + .HasColumnType("INTEGER"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FlagName", "OverrideType", "OverrideValue") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlagOverrides", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FileStorage.Contracts.StoredFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Folder") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("Folder", "FileName") + .IsUnique(); + + b.ToTable("FileStorage_StoredFiles", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Orders_Orders", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.Property("OrderId") + .HasColumnType("INTEGER"); + + b.Property("ProductId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("OrderId", "ProductId"); + + b.ToTable("Orders_OrderItems", (string)null); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DraftContent") + .HasColumnType("TEXT"); + + b.Property("IsPublished") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("MetaDescription") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MetaKeywords") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OgImage") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Order") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletedAt"); + + b.HasIndex("IsPublished"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("PageBuilder_Pages", (string)null); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PageId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PageId"); + + b.ToTable("PageBuilder_Tags", (string)null); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PageBuilder_Templates", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("RoleId", "Permission"); + + b.ToTable("Permissions_RolePermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Entities.UserPermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Permission"); + + b.ToTable("Permissions_UserPermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Products.Contracts.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Products_Products", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Fantastic Rubber Shoes", + Price = 991.68m + }, + new + { + Id = 2, + Name = "Fantastic Rubber Bacon", + Price = 446.22m + }, + new + { + Id = 3, + Name = "Fantastic Concrete Bike", + Price = 660.12m + }, + new + { + Id = 4, + Name = "Handcrafted Concrete Keyboard", + Price = 633.67m + }, + new + { + Id = 5, + Name = "Intelligent Frozen Mouse", + Price = 674.30m + }, + new + { + Id = 6, + Name = "Sleek Soft Hat", + Price = 851.63m + }, + new + { + Id = 7, + Name = "Practical Fresh Bike", + Price = 417.48m + }, + new + { + Id = 8, + Name = "Handmade Steel Ball", + Price = 975.56m + }, + new + { + Id = 9, + Name = "Ergonomic Fresh Pants", + Price = 928.09m + }, + new + { + Id = 10, + Name = "Licensed Steel Sausages", + Price = 592.60m + }); + }); + + modelBuilder.Entity("SimpleModule.Rag.StructuredRag.Data.CachedStructuredKnowledge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("HitCount") + .HasColumnType("INTEGER"); + + b.Property("SourceTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("StructureType") + .HasColumnType("INTEGER"); + + b.Property("StructuredContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("CollectionName", "DocumentHash", "StructureType") + .IsUnique(); + + b.ToTable("Rag_CachedStructuredKnowledge", (string)null); + }); + + modelBuilder.Entity("SimpleModule.RateLimiting.Contracts.RateLimitRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EndpointPattern") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PermitLimit") + .HasColumnType("INTEGER"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PolicyType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QueueLimit") + .HasColumnType("INTEGER"); + + b.Property("ReplenishmentPeriodSeconds") + .HasColumnType("INTEGER"); + + b.Property("SegmentsPerWindow") + .HasColumnType("INTEGER"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TokenLimit") + .HasColumnType("INTEGER"); + + b.Property("TokensPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WindowSeconds") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName") + .IsUnique(); + + b.ToTable("RateLimiting_Rules", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Settings.Entities.PublicMenuItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CssClass") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsHomePage") + .HasColumnType("INTEGER"); + + b.Property("IsVisible") + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("OpenInNewTab") + .HasColumnType("INTEGER"); + + b.Property("PageRoute") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentId", "SortOrder"); + + b.ToTable("Settings_PublicMenuItems", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Settings.Entities.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Scope", "UserId") + .IsUnique(); + + b.ToTable("Settings_Settings", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Tenants.Entities.TenantEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdminEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConnectionString") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("TEXT"); + + b.Property("EditionName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedBy") + .HasColumnType("TEXT"); + + b.Property("ValidUpTo") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants_Tenants", (string)null); + + b.HasData( + new + { + Id = 1, + AdminEmail = "admin@acme.com", + ConcurrencyStamp = "seed-acme", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + EditionName = "Enterprise", + Name = "Acme Corporation", + Slug = "acme", + Status = 0, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }, + new + { + Id = 2, + AdminEmail = "admin@contoso.com", + ConcurrencyStamp = "seed-contoso", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + EditionName = "Standard", + Name = "Contoso Ltd", + Slug = "contoso", + Status = 0, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }, + new + { + Id = 3, + AdminEmail = "admin@suspended.com", + ConcurrencyStamp = "seed-suspended", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Name = "Suspended Corp", + Slug = "suspended-corp", + Status = 1, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }); + }); + + modelBuilder.Entity("SimpleModule.Tenants.Entities.TenantHostEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("TenantId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostName") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Tenants_TenantHosts", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "seed-host-1", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HostName = "acme.localhost", + IsActive = true, + TenantId = 1, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }, + new + { + Id = 2, + ConcurrencyStamp = "seed-host-2", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HostName = "acme.local", + IsActive = true, + TenantId = 1, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }, + new + { + Id = 3, + ConcurrencyStamp = "seed-host-3", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HostName = "contoso.localhost", + IsActive = true, + TenantId = 2, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }); + }); + + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Users_AspNetRoles", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeactivatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users_AspNetUsers", (string)null); + }); + + modelBuilder.Entity("TickerQ.Utilities.Entities.CronTickerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Expression") + .HasColumnType("TEXT"); + + b.Property("Function") + .HasColumnType("TEXT"); + + b.Property("InitIdentifier") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Request") + .HasColumnType("BLOB"); + + b.Property("Retries") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("RetryIntervals") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BackgroundJobs_CronTickers", (string)null); + }); + + modelBuilder.Entity("TickerQ.Utilities.Entities.CronTickerOccurrenceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CronTickerId") + .HasColumnType("TEXT"); + + b.Property("ElapsedTime") + .HasColumnType("INTEGER"); + + b.Property("ExceptionMessage") + .HasColumnType("TEXT"); + + b.Property("ExecutedAt") + .HasColumnType("TEXT"); + + b.Property("ExecutionTime") + .HasColumnType("TEXT"); + + b.Property("LockHolder") + .HasColumnType("TEXT"); + + b.Property("LockedAt") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SkippedReason") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CronTickerId"); + + b.ToTable("BackgroundJobs_CronTickerOccurrences", (string)null); + }); + + modelBuilder.Entity("TickerQ.Utilities.Entities.TimeTickerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ElapsedTime") + .HasColumnType("INTEGER"); + + b.Property("ExceptionMessage") + .HasColumnType("TEXT"); + + b.Property("ExecutedAt") + .HasColumnType("TEXT"); + + b.Property("ExecutionTime") + .HasColumnType("TEXT"); + + b.Property("Function") + .HasColumnType("TEXT"); + + b.Property("InitIdentifier") + .HasColumnType("TEXT"); + + b.Property("LockHolder") + .HasColumnType("TEXT"); + + b.Property("LockedAt") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Request") + .HasColumnType("BLOB"); + + b.Property("Retries") + .HasColumnType("INTEGER"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("RetryIntervals") + .HasColumnType("TEXT"); + + b.Property("RunCondition") + .HasColumnType("INTEGER"); + + b.Property("SkippedReason") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("BackgroundJobs_TimeTickers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId"); + + b1.Property("AttestationObject") + .IsRequired(); + + b1.Property("ClientDataJson") + .IsRequired(); + + b1.Property("CreatedAt"); + + b1.Property("IsBackedUp"); + + b1.Property("IsBackupEligible"); + + b1.Property("IsUserVerified"); + + b1.Property("Name"); + + b1.Property("PublicKey") + .IsRequired(); + + b1.Property("SignCount"); + + b1.PrimitiveCollection("Transports"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("Users_AspNetUserPasskeys"); + + b1 + .ToJson("Data") + .HasColumnType("TEXT"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.HasOne("SimpleModule.Orders.Contracts.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTag", b => + { + b.HasOne("SimpleModule.PageBuilder.Contracts.Page", null) + .WithMany("Tags") + .HasForeignKey("PageId"); + }); + + modelBuilder.Entity("SimpleModule.Settings.Entities.PublicMenuItemEntity", b => + { + b.HasOne("SimpleModule.Settings.Entities.PublicMenuItemEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("SimpleModule.Tenants.Entities.TenantHostEntity", b => + { + b.HasOne("SimpleModule.Tenants.Entities.TenantEntity", "Tenant") + .WithMany("Hosts") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("TickerQ.Utilities.Entities.CronTickerOccurrenceEntity", b => + { + b.HasOne("TickerQ.Utilities.Entities.CronTickerEntity", "CronTicker") + .WithMany() + .HasForeignKey("CronTickerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CronTicker"); + }); + + modelBuilder.Entity("TickerQ.Utilities.Entities.TimeTickerEntity", b => + { + b.HasOne("TickerQ.Utilities.Entities.TimeTickerEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.Page", b => + { + b.Navigation("Tags"); + }); + + modelBuilder.Entity("SimpleModule.Settings.Entities.PublicMenuItemEntity", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("SimpleModule.Tenants.Entities.TenantEntity", b => + { + b.Navigation("Hosts"); + }); + + modelBuilder.Entity("TickerQ.Utilities.Entities.TimeTickerEntity", b => + { + b.Navigation("Children"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/template/SimpleModule.Host/Migrations/20260406140223_AddPasskeySupport.cs b/template/SimpleModule.Host/Migrations/20260406140223_AddPasskeySupport.cs new file mode 100644 index 00000000..11ee8103 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260406140223_AddPasskeySupport.cs @@ -0,0 +1,315 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + /// + public partial class AddPasskeySupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder + .AlterColumn( + name: "Id", + table: "Tenants_Tenants", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Tenants_TenantHosts", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "RateLimiting_Rules", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Products_Products", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "PageBuilder_Templates", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "PageBuilder_Tags", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "PageBuilder_Pages", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Orders_Orders", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "FileStorage_StoredFiles", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Email_EmailTemplates", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Email_EmailMessages", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "AuditLogs_AuditEntries", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder.CreateTable( + name: "Users_AspNetUserPasskeys", + columns: table => new + { + CredentialId = table.Column( + type: "BLOB", + maxLength: 1024, + nullable: false + ), + UserId = table.Column(type: "TEXT", nullable: false), + Data = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Users_AspNetUserPasskeys", x => x.CredentialId); + table.ForeignKey( + name: "FK_Users_AspNetUserPasskeys_Users_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "Users_AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_Users_AspNetUserPasskeys_UserId", + table: "Users_AspNetUserPasskeys", + column: "UserId" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "Users_AspNetUserPasskeys"); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Tenants_Tenants", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Tenants_TenantHosts", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "RateLimiting_Rules", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Products_Products", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "PageBuilder_Templates", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "PageBuilder_Tags", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "PageBuilder_Pages", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Orders_Orders", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "FileStorage_StoredFiles", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Email_EmailTemplates", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Email_EmailMessages", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "AuditLogs_AuditEntries", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + } + } +} diff --git a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs index 021e9121..7d690817 100644 --- a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs +++ b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs @@ -66,9 +66,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderKey") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderDisplayName") @@ -85,6 +87,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetUserLogins", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.Property("CredentialId") + .HasMaxLength(1024) + .HasColumnType("BLOB"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CredentialId"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserPasskeys", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -106,9 +125,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("LoginProvider") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Name") + .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Value") @@ -327,7 +348,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("OpenIddict_OpenIddictTokens", (string)null); }); - modelBuilder.Entity("SimpleModule.Agents.Sessions.AgentMessage", b => + modelBuilder.Entity("SimpleModule.Agents.Module.AgentMessage", b => { b.Property("Id") .HasMaxLength(36) @@ -362,7 +383,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Agents_Messages", (string)null); }); - modelBuilder.Entity("SimpleModule.Agents.Sessions.AgentSession", b => + modelBuilder.Entity("SimpleModule.Agents.Module.AgentSession", b => { b.Property("Id") .HasMaxLength(36) @@ -1466,6 +1487,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT"); b.Property("PhoneNumber") + .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("PhoneNumberConfirmed") @@ -1680,6 +1702,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => + { + b1.Property("IdentityUserPasskeyCredentialId"); + + b1.Property("AttestationObject") + .IsRequired(); + + b1.Property("ClientDataJson") + .IsRequired(); + + b1.Property("CreatedAt"); + + b1.Property("IsBackedUp"); + + b1.Property("IsBackupEligible"); + + b1.Property("IsUserVerified"); + + b1.Property("Name"); + + b1.Property("PublicKey") + .IsRequired(); + + b1.Property("SignCount"); + + b1.PrimitiveCollection("Transports"); + + b1.HasKey("IdentityUserPasskeyCredentialId"); + + b1.ToTable("Users_AspNetUserPasskeys"); + + b1 + .ToJson("Data") + .HasColumnType("TEXT"); + + b1.WithOwner() + .HasForeignKey("IdentityUserPasskeyCredentialId"); + }); + + b.Navigation("Data") + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) diff --git a/template/SimpleModule.Host/appsettings.Development.json b/template/SimpleModule.Host/appsettings.Development.json index 05def9a0..9df8ab6b 100644 --- a/template/SimpleModule.Host/appsettings.Development.json +++ b/template/SimpleModule.Host/appsettings.Development.json @@ -5,6 +5,9 @@ "OpenIddict": { "AllowPasswordGrant": true }, + "Passkeys": { + "ServerDomain": "localhost" + }, "Logging": { "LogLevel": { "Microsoft.EntityFrameworkCore.Database.Command": "Information" diff --git a/template/SimpleModule.Host/appsettings.json b/template/SimpleModule.Host/appsettings.json index c0d37cf8..c1ac6311 100644 --- a/template/SimpleModule.Host/appsettings.json +++ b/template/SimpleModule.Host/appsettings.json @@ -42,6 +42,9 @@ "Localization": { "DefaultLocale": "en" }, + "Passkeys": { + "ServerDomain": "yourdomain.com" + }, "Logging": { "LogLevel": { "Default": "Information", From 03cbef690a5386135185b315963f7799f8110371 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:11:28 +0200 Subject: [PATCH 06/22] fix: use SchemaVersion override for passkey schema, revert broad DI changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `protected override Version SchemaVersion => IdentitySchemaVersions.Version3` to the HostDbContext partial — this compiles cleanly against Identity 10.0.3 and is the correct way to opt into the AspNetUserPasskeys table. Because SchemaVersion is now a compile-time constant on the class, the two IOptions workarounds are no longer needed: remove UseApplicationServiceProvider(sp) from ModuleDbContextOptionsBuilder (which would have applied the full app DI to every module DbContext) and revert the ServiceCollection + BuildServiceProvider block from HostDbContextFactory (design-time). --- .../ModuleDbContextOptionsBuilder.cs | 4 ---- .../SimpleModule.Host/HostDbContextFactory.cs | 12 ------------ .../SimpleModule.Host/HostDbContextPasskeys.cs | 15 +++++++-------- 3 files changed, 7 insertions(+), 24 deletions(-) diff --git a/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs b/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs index fc09d4f0..7d46f153 100644 --- a/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs +++ b/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs @@ -34,10 +34,6 @@ public static IServiceCollection AddModuleDbContext( services.AddDbContext( (sp, options) => { - // Expose the application service provider so that EF model creation can - // read IOptions values (e.g. IdentityOptions.Stores.SchemaVersion). - options.UseApplicationServiceProvider(sp); - switch (provider) { case DatabaseProvider.PostgreSql: diff --git a/template/SimpleModule.Host/HostDbContextFactory.cs b/template/SimpleModule.Host/HostDbContextFactory.cs index 0481e066..66c74c9c 100644 --- a/template/SimpleModule.Host/HostDbContextFactory.cs +++ b/template/SimpleModule.Host/HostDbContextFactory.cs @@ -1,8 +1,6 @@ -using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using SimpleModule.Database; @@ -22,15 +20,6 @@ public HostDbContext CreateDbContext(string[] args) var dbOptions = config.GetSection("Database").Get() ?? new DatabaseOptions(); - // Build a minimal service provider that includes IdentityOptions with SchemaVersion 3. - // This allows HostDbContext (which inherits from IdentityDbContext) to discover - // the passkeys schema (AspNetUserPasskeys table) during model creation. - var services = new ServiceCollection(); - services.Configure(options => - options.Stores.SchemaVersion = IdentitySchemaVersions.Version3 - ); - var sp = services.BuildServiceProvider(); - var optionsBuilder = new DbContextOptionsBuilder(); var provider = DatabaseProviderDetector.Detect( dbOptions.DefaultConnection, @@ -51,7 +40,6 @@ public HostDbContext CreateDbContext(string[] args) } optionsBuilder.UseOpenIddict(); - optionsBuilder.UseApplicationServiceProvider(sp); return new HostDbContext(optionsBuilder.Options, Options.Create(dbOptions)); } diff --git a/template/SimpleModule.Host/HostDbContextPasskeys.cs b/template/SimpleModule.Host/HostDbContextPasskeys.cs index 4a4eee9e..5f75c663 100644 --- a/template/SimpleModule.Host/HostDbContextPasskeys.cs +++ b/template/SimpleModule.Host/HostDbContextPasskeys.cs @@ -1,11 +1,10 @@ -// Passkey support is enabled by configuring IdentityOptions.Stores.SchemaVersion -// in UsersModule.ConfigureServices. The source-generated HostDbContext inherits from -// IdentityDbContext, which reads SchemaVersion from IdentityOptions at model creation time -// and adds the AspNetUserPasskeys table when Version3 is active. -// -// This file is a placeholder for any future hand-written HostDbContext extensions -// related to passkey infrastructure (e.g. custom entity configurations). +using Microsoft.AspNetCore.Identity; namespace SimpleModule.Host; -public partial class HostDbContext; +public partial class HostDbContext +{ + // Override SchemaVersion to opt into Identity Schema Version 3, which provisions the + // AspNetUserPasskeys table required for WebAuthn/passkey authentication support. + protected override Version SchemaVersion => IdentitySchemaVersions.Version3; +} From 6d4106834556e56d21a3dfd7874489b792498f94 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:36:11 +0200 Subject: [PATCH 07/22] feat: add PasskeyRegisterBeginEndpoint Adds the POST /api/passkeys/register/begin endpoint that calls MakePasskeyCreationOptionsAsync and returns WebAuthn creation options JSON. Also fixes test infrastructure: UsersDbContext now overrides SchemaVersion to Version3 so the EF model includes the AspNetUserPasskeys table, and the shared test factory now calls UseApplicationServiceProvider so IdentityOptions (including SchemaVersion) are accessible during model creation. Adds appsettings.Testing.json to configure ServerDomain = "localhost" for tests. --- .../Passkeys/PasskeyRegisterBeginEndpoint.cs | 50 +++++++++++++++++++ .../src/SimpleModule.Users/UsersDbContext.cs | 4 ++ .../appsettings.Testing.json | 11 ++++ .../SimpleModuleWebApplicationFactory.cs | 6 ++- 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterBeginEndpoint.cs create mode 100644 template/SimpleModule.Host/appsettings.Testing.json diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterBeginEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterBeginEndpoint.cs new file mode 100644 index 00000000..d2c718b3 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterBeginEndpoint.cs @@ -0,0 +1,50 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class PasskeyRegisterBeginEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + "/api/passkeys/register/begin", + async ( + ClaimsPrincipal principal, + UserManager userManager, + SignInManager signInManager + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return Results.Unauthorized(); + + var userName = await userManager.GetUserNameAsync(user); + var displayName = + user.DisplayName.Length > 0 + ? user.DisplayName + : (userName ?? user.Email ?? user.Id); + + var userEntity = new PasskeyUserEntity + { + Id = await userManager.GetUserIdAsync(user), + Name = userName ?? user.Email ?? user.Id, + DisplayName = displayName, + }; + + var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync( + userEntity + ); + return Results.Content(optionsJson, "application/json"); + } + ) + .RequireAuthorization() + .DisableAntiforgery() + .WithTags("Passkeys"); + } +} diff --git a/modules/Users/src/SimpleModule.Users/UsersDbContext.cs b/modules/Users/src/SimpleModule.Users/UsersDbContext.cs index 43abe937..d38c0ca0 100644 --- a/modules/Users/src/SimpleModule.Users/UsersDbContext.cs +++ b/modules/Users/src/SimpleModule.Users/UsersDbContext.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -11,6 +12,9 @@ public class UsersDbContext( IOptions dbOptions ) : IdentityDbContext(options) { + // Opt into Identity Schema Version 3 to provision the AspNetUserPasskeys table. + protected override Version SchemaVersion => IdentitySchemaVersions.Version3; + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/template/SimpleModule.Host/appsettings.Testing.json b/template/SimpleModule.Host/appsettings.Testing.json new file mode 100644 index 00000000..c6f9667d --- /dev/null +++ b/template/SimpleModule.Host/appsettings.Testing.json @@ -0,0 +1,11 @@ +{ + "Passkeys": { + "ServerDomain": "localhost" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore.Diagnostics": "Error" + } + } +} diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs index 7fe277a6..af9f7776 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs @@ -227,12 +227,14 @@ private void ReplaceDbContext(IServiceCollection services, bool useOpe services.Remove(descriptor); } - // Register fresh options that use the shared in-memory SQLite connection - // WITHOUT resolving interceptors from DI (avoids circular dependency) + // Register fresh options that use the shared in-memory SQLite connection. + // UseApplicationServiceProvider is required so that IdentityDbContext can resolve + // IdentityOptions (e.g. SchemaVersion = Version3) during OnModelCreating. services.AddScoped(sp => { var builder = new DbContextOptionsBuilder(); builder.UseSqlite(_connection); + builder.UseApplicationServiceProvider(sp); builder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); if (useOpenIddict) { From 5769bcae3dde100d192b67ae50ae06428067eaef Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:36:18 +0200 Subject: [PATCH 08/22] feat: add PasskeyRegisterCompleteEndpoint Adds the POST /api/passkeys/register/complete endpoint that calls PerformPasskeyAttestationAsync to validate WebAuthn attestation and stores the passkey. Catches InvalidOperationException (no challenge cookie) and PasskeyException (crypto failure) to return 400 instead of 500. Also adds PasskeyApiEndpointTests integration test class covering all four scenarios: RegisterBegin authenticated/unauthenticated and RegisterComplete unauthenticated/invalid-credential. Tests seed a real user (passkey-test-user-id) with CreateAuthenticatedClient targeting that user ID. --- .../PasskeyRegisterCompleteEndpoint.cs | 60 ++++++++++ .../Integration/PasskeyApiEndpointTests.cs | 106 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs create mode 100644 modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs new file mode 100644 index 00000000..a69c3b2c --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class PasskeyRegisterCompleteEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + "/api/passkeys/register/complete", + async ( + HttpRequest request, + ClaimsPrincipal principal, + UserManager userManager, + SignInManager signInManager + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return Results.Unauthorized(); + + var credentialJson = await new StreamReader(request.Body).ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(credentialJson)) + return Results.BadRequest("Credential JSON is required."); + + // PerformPasskeyAttestationAsync validates the WebAuthn attestation. + // It throws InvalidOperationException if no attestation challenge cookie exists + // (i.e. register/begin was not called first) or PasskeyException on crypto failures. + PasskeyAttestationResult result; + try + { + result = await signInManager.PerformPasskeyAttestationAsync(credentialJson); + } + catch (InvalidOperationException) + { + return Results.BadRequest("No passkey registration in progress."); + } + catch (PasskeyException) + { + return Results.BadRequest("Passkey registration failed."); + } + + if (!result.Succeeded) + return Results.BadRequest("Passkey registration failed."); + + await userManager.AddOrUpdatePasskeyAsync(user, result.Passkey); + return Results.Ok(); + } + ) + .RequireAuthorization() + .DisableAntiforgery() + .WithTags("Passkeys"); + } +} diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs new file mode 100644 index 00000000..d5d6fc51 --- /dev/null +++ b/modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users.Contracts; + +namespace Users.Tests.Integration; + +[Collection(TestCollections.Integration)] +public class PasskeyApiEndpointTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + private readonly HttpClient _unauthenticated; + + public PasskeyApiEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + _unauthenticated = factory.CreateClient(); + } + + private async Task SeedTestUserAsync() + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + const string userId = "passkey-test-user-id"; + var existing = await userManager.FindByIdAsync(userId); + if (existing is not null) + return userId; + + var user = new ApplicationUser + { + Id = userId, + UserName = "passkeytest@example.com", + Email = "passkeytest@example.com", + DisplayName = "Passkey Test User", + }; + await userManager.CreateAsync(user, "TestPass1234!"); + return userId; + } + + // ── Register Begin ────────────────────────────────────────────── + + [Fact] + public async Task RegisterBegin_WhenAuthenticated_Returns200WithJson() + { + var userId = await SeedTestUserAsync(); + var client = _factory.CreateAuthenticatedClient( + new Claim(ClaimTypes.NameIdentifier, userId) + ); + + var response = await client.PostAsync("/api/passkeys/register/begin", null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + var body = await response.Content.ReadAsStringAsync(); + body.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task RegisterBegin_WhenUnauthenticated_Returns401() + { + var response = await _unauthenticated.PostAsync("/api/passkeys/register/begin", null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + // ── Register Complete ───────────────────────────────────────────── + + [Fact] + public async Task RegisterComplete_WhenUnauthenticated_Returns401() + { + using var content = new StringContent( + """{"id":"test"}""", + System.Text.Encoding.UTF8, + "application/json" + ); + + var response = await _unauthenticated.PostAsync("/api/passkeys/register/complete", content); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task RegisterComplete_WithInvalidCredential_ReturnsBadRequest() + { + var userId = await SeedTestUserAsync(); + var client = _factory.CreateAuthenticatedClient( + new Claim(ClaimTypes.NameIdentifier, userId) + ); + using var content = new StringContent( + """{"invalid":"data"}""", + System.Text.Encoding.UTF8, + "application/json" + ); + + var response = await client.PostAsync("/api/passkeys/register/complete", content); + + // Invalid attestation should be rejected + response + .StatusCode.Should() + .BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.UnprocessableEntity); + } +} From 57002417e5c84af0fb7999081b53165e02d0b860 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:39:43 +0200 Subject: [PATCH 09/22] fix: dispose StreamReader and verify PasskeyException handling in RegisterCompleteEndpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace undisposed StreamReader with a using block (leaveOpen: true) to avoid closing the framework-owned request body stream. PasskeyException confirmed present in .NET 10 Identity — catch block retained as-is. --- .../Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs index a69c3b2c..97e7943f 100644 --- a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyRegisterCompleteEndpoint.cs @@ -25,7 +25,11 @@ SignInManager signInManager if (user is null) return Results.Unauthorized(); - var credentialJson = await new StreamReader(request.Body).ReadToEndAsync(); + string credentialJson; + using (var reader = new StreamReader(request.Body, leaveOpen: true)) + { + credentialJson = await reader.ReadToEndAsync(); + } if (string.IsNullOrWhiteSpace(credentialJson)) return Results.BadRequest("Credential JSON is required."); From bca5707d291e51fa23a505396e26ab0f5e6e3ff9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:46:22 +0200 Subject: [PATCH 10/22] feat: add passkey login and management API endpoints (Tasks 4-7) Add PasskeyLoginBeginEndpoint, PasskeyLoginCompleteEndpoint, GetPasskeysEndpoint, and DeletePasskeyEndpoint with full integration test coverage (38 tests passing). --- .../Passkeys/DeletePasskeyEndpoint.cs | 54 +++++++++++ .../Endpoints/Passkeys/GetPasskeysEndpoint.cs | 41 +++++++++ .../Passkeys/PasskeyLoginBeginEndpoint.cs | 28 ++++++ .../Passkeys/PasskeyLoginCompleteEndpoint.cs | 65 ++++++++++++++ .../Integration/PasskeyApiEndpointTests.cs | 90 +++++++++++++++++++ 5 files changed, 278 insertions(+) create mode 100644 modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/DeletePasskeyEndpoint.cs create mode 100644 modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs create mode 100644 modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginBeginEndpoint.cs create mode 100644 modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginCompleteEndpoint.cs diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/DeletePasskeyEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/DeletePasskeyEndpoint.cs new file mode 100644 index 00000000..4d7f4c55 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/DeletePasskeyEndpoint.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class DeletePasskeyEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapDelete( + "/api/passkeys/{credentialId}", + async ( + string credentialId, + ClaimsPrincipal principal, + UserManager userManager + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return Results.Unauthorized(); + + byte[] credentialIdBytes; + try + { + // Decode base64url (URL-safe base64 without padding) + var base64 = credentialId.Replace('-', '+').Replace('_', '/'); + var padding = (4 - (base64.Length % 4)) % 4; + base64 = base64.PadRight(base64.Length + padding, '='); + credentialIdBytes = Convert.FromBase64String(base64); + } + catch (FormatException) + { + return Results.BadRequest("Invalid credential ID format."); + } + + // Verify the passkey belongs to this user before deleting + var passkeys = await userManager.GetPasskeysAsync(user); + var exists = passkeys.Any(p => p.CredentialId.SequenceEqual(credentialIdBytes)); + if (!exists) + return Results.NotFound(); + + await userManager.RemovePasskeyAsync(user, credentialIdBytes); + return Results.NoContent(); + } + ) + .RequireAuthorization() + .WithTags("Passkeys"); + } +} diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs new file mode 100644 index 00000000..80823b92 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class GetPasskeysEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/api/passkeys", + async (ClaimsPrincipal principal, UserManager userManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return Results.Unauthorized(); + + var passkeys = await userManager.GetPasskeysAsync(user); + + var result = passkeys.Select(p => new + { + credentialId = ToBase64Url(p.CredentialId), + name = p.Name, + createdAt = p.CreatedAt, + }); + + return Results.Ok(result); + } + ) + .RequireAuthorization() + .WithTags("Passkeys"); + } + + private static string ToBase64Url(byte[] bytes) => + Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); +} diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginBeginEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginBeginEndpoint.cs new file mode 100644 index 00000000..fbc66edd --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginBeginEndpoint.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class PasskeyLoginBeginEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + "/api/passkeys/login/begin", + async (SignInManager signInManager) => + { + // MakePasskeyRequestOptionsAsync stores challenge in encrypted auth cookie. + // Pass null for user to allow any registered passkey (discoverable credentials). + var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(null); + return Results.Content(optionsJson, "application/json"); + } + ) + .AllowAnonymous() + .DisableAntiforgery() + .WithTags("Passkeys"); + } +} diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginCompleteEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginCompleteEndpoint.cs new file mode 100644 index 00000000..c9d16a01 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyLoginCompleteEndpoint.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Endpoints.Passkeys; + +public class PasskeyLoginCompleteEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + "/api/passkeys/login/complete", + async ( + HttpRequest request, + SignInManager signInManager, + [FromQuery] string? returnUrl = null + ) => + { + string credentialJson; + using (var reader = new StreamReader(request.Body, leaveOpen: true)) + { + credentialJson = await reader.ReadToEndAsync(); + } + + if (string.IsNullOrWhiteSpace(credentialJson)) + return Results.BadRequest("Credential JSON is required."); + + // PerformPasskeyAssertionAsync validates the WebAuthn assertion. + // It throws InvalidOperationException if no assertion challenge cookie exists + // (i.e. login/begin was not called first) or PasskeyException on crypto failures. + Microsoft.AspNetCore.Identity.SignInResult result; + try + { + result = await signInManager.PasskeySignInAsync(credentialJson); + } + catch (InvalidOperationException) + { + return Results.Unauthorized(); + } + catch (PasskeyException) + { + return Results.Unauthorized(); + } + + if (result.Succeeded) + { + var redirectUrl = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl; + return Results.Ok(new { redirectUrl }); + } + + if (result.IsLockedOut) + return Results.Problem("Account is locked out.", statusCode: 423); + + return Results.Unauthorized(); + } + ) + .AllowAnonymous() + .DisableAntiforgery() + .WithTags("Passkeys"); + } +} diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs index d5d6fc51..485652c5 100644 --- a/modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs +++ b/modules/Users/tests/SimpleModule.Users.Tests/Integration/PasskeyApiEndpointTests.cs @@ -103,4 +103,94 @@ public async Task RegisterComplete_WithInvalidCredential_ReturnsBadRequest() .StatusCode.Should() .BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.UnprocessableEntity); } + + // ── Login Begin ─────────────────────────────────────────────────── + + [Fact] + public async Task LoginBegin_WhenAnonymous_Returns200WithJson() + { + var response = await _unauthenticated.PostAsync("/api/passkeys/login/begin", null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + var body = await response.Content.ReadAsStringAsync(); + body.Should().NotBeNullOrEmpty(); + } + + // ── Login Complete ──────────────────────────────────────────────── + + [Fact] + public async Task LoginComplete_WithInvalidCredential_ReturnsUnauthorized() + { + using var content = new StringContent( + """{"id":"invalid","type":"public-key"}""", + System.Text.Encoding.UTF8, + "application/json" + ); + + var response = await _unauthenticated.PostAsync("/api/passkeys/login/complete", content); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task LoginComplete_WithEmptyBody_ReturnsBadRequest() + { + var response = await _unauthenticated.PostAsync("/api/passkeys/login/complete", null); + + response + .StatusCode.Should() + .BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.Unauthorized); + } + + // ── Get Passkeys ────────────────────────────────────────────────── + + [Fact] + public async Task GetPasskeys_WhenUnauthenticated_Returns401() + { + var response = await _unauthenticated.GetAsync("/api/passkeys"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetPasskeys_WhenAuthenticated_ReturnsOkWithList() + { + var userId = await SeedTestUserAsync(); + var client = _factory.CreateAuthenticatedClient( + new Claim(ClaimTypes.NameIdentifier, userId) + ); + + var response = await client.GetAsync("/api/passkeys"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + body.Should().NotBeNull(); + // New users have no passkeys — should return empty array + body.Should().Be("[]"); + } + + // ── Delete Passkey ──────────────────────────────────────────────── + + [Fact] + public async Task DeletePasskey_WhenUnauthenticated_Returns401() + { + var response = await _unauthenticated.DeleteAsync("/api/passkeys/someCredentialId"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task DeletePasskey_WithNonExistentCredential_ReturnsNotFound() + { + var userId = await SeedTestUserAsync(); + var client = _factory.CreateAuthenticatedClient( + new Claim(ClaimTypes.NameIdentifier, userId) + ); + + // Use a valid base64url-encoded value that doesn't match any passkey + var response = await client.DeleteAsync("/api/passkeys/dGVzdC1jcmVkZW50aWFsLWlk"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } From b5de20428255d700a9bf4302f39bbc7b0104e769 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:53:30 +0200 Subject: [PATCH 11/22] feat: add ManagePasskeysEndpoint (Inertia view) --- .../Account/Manage/ManagePasskeysEndpoint.cs | 44 +++++++++++++++++++ .../ManagePasskeysEndpointTests.cs | 41 +++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs create mode 100644 modules/Users/tests/SimpleModule.Users.Tests/Integration/ManagePasskeysEndpointTests.cs diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs new file mode 100644 index 00000000..7cbf6f2a --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Pages.Account.Manage; + +public class ManagePasskeysEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/Manage/Passkeys", + async (ClaimsPrincipal principal, UserManager userManager) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return TypedResults.Redirect("/Identity/Account/Login"); + + var passkeys = await userManager.GetPasskeysAsync(user); + + var passkeysDto = passkeys.Select(p => new + { + credentialId = ToBase64Url(p.CredentialId), + name = p.Name, + createdAt = p.CreatedAt, + }); + + return Inertia.Render( + "Users/Account/Manage/Passkeys", + new { passkeys = passkeysDto } + ); + } + ) + .RequireAuthorization(); + } + + private static string ToBase64Url(byte[] bytes) => + Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); +} diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Integration/ManagePasskeysEndpointTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Integration/ManagePasskeysEndpointTests.cs new file mode 100644 index 00000000..8b607eb2 --- /dev/null +++ b/modules/Users/tests/SimpleModule.Users.Tests/Integration/ManagePasskeysEndpointTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using SimpleModule.Tests.Shared.Fixtures; + +namespace Users.Tests.Integration; + +[Collection(TestCollections.Integration)] +public class ManagePasskeysEndpointTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public ManagePasskeysEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Get_WhenAuthenticated_Returns200() + { + var client = _factory.CreateAuthenticatedClient(); + + var response = await client.GetAsync("/Identity/Account/Manage/Passkeys"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Get_WhenUnauthenticated_RedirectsToLogin() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var response = await client.GetAsync("/Identity/Account/Manage/Passkeys"); + + response + .StatusCode.Should() + .BeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found, HttpStatusCode.Unauthorized); + } +} From 62b41a5dde7de373eb4ea053bcc03645e0bac4f1 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:54:04 +0200 Subject: [PATCH 12/22] feat: add WebAuthn browser API utility (passkey.ts) --- .../src/SimpleModule.Users/Pages/passkey.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 modules/Users/src/SimpleModule.Users/Pages/passkey.ts diff --git a/modules/Users/src/SimpleModule.Users/Pages/passkey.ts b/modules/Users/src/SimpleModule.Users/Pages/passkey.ts new file mode 100644 index 00000000..3975bef7 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/passkey.ts @@ -0,0 +1,123 @@ +// WebAuthn uses base64url encoding for all binary data. +// These helpers convert between ArrayBuffer (required by browser API) and base64url strings. + +function base64urlToArrayBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const binary = atob(padded); + const buffer = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + buffer[i] = binary.charCodeAt(i); + } + return buffer.buffer; +} + +function arrayBufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function prepareCreationOptions(json: Record): PublicKeyCredentialCreationOptions { + const opts = json as Record; + return { + ...opts, + challenge: base64urlToArrayBuffer(opts.challenge as string), + user: { + ...(opts.user as Record), + id: base64urlToArrayBuffer((opts.user as Record).id as string), + }, + excludeCredentials: ((opts.excludeCredentials as unknown[]) ?? []).map((c: unknown) => { + const cred = c as Record; + return { ...cred, id: base64urlToArrayBuffer(cred.id as string) }; + }), + } as unknown as PublicKeyCredentialCreationOptions; +} + +function prepareRequestOptions(json: Record): PublicKeyCredentialRequestOptions { + const opts = json as Record; + return { + ...opts, + challenge: base64urlToArrayBuffer(opts.challenge as string), + allowCredentials: ((opts.allowCredentials as unknown[]) ?? []).map((c: unknown) => { + const cred = c as Record; + return { ...cred, id: base64urlToArrayBuffer(cred.id as string) }; + }), + } as unknown as PublicKeyCredentialRequestOptions; +} + +function serializeAttestation(credential: PublicKeyCredential): Record { + const r = credential.response as AuthenticatorAttestationResponse; + return { + id: credential.id, + rawId: arrayBufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: arrayBufferToBase64url(r.clientDataJSON), + attestationObject: arrayBufferToBase64url(r.attestationObject), + transports: r.getTransports?.() ?? [], + }, + clientExtensionResults: credential.getClientExtensionResults(), + }; +} + +function serializeAssertion(credential: PublicKeyCredential): Record { + const r = credential.response as AuthenticatorAssertionResponse; + return { + id: credential.id, + rawId: arrayBufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: arrayBufferToBase64url(r.clientDataJSON), + authenticatorData: arrayBufferToBase64url(r.authenticatorData), + signature: arrayBufferToBase64url(r.signature), + userHandle: r.userHandle ? arrayBufferToBase64url(r.userHandle) : null, + }, + clientExtensionResults: credential.getClientExtensionResults(), + }; +} + +/** + * Full passkey registration flow: + * 1. Fetches creation options from the server + * 2. Prompts the user's device for biometric/PIN confirmation + * 3. Returns the serialized credential to be posted to /api/passkeys/register/complete + */ +export async function startPasskeyRegistration(): Promise> { + const beginRes = await fetch('/api/passkeys/register/begin', { method: 'POST' }); + if (!beginRes.ok) { + throw new Error('Failed to start passkey registration'); + } + const optionsJson = (await beginRes.json()) as Record; + const options = prepareCreationOptions(optionsJson); + + const credential = await navigator.credentials.create({ publicKey: options }); + if (!credential) { + throw new Error('No credential returned from device'); + } + return serializeAttestation(credential as PublicKeyCredential); +} + +/** + * Full passkey authentication flow: + * 1. Fetches request options from the server + * 2. Prompts the user's device for biometric/PIN confirmation + * 3. Returns the serialized credential to be posted to /api/passkeys/login/complete + */ +export async function startPasskeyAssertion(): Promise> { + const beginRes = await fetch('/api/passkeys/login/begin', { method: 'POST' }); + if (!beginRes.ok) { + throw new Error('Failed to start passkey sign-in'); + } + const optionsJson = (await beginRes.json()) as Record; + const options = prepareRequestOptions(optionsJson); + + const credential = await navigator.credentials.get({ publicKey: options }); + if (!credential) { + throw new Error('No credential returned from device'); + } + return serializeAssertion(credential as PublicKeyCredential); +} From 47bdd30bce42320aef8c2e585b9cd2198d6c6931 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:54:43 +0200 Subject: [PATCH 13/22] feat: add ManagePasskeys React page --- .../Pages/Account/Manage/ManagePasskeys.tsx | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx new file mode 100644 index 00000000..fa25ad75 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx @@ -0,0 +1,110 @@ +import { router } from '@inertiajs/react'; +import { Button } from '@simplemodule/ui'; +import { useState } from 'react'; +import ManageLayout from '@/components/ManageLayout'; +import { startPasskeyRegistration } from '../../passkey'; + +interface Passkey { + credentialId: string; + name: string; + createdAt: string; +} + +interface Props { + passkeys: Passkey[]; +} + +export default function ManagePasskeys({ passkeys }: Props) { + const [registering, setRegistering] = useState(false); + const [error, setError] = useState(null); + + async function handleAddPasskey() { + if (!window.PublicKeyCredential) { + setError('Your browser does not support passkeys.'); + return; + } + setRegistering(true); + setError(null); + try { + const credential = await startPasskeyRegistration(); + const res = await fetch('/api/passkeys/register/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credential), + }); + if (!res.ok) { + setError('Passkey registration failed. Please try again.'); + return; + } + router.reload(); + } catch (err) { + if (err instanceof Error && err.name === 'NotAllowedError') { + setError('Registration was cancelled.'); + } else { + setError('An unexpected error occurred. Please try again.'); + } + } finally { + setRegistering(false); + } + } + + async function handleDeletePasskey(credentialId: string) { + if (!confirm('Remove this passkey?')) return; + const res = await fetch(`/api/passkeys/${encodeURIComponent(credentialId)}`, { + method: 'DELETE', + }); + if (res.ok) { + router.reload(); + } else { + setError('Failed to remove passkey. Please try again.'); + } + } + + return ( + +

Passkeys

+ +

+ Passkeys let you sign in with your fingerprint, face, or device PIN — no password needed. +

+ + {error && ( +
+ {error} +
+ )} + + {passkeys.length === 0 ? ( +

No passkeys registered yet.

+ ) : ( +
    + {passkeys.map((passkey) => ( +
  • +
    +

    {passkey.name || 'Passkey'}

    +

    + Added {new Date(passkey.createdAt).toLocaleDateString()} +

    +
    + +
  • + ))} +
+ )} + + +
+ ); +} From f1831b6ec609c9744ffd86cc5afc0038feebbe7a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:55:13 +0200 Subject: [PATCH 14/22] feat: add passkey sign-in button to login page --- .../Pages/Account/Login.tsx | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx index 9d81b7ee..6521e32c 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx @@ -9,14 +9,20 @@ import { Input, Label, } from '@simplemodule/ui'; +import { useState } from 'react'; +import { startPasskeyAssertion } from '../passkey'; interface Props { returnUrl: string; showTestAccounts: boolean; + passkeyEnabled: boolean; errors?: { email?: string }; } -export default function Login({ returnUrl, showTestAccounts, errors }: Props) { +export default function Login({ returnUrl, showTestAccounts, passkeyEnabled, errors }: Props) { + const [passkeyError, setPasskeyError] = useState(null); + const [passkeyLoading, setPasskeyLoading] = useState(false); + function handleSubmit(e: React.FormEvent) { e.preventDefault(); const formData = new FormData(e.currentTarget); @@ -29,6 +35,42 @@ export default function Login({ returnUrl, showTestAccounts, errors }: Props) { (form.querySelector('[name="password"]') as HTMLInputElement).value = password; } + async function handlePasskeySignIn() { + if (!window.PublicKeyCredential) { + setPasskeyError('Your browser does not support passkeys.'); + return; + } + setPasskeyLoading(true); + setPasskeyError(null); + try { + const credential = await startPasskeyAssertion(); + const res = await fetch( + `/api/passkeys/login/complete?returnUrl=${encodeURIComponent(returnUrl)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credential), + }, + ); + if (res.ok) { + const data = (await res.json()) as { redirectUrl: string }; + window.location.href = data.redirectUrl; + } else if (res.status === 423) { + setPasskeyError('Your account is locked. Please try again later.'); + } else { + setPasskeyError('Passkey sign-in failed. Use your password instead.'); + } + } catch (err) { + if (err instanceof Error && err.name === 'NotAllowedError') { + setPasskeyError('Passkey sign-in was cancelled.'); + } else { + setPasskeyError('An unexpected error occurred.'); + } + } finally { + setPasskeyLoading(false); + } + } + return (
@@ -105,6 +147,41 @@ export default function Login({ returnUrl, showTestAccounts, errors }: Props) { + {passkeyEnabled && ( + <> +
+
+ + or + +
+ {passkeyError && ( +
+ {passkeyError} +
+ )} + + + )} + {showTestAccounts && ( <>
From 64e7ba6bca85a2894000637a90acc299a38cf0db Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:55:47 +0200 Subject: [PATCH 15/22] feat: pass passkeyEnabled prop from IdentityPasskeyOptions to login page --- .../src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs index 7b90313a..4800941e 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Core.Settings; @@ -24,6 +25,7 @@ public void Map(IEndpointRouteBuilder app) HttpContext context, ISettingsContracts settingsService, ISettingsDefinitionRegistry settingsDefinitions, + IOptions passkeyOptions, [FromQuery] string? returnUrl ) => { @@ -43,6 +45,9 @@ [FromQuery] string? returnUrl { returnUrl = returnUrl ?? "/", showTestAccounts = showTestAccounts == "true", + passkeyEnabled = !string.IsNullOrEmpty( + passkeyOptions.Value.ServerDomain + ), } ); } @@ -92,6 +97,7 @@ ILogger logger { returnUrl = returnUrl ?? "/", showTestAccounts = false, + passkeyEnabled = false, errors = new { email = "Invalid login attempt." }, } ); From 5bb0519b2f582e2661ceddbd689372c5d5619783 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 16:56:08 +0200 Subject: [PATCH 16/22] feat: register ManagePasskeys page and add Passkeys nav item to account sidebar --- modules/Users/src/SimpleModule.Users/Pages/index.ts | 1 + .../src/SimpleModule.Users/components/ManageLayout.tsx | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/modules/Users/src/SimpleModule.Users/Pages/index.ts b/modules/Users/src/SimpleModule.Users/Pages/index.ts index f2067d1c..9f7f02bf 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/index.ts +++ b/modules/Users/src/SimpleModule.Users/Pages/index.ts @@ -25,6 +25,7 @@ export const pages: Record = { 'Users/Account/Manage/DeletePersonalData': () => import('./Account/Manage/DeletePersonalData'), 'Users/Account/Manage/PersonalData': () => import('./Account/Manage/PersonalData'), 'Users/Account/Manage/ExternalLogins': () => import('./Account/Manage/ExternalLogins'), + 'Users/Account/Manage/Passkeys': () => import('./Account/Manage/ManagePasskeys'), 'Users/Account/TwoFactorAuthentication': () => import('./Account/TwoFactorAuthentication'), 'Users/Account/EnableAuthenticator': () => import('./Account/EnableAuthenticator'), 'Users/Account/Disable2fa': () => import('./Account/Disable2fa'), diff --git a/modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx b/modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx index f8f70067..a787673e 100644 --- a/modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx +++ b/modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx @@ -30,6 +30,12 @@ const navItems = [ label: 'Two-factor auth', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z', }, + { + href: '/Identity/Account/Manage/Passkeys', + page: 'Passkeys', + label: 'Passkeys', + icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', + }, { href: '/Identity/Account/Manage/PersonalData', page: 'PersonalData', From f76c96d67aec92847e2652d15e0326c3a2a4564f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 17:01:54 +0200 Subject: [PATCH 17/22] feat: add device type hint to passkeys list Adds transports field to the passkeys DTO and derives a human-readable device type hint (e.g. 'Built-in sensor', 'Security key (USB)') shown below the passkey name in the Manage Passkeys page. --- .../Pages/Account/Manage/ManagePasskeys.tsx | 11 +++++++++++ .../Pages/Account/Manage/ManagePasskeysEndpoint.cs | 1 + 2 files changed, 12 insertions(+) diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx index fa25ad75..6b4cd21c 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeys.tsx @@ -8,6 +8,16 @@ interface Passkey { credentialId: string; name: string; createdAt: string; + transports: string[]; +} + +function deviceTypeHint(transports: string[]): string { + if (transports.includes('internal')) return 'Built-in sensor'; + if (transports.includes('hybrid')) return 'Passkey on another device'; + if (transports.includes('usb')) return 'Security key (USB)'; + if (transports.includes('nfc')) return 'Security key (NFC)'; + if (transports.includes('ble')) return 'Security key (Bluetooth)'; + return 'Passkey'; } interface Props { @@ -85,6 +95,7 @@ export default function ManagePasskeys({ passkeys }: Props) { >

{passkey.name || 'Passkey'}

+

{deviceTypeHint(passkey.transports)}

Added {new Date(passkey.createdAt).toLocaleDateString()}

diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs index 7cbf6f2a..bfe8bcc9 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs @@ -28,6 +28,7 @@ public void Map(IEndpointRouteBuilder app) credentialId = ToBase64Url(p.CredentialId), name = p.Name, createdAt = p.CreatedAt, + transports = p.Transports, }); return Inertia.Render( From 80b023e9d52c74b9fc4f93c76b7ef7e8c071252f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 17:06:15 +0200 Subject: [PATCH 18/22] refactor: fix passkeyEnabled in login error path, extract ToBase64Url helper --- .../Endpoints/Passkeys/GetPasskeysEndpoint.cs | 5 +---- .../Endpoints/Passkeys/PasskeyHelpers.cs | 7 +++++++ .../src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs | 7 +++++-- .../Pages/Account/Manage/ManagePasskeysEndpoint.cs | 6 ++---- 4 files changed, 15 insertions(+), 10 deletions(-) create mode 100644 modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyHelpers.cs diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs index 80823b92..935dadec 100644 --- a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/GetPasskeysEndpoint.cs @@ -24,7 +24,7 @@ public void Map(IEndpointRouteBuilder app) var result = passkeys.Select(p => new { - credentialId = ToBase64Url(p.CredentialId), + credentialId = PasskeyHelpers.ToBase64Url(p.CredentialId), name = p.Name, createdAt = p.CreatedAt, }); @@ -35,7 +35,4 @@ public void Map(IEndpointRouteBuilder app) .RequireAuthorization() .WithTags("Passkeys"); } - - private static string ToBase64Url(byte[] bytes) => - Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); } diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyHelpers.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyHelpers.cs new file mode 100644 index 00000000..45a5a6ff --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Passkeys/PasskeyHelpers.cs @@ -0,0 +1,7 @@ +namespace SimpleModule.Users.Endpoints.Passkeys; + +internal static class PasskeyHelpers +{ + internal static string ToBase64Url(byte[] bytes) => + Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs index 4800941e..1a62ea8a 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs @@ -62,7 +62,8 @@ [FromQuery] string? returnUrl [FromForm] bool? rememberMe, [FromQuery] string? returnUrl, SignInManager signInManager, - ILogger logger + ILogger logger, + IOptions passkeyOptions ) => { var result = await signInManager.PasswordSignInAsync( @@ -97,7 +98,9 @@ ILogger logger { returnUrl = returnUrl ?? "/", showTestAccounts = false, - passkeyEnabled = false, + passkeyEnabled = !string.IsNullOrEmpty( + passkeyOptions.Value.ServerDomain + ), errors = new { email = "Invalid login attempt." }, } ); diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs index bfe8bcc9..e880c559 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManagePasskeysEndpoint.cs @@ -6,6 +6,7 @@ using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Users.Contracts; +using SimpleModule.Users.Endpoints.Passkeys; namespace SimpleModule.Users.Pages.Account.Manage; @@ -25,7 +26,7 @@ public void Map(IEndpointRouteBuilder app) var passkeysDto = passkeys.Select(p => new { - credentialId = ToBase64Url(p.CredentialId), + credentialId = PasskeyHelpers.ToBase64Url(p.CredentialId), name = p.Name, createdAt = p.CreatedAt, transports = p.Transports, @@ -39,7 +40,4 @@ public void Map(IEndpointRouteBuilder app) ) .RequireAuthorization(); } - - private static string ToBase64Url(byte[] bytes) => - Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); } From 01f8312ff9e26675bb28d497a2e818156bfb887c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 17:10:37 +0200 Subject: [PATCH 19/22] style: exclude test-projects from biome root config scan The test-projects directory contains its own biome.json which conflicts with the root configuration. Since test-projects is already excluded from the files.includes scope, explicitly adding it as an ignore pattern prevents the nested root config error. --- biome.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/biome.json b/biome.json index f66365d9..42c6ae21 100644 --- a/biome.json +++ b/biome.json @@ -47,7 +47,8 @@ "docs/**", "website/**", "!**/wwwroot", - "!modules/*/src/*/types.ts" + "!modules/*/src/*/types.ts", + "!test-projects" ] } } From dd8a03e0b87e5bb38126d33bc1102344541cb506 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 17:45:59 +0200 Subject: [PATCH 20/22] test: add e2e tests for passkey registration, sign-in, and management --- tests/e2e/pages/users/passkeys.page.ts | 25 +++++ tests/e2e/tests/flows/users-passkey.spec.ts | 115 ++++++++++++++++++++ tests/e2e/tests/smoke/users-account.spec.ts | 20 ++++ 3 files changed, 160 insertions(+) create mode 100644 tests/e2e/pages/users/passkeys.page.ts create mode 100644 tests/e2e/tests/flows/users-passkey.spec.ts diff --git a/tests/e2e/pages/users/passkeys.page.ts b/tests/e2e/pages/users/passkeys.page.ts new file mode 100644 index 00000000..9199191a --- /dev/null +++ b/tests/e2e/pages/users/passkeys.page.ts @@ -0,0 +1,25 @@ +import type { Page } from '@playwright/test'; + +export class PasskeysPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/Identity/Account/Manage/Passkeys'); + } + + get heading() { + return this.page.getByRole('heading', { name: /passkeys/i }); + } + + get addPasskeyButton() { + return this.page.getByRole('button', { name: /add passkey/i }); + } + + get emptyState() { + return this.page.getByText(/no passkeys registered yet/i); + } + + get removeButtons() { + return this.page.getByRole('button', { name: /remove/i }); + } +} diff --git a/tests/e2e/tests/flows/users-passkey.spec.ts b/tests/e2e/tests/flows/users-passkey.spec.ts new file mode 100644 index 00000000..19324af0 --- /dev/null +++ b/tests/e2e/tests/flows/users-passkey.spec.ts @@ -0,0 +1,115 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '../../fixtures/base'; +import { PasskeysPage } from '../../pages/users/passkeys.page'; + +// Helper: set up a CDP virtual WebAuthn authenticator on the page. +// The virtual authenticator auto-responds to navigator.credentials.create/get +// with isUserVerified:true — no real hardware or biometrics needed. +async function setupVirtualAuthenticator(page: Page) { + const cdp = await page.context().newCDPSession(page); + await cdp.send('WebAuthn.enable', { enableUI: false }); + await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + return cdp; +} + +test.describe('Passkeys flows', () => { + test('register a passkey - it appears in list', async ({ page }) => { + await setupVirtualAuthenticator(page); + + const passkeys = new PasskeysPage(page); + await passkeys.goto(); + await expect(passkeys.heading).toBeVisible(); + await expect(passkeys.emptyState).toBeVisible(); + + await passkeys.addPasskeyButton.click(); + + // Virtual authenticator auto-responds; wait for the list to update + await expect(passkeys.removeButtons.first()).toBeVisible({ timeout: 10_000 }); + await expect(passkeys.removeButtons).not.toHaveCount(0); + }); + + test('delete a passkey - it is removed from list', async ({ page }) => { + await setupVirtualAuthenticator(page); + + const passkeys = new PasskeysPage(page); + await passkeys.goto(); + + // Register first so there is something to delete + await passkeys.addPasskeyButton.click(); + await expect(passkeys.removeButtons.first()).toBeVisible({ timeout: 10_000 }); + + // Accept the confirm() dialog before clicking Remove + page.once('dialog', (dialog) => dialog.accept()); + await passkeys.removeButtons.first().click(); + + // Passkey should be gone + await expect(passkeys.emptyState).toBeVisible({ timeout: 5_000 }); + }); + + test('register passkey and sign in with it', async ({ page }) => { + // Start: logged in as admin (via storageState from base fixture) + await setupVirtualAuthenticator(page); + + // Register a passkey while authenticated + const passkeys = new PasskeysPage(page); + await passkeys.goto(); + await passkeys.addPasskeyButton.click(); + await expect(passkeys.removeButtons.first()).toBeVisible({ timeout: 10_000 }); + + // Simulate logout by clearing the auth cookie + // (CDP session stays alive so the virtual authenticator retains the registered credential) + await page.context().clearCookies(); + + // Navigate to login page — passkey button must be visible + await page.goto('/Identity/Account/Login'); + const passkeySignInButton = page.getByRole('button', { name: /sign in with passkey/i }); + await expect(passkeySignInButton).toBeVisible(); + + // Click — virtual authenticator auto-responds with the registered credential + await passkeySignInButton.click(); + + // Successful sign-in redirects to the root (or dashboard) + await page.waitForURL('/', { timeout: 10_000 }); + await expect(page.locator('body')).toBeVisible(); + }); + + test('passkey sign-in cancelled - shows error message', async ({ page }) => { + // Start unauthenticated + await page.context().clearCookies(); + await page.goto('/Identity/Account/Login'); + + // Set up virtual authenticator with automaticPresenceSimulation: false + // so that navigator.credentials.get() will be rejected as if the user cancelled + const cdp = await page.context().newCDPSession(page); + await cdp.send('WebAuthn.enable', { enableUI: false }); + await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: false, // will cause NotAllowedError after timeout + }, + }); + + const passkeySignInButton = page.getByRole('button', { name: /sign in with passkey/i }); + await expect(passkeySignInButton).toBeVisible(); + await passkeySignInButton.click(); + + // With no registered credential and no auto-presence, the browser rejects the request. + // The login page should show an error message. + await expect(page.getByRole('alert').or(page.getByText(/passkey sign-in/i))).toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/tests/e2e/tests/smoke/users-account.spec.ts b/tests/e2e/tests/smoke/users-account.spec.ts index 25774ce2..47e3c6b0 100644 --- a/tests/e2e/tests/smoke/users-account.spec.ts +++ b/tests/e2e/tests/smoke/users-account.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '../../fixtures/base'; +import { PasskeysPage } from '../../pages/users/passkeys.page'; import { TwoFactorPage } from '../../pages/users/two-factor.page'; test.describe('Users account pages', () => { @@ -7,4 +8,23 @@ test.describe('Users account pages', () => { await twoFactor.goto(); await expect(twoFactor.heading).toBeVisible(); }); + + test('passkeys management page loads', async ({ page }) => { + const passkeys = new PasskeysPage(page); + await passkeys.goto(); + await expect(passkeys.heading).toBeVisible(); + }); + + test('passkeys page shows add passkey button', async ({ page }) => { + const passkeys = new PasskeysPage(page); + await passkeys.goto(); + await expect(passkeys.addPasskeyButton).toBeVisible(); + }); + + test('login page shows sign in with passkey button', async ({ page }) => { + // Sign out first so we can see the login page properly + await page.context().clearCookies(); + await page.goto('/Identity/Account/Login'); + await expect(page.getByRole('button', { name: /sign in with passkey/i })).toBeVisible(); + }); }); From 4bf1d6a78447b556388c685cb8bfa624457898de Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 18:53:55 +0200 Subject: [PATCH 21/22] fix: make passkey e2e tests reliable across parallel runs and page navigations - serial mode + beforeEach cleanup eliminates shared-state races between tests - migrate credential ID via CDP WebAuthn.getCredentials after registration so the sign-in assertion works whether or not the credential was stored as a resident key - register route handlers before navigation so no request can slip through unintercepted - replace page.waitForTimeout(500) with try/catch on waitForURL for deterministic error reporting - fix auth.setup.ts to navigate directly to /Identity/Account/Login instead of clicking the missing "Log in" link on the landing page --- tests/e2e/tests/auth/auth.setup.ts | 7 +- tests/e2e/tests/flows/users-passkey.spec.ts | 120 ++++++++++++++------ 2 files changed, 89 insertions(+), 38 deletions(-) diff --git a/tests/e2e/tests/auth/auth.setup.ts b/tests/e2e/tests/auth/auth.setup.ts index f32fd4c9..c7d8b77d 100644 --- a/tests/e2e/tests/auth/auth.setup.ts +++ b/tests/e2e/tests/auth/auth.setup.ts @@ -4,11 +4,8 @@ import { expect, test as setup } from '@playwright/test'; const authFile = path.resolve(__dirname, '../../auth/.auth/user.json'); setup('authenticate as admin', async ({ page }) => { - // Navigate to the app — unauthenticated users see the landing page with login button - await page.goto('/'); - - // Click the login link to navigate to Identity login page - await page.getByRole('link', { name: 'Log in' }).click(); + // Navigate directly to the login page + await page.goto('/Identity/Account/Login'); await page.waitForURL('**/Identity/Account/Login**'); // Fill the login form (labels render as divs, use placeholders) diff --git a/tests/e2e/tests/flows/users-passkey.spec.ts b/tests/e2e/tests/flows/users-passkey.spec.ts index 19324af0..c7fc1d64 100644 --- a/tests/e2e/tests/flows/users-passkey.spec.ts +++ b/tests/e2e/tests/flows/users-passkey.spec.ts @@ -1,14 +1,22 @@ -import type { Page } from '@playwright/test'; +import type { CDPSession, Page } from '@playwright/test'; import { expect, test } from '../../fixtures/base'; import { PasskeysPage } from '../../pages/users/passkeys.page'; +// CDP returns byte arrays as standard base64; WebAuthn needs base64url (no +, /, or =). +function cdpBase64ToBase64Url(base64: string): string { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + // Helper: set up a CDP virtual WebAuthn authenticator on the page. // The virtual authenticator auto-responds to navigator.credentials.create/get // with isUserVerified:true — no real hardware or biometrics needed. -async function setupVirtualAuthenticator(page: Page) { +// Returns the CDP session and authenticator ID for follow-up CDP calls. +async function setupVirtualAuthenticator( + page: Page, +): Promise<{ cdp: CDPSession; authenticatorId: string }> { const cdp = await page.context().newCDPSession(page); await cdp.send('WebAuthn.enable', { enableUI: false }); - await cdp.send('WebAuthn.addVirtualAuthenticator', { + const { authenticatorId } = (await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', @@ -17,11 +25,35 @@ async function setupVirtualAuthenticator(page: Page) { isUserVerified: true, automaticPresenceSimulation: true, }, - }); - return cdp; + })) as { authenticatorId: string }; + return { cdp, authenticatorId }; } test.describe('Passkeys flows', () => { + // Run tests serially so they don't race on the shared admin user's passkey list + test.describe.configure({ mode: 'serial' }); + + // Clean up all passkeys before each test to start from a known state. + // Passkeys accumulate across runs (file-based SQLite) and parallel suites. + test.beforeEach(async ({ page }) => { + const passkeys = new PasskeysPage(page); + await passkeys.goto(); + // Re-query the DOM after each deletion rather than decrementing a local counter, + // so the loop stays correct if a delete is slower than expected. + let count = await passkeys.removeButtons.count(); + while (count > 0) { + page.once('dialog', (dialog) => dialog.accept()); + await passkeys.removeButtons.first().click(); + count--; + if (count > 0) { + await expect(passkeys.removeButtons).toHaveCount(count, { timeout: 5_000 }); + } else { + await expect(passkeys.emptyState).toBeVisible({ timeout: 5_000 }); + } + count = await passkeys.removeButtons.count(); + } + }); + test('register a passkey - it appears in list', async ({ page }) => { await setupVirtualAuthenticator(page); @@ -51,13 +83,13 @@ test.describe('Passkeys flows', () => { page.once('dialog', (dialog) => dialog.accept()); await passkeys.removeButtons.first().click(); - // Passkey should be gone + // Passkey should be gone (beforeEach guarantees we started from empty) await expect(passkeys.emptyState).toBeVisible({ timeout: 5_000 }); }); test('register passkey and sign in with it', async ({ page }) => { // Start: logged in as admin (via storageState from base fixture) - await setupVirtualAuthenticator(page); + const { cdp, authenticatorId } = await setupVirtualAuthenticator(page); // Register a passkey while authenticated const passkeys = new PasskeysPage(page); @@ -65,51 +97,73 @@ test.describe('Passkeys flows', () => { await passkeys.addPasskeyButton.click(); await expect(passkeys.removeButtons.first()).toBeVisible({ timeout: 10_000 }); - // Simulate logout by clearing the auth cookie - // (CDP session stays alive so the virtual authenticator retains the registered credential) + // Read the registered credential ID from the virtual authenticator. + // Chrome's virtual authenticator persists across same-origin navigations + // (same browser tab = same CDP target), but the credential may not have been + // stored as a discoverable/resident key depending on the server's creation options. + // We include it explicitly in allowCredentials to guarantee the authenticator + // can find it regardless of whether it is resident. + const { credentials } = (await cdp.send('WebAuthn.getCredentials', { + authenticatorId, + })) as { credentials: Array<{ credentialId: string }> }; + const credentialIdBase64url = cdpBase64ToBase64Url(credentials[0]?.credentialId ?? ''); + + // Intercept the assertion-begin response to add this credential's ID to allowCredentials. + // Registered before navigation so no request can slip through on page load. + // The challenge cookie is set by the real server response (route.fetch forwards headers). + await page.route('**/api/passkeys/login/begin', async (route) => { + const response = await route.fetch(); + const body = (await response.json()) as Record; + if (credentialIdBase64url) { + body.allowCredentials = [{ type: 'public-key', id: credentialIdBase64url }]; + } + await route.fulfill({ response, json: body }); + }); + + // Simulate logout by clearing auth cookies await page.context().clearCookies(); - // Navigate to login page — passkey button must be visible + // Navigate to login page — the virtual authenticator persists (same browser tab) await page.goto('/Identity/Account/Login'); + const passkeySignInButton = page.getByRole('button', { name: /sign in with passkey/i }); await expect(passkeySignInButton).toBeVisible(); // Click — virtual authenticator auto-responds with the registered credential await passkeySignInButton.click(); - // Successful sign-in redirects to the root (or dashboard) - await page.waitForURL('/', { timeout: 10_000 }); + // Successful sign-in redirects to '/'. If it fails, surface the alert text as the error. + try { + await page.waitForURL('/', { timeout: 12_000 }); + } catch { + const alertText = await page + .getByRole('alert') + .textContent() + .catch(() => null); + throw new Error( + alertText ? `Passkey sign-in failed: ${alertText}` : 'No redirect to / and no error alert', + ); + } await expect(page.locator('body')).toBeVisible(); }); - test('passkey sign-in cancelled - shows error message', async ({ page }) => { + test('passkey sign-in failure - shows error message', async ({ page }) => { // Start unauthenticated await page.context().clearCookies(); - await page.goto('/Identity/Account/Login'); - // Set up virtual authenticator with automaticPresenceSimulation: false - // so that navigator.credentials.get() will be rejected as if the user cancelled - const cdp = await page.context().newCDPSession(page); - await cdp.send('WebAuthn.enable', { enableUI: false }); - await cdp.send('WebAuthn.addVirtualAuthenticator', { - options: { - protocol: 'ctap2', - transport: 'internal', - hasResidentKey: true, - hasUserVerification: true, - isUserVerified: true, - automaticPresenceSimulation: false, // will cause NotAllowedError after timeout - }, - }); + // Mock the begin endpoint to fail immediately — simulates any network or server error + // that prevents passkey sign-in from starting. This is more reliable than relying on + // the virtual authenticator's NotAllowedError timing (which varies across Chrome versions). + await page.route('**/api/passkeys/login/begin', (route) => + route.fulfill({ status: 500, body: 'Internal Server Error' }), + ); + await page.goto('/Identity/Account/Login'); const passkeySignInButton = page.getByRole('button', { name: /sign in with passkey/i }); await expect(passkeySignInButton).toBeVisible(); await passkeySignInButton.click(); - // With no registered credential and no auto-presence, the browser rejects the request. - // The login page should show an error message. - await expect(page.getByRole('alert').or(page.getByText(/passkey sign-in/i))).toBeVisible({ - timeout: 10_000, - }); + // The login page should show an error alert + await expect(page.getByRole('alert')).toBeVisible({ timeout: 5_000 }); }); }); From 2b278df44e0cb8c4676c5f18da19c5443b662021 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 6 Apr 2026 19:16:15 +0200 Subject: [PATCH 22/22] fix: restore SM0054 loop inside foreach module after merge with main Auto-merge of PR #87 (type-safe routes) placed the SM0054 endpoint/view Route-const diagnostics outside the 'foreach (var module in data.Modules)' loop, leaving 'module' out of scope. Move them back inside the loop. --- .../Emitters/DiagnosticEmitter.cs | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index f40937c0..2160d788 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -1164,34 +1164,34 @@ public void Emit(SourceProductionContext context, DiscoveryData data) ) ); } - } - // SM0054: Endpoint missing Route const - foreach (var endpoint in module.Endpoints) - { - if (string.IsNullOrEmpty(endpoint.RouteTemplate)) + // SM0054: Endpoint missing Route const + foreach (var endpoint in module.Endpoints) { - context.ReportDiagnostic( - Diagnostic.Create( - MissingEndpointRouteConst, - Location.None, - Strip(endpoint.FullyQualifiedName) - ) - ); + if (string.IsNullOrEmpty(endpoint.RouteTemplate)) + { + context.ReportDiagnostic( + Diagnostic.Create( + MissingEndpointRouteConst, + Location.None, + Strip(endpoint.FullyQualifiedName) + ) + ); + } } - } - foreach (var view in module.Views) - { - if (string.IsNullOrEmpty(view.RouteTemplate)) + foreach (var view in module.Views) { - context.ReportDiagnostic( - Diagnostic.Create( - MissingEndpointRouteConst, - LocationHelper.ToLocation(view.Location), - Strip(view.FullyQualifiedName) - ) - ); + if (string.IsNullOrEmpty(view.RouteTemplate)) + { + context.ReportDiagnostic( + Diagnostic.Create( + MissingEndpointRouteConst, + LocationHelper.ToLocation(view.Location), + Strip(view.FullyQualifiedName) + ) + ); + } } } }