();
+ return base.InitializeAsync(cancellationToken);
+ }
+}
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor
new file mode 100644
index 00000000..aa516a74
--- /dev/null
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor
@@ -0,0 +1,57 @@
+@page "/login"
+@using Elsa.Studio.Branding
+@using Elsa.Studio.Layouts
+@using Elsa.Studio.Localization
+@inherits Elsa.Studio.Components.StudioComponentBase
+@inject ILocalizer Localizer
+@inject IBrandingProvider BrandingProvider
+@layout BasicLayout
+
+
+
+ @if (BrandingProvider.AppBarIcons.ShowDocumentationLink)
+ {
+
+ }
+ @if (BrandingProvider.AppBarIcons.ShowGitHubLink)
+ {
+
+ }
+
+
+
+
+
+
+
+
+ @BrandingProvider.AppName
+ @BrandingProvider.AppTagline
+
+
+ @ClientVersion
+
+
+
+
+
+ @Localizer["Login"]
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor.cs
new file mode 100644
index 00000000..fd18385b
--- /dev/null
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Pages/Login/Login.razor.cs
@@ -0,0 +1,73 @@
+using Elsa.Studio.Authentication.ElsaAuth.Contracts;
+using Elsa.Studio.Authentication.ElsaAuth.Services;
+using Elsa.Studio.Contracts;
+using Elsa.Studio.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.WebUtilities;
+using MudBlazor;
+using Radzen;
+
+namespace Elsa.Studio.Authentication.ElsaAuth.UI.Pages.Login;
+
+///
+/// The login page.
+///
+[AllowAnonymous]
+public partial class Login
+{
+ [Inject] private IJwtAccessor JwtAccessor { get; set; } = null!;
+ [Inject] private ICredentialsValidator CredentialsValidator { get; set; } = null!;
+ [Inject] private NavigationManager NavigationManager { get; set; } = null!;
+ [Inject] private AuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!;
+ [Inject] private IClientInformationProvider ClientInformationProvider { get; set; } = null!;
+ [Inject] private IServerInformationProvider ServerInformationProvider { get; set; } = null!;
+ [Inject] private IUserMessageService UserMessageService { get; set; } = null!;
+
+ private string ClientVersion { get; set; } = "3.0.0";
+ private string ServerVersion { get; set; } = "3.0.0";
+
+ ///
+ protected override async Task OnInitializedAsync()
+ {
+ var clientInformation = await ClientInformationProvider.GetInfoAsync();
+ var serverInformation = await ServerInformationProvider.GetInfoAsync();
+ ClientVersion = clientInformation.PackageVersion;
+ ServerVersion = string.Join('.', serverInformation.PackageVersion.Split('.').Take(2));
+ }
+
+ private async Task TryLogin(LoginArgs args)
+ {
+ var isValid = await ValidateCredentials(args.Username, args.Password);
+ if (!isValid)
+ {
+ UserMessageService.ShowSnackbarTextMessage("Invalid credentials. Please try again", Severity.Error);
+ return;
+ }
+
+ if (AuthenticationStateProvider is AccessTokenAuthenticationStateProvider tokenProvider)
+ tokenProvider.NotifyAuthenticationStateChanged();
+
+ var uri = new Uri(NavigationManager.Uri);
+ if (QueryHelpers.ParseQuery(uri.Query).TryGetValue("returnUrl", out var returnUrl))
+ NavigationManager.NavigateTo(returnUrl.FirstOrDefault() ?? string.Empty, true);
+ else
+ NavigationManager.NavigateTo(string.Empty, true);
+ }
+
+ private async Task ValidateCredentials(string username, string password)
+ {
+ if (string.IsNullOrEmpty(username) && string.IsNullOrEmpty(password))
+ return false;
+
+ var result = await CredentialsValidator.ValidateCredentialsAsync(username, password);
+
+ if (!result.IsValid)
+ return false;
+
+ await JwtAccessor.WriteTokenAsync(TokenNames.AccessToken, result.AccessToken!);
+ await JwtAccessor.WriteTokenAsync(TokenNames.RefreshToken, result.RefreshToken!);
+ return true;
+ }
+}
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/README.md b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/README.md
new file mode 100644
index 00000000..8cc869d6
--- /dev/null
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/README.md
@@ -0,0 +1,33 @@
+# Elsa.Studio.Authentication.ElsaAuth.UI
+
+Provides the Elsa Identity login UI for Elsa Studio.
+
+## What you get
+- A Blazor login page at `/login`
+- A default unauthorized component provider that redirects to `/login?returnUrl=...`
+- A simple app-bar login component (optional)
+
+## Usage
+### 1) Configure ElsaAuth (Elsa Identity)
+This UI module assumes you are using **Elsa Identity** (username/password against the Elsa backend API).
+
+In your host (Server or WASM), register ElsaAuth and the identity flow, then add the UI:
+
+```csharp
+// Platform services:
+builder.Services.AddElsaAuth();
+
+// Core + Elsa Identity flow:
+builder.Services.AddElsaAuthCore().UseElsaIdentityAuth();
+
+// UI (this package):
+builder.Services.AddElsaAuthUI();
+```
+
+### 2) Switching providers (hosts)
+Elsa Studio hosts can switch providers using configuration:
+
+- `Authentication:Provider` = `OpenIdConnect` or `ElsaAuth`
+- `Authentication:OpenIdConnect` contains OIDC settings when using `OpenIdConnect`
+
+> Note: You still need to configure the backend URL to point to your Elsa API.
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/_Imports.razor b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/_Imports.razor
new file mode 100644
index 00000000..77a3849f
--- /dev/null
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/_Imports.razor
@@ -0,0 +1,9 @@
+@using Elsa.Studio.Abstractions
+@using Elsa.Studio.Contracts
+@using Elsa.Studio.Shared
+@using Elsa.Studio.Shared.Layouts
+@using Elsa.Studio.Shared.Components
+@using Elsa.Studio.Shared.Services
+@using MudBlazor
+@using Radzen
+@using Radzen.Blazor
From 48552abf876767e4326dc6618203102cc73fbdbd Mon Sep 17 00:00:00 2001
From: Sipke Schoorstra
Date: Thu, 8 Jan 2026 20:06:06 +0100
Subject: [PATCH 16/27] Migrate `AUTHENTICATION_ARCHITECTURE.md` to `doc/`
folder, update project references, and refine namespace imports for
authentication module.
---
Elsa.Studio.sln | 5 +
IMPLEMENTATION_SUMMARY.md | 207 ------------------
.../AUTHENTICATION_ARCHITECTURE.md | 0
...a.Studio.Authentication.ElsaAuth.UI.csproj | 3 +-
.../_Imports.razor | 7 +-
5 files changed, 10 insertions(+), 212 deletions(-)
delete mode 100644 IMPLEMENTATION_SUMMARY.md
rename {src/modules => doc}/AUTHENTICATION_ARCHITECTURE.md (100%)
diff --git a/Elsa.Studio.sln b/Elsa.Studio.sln
index e3bec5d0..92c66728 100644
--- a/Elsa.Studio.sln
+++ b/Elsa.Studio.sln
@@ -115,6 +115,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dashboard", "dashboard", "{
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Authentication.ElsaAuth.UI", "src\modules\Elsa.Studio.Authentication.ElsaAuth.UI\Elsa.Studio.Authentication.ElsaAuth.UI.csproj", "{9953E8DA-2ADD-42CA-957F-1DFDB284BEFD}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{60B07BD6-80AE-496A-B5C4-55EBB12EF3BC}"
+ ProjectSection(SolutionItems) = preProject
+ doc\AUTHENTICATION_ARCHITECTURE.md = doc\AUTHENTICATION_ARCHITECTURE.md
+ EndProjectSection
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md
deleted file mode 100644
index 56c9a454..00000000
--- a/IMPLEMENTATION_SUMMARY.md
+++ /dev/null
@@ -1,207 +0,0 @@
-# Implementation Summary: Standalone OIDC Authentication Module
-
-## What Was Built
-
-A complete, best-practices OpenID Connect authentication module for Elsa Studio that serves as a clean alternative to the existing OIDC implementation in `Elsa.Studio.Login`.
-
-## New Projects (4)
-
-### 1. Elsa.Studio.Authentication.Abstractions
-**Purpose**: Shared abstractions for authentication providers
-
-**Contents**:
-- `ITokenAccessor` - Provider-agnostic token access interface
-- `AuthenticationOptions` - Base configuration class for all providers
-
-**Why**: Enables future authentication providers (OAuth2, JWT, SAML) to reuse common patterns
-
-### 2. Elsa.Studio.Authentication.OpenIdConnect
-**Purpose**: Core OIDC abstractions
-
-**Contents**:
-- `IOidcTokenAccessor` (extends `ITokenAccessor`)
-- `OidcOptions` (extends `AuthenticationOptions`)
-- `OidcAuthenticationProvider` (implements `IAuthenticationProvider`)
-
-**Why**: Provides OIDC-specific functionality while building on shared abstractions
-
-### 3. Elsa.Studio.Authentication.OpenIdConnect.BlazorServer
-**Purpose**: Blazor Server implementation
-
-**Key Features**:
-- Uses `Microsoft.AspNetCore.Authentication.OpenIdConnect` middleware
-- Cookie-based authentication (HTTP-only, secure)
-- Tokens stored server-side via authentication properties
-- Retrieved using `HttpContext.GetTokenAsync()`
-- **No browser storage** - tokens never exposed to client
-
-**Why Server-Specific**: Server can use ASP.NET Core authentication pipeline and maintain server-side sessions
-
-### 4. Elsa.Studio.Authentication.OpenIdConnect.BlazorWasm
-**Purpose**: Blazor WebAssembly implementation
-
-**Key Features**:
-- Uses `Microsoft.AspNetCore.Components.WebAssembly.Authentication`
-- Leverages `IAccessTokenProvider` for automatic token management
-- Framework handles token refresh, expiry, and renewal automatically
-- Secure token handling (refresh/ID tokens hidden from application code)
-
-**Why WASM-Specific**: WASM runs in browser and needs specialized token management with automatic refresh
-
-## Key Improvements Over Legacy Implementation
-
-| Aspect | Legacy (Elsa.Studio.Login) | New (This Module) |
-|--------|---------------------------|-------------------|
-| **Token Storage** | Browser localStorage/sessionStorage | Server: Auth properties (server-side)
WASM: Framework-managed |
-| **Token Refresh** | Manual implementation | Automatic via framework |
-| **PKCE** | Manual implementation | Built-in framework support |
-| **Middleware** | Custom authorization flow | Standard ASP.NET Core pipeline |
-| **Security** | Tokens exposed in browser | Server: No client exposure
WASM: Framework-secured |
-| **Coupling** | Tight with Login module | Fully decoupled |
-
-## Architecture
-
-```
-Elsa Studio App → IAuthenticationProviderManager
- ↓
- IAuthenticationProvider (Core)
- ↓
- ITokenAccessor (Abstractions) ← Provider-agnostic
- ↓
- IOidcTokenAccessor (OIDC) ← OIDC-specific
- ↓
- ┌────────────┴────────────┐
- ▼ ▼
-ServerOidcTokenAccessor WasmOidcTokenAccessor
-(HttpContext) (IAccessTokenProvider)
-```
-
-## Compatibility
-
-✅ **WorkflowInstanceObserverFactory**: Tested pattern, works with both implementations
-✅ **SignalR Hub Connections**: Token access via `IAuthenticationProviderManager`
-✅ **API HTTP Calls**: Compatible with existing `AuthenticatingApiHttpMessageHandler`
-✅ **Backward Compatible**: Does not modify or break existing `Elsa.Studio.Login`
-
-## Usage Examples
-
-### Blazor Server
-```csharp
-builder.Services.AddOidcAuthentication(options =>
-{
- options.Authority = "https://identity-server.com";
- options.ClientId = "elsa-studio";
- options.ClientSecret = "secret";
- options.Scopes = new[] { "openid", "profile", "elsa_api", "offline_access" };
-});
-
-app.UseAuthentication();
-app.UseAuthorization();
-```
-
-### Blazor WASM
-```csharp
-builder.Services.AddOidcAuthentication(options =>
-{
- options.Authority = "https://identity-server.com";
- options.ClientId = "elsa-studio-wasm";
- options.Scopes = new[] { "openid", "profile", "elsa_api", "offline_access" };
-});
-```
-
-## Documentation
-
-Three comprehensive documentation files:
-
-1. **`src/modules/Elsa.Studio.Authentication.Abstractions/README.md`**
- - How to create new authentication providers
- - Shared abstractions explanation
- - Examples for future providers
-
-2. **`src/modules/Elsa.Studio.Authentication.OpenIdConnect/README.md`**
- - Complete OIDC usage guide
- - Configuration options
- - Migration from legacy implementation
- - Troubleshooting guide
-
-3. **`src/modules/AUTHENTICATION_ARCHITECTURE.md`**
- - System-wide authentication architecture
- - Integration points (API calls, SignalR, UI)
- - Security considerations
- - Multi-provider design
-
-## Build Status
-
-✅ All 4 projects build successfully
-✅ No build errors
-✅ Added to solution
-✅ Package dependencies managed via Central Package Management
-
-## What Was NOT Changed
-
-❌ Existing `Elsa.Studio.Login` module - **completely untouched**
-❌ Host applications - kept as-is (new module is optional)
-❌ Any other authentication code
-
-## Design Decisions
-
-### Why Separate Projects for Server/WASM?
-- Different authentication mechanisms
-- Server uses middleware + cookies
-- WASM uses built-in token provider
-- Avoids conditional compilation complexity
-
-### Why Not Use Existing IJwtAccessor?
-- Legacy interface tied to browser storage
-- New approach uses framework-native token access
-- Cleaner separation of concerns
-
-### Why Create Abstractions Project?
-- Per user requirement: OIDC is one of many potential providers
-- Enables OAuth2, JWT, SAML, etc. in the future
-- Promotes consistency across providers
-- Minimal abstraction (just token access + config base)
-
-### Why Not Integrate with Hosts?
-- Module is optional and standalone
-- Users can choose when/how to adopt
-- Avoids forcing breaking changes
-- Easier to test independently
-
-## Testing Recommendations
-
-For users to test:
-
-1. **Blazor Server**:
- - Replace `UseOpenIdConnect` with new `AddOidcAuthentication`
- - Add `app.UseAuthentication()` and `app.UseAuthorization()`
- - Configure with your identity provider
- - Test login, API calls, SignalR connections
-
-2. **Blazor WASM**:
- - Replace legacy OIDC setup with new `AddOidcAuthentication`
- - Add authentication routes and components
- - Configure with your identity provider
- - Test login, token refresh, API calls
-
-## Future Possibilities
-
-With the abstractions in place, adding new providers is straightforward:
-
-- `Elsa.Studio.Authentication.OAuth2` - Pure OAuth2
-- `Elsa.Studio.Authentication.Jwt` - JWT bearer tokens
-- `Elsa.Studio.Authentication.Saml` - SAML authentication
-- `Elsa.Studio.Authentication.AzureAD` - Azure AD optimizations
-- Custom providers for proprietary systems
-
-## Summary
-
-This implementation provides a **clean-slate, best-practices OpenID Connect module** that:
-- Leverages Microsoft's proven authentication infrastructure
-- Properly supports both Blazor hosting models
-- Eliminates manual token management
-- Improves security
-- Enables future authentication providers
-- Maintains complete backward compatibility
-
-**Ready for review and user testing!**
diff --git a/src/modules/AUTHENTICATION_ARCHITECTURE.md b/doc/AUTHENTICATION_ARCHITECTURE.md
similarity index 100%
rename from src/modules/AUTHENTICATION_ARCHITECTURE.md
rename to doc/AUTHENTICATION_ARCHITECTURE.md
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Elsa.Studio.Authentication.ElsaAuth.UI.csproj b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Elsa.Studio.Authentication.ElsaAuth.UI.csproj
index e9f99126..da0b2672 100644
--- a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Elsa.Studio.Authentication.ElsaAuth.UI.csproj
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/Elsa.Studio.Authentication.ElsaAuth.UI.csproj
@@ -10,6 +10,7 @@
+
@@ -17,7 +18,7 @@
-
+
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/_Imports.razor b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/_Imports.razor
index 77a3849f..bd1015a9 100644
--- a/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/_Imports.razor
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.UI/_Imports.razor
@@ -1,9 +1,8 @@
@using Elsa.Studio.Abstractions
@using Elsa.Studio.Contracts
-@using Elsa.Studio.Shared
-@using Elsa.Studio.Shared.Layouts
-@using Elsa.Studio.Shared.Components
-@using Elsa.Studio.Shared.Services
+@using Elsa.Studio.Components
+@using Elsa.Studio.Layouts
+@using Elsa.Studio.Services
@using MudBlazor
@using Radzen
@using Radzen.Blazor
From b16851ee4f46770ce35aac5953b840bdb5e2f5ef Mon Sep 17 00:00:00 2001
From: Sipke Schoorstra
Date: Thu, 8 Jan 2026 20:30:15 +0100
Subject: [PATCH 17/27] Introduce `IAnonymousBackendApiClientProvider` and
refactor API client creation to support non-authenticated backend calls.
---
.../IAnonymousBackendApiClientProvider.cs | 24 ++++++++++++++
.../Contracts/IBackendApiClientProvider.cs | 4 ++-
.../Extensions/ServiceCollectionExtensions.cs | 1 +
.../Services/ApiClientFactory.cs | 14 ++++++++
.../Services/BlazorScopedProxyApi.cs | 3 +-
...efaultAnonymousBackendApiClientProvider.cs | 32 +++++++++++++++++++
.../DefaultBackendApiClientProvider.cs | 6 ++--
src/hosts/Elsa.Studio.Host.Server/Program.cs | 1 -
.../Elsa.Studio.Host.Server/appsettings.json | 2 +-
.../AuthenticatingApiHttpMessageHandler.cs | 6 ++--
.../Extensions/ServiceCollectionExtensions.cs | 4 ++-
.../ElsaIdentityCredentialsValidator.cs | 2 +-
12 files changed, 88 insertions(+), 11 deletions(-)
create mode 100644 src/framework/Elsa.Studio.Core/Contracts/IAnonymousBackendApiClientProvider.cs
create mode 100644 src/framework/Elsa.Studio.Core/Services/ApiClientFactory.cs
create mode 100644 src/framework/Elsa.Studio.Core/Services/DefaultAnonymousBackendApiClientProvider.cs
diff --git a/src/framework/Elsa.Studio.Core/Contracts/IAnonymousBackendApiClientProvider.cs b/src/framework/Elsa.Studio.Core/Contracts/IAnonymousBackendApiClientProvider.cs
new file mode 100644
index 00000000..44ae9890
--- /dev/null
+++ b/src/framework/Elsa.Studio.Core/Contracts/IAnonymousBackendApiClientProvider.cs
@@ -0,0 +1,24 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Elsa.Studio.Contracts;
+
+///
+/// Provides API clients to the backend for anonymous (non-authenticated) calls.
+///
+///
+/// This provider is intended for endpoints like /identity/login where attaching an access token is not required
+/// and can even be harmful (e.g., stale tokens, circular dependencies during sign-in).
+///
+public interface IAnonymousBackendApiClientProvider
+{
+ ///
+ /// Gets the URL to the backend.
+ ///
+ Uri Url { get; }
+
+ ///
+ /// Gets an API client that does not attach access tokens.
+ ///
+ /// The API client type.
+ ValueTask GetApiAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(CancellationToken cancellationToken = default) where T : class;
+}
diff --git a/src/framework/Elsa.Studio.Core/Contracts/IBackendApiClientProvider.cs b/src/framework/Elsa.Studio.Core/Contracts/IBackendApiClientProvider.cs
index 0e699cc6..b1f69e02 100644
--- a/src/framework/Elsa.Studio.Core/Contracts/IBackendApiClientProvider.cs
+++ b/src/framework/Elsa.Studio.Core/Contracts/IBackendApiClientProvider.cs
@@ -1,3 +1,5 @@
+using System.Diagnostics.CodeAnalysis;
+
namespace Elsa.Studio.Contracts;
///
@@ -15,5 +17,5 @@ public interface IBackendApiClientProvider
///
/// The API client type.
/// The API client.
- ValueTask GetApiAsync(CancellationToken cancellationToken = default) where T : class;
+ ValueTask GetApiAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(CancellationToken cancellationToken = default) where T : class;
}
\ No newline at end of file
diff --git a/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs b/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs
index 870ed98a..335890a1 100644
--- a/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs
+++ b/src/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs
@@ -60,6 +60,7 @@ public static IServiceCollection AddRemoteBackend(this IServiceCollection servic
services.AddDefaultApiClients(config?.ConfigureHttpClientBuilder);
services.TryAddScoped();
services.TryAddScoped();
+ services.TryAddScoped();
return services;
}
diff --git a/src/framework/Elsa.Studio.Core/Services/ApiClientFactory.cs b/src/framework/Elsa.Studio.Core/Services/ApiClientFactory.cs
new file mode 100644
index 00000000..50b3cb2a
--- /dev/null
+++ b/src/framework/Elsa.Studio.Core/Services/ApiClientFactory.cs
@@ -0,0 +1,14 @@
+using System.Diagnostics.CodeAnalysis;
+using Elsa.Api.Client.Extensions;
+
+namespace Elsa.Studio.Services;
+
+///
+/// Bridges calls to Elsa.Api.Client API client creation methods while preserving trimming annotations.
+///
+internal static class ApiClientFactory
+{
+ internal static T Create<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(IServiceProvider serviceProvider, Uri backendUrl) where T : class
+ => serviceProvider.CreateApi(backendUrl);
+}
+
diff --git a/src/framework/Elsa.Studio.Core/Services/BlazorScopedProxyApi.cs b/src/framework/Elsa.Studio.Core/Services/BlazorScopedProxyApi.cs
index e9bc00a7..cd3a7f59 100644
--- a/src/framework/Elsa.Studio.Core/Services/BlazorScopedProxyApi.cs
+++ b/src/framework/Elsa.Studio.Core/Services/BlazorScopedProxyApi.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Elsa.Studio.Contracts;
@@ -6,7 +7,7 @@ namespace Elsa.Studio.Services;
///
/// Decorates an API client with a Blazor service accessor, ensuring that the service provider is available to the API client when calling DI-resolved delegating handlers.
///
-public class BlazorScopedProxyApi : DispatchProxy
+public class BlazorScopedProxyApi<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T> : DispatchProxy
{
private T _decoratedApi = default!;
private IBlazorServiceAccessor _blazorServiceAccessor = null!;
diff --git a/src/framework/Elsa.Studio.Core/Services/DefaultAnonymousBackendApiClientProvider.cs b/src/framework/Elsa.Studio.Core/Services/DefaultAnonymousBackendApiClientProvider.cs
new file mode 100644
index 00000000..f131045e
--- /dev/null
+++ b/src/framework/Elsa.Studio.Core/Services/DefaultAnonymousBackendApiClientProvider.cs
@@ -0,0 +1,32 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using Elsa.Studio.Contracts;
+
+namespace Elsa.Studio.Services;
+
+///
+/// Default implementation of .
+///
+public class DefaultAnonymousBackendApiClientProvider(
+ IRemoteBackendAccessor remoteBackendAccessor,
+ IBlazorServiceAccessor blazorServiceAccessor,
+ IServiceProvider serviceProvider) : IAnonymousBackendApiClientProvider
+{
+ ///
+ public Uri Url => remoteBackendAccessor.RemoteBackend.Url;
+
+ ///
+ public ValueTask GetApiAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(CancellationToken cancellationToken = default) where T : class
+ {
+ var backendUrl = remoteBackendAccessor.RemoteBackend.Url;
+
+ // Create a raw API client. This bypasses DI-resolved delegating handlers (like AuthenticatingApiHttpMessageHandler).
+ var client = ApiClientFactory.Create(serviceProvider, backendUrl);
+
+ // Keep the Blazor scoped-service-provider behavior consistent.
+ var decorator = DispatchProxy.Create>();
+ (decorator as BlazorScopedProxyApi)!.Initialize(client, blazorServiceAccessor, serviceProvider);
+
+ return new(decorator);
+ }
+}
diff --git a/src/framework/Elsa.Studio.Core/Services/DefaultBackendApiClientProvider.cs b/src/framework/Elsa.Studio.Core/Services/DefaultBackendApiClientProvider.cs
index dc91bf64..fb014b04 100644
--- a/src/framework/Elsa.Studio.Core/Services/DefaultBackendApiClientProvider.cs
+++ b/src/framework/Elsa.Studio.Core/Services/DefaultBackendApiClientProvider.cs
@@ -1,5 +1,5 @@
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
-using Elsa.Api.Client.Extensions;
using Elsa.Studio.Contracts;
namespace Elsa.Studio.Services;
@@ -13,10 +13,10 @@ public class DefaultBackendApiClientProvider(IRemoteBackendAccessor remoteBacken
public Uri Url => remoteBackendAccessor.RemoteBackend.Url;
///
- public ValueTask GetApiAsync(CancellationToken cancellationToken) where T : class
+ public ValueTask GetApiAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(CancellationToken cancellationToken = default) where T : class
{
var backendUrl = remoteBackendAccessor.RemoteBackend.Url;
- var client = serviceProvider.CreateApi(backendUrl);
+ var client = ApiClientFactory.Create(serviceProvider, backendUrl);
var decorator = DispatchProxy.Create>();
(decorator as BlazorScopedProxyApi)!.Initialize(client, blazorServiceAccessor, serviceProvider);
return new(decorator);
diff --git a/src/hosts/Elsa.Studio.Host.Server/Program.cs b/src/hosts/Elsa.Studio.Host.Server/Program.cs
index af322ce5..41974705 100644
--- a/src/hosts/Elsa.Studio.Host.Server/Program.cs
+++ b/src/hosts/Elsa.Studio.Host.Server/Program.cs
@@ -1,6 +1,5 @@
using Elsa.Studio.Authentication.Abstractions.HttpMessageHandlers;
using Elsa.Studio.Authentication.ElsaAuth.BlazorServer.Extensions;
-using Elsa.Studio.Authentication.ElsaAuth.Extensions;
using Elsa.Studio.Authentication.ElsaAuth.UI.Extensions;
using Elsa.Studio.Authentication.OpenIdConnect.BlazorServer.Extensions;
using Elsa.Studio.Branding;
diff --git a/src/hosts/Elsa.Studio.Host.Server/appsettings.json b/src/hosts/Elsa.Studio.Host.Server/appsettings.json
index 689f4f3e..e92155d4 100644
--- a/src/hosts/Elsa.Studio.Host.Server/appsettings.json
+++ b/src/hosts/Elsa.Studio.Host.Server/appsettings.json
@@ -20,7 +20,7 @@
]
},
"Authentication": {
- "Provider": "OpenIdConnect",
+ "Provider": "ElsaAuth",
"OpenIdConnect": {
"Authority": "https://login.microsoftonline.com/{tenantIdOrVerifiedDomain}/v2.0",
"ClientId": "",
diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/HttpMessageHandlers/AuthenticatingApiHttpMessageHandler.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/HttpMessageHandlers/AuthenticatingApiHttpMessageHandler.cs
index 2d90111b..514bc548 100644
--- a/src/modules/Elsa.Studio.Authentication.Abstractions/HttpMessageHandlers/AuthenticatingApiHttpMessageHandler.cs
+++ b/src/modules/Elsa.Studio.Authentication.Abstractions/HttpMessageHandlers/AuthenticatingApiHttpMessageHandler.cs
@@ -17,7 +17,10 @@ public class AuthenticatingApiHttpMessageHandler(IBlazorServiceAccessor blazorSe
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var sp = blazorServiceAccessor.Services;
- var authenticationProvider = sp.GetRequiredService();
+ var authenticationProvider = sp.GetService();
+
+ if (authenticationProvider == null)
+ return await base.SendAsync(request, cancellationToken);
var accessToken = await authenticationProvider.GetAccessTokenAsync(TokenNames.AccessToken, cancellationToken);
@@ -29,4 +32,3 @@ protected override async Task SendAsync(HttpRequestMessage
return await base.SendAsync(request, cancellationToken);
}
}
-
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Extensions/ServiceCollectionExtensions.cs
index a0bbb83d..71c14408 100644
--- a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Extensions/ServiceCollectionExtensions.cs
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Extensions/ServiceCollectionExtensions.cs
@@ -26,7 +26,9 @@ public static IServiceCollection AddElsaAuthCore(this IServiceCollection service
.AddAuthorizationCore()
.AddAuthenticationInfrastructure()
.AddScoped()
- .AddScoped();
+ .AddScoped()
+ .AddScoped()
+ .AddScoped();
// Default token claims mapping.
services.TryAddSingleton, DefaultIdentityTokenOptionsSetup>();
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityCredentialsValidator.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityCredentialsValidator.cs
index 7f587969..6c7566da 100644
--- a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityCredentialsValidator.cs
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Services/ElsaIdentityCredentialsValidator.cs
@@ -9,7 +9,7 @@ namespace Elsa.Studio.Authentication.ElsaAuth.Services;
///
/// An implementation of that consumes endpoints from Elsa.Identity.
///
-public class ElsaIdentityCredentialsValidator(IBackendApiClientProvider backendApiClientProvider) : ICredentialsValidator
+public class ElsaIdentityCredentialsValidator(IAnonymousBackendApiClientProvider backendApiClientProvider) : ICredentialsValidator
{
///
public async ValueTask ValidateCredentialsAsync(string username, string password, CancellationToken cancellationToken = default)
From 7806db9f54d92490917c215de3aceadc709d6458 Mon Sep 17 00:00:00 2001
From: Sipke Schoorstra
Date: Thu, 8 Jan 2026 21:03:00 +0100
Subject: [PATCH 18/27] Add token refresh mechanism for OpenID Connect and Elsa
Identity authentication modules. Introduce token refresh coordinators,
configuration providers, and support for silent token refresh. Update related
services and integrate advanced options for customization.
---
.../Contracts/ITokenRefreshCoordinator.cs | 13 ++
.../Extensions/ServiceCollectionExtensions.cs | 4 +
.../Services/TokenRefreshCoordinator.cs | 27 +++++
.../Services/BlazorServerJwtAccessor.cs | 9 ++
.../Services/BlazorWasmJwtAccessor.cs | 4 +-
.../Contracts/IJwtAccessor.cs | 5 +
.../Contracts/PkceData.cs | 3 -
.../Extensions/ServiceCollectionExtensions.cs | 3 +
.../ElsaIdentityRefreshTokenService.cs | 24 ++--
.../Services/JwtAuthenticationProvider.cs | 62 +++++++++-
.../Extensions/ServiceCollectionExtensions.cs | 6 +-
.../Models/OidcTokenRefreshOptions.cs | 34 ++++++
...DefaultOidcRefreshConfigurationProvider.cs | 47 ++++++++
.../IOidcRefreshConfigurationProvider.cs | 29 +++++
.../Services/ServerOidcTokenAccessor.cs | 113 +++++++++++++++++-
.../README.md | 84 ++++++++++---
16 files changed, 433 insertions(+), 34 deletions(-)
create mode 100644 src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/ITokenRefreshCoordinator.cs
create mode 100644 src/modules/Elsa.Studio.Authentication.Abstractions/Services/TokenRefreshCoordinator.cs
delete mode 100644 src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/PkceData.cs
create mode 100644 src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Models/OidcTokenRefreshOptions.cs
create mode 100644 src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/DefaultOidcRefreshConfigurationProvider.cs
create mode 100644 src/modules/Elsa.Studio.Authentication.OpenIdConnect.BlazorServer/Services/IOidcRefreshConfigurationProvider.cs
diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/ITokenRefreshCoordinator.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/ITokenRefreshCoordinator.cs
new file mode 100644
index 00000000..acee4f66
--- /dev/null
+++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Contracts/ITokenRefreshCoordinator.cs
@@ -0,0 +1,13 @@
+namespace Elsa.Studio.Authentication.Abstractions.Contracts;
+
+///
+/// Coordinates token refresh operations to prevent multiple concurrent refresh attempts.
+///
+public interface ITokenRefreshCoordinator
+{
+ ///
+ /// Ensures only one refresh operation runs at a time for the current scope.
+ ///
+ Task RunAsync(Func> action, CancellationToken cancellationToken);
+}
+
diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs
index 9fcbb031..3ed38939 100644
--- a/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs
+++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Extensions/ServiceCollectionExtensions.cs
@@ -3,6 +3,7 @@
using Elsa.Studio.Contracts;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using Elsa.Studio.Authentication.Abstractions.Contracts;
namespace Elsa.Studio.Authentication.Abstractions.Extensions;
@@ -22,6 +23,9 @@ public static IServiceCollection AddAuthenticationInfrastructure(this IServiceCo
// Used by modules (e.g. Workflows) to retrieve tokens without depending on a specific auth provider.
services.TryAddScoped();
+ // Coordinates refresh operations to prevent refresh storms under parallel requests.
+ services.TryAddScoped();
+
return services;
}
}
diff --git a/src/modules/Elsa.Studio.Authentication.Abstractions/Services/TokenRefreshCoordinator.cs b/src/modules/Elsa.Studio.Authentication.Abstractions/Services/TokenRefreshCoordinator.cs
new file mode 100644
index 00000000..53d70870
--- /dev/null
+++ b/src/modules/Elsa.Studio.Authentication.Abstractions/Services/TokenRefreshCoordinator.cs
@@ -0,0 +1,27 @@
+using Elsa.Studio.Authentication.Abstractions.Contracts;
+
+namespace Elsa.Studio.Authentication.Abstractions.Services;
+
+///
+/// Default implementation of .
+///
+public class TokenRefreshCoordinator : ITokenRefreshCoordinator
+{
+ private readonly SemaphoreSlim _semaphore = new(1, 1);
+
+ ///
+ public async Task RunAsync(Func> action, CancellationToken cancellationToken)
+ {
+ await _semaphore.WaitAsync(cancellationToken);
+
+ try
+ {
+ return await action(cancellationToken);
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+}
+
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Services/BlazorServerJwtAccessor.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Services/BlazorServerJwtAccessor.cs
index af1d0adf..197a681e 100644
--- a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Services/BlazorServerJwtAccessor.cs
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorServer/Services/BlazorServerJwtAccessor.cs
@@ -34,4 +34,13 @@ public BlazorServerJwtAccessor(IHttpContextAccessor httpContextAccessor, ILocalS
return await _localStorageService.GetItemAsync(name);
}
+
+ ///
+ public async ValueTask ClearTokenAsync(string name)
+ {
+ if (IsPrerendering())
+ return;
+
+ await _localStorageService.RemoveItemAsync(name);
+ }
}
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Services/BlazorWasmJwtAccessor.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Services/BlazorWasmJwtAccessor.cs
index 2af193a4..4b52ba62 100644
--- a/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Services/BlazorWasmJwtAccessor.cs
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth.BlazorWasm/Services/BlazorWasmJwtAccessor.cs
@@ -15,5 +15,7 @@ public class BlazorWasmJwtAccessor : IJwtAccessor
///
public async ValueTask WriteTokenAsync(string name, string token) => await _localStorageService.SetItemAsStringAsync(name, token);
-}
+ ///
+ public async ValueTask ClearTokenAsync(string name) => await _localStorageService.RemoveItemAsync(name);
+}
diff --git a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IJwtAccessor.cs b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IJwtAccessor.cs
index a02881db..fa95da1f 100644
--- a/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IJwtAccessor.cs
+++ b/src/modules/Elsa.Studio.Authentication.ElsaAuth/Contracts/IJwtAccessor.cs
@@ -14,4 +14,9 @@ public interface IJwtAccessor
/// Writes a token by name.
///