How to add Microsoft Identity to startup in v3? #4814
Replies: 3 comments 5 replies
-
I added a Stack Overflow question to hopefully spur a little more conversation around this since not having much luck here: |
Beta Was this translation helpful? Give feedback.
-
Related to #2681. @sfmskywalker has there been any update on supporting Azure Identity in Elsa v3? You mentioned last May a customer was working on an implementation so curious what that looks like if it's available? |
Beta Was this translation helpful? Give feedback.
-
Here is my approach. Using separate services with Elsa Web and Elsa Studio Server with .NET8. WASM not supported with these libraries and helpers. 1. Microsoft Entra SetupCreate separate app registrations for Studio and Backend. Create group for authenticated users, for example ElsaAdmins and add yourself initially into it. 1.1. Backend App Registration setupExpose API for FrontEnd by opening App Registration and under Manage -> Expose an API. Add Scope and name it for example ApiAccess. Let both Admins and users consent and fill rest of the fields freely. Set state as "Enabled" and click "Add scope". Define a role for Backend from App Roles under the Manage section, for example with value Elsa.Core.Admin. Set Allowed member types as Users/Groups. 1.2. Studio App Registration setupOpen "Api Permissions" from the Manage section and hit "Add a permission". From there search exposed from "APIs my organization uses" or "My APIs", whatever convenient. After selecting Backends app registration, set "Delegated permissions" active if not already and tick the created permission and finally, "Add permissions". Define a role for Backend from App Roles under the Manage section, for example with value Elsa.UI.Admin. Set Allowed member types as Users/Groups. From "Certificates and secrets" under the Manage section, generate new Client secret and store client secret for later use. From "Authentication" under the Manage section, add new platform configuration. Select "Web" and enter https://yourhost/signin-oidc as Redirect URI of the application and hit "Configure". From "Select the tokens you would like to be issued by the authorization endpoint:" tick both Access tokens and ID tokens. 1.3. Role mappingFrom the starting page of Microsoft Entra ID, open Enterprise Applications from the Manage section and do the same for both applications. Open the corresponding application and yet again from the Manage section open "Users and groups". Add previously created security group and assign it to corresponding Role, in my example Elsa.Core.Admin in Backend and Elsa.UI.Admin in Studio. This concludes the Azure and Entra. 2. Elsa Web Backend2.1. appsettings.jsonRemove all Elsa.Identity related invocations from Program.cs and references too. Configure appsettings.json with values from the Backends App Registration: {
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"ClientId": "your-backend-appregistration-clientid",
"TenantId": "your-backend-appregistration-tenantid"
}
} 2.2. PackagesReference package 2.3. ServicesAdd Authentication: services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(configuration); Implement public class ElsaPermissionTransformation : IClaimsTransformation
{
/// <inheritdoc />
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity is not ClaimsIdentity claimsIdentity)
return Task.FromResult(principal);
if (principal.IsInRole("Elsa.Core.Admin"))
claimsIdentity.AddClaim(new("permissions", "*"));
return Task.FromResult(principal);
}
} Later if you need to specify permissions in more detailed manner, you add more security groups in Entra and assign them to the corresponding roles and in this implementation you do some more iffing. services.AddTransient<IClaimsTransformation, ElsaPermissionTransformation>(); 3. Elsa StudioI'm not really frontend oriented so I hope at least this will provide you some good laughs :) Firstly, remove reference to Login module and all related invocations from Program.cs 3.1. appsettings.json{
"Backend": {
"Url": "https://yourhost/elsa/api"
},
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "your-frontend-appregistration-tenantid",
"ClientId": "your-frontend-appregistration-clientid",
"ClientSecret": "your-frontend-appregistration-clientsecret",
"CallbackPath": "/signin-oidc"
},
"ElsaBackend": {
"Scopes": ["api://application-id-uri-from-backend-appreg-api-exposing/ApiAccess"],
"BaseUrl": "api://application-id-uri-from-backend-appreg-api-exposing"
}
} 3.2. PackagesImport packages 3.3. Helper services3.3.1 AuthenticationStateProviderpublic class AdAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
private static readonly AuthenticationState EmptyAuthState = new(new());
public AdAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
/// <inheritdoc />
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user is null)
return EmptyAuthState;
return user.Identity?.IsAuthenticated ?? false
? new(user)
: EmptyAuthState;
}
/// <summary>
/// Notifies the authentication state has changed.
/// </summary>
public void NotifyAuthenticationStateChanged()
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
} 3.3.2. JWT HandlerMaybe should snip few minutes out from the expires on so last second expirations don't result in 401/403. Yes, tokens are cached also in ITokenCache behin ITokenAcquisition but could not really figure out how to use it in delegating handler and reasonably handle possible consents. public interface IBackendJwtHandler
{
ValueTask TrySaveTokenAsync();
bool TryGetToken(out string? token);
}
public class ElsaBackendJwtHandler : IBackendJwtHandler
{
private readonly ITokenAcquisition _tokenAcquisition;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IMemoryCache _memoryCache;
private readonly IConfiguration _configuration;
private const string ScopesKey = "ElsaBackend:Scopes";
public ElsaBackendJwtHandler(ITokenAcquisition tokenAcquisition,
IHttpContextAccessor httpContextAccessor,
IMemoryCache memoryCache,
IConfiguration configuration)
{
_tokenAcquisition = tokenAcquisition;
_httpContextAccessor = httpContextAccessor;
_memoryCache = memoryCache;
_configuration = configuration;
}
/// <inheritdoc />
public async ValueTask TrySaveTokenAsync()
{
if (_httpContextAccessor.HttpContext?.User is not {Identity.IsAuthenticated: true} user)
return;
var key = GetKey(user);
var scopes = _configuration.GetSection(ScopesKey).Get<string[]>()!;
var result = await _tokenAcquisition.GetAuthenticationResultForUserAsync(scopes);
_memoryCache.Set(key, result.AccessToken, result.ExpiresOn);
}
/// <inheritdoc />
public bool TryGetToken(out string? token)
{
token = null;
if (_httpContextAccessor.HttpContext?.User is not {Identity.IsAuthenticated: true} user)
return false;
var key = GetKey(user);
return _memoryCache.TryGetValue(key, out token);
}
private string GetKey(ClaimsPrincipal user) =>
$"{user.GetLoginHint()}-{user.GetObjectId()}-{user.GetHomeTenantId()}";
} 3.3.3. DelegatingHandler for Elsa Clientpublic class ElsaBackendAuthenticationHandler : DelegatingHandler
{
private readonly IBackendJwtHandler _backendJwtHandler;
public ElsaBackendAuthenticationHandler (IBackendJwtHandler backendJwtHandler)
{
_backendJwtHandler = backendJwtHandler;
}
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (!_backendJwtHandler.TryGetToken(out var token))
return await base.SendAsync(request, cancellationToken);
const string schema = "Bearer";
var sanitizedToken = token!.StartsWith(schema) ? token[(schema.Length + 1)..] : token;
request.Headers.Authorization = new(schema, sanitizedToken);
return await base.SendAsync(request, cancellationToken);
}
} 3.3.4 FeatureBy registering this executing assembly is included in Routers AdditionalAssemblies public class Feature : FeatureBase
{
} 3.4 Components3.4.1 RedirectToAzureAdLoginpublic class RedirectToAzureAdLogin : ComponentBase
{
/// <summary>
/// Gets or sets the <see cref="NavigationManager"/>.
/// </summary>
[Inject] protected NavigationManager NavigationManager { get; set; } = default!;
/// <inheritdoc />
protected override Task OnAfterRenderAsync(bool firstRender)
{
NavigationManager.NavigateTo("ad-login", true);
return Task.CompletedTask;
}
} 3.4.2 ExtendedAppThis new root element basically takes care of keeping up-to-date Access token for backend in cache and if consent is required, it will be handled. Breaks DownstreamApi pattern a bit, but basically same stuff under the hood. Had to ditch idea of using Blazored LocalStorage since getting exceptions on accessing jsInterop on Prerendered + OnInitializedAsync combination everywhere. public class ExtendedApp : App
{
/// <summary>
/// Gets or sets the <see cref="NavigationManager"/>.
/// </summary>
[Inject] protected NavigationManager NavigationManager { get; set; } = default!;
[Inject] protected MicrosoftIdentityConsentAndConditionalAccessHandler ConsentHandler { get; set; } = default!;
[Inject] protected IBackendJwtHandler BackendJwtHandler { get; set; } = default!;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
var httpContext = BlazorServiceAccessor.Services.GetRequiredService<IHttpContextAccessor>().HttpContext;
// Early exit if we are already redirected
if (NavigationManager.Uri.Contains("/MicrosoftIdentity/Account/Challenge"))
await base.OnInitializedAsync();
try
{
await BackendJwtHandler.TrySaveTokenAsync();
}
catch (Exception ex)
{
ConsentHandler.HandleException(ex);
}
await base.OnInitializedAsync();
}
} 3.4.3. IUnauthorizedComponentProvider implementationpublic class RedirectToAzureAdLoginComponentProvider : IUnauthorizedComponentProvider
{
/// <inheritdoc />
public RenderFragment GetUnauthorizedComponent()
{
return builder => builder.CreateComponent<RedirectToAzureAdLogin>();
}
} 3.5 Pages3.5.1 AdLogin.razor@page "/ad-login"
@using MudBlazor
@using Elsa.Studio.Components
@using Elsa.Studio.Layouts
@inherits StudioComponentBase
@layout BasicLayout
<div class="d-flex justify-end flex-grow-1">
<MudIconButton Icon="@Icons.Material.Outlined.Book" Color="Color.Inherit" Link="https://v3.elsaworkflows.io/" Target="_blank" Title="Documentation"/>
<MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Inherit" Link="https://github.com/elsa-workflows/elsa-core" Target="_blank" Title="Source code"/>
</div>
<MudContainer MaxWidth="MaxWidth.Small">
<MudStack Spacing="10">
<MudPaper Elevation="1">
<MudGrid Spacing="0" Justify="Justify.Center">
<MudItem md="9" xs="7" Class="pa-4 mx-auto my-4">
<MudStack Spacing="1">
<MudText Typo="Typo.h5">Login</MudText>
<MudButton OnClick="AdLoginAsync">AD Login</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
</MudStack>
</MudContainer> 3.5.2. AdLogin.razor.cs[AllowAnonymous]
public partial class AdLogin
{
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
[Inject] private AuthenticationStateProvider AuthenticationStateProvider { get; set; } = default!;
private async Task AdLoginAsync()
{
var returnUri = NavigationManager.ToBaseRelativePath(NavigationManager.BaseUri) + "/post-login";
NavigationManager.NavigateTo($"/MicrosoftIdentity/Account/SignIn?redirectUri={returnUri}", true);
}
} 3.5.3. PostLogin.razorEmpty page after login to enforce AuthenticationStateChange @page "/post-login"
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider;
<h3>PostLogin</h3>
@code {
/// <inheritdoc />
protected override Task OnInitializedAsync()
{
((AdAuthenticationStateProvider)AuthenticationStateProvider).NotifyAuthenticationStateChanged();
NavigationManager.NavigateTo("", true);
return base.OnInitializedAsync();
}
} 3.6. Code configuration3.6.1. Add our custom stuff services
.AddAuthenticationCore()
.AddOptions()
.AddHttpContextAccessor()
.AddScoped<ElsaBackendAuthenticationHandler>()
.AddScoped<AuthenticationStateProvider, AdAuthenticationStateProvider>()
.AddScoped<IUnauthorizedComponentProvider, RedirectToAzureAdLoginComponentProvider>()
.AddSingleton<IBackendJwtHandler, ElsaBackendJwtHandler>()
.AddMemoryCache()
; 3.6.2. Add Azure AD services
.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(configuration)
.EnableTokenAcquisitionToCallDownstreamApi()
.AddDownstreamApi("ElsaBackEnd", configuration.GetSection("ElsaBackEnd"))
.AddInMemoryTokenCaches();
services.AddAuthorization(options => options.FallbackPolicy = options.DefaultPolicy); 3.6.3. Add MS Identity UI and Consent Handler services
.AddRazorPages()
.AddMicrosoftIdentityUI();
services
.AddRazorComponents()
.AddInteractiveServerComponents(
circuitOptions => circuitOptions.RootComponents.RegisterCustomElsaStudioElements())
.AddMicrosoftIdentityConsentHandler(); 3.6.4. Assign correct delegating handler services.AddRemoteBackend(
elsaClient => elsaClient.AuthenticationHandler = typeof(ElsaBackendAuthenticationHandler),
options => configuration.GetSection("Backend").Bind(options)); 3.6.5. Add Featureservices.AddScoped<IFeature, Feature>(); 3.6.6. MapControllersThis on is crucial for Identity controllers to be available app.MapControllers(); 3.6.7. Modify _Host.cshtmlChange Phew. I hope I succeeded to include everything. Feedback is highly appreciated and improvements also. Thank you for the amazing Workflow engine! |
Beta Was this translation helpful? Give feedback.
-
In v2 we did something like this:
But curious how this would be configured or done in the v3 code below? The
UseAdminUserProvider()
call would obviously go away but wondering what options I need to setup for it to use Azure AD based authentication like we did in v2 above? ☝️Beta Was this translation helpful? Give feedback.
All reactions