+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+The token flow in the MVC version is simpler than the Angular version:
+
+1. The MVC controller generates a JWT and appends it to the iframe URL as `?access_token=`
+2. `_Host.cshtml` reads it from the query string on page load
+3. It is stored in `localStorage` for persistence across Blazor reconnects
+4. `Blazor.start()` is called with `accessTokenFactory` returning this token for the SignalR connection
+
+## Step 6: API Authorization Middleware
+
+Create `Middleware/ElsaApiAuthorizationMiddleware.cs` to protect the Elsa REST API endpoints:
+
+```csharp
+namespace YourProjectName.ElsaServer.Middleware;
+
+public class ElsaApiAuthorizationMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public ElsaApiAuthorizationMiddleware(
+ RequestDelegate next,
+ ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
+
+ if (path.StartsWith("/elsa/api"))
+ {
+ if (context.User?.Identity?.IsAuthenticated != true)
+ {
+ _logger.LogWarning(
+ "Unauthorized access attempt to Elsa API: {Path}", path);
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ await context.Response.WriteAsync("Authentication required.");
+ return;
+ }
+ }
+
+ await _next(context);
+ }
+}
+
+public static class ElsaApiAuthorizationMiddlewareExtensions
+{
+ public static IApplicationBuilder UseElsaApiAuthorization(
+ this IApplicationBuilder builder) =>
+ builder.UseMiddleware();
+}
+```
+
+## Step 7: ElsaServer Configuration & Middleware Pipeline
+
+### appsettings.json
+
+The critical requirement is that `Authentication:JwtBearer` values must match your MVC project's `appsettings.json` exactly:
+
+```json
+{
+ "ConnectionStrings": {
+ "Default": "Server=localhost; Database=YourProjectNameDb; Trusted_Connection=True; TrustServerCertificate=True;"
+ },
+ "App": {
+ "CorsOrigins": "https://localhost:44302,https://localhost:44313",
+ "WebSiteRootAddress": "https://localhost:44302"
+ },
+ "Authentication": {
+ "JwtBearer": {
+ "IsEnabled": "true",
+ "SecurityKey": "YourAspNetZeroProjectJWTSecurityKey",
+ "Issuer": "YourProjectName",
+ "Audience": "YourProjectName"
+ }
+ },
+ "Elsa": {
+ "Http": {
+ "BaseUrl": "https://localhost:44313",
+ "BasePath": "/workflows"
+ },
+ "Server": {
+ "BaseUrl": "https://localhost:44313",
+ "Url": "https://localhost:44313/elsa/api"
+ }
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Elsa": "Information"
+ }
+ },
+ "AllowedHosts": "*"
+}
+```
+
+> **Important:** The `SecurityKey`, `Issuer`, and `Audience` values must be identical to those in your MVC project's `appsettings.json`. This is what allows tokens issued by ASP.NET Zero MVC to be validated by the Elsa Server.
+
+### CORS Configuration
+
+```csharp
+services.AddCors(cors => cors
+ .AddDefaultPolicy(policy => policy
+ .WithOrigins(
+ configuration["App:CorsOrigins"]?
+ .Split(",", StringSplitOptions.RemoveEmptyEntries)
+ ?? ["https://localhost:44302", "https://localhost:44313"]
+ )
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials()
+ .WithExposedHeaders("x-elsa-workflow-instance-id")));
+
+services.AddHealthChecks();
+```
+
+### The Complete Middleware Pipeline
+
+```csharp
+var app = builder.Build();
+
+if (app.Environment.IsDevelopment())
+ app.UseDeveloperExceptionPage();
+else
+{
+ app.UseExceptionHandler("/Error");
+ app.UseHsts();
+}
+
+app.UseHttpsRedirection();
+app.UseStaticFiles();
+
+// Allow embedding in iframe from the MVC host origin
+app.Use(async (context, next) =>
+{
+ var mvcOrigin = configuration["App:WebSiteRootAddress"]?.TrimEnd('/')
+ ?? "https://localhost:44302";
+ context.Response.Headers.Remove("X-Frame-Options");
+ context.Response.Headers.Append(
+ "Content-Security-Policy",
+ $"frame-ancestors 'self' {mvcOrigin}");
+ await next();
+});
+
+app.UseRouting();
+app.UseCors();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+app.UseTenantMiddleware();
+app.UseElsaApiAuthorization();
+
+app.UseWorkflowsApi();
+app.UseWorkflows();
+app.UseWorkflowsSignalRHubs();
+
+app.MapBlazorHub();
+app.MapFallbackToPage("/_Host");
+app.MapHealthChecks("/health");
+
+app.Run();
+```
+
+> **MVC-specific middleware note:** The `frame-ancestors` CSP header middleware is unique to the MVC version. By default, browsers block cross-origin iframes. This middleware removes the default `X-Frame-Options` header and replaces it with a `Content-Security-Policy: frame-ancestors` directive that explicitly allows the MVC host origin to embed the Elsa Studio in an iframe.
+
+The middleware order matters:
+1. **Static files** served before routing
+2. **CSP/frame-ancestors** set before any response is written
+3. **Authentication** validates JWT tokens
+4. **TenantMiddleware** maps ABP tenant → Elsa tenant context
+5. **ElsaApiAuthorization** protects `/elsa/api/*` endpoints
+6. **Elsa endpoints** handle workflow API and execution
+7. **Blazor Server** serves the Studio UI for all other routes
+
+## Step 8: Auto-Starting ElsaServer from Web.Mvc
+
+Instead of starting the Elsa Server manually, the MVC host automatically launches it as a child process via a hosted service.
+
+### ChildProcessHostedService Base Class
+
+Create `ElsaServer/ChildProcessHostedService.cs` in the `Web.Mvc` project:
+
+```csharp
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace YourProjectName.Web.ElsaServer;
+
+public abstract class ChildProcessHostedService : IDisposable
+{
+ private readonly ILogger _logger;
+ private Process? _process;
+
+ protected ChildProcessHostedService(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ protected abstract ChildProcessOptions GetOptions();
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ var options = GetOptions();
+
+ if (!options.AutoStart)
+ {
+ _logger.LogInformation("{Name} auto-start is disabled.", options.Name);
+ return Task.CompletedTask;
+ }
+
+ var executablePath = ResolveExecutablePath(options);
+
+ if (string.IsNullOrWhiteSpace(executablePath) || !File.Exists(executablePath))
+ {
+ _logger.LogWarning(
+ "{Name} executable not found at '{Path}'. " +
+ "Build the project first or set ExecutablePath in appsettings.json.",
+ options.Name, executablePath ?? "");
+ return Task.CompletedTask;
+ }
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = executablePath,
+ WorkingDirectory = Path.GetDirectoryName(executablePath)!,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+
+ startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = options.Environment;
+ if (!string.IsNullOrWhiteSpace(options.Urls))
+ startInfo.Environment["ASPNETCORE_URLS"] = options.Urls;
+
+ _process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
+
+ var name = options.Name;
+ _process.OutputDataReceived += (_, e) =>
+ {
+ if (!string.IsNullOrEmpty(e.Data))
+ _logger.LogInformation("[{Name}] {Line}", name, e.Data);
+ };
+ _process.ErrorDataReceived += (_, e) =>
+ {
+ if (!string.IsNullOrEmpty(e.Data))
+ _logger.LogWarning("[{Name}] {Line}", name, e.Data);
+ };
+ _process.Exited += (_, _) =>
+ _logger.LogWarning("{Name} process exited with code {Code}.",
+ name, _process?.ExitCode);
+
+ try
+ {
+ _process.Start();
+ _process.BeginOutputReadLine();
+ _process.BeginErrorReadLine();
+ _logger.LogInformation(
+ "{Name} started (PID {Pid}).", options.Name, _process.Id);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to start {Name} process.", options.Name);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ if (_process == null || _process.HasExited) return;
+
+ try
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ _process.CloseMainWindow();
+ else
+ _process.Kill(entireProcessTree: false);
+
+ await _process.WaitForExitAsync(cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error stopping {Name}. Forcing kill.", GetOptions().Name);
+ try { _process.Kill(entireProcessTree: true); } catch { }
+ }
+ }
+
+ public void Dispose()
+ {
+ _process?.Dispose();
+ _process = null;
+ }
+
+ private string? ResolveExecutablePath(ChildProcessOptions options)
+ {
+ if (!string.IsNullOrWhiteSpace(options.ExecutablePath))
+ return options.ExecutablePath;
+
+ var hostBinDir = AppContext.BaseDirectory;
+ var buildConfig = hostBinDir.Contains("Release",
+ StringComparison.OrdinalIgnoreCase) ? "Release" : "Debug";
+ var framework = $"net{Environment.Version.Major}.{Environment.Version.Minor}";
+
+ return Path.GetFullPath(
+ Path.Combine(hostBinDir,
+ "..", "..", "..", "..",
+ options.ProjectName,
+ "bin", buildConfig, framework,
+ ExeName(options.ProjectName)));
+ }
+
+ private static string ExeName(string name) =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? $"{name}.exe" : name;
+}
+
+public sealed class ChildProcessOptions
+{
+ public string Name { get; init; } = "";
+ public string ProjectName { get; init; } = "";
+ public string? ExecutablePath { get; init; }
+ public string Environment { get; init; } = "Development";
+ public bool AutoStart { get; init; } = true;
+ public string? Urls { get; init; }
+}
+```
+
+`ResolveExecutablePath` navigates four levels up from the MVC host's `bin/Debug/net10.0/` directory to reach the `src/` folder, then constructs the path to the ElsaServer executable. This works automatically when both projects are siblings inside `src/`.
+
+### ElsaServerHostedService
+
+Create `ElsaServer/ElsaServerHostedService.cs` in the `Web.Mvc` project:
+
+```csharp
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace YourProjectName.Web.ElsaServer;
+
+public class ElsaServerHostedService : ChildProcessHostedService, IHostedService
+{
+ private readonly IConfiguration _configuration;
+
+ public ElsaServerHostedService(
+ IConfiguration configuration,
+ ILogger logger)
+ : base(logger)
+ {
+ _configuration = configuration;
+ }
+
+ protected override ChildProcessOptions GetOptions()
+ {
+ var section = _configuration.GetSection("ElsaServer");
+ return new ChildProcessOptions
+ {
+ Name = "ElsaServer",
+ ProjectName = "YourProjectName.ElsaServer",
+ ExecutablePath = section["ExecutablePath"],
+ Environment = section["Environment"] ?? "Development",
+ AutoStart = section.GetValue("AutoStart", defaultValue: true),
+ Urls = section["Urls"] ?? "https://localhost:44313;http://localhost:44312",
+ };
+ }
+}
+```
+
+### Register the Hosted Service
+
+In `Web.Mvc/Startup/Startup.cs`, add the registration inside `ConfigureServices`:
+
+```csharp
+using YourProjectName.Web.ElsaServer;
+
+// Inside ConfigureServices:
+services.AddHostedService();
+```
+
+### Add ElsaServer Configuration
+
+Add the `ElsaServer` section to `Web.Mvc/appsettings.json`:
+
+```json
+{
+ "ElsaServer": {
+ "AutoStart": true,
+ "ExecutablePath": "",
+ "Environment": "Development",
+ "Urls": "https://localhost:44313;http://localhost:44312"
+ }
+}
+```
+
+Leave `ExecutablePath` empty to use the automatic path resolution. You can set it to an absolute path if you need to point to a specific binary (e.g., in a production deployment).
+
+> **Before running:** You must build the ElsaServer project at least once so the executable exists:
+> ```bash
+> dotnet build src/YourProjectName.ElsaServer
+> ```
+
+## Step 9: Add the Elsa Workflows Page to the MVC UI
+
+Now we wire everything into the MVC application: a page name constant, a menu item, a controller that generates the JWT, and a Razor view that renders the iframe.
+
+### Add the Page Name Constant
+
+In `Areas/App/Startup/AppPageNames.cs`, add `ElsaWorkflows` to the `Common` class:
+
+```csharp
+public static class Common
+{
+ // ... existing constants
+
+ public const string ElsaWorkflows = "ElsaWorkflows";
+}
+```
+
+### Add the Navigation Menu Item
+
+In `Areas/App/Startup/AppNavigationProvider.cs`, add the menu item:
+
+```csharp
+.AddItem(new MenuItemDefinition(
+ AppPageNames.Common.ElsaWorkflows,
+ L("ElsaWorkflows"),
+ url: "App/ElsaWorkflows",
+ icon: "flaticon-squares-4"
+))
+```
+
+### Add the Localization Key
+
+In `Core/Localization/YourProjectName/YourProjectName.xml`:
+
+```xml
+Elsa Workflows
+```
+
+### Create the Controller
+
+Create `Areas/App/Controllers/ElsaWorkflowsController.cs`. This controller generates a short-lived JWT using the same signing key as the Elsa Server, then passes the iframe URL to the view:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using Abp.AspNetCore.Mvc.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using YourProjectName.Web.Authentication.JwtBearer;
+using YourProjectName.Web.Controllers;
+
+namespace YourProjectName.Web.Areas.App.Controllers;
+
+[Area("App")]
+[AbpMvcAuthorize]
+public class ElsaWorkflowsController : YourProjectNameControllerBase
+{
+ private readonly TokenAuthConfiguration _tokenAuthConfiguration;
+ private readonly IConfiguration _configuration;
+
+ public ElsaWorkflowsController(
+ TokenAuthConfiguration tokenAuthConfiguration,
+ IConfiguration configuration)
+ {
+ _tokenAuthConfiguration = tokenAuthConfiguration;
+ _configuration = configuration;
+ }
+
+ public IActionResult Index()
+ {
+ var elsaServerUrl = _configuration["ElsaServer:Urls"]?
+ .Split(";")[0]
+ ?? "https://localhost:44313";
+
+ var token = GenerateElsaToken();
+ ViewBag.ElsaIframeUrl =
+ $"{elsaServerUrl}?access_token={Uri.EscapeDataString(token)}";
+
+ return View();
+ }
+
+ private string GenerateElsaToken()
+ {
+ var claims = new List();
+
+ if (AbpSession.UserId.HasValue)
+ {
+ claims.Add(new Claim(JwtRegisteredClaimNames.Sub,
+ AbpSession.UserId.Value.ToString()));
+ claims.Add(new Claim(JwtRegisteredClaimNames.NameId,
+ AbpSession.UserId.Value.ToString()));
+ }
+
+ if (AbpSession.TenantId.HasValue)
+ {
+ claims.Add(new Claim(
+ "http://www.aspnetboilerplate.com/identity/claims/tenantId",
+ AbpSession.TenantId.Value.ToString()));
+ }
+
+ var nameClaim = User.FindFirst(ClaimTypes.Name)
+ ?? User.FindFirst(JwtRegisteredClaimNames.Name);
+ if (nameClaim != null)
+ claims.Add(new Claim(JwtRegisteredClaimNames.Name, nameClaim.Value));
+
+ var emailClaim = User.FindFirst(ClaimTypes.Email)
+ ?? User.FindFirst(JwtRegisteredClaimNames.Email);
+ if (emailClaim != null)
+ claims.Add(new Claim(JwtRegisteredClaimNames.Email, emailClaim.Value));
+
+ var now = DateTime.UtcNow;
+ var jwt = new JwtSecurityToken(
+ issuer: _tokenAuthConfiguration.Issuer,
+ audience: _tokenAuthConfiguration.Audience,
+ claims: claims,
+ notBefore: now,
+ signingCredentials: _tokenAuthConfiguration.SigningCredentials,
+ expires: now.AddHours(8)
+ );
+
+ return new JwtSecurityTokenHandler().WriteToken(jwt);
+ }
+}
+```
+
+The controller uses `TokenAuthConfiguration` (already registered by ABP) to generate a JWT. Since both the MVC host and the Elsa Server share the same `SecurityKey`, `Issuer`, and `Audience`, the token generated here is valid for the Elsa Server's JWT middleware.
+
+The token includes:
+- `sub` / `nameid` — the ABP user ID
+- The ABP tenant ID claim — used by the Elsa Server's `TenantMiddleware` for tenant isolation
+- `name` and `email` — forwarded from the current MVC user principal
+
+### Create the Razor View
+
+Create `Areas/App/Views/ElsaWorkflows/Index.cshtml`:
+
+```html
+@{
+ ViewBag.CurrentPageName =
+ YourProjectName.Web.Areas.App.Startup.AppPageNames.Common.ElsaWorkflows;
+}
+
+
+
+
+
+
+```
+
+The iframe is set to `height: calc(100vh - 65px)` to fill the full height of the content area below the top navbar, and `width: 100%` to fill the area to the right of the sidebar. The result is a full-page Elsa Studio experience embedded within the MVC layout.
+
+## Testing the Integration
+
+### 1. Build the ElsaServer
+
+The MVC host auto-starts the ElsaServer executable, so you must build it first:
+
+```bash
+dotnet build src/YourProjectName.ElsaServer
+```
+
+### 2. Start the MVC Application
+
+```bash
+dotnet run --project src/YourProjectName.Web.Mvc
+```
+
+You should see log output confirming both processes have started:
+
+```
+Web.Mvc listening on https://localhost:44302
+[ElsaServer] Process started (PID: XXXXX)
+[ElsaServer] Listening on https://localhost:44313
+[ElsaServer] Running Elsa database migrations...
+[ElsaServer] Migrations completed successfully.
+```
+
+### 3. Verify the Workflow
+
+1. Open `https://localhost:44302` in your browser
+2. Log in with an admin account
+3. Look for **"Elsa Workflows"** in the sidebar navigation
+4. Click it — the Elsa Studio should load inside the iframe
+5. Try creating a workflow to verify the Elsa API is accessible
+
+
+
+### Troubleshooting
+
+| Problem | Cause | Solution |
+|---|---|---|
+| Sidebar item not visible | Localization key missing | Add `Elsa Workflows` to the localization XML |
+| "localhost refused to connect" | ElsaServer executable not found | Run `dotnet build src/YourProjectName.ElsaServer` first |
+| Iframe shows blank / CSP error | Missing `frame-ancestors` header | Ensure the CSP middleware is in `Program.cs` and `App:WebSiteRootAddress` matches the MVC host URL |
+| Blazor connection error (401) | JWT key mismatch | Verify `SecurityKey`, `Issuer`, and `Audience` match in both `appsettings.json` files |
+| Workflows not isolated by tenant | Tenant middleware not resolving | Check that the ABP tenant ID claim (`http://www.aspnetboilerplate.com/identity/claims/tenantId`) is present in the generated JWT |
+
+## Summary
+
+In this guide, we integrated the **Elsa v3 workflow engine** into an **ASP.NET Zero MVC** application end-to-end:
+
+1. **Created the `ElsaServer` project** with all required Elsa v3 packages and Blazor Server Studio
+2. **Configured the workflow engine** with SQL Server persistence and automatic migrations
+3. **Shared JWT authentication** by using the same security key, issuer, and audience as the MVC host
+4. **Implemented multi-tenancy** by mapping ABP integer tenant IDs to Elsa string tenant IDs
+5. **Built the Blazor token pipeline** (`TokenProvider` → `TokenCircuitHandler` → `AuthenticatingApiHttpMessageHandler`) so the Studio can authenticate its API calls
+6. **Added the `frame-ancestors` CSP header** to allow the MVC host to embed the Blazor Studio in an iframe
+7. **Auto-started the ElsaServer** as a child process via `ElsaServerHostedService`
+8. **Added the MVC page** with a controller that generates a scoped JWT and a Razor view that renders the full-page iframe
+
+The architecture keeps a clean separation of concerns: the Elsa Server runs independently and can be scaled or updated without touching the MVC application. From the user's perspective, the workflow designer feels like a native part of the MVC application.