I am using Microsoft Blazor with Hosted WebAssembly model for some applications. This means that the solution has three projects, the client, the server and the shared.
I have already build one application with basic login providing username and password, which are validated into database and the user is capable to login. For this approach I use a CustomAuthStateProvider.
Under Client, in Program.cs
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
The CustomAuthStateProvider.cs
public class CustomAuthStateProvider : AuthenticationStateProvider
{
private readonly HttpClient _httpClient;
private readonly IJSRuntime _jSRuntime;
private readonly ILocalStorageService _iLocalStorage;
public CustomAuthStateProvider(HttpClient httpClient, IJSRuntime jSRuntime, ILocalStorageService iLocalStorage)
{
_httpClient = httpClient;
_jSRuntime = jSRuntime;
_iLocalStorage = iLocalStorage;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
//CREATE AN EMPTY STATE
var state = new AuthenticationState(new ClaimsPrincipal());
try
{
AuthenticationData? authenticationData = null;
if (authenticationData == null)
{
//TRY WITH SESSION & COOKIE DATA
HttpResponseMessage httpResponseMessage = await _httpClient.GetAsync("/api/auth/validate");
if (httpResponseMessage.IsSuccessStatusCode)
{
HttpContent content = httpResponseMessage.Content;
AuthenticationData? authenticationData_ = await content.ReadFromJsonAsync<AuthenticationData>();
authenticationData = CheckAuthenticationData(authenticationData_);
//HELPER MESSAGE
if (authenticationData != null)
{ Console.WriteLine("user_is_authorized"); }
else
{ Console.WriteLine("user_not_authorized"); }
}
}
if (authenticationData != null)
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Sid, authenticationData.Sid.ToString()),
new Claim(ClaimTypes.Name, authenticationData.Name.ToString()),
new Claim("preferred_username", authenticationData.Name.ToString()),
}, "Authentication");
Parameters.signuser = authenticationData.Name.ToString();
state = new AuthenticationState(new ClaimsPrincipal(identity));
}
else
{
//TRY WITH FORM DATA
state = new AuthenticationState(new ClaimsPrincipal());
}
}
catch (Exception e)
{
state = new AuthenticationState(new ClaimsPrincipal());
throw new Exception(e.Message);
}
finally
{
NotifyAuthenticationStateChanged(Task.FromResult(state));
}
return state;
}
private AuthenticationData? CheckAuthenticationData(AuthenticationData? authenticationData_)
{
AuthenticationData? authenticationData = null;
if (authenticationData_ != null)
{
if (
(!string.IsNullOrEmpty(authenticationData_.Sid)) &&
(!string.IsNullOrEmpty(authenticationData_.Name))
)
{ authenticationData = authenticationData_; }
}
return authenticationData;
}
}
And this works as expected with a Login and Logout razor components, but then I have created another application using Keycloack, for this approach I use AddOidcAuthentication:
Under Client, in Program.cs
var authHostUrl =
$"{authHost}/auth";
var authRealUrl =
$"{authHost}/auth/realms/{authReal}";
var authOpenIdUrl =
$"{authHost}/auth/realms/{authReal}/.well-known/openid-configuration";
var authTokenUrl =
$"{authHost}/auth/realms/{authReal}/protocol/openid-connect/token";
RegisterClient.RegisterHttpClient(builder, builder.Services);
builder.Services.AddOidcAuthentication(options =>
{
options.ProviderOptions.Authority = authRealUrl;
options.ProviderOptions.MetadataUrl = authOpenIdUrl;
options.ProviderOptions.ClientId = authClient;
options.ProviderOptions.ResponseType = "id_token token";
options.UserOptions.NameClaim = "preferred_username";
options.UserOptions.RoleClaim = "roles";
options.UserOptions.ScopeClaim = "scope";
});
Of course there are some additional things which need to be configured into the Client like some js files and razor pages like the following:
@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="@Action" />
@inject IJSRuntime JS
@inject NavigationManager navigationManager
@code {
[Parameter]
public string? Action { get; set; }
protected override async Task OnInitializedAsync()
{
//await JS.InvokeVoidAsync("alert", "Authentication!");
//navigationManager.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(navigationManager.Uri)}");
}
}
When trying to combine those two ways of authentication in one application, the application fails with strange errors like the following:
I figured out then when builder.Services.AddOidcAuthentication coexists with builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>() it is failing.
Of course when Keycloak way is used there will be no need for database validation as Keycloak is responsible for this validation in it's service.
My intention is to be able to use both ways for login in to the application, any idea?
============ UPDATE 2024-06-22 ============
I was experimenting with this hybrid authentication method using basic authentication from database and oidc using keycloak, and I found that I can create a composite authentication state provider like the following:
public CompositeAuthStateProvider(
DatabaseAuthStateProvider databaseAuthStateProvider,
KeycloakAuthStateProvider keycloakAuthStateProvider,
ILocalStorageService iLocalStorage
)
{
_databaseAuthStateProvider = databaseAuthStateProvider;
_keycloakAuthStateProvider = keycloakAuthStateProvider;
_iLocalStorage = iLocalStorage;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
//CREATE AN EMPTY STATE
var state = new AuthenticationState(new ClaimsPrincipal());
// Determine which provider to use
switch (await CustomAuthSwitch())
{
case "database":
state = await _databaseAuthStateProvider.GetAuthenticationStateAsync();
break;
case "keycloak":
state = await _keycloakAuthStateProvider.GetAuthenticationStateAsync();
break;
default:
state = await _databaseAuthStateProvider.GetAuthenticationStateAsync();
break;
}
NotifyAuthenticationStateChanged(Task.FromResult(state));
return state;
}
This code will select the proper provider based on the method CustomAuthSwitch which will pull the user option, but now the issue is that the application works perfectly with database method but using keycloak is not redirecting and throw this message:
Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
Unhandled exception rendering component: Specified cast is not valid.
System.InvalidCastException: Specified cast is not valid.
at Microsoft.Extensions.DependencyInjection.WebAssemblyAuthenticationServiceCollectionExtensions.<>c__1`1[[Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationState, Microsoft.AspNetCore.Components.WebAssembly.Authentication, Version=8.0.6.0, Culture=neutral, PublicKeyToken=adb9793829ddae60]].<AddAuthenticationStateProvider>b__1_0(IServiceProvider sp)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitFactory(FactoryCallSite factoryCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2[[Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext, Microsoft.Extensions.DependencyInjection, Version=8.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60],[System.Object, System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]].VisitCallSiteMain(ServiceCallSite callSite, RuntimeResolverContext argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite callSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2[[Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext, Microsoft.Extensions.DependencyInjection, Version=8.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60],[System.Object, System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]].VisitCallSite(ServiceCallSite callSite, RuntimeResolverContext argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.Resolve(ServiceCallSite callSite, ServiceProviderEngineScope scope)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeServiceProviderEngine.<>c__DisplayClass4_0.<RealizeService>b__0(ServiceProviderEngineScope scope)
at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(ServiceIdentifier serviceIdentifier, ServiceProviderEngineScope serviceProviderEngineScope)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
at Microsoft.AspNetCore.Components.ComponentFactory.<>c__DisplayClass9_0.<CreatePropertyInjector>g__Initialize|1(IServiceProvider serviceProvider, IComponent component)
at Microsoft.AspNetCore.Components.ComponentFactory.InstantiateComponent(IServiceProvider serviceProvider, Type componentType, IComponentRenderMode callerSpecifiedRenderMode, Nullable`1 parentComponentId)
at Microsoft.AspNetCore.Components.RenderTree.Renderer.InstantiateChildComponentOnFrame(RenderTreeFrame[] frames, Int32 frameIndex, Int32 parentComponentId)
at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InitializeNewComponentFrame(DiffContext& diffContext, Int32 frameIndex)
at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InitializeNewSubtree(DiffContext& diffContext, Int32 frameIndex)
at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InsertNewFrame(DiffContext& diffContext, Int32 newFrameIndex)
at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.AppendDiffEntriesForRange(DiffContext& diffContext, Int32 oldStartIndex, Int32 oldEndIndexExcl, Int32 newStartIndex, Int32 newEndIndexExcl)
at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.ComputeDiff(Renderer renderer, RenderBatchBuilder batchBuilder, Int32 componentId, ArrayRange`1 oldTree, ArrayRange`1 newTree)
at Microsoft.AspNetCore.Components.Rendering.ComponentState.RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment renderFragment, Exception& renderFragmentException)
at Microsoft.AspNetCore.Components.RenderTree.Renderer.RenderInExistingBatch(RenderQueueEntry renderQueueEntry)
at Microsoft.AspNetCore.Components.RenderTree.Renderer.ProcessRenderQueue()
The problem seems to be related with RemoteAuthenticatorView when using OIDC and Custom-AuthenticationStateProvider.
Any ideas?
Finally I found out that when registering a custom AuthenticationStateProvider should also register the RemoteAuthenticationService as well in the Client side.
builder.Services.AddScoped<IRemoteAuthenticationService<RemoteAuthenticationState>, RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>>();