Blazor WASM - Multiple Authentication Schemes (Azure AD and B2C) - azure-active-directory

I am trying to implement multiple authentication schemes in Blazor WASM. I want my users to be able to login using either Azure AD or Azure B2C and I don't want to use Custom User Flows in Azure B2C as I have found that to be very complex and error-prone to configure. I would like to have 2 x Login buttons ie. Login AD and Login B2C.
Each button on its own is simple to implement using MSAL, but I am struggling to get both working. In Microsoft.Web.Identity, we have the option of defining multiple Authentication Schemes. However, I don't see anything like that in WASM / MSAL.
I have been working on the following concept adjusting the authentication urls for each scheme.
LoginDisplay.razor
#using Microsoft.AspNetCore.Components.Authorization
#using Microsoft.AspNetCore.Components.WebAssembly.Authentication
#inject NavigationManager Navigation
<AuthorizeView>
<Authorized>
Hello, #context.User.Identity?.Name!
<button class="nav-link btn btn-link" #onclick="BeginLogOut">Log out</button>
</Authorized>
<NotAuthorized>
Log in AD
Log in B2C
</NotAuthorized>
</AuthorizeView>
#code{
public void BeginLogOut()
{
Navigation.NavigateToLogout("authenticationAD/logout");
}
}
AuthenticationAD.razor
#page "/authenticationAD/{action}" /*NOTE Adjusted url*/
#using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="#Action" >
</RemoteAuthenticatorView>
#code{
[Parameter] public string? Action { get; set; }
}
AuthenticationB2C.razor
#page "/authenticationB2C/{action}" /*NOTE Adjusted url*/
#using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="#Action" >
</RemoteAuthenticatorView>
#code{
[Parameter] public string? Action { get; set; }
}
Program.cs
var builder = WebAssemblyHostBuilder.CreateDefault(args);
............
builder.Services.AddMsalAuthentication(options =>
{
builder.Configuration.Bind("AzureB2C", options.ProviderOptions.Authentication);
options.ProviderOptions.Authentication.PostLogoutRedirectUri = "authenticationB2C/logout-callback";
options.ProviderOptions.Authentication.RedirectUri = "authenticationB2C/login-callback";
var webApiScopes = builder.Configuration["AzureB2C:WebApiScopes"];
var webApiScopesArr = webApiScopes.Split(" ");
foreach (var scope in webApiScopesArr)
{
options.ProviderOptions.DefaultAccessTokenScopes.Add(scope);
}
var appPaths = options.AuthenticationPaths;
appPaths.LogInCallbackPath = "authenticationB2C/login-callback";
appPaths.LogInFailedPath = "authenticationB2C/login-failed";
appPaths.LogInPath = "authenticationB2C/login";
appPaths.LogOutCallbackPath = "authenticationB2C/logout-callback";
appPaths.LogOutFailedPath = "authenticationB2C/logout-failed";
appPaths.LogOutPath = "authenticationB2C/logout";
appPaths.LogOutSucceededPath = "authenticationB2C/logged-out";
appPaths.ProfilePath = "authenticationB2C/profile";
appPaths.RegisterPath = "authenticationB2C/register";
});
builder.Services.AddOidcAuthentication(options => //THIS CODE DOES NOT RUN
{
builder.Configuration.Bind("AzureAd", options.ProviderOptions);
options.ProviderOptions.PostLogoutRedirectUri = "authenticationAD/logout-callback";
options.ProviderOptions.RedirectUri = "authenticationAD/login-callback";
options.ProviderOptions.ResponseType = "code";
var webApiScopes = builder.Configuration["AzureAd:WebApiScopes"];
var webApiScopesArr = webApiScopes.Split(" ");
foreach (var scope in webApiScopesArr)
{
options.ProviderOptions.DefaultScopes.Add(scope);
}
var appPaths = options.AuthenticationPaths;
appPaths.LogInCallbackPath = "authenticationAD/login-callback";
appPaths.LogInFailedPath = "authenticationAD/login-failed";
appPaths.LogInPath = "authenticationAD/login";
appPaths.LogOutCallbackPath = "authenticationAD/logout-callback";
appPaths.LogOutFailedPath = "authenticationAD/logout-failed";
appPaths.LogOutPath = "authenticationAD/logout";
appPaths.LogOutSucceededPath = "authenticationAD/logged-out";
appPaths.ProfilePath = "authenticationAD/profile";
appPaths.RegisterPath = "authenticationAD/register";
});
await builder.Build().RunAsync();
This works as far as pressing the Login Button routes me to the correct authenticationXX.razor view.
The issue that I'm facing is that the second .AddXXXAuthentication does not run, so the OAuth settings for the second statement are not set. If I change the order, it is always the second statement that doesn't run. Authentication works fine based upon the first statement.
I tried using 2 off .AddMSALAuthentication statements and in that case, both statements did run. However, the ProviderOptions from appsettings.json were just over-written in the second statement. ie. it didn't create two instances of the MSAL Authentication scheme.
I know that I can hand-craft the url strings for each of the oauth steps using tags in the < RemoteAuthenticationView > component, but I was hoping to find a way to use native libraries where-ever possible to reduce the risk of introducing a security weakness in my application.
Has anyone else had experience with this scenario and can point me to some documentation / an example of how it can be done?

Related

Blazor Wasm setting end user defined startpage

In my application I have many areas for different kinds of users. Controlled by roles.
So I would like to give the user an option to set a preferred startpage.
Deep Linking works, so no problems there.
My first attempt was this
#code{
[Parameter] public string Action { get; set; }
[Inject] private NavigationManager Navigation { get; set; }
private void LoginSucceeded(RemoteAuthenticationState state)
{
Console.WriteLine("navigate to DepartmentAccess");
// This works with on extra login
Navigation.NavigateTo("/DepartmentAccess", true);
// This loads 3 or more times
// state.ReturnUrl = "/DepartmentAccess";
}
}
Then I tried altering the return url in RedirectToLogin.razor. Here I added “MyStartPage”
#inject NavigationManager Navigation
#using Microsoft.AspNetCore.Components.WebAssembly.Authentication
#using Zeus.Client.PortfolioRights
#code {
protected override void OnInitialized()
{
Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)/MyStartPage}");
}
}
This has absolutely no effekt!
Okay time to dig a little deeper
It’s the RemoteAuthenticatorViewCore that holds all the code related to the login process.
This function handles the Login state. It’s called after the redirect from Azure AD
private async Task ProcessLogIn(string returnUrl)
{
AuthenticationState.ReturnUrl = returnUrl;
var result = await AuthenticationService.SignInAsync(new RemoteAuthenticationContext<TAuthenticationState>
{
State = AuthenticationState
});
switch (result.Status)
{
case RemoteAuthenticationStatus.Redirect:
break;
case RemoteAuthenticationStatus.Success:
await OnLogInSucceeded.InvokeAsync(result.State);
await NavigateToReturnUrl(GetReturnUrl(result.State, returnUrl));
break;
case RemoteAuthenticationStatus.Failure:
_message = result.ErrorMessage;
Navigation.NavigateTo(ApplicationPaths.LogInFailedPath);
break;
case RemoteAuthenticationStatus.OperationCompleted:
default:
throw new InvalidOperationException($"Invalid authentication result status '{result.Status}'.");
}
}
The input parameter “returnUri” are set from this function
private string GetReturnUrl(TAuthenticationState state, string defaultReturnUrl = null)
{
if (state?.ReturnUrl != null)
{
return state.ReturnUrl;
}
var fromQuery = QueryStringHelper.GetParameter(new Uri(Navigation.Uri).Query, "returnUrl");
if (!string.IsNullOrWhiteSpace(fromQuery) && !fromQuery.StartsWith(Navigation.BaseUri))
{
// This is an extra check to prevent open redirects.
throw new InvalidOperationException("Invalid return url. The return url needs to have the same origin as the current page.");
}
return fromQuery ?? defaultReturnUrl ?? Navigation.BaseUri;
}
So I wonder. Why is the “defaultReturnUrl” not set when I alter the return uri?
Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)/MyStartPage}
I guess that I don't understand the login flow. But got a feeling that I am close.
Just need to find a way to set the defaultReturnUrl
Please check if below points can be worked around.
The first step in getting a page (component) ready to participate in routing is to assign the component a route. You do that in the cshmtl file, using the page directive.
#page "/redirectpage" above #inject NavigationManager NavManager
Make sure to Add CascadingAuthenticationState and <AuthorizeView> in apps.razor and LoginDisplay component.It is responsible to display pages that the user is authorised to see.
(or)
If the page component for the route contains an authorize attribute (#attribute [Authorize]) then the user must be logged in, otherwise they will be redirected to the login page.
Example: if you wanted to secure the Counter.razor page just add an Authorize attribute to the top:
#using Microsoft.AspNetCore.Authorization
#attribute [Authorize]
Or
#attribute [Authorize(Roles = "finance")]
Add this to the pages that require authentication.
References:
Secure an ASP.NET Core Blazor WebAssembly standalone app with the
Authentication library | Microsoft Docs
Using Azure Active Directory to Secure Blazor WebAssembly Hosted
Apps (code-maze.com)
Secure an ASP.NET Core Blazor WebAssembly standalone app with Azure
Active Directory | Microsoft Docs

.NET Core 3.1 web application with React - how to prevent access based on Active Directory group

I have a .NET Core 3.1 web application with React using windows authentication.
When a user enters their Active Directory credentials i would like to verify they belong to a particular Active Directory group before allowing access to the React app.
I have tried setting the default endpoint to a Login Controller to verify the user's groups but i don't know how to redirect to the React app if they do have the valid group.
Startup.cs:
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller}/{action=Index}/{id?}",
defaults: new { Controller = "Login", action = "Index" });
});
LoginController:
public IActionResult Index()
{
if (HttpContext.User.Identity.IsAuthenticated)
{
string[] domainAndUserName = HttpContext.User.Identity.Name.Split('\\');
//AuthenticateUser verifies if the user is in the correct Active Directory group
if (AuthenticateUser(domainAndUserName[0], domainAndUserName[1]))
{
//This is where i would like to redirect to the React app
return Ok(); //This does not go to the react app
return LocalRedirect("http://localhost:50296/"); //This will keep coming back to this method
}
return BadRequest();
}
}
Is it possible to redirect to the React app from the controller?
Is there a better way to verify an active directory group, possibly through authorizationService.js?
I've been in this situation before, and solved it with custom implementation of IClaimsTransformation. This approach may also be used with OpenId Connect and other authentication systems that requires additional authorization.
With this approach, you can use authorize attribute on controller that serves your React app
[Authorize(Roles = "HasAccessToThisApp")]
and
User.IsInRole("HasAccessToThisApp")
elsewhere in code.
Implementation. Please note that TransformAsync will be called on every request, some caching is recommended if any time-consuming calls.
public class YourClaimsTransformer : IClaimsTransformation
{
private readonly IMemoryCache _cache;
public YourClaimsTransformer(IMemoryCache cache)
{
_cache = cache;
}
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal incomingPrincipal)
{
if (!incomingPrincipal.Identity.IsAuthenticated)
{
return Task.FromResult(incomingPrincipal);
}
var principal = new ClaimsPrincipal();
if (!string.IsNullOrEmpty(incomingPrincipal.Identity.Name)
&& _cache.TryGetValue(incomingPrincipal.Identity.Name, out ClaimsIdentity claimsIdentity))
{
principal.AddIdentity(claimsIdentity);
return Task.FromResult(principal);
}
// verifies that the user is in the correct Active Directory group
var domainAndUserName = incomingPrincipal.Identity.Name?.Split('\\');
if (!(domainAndUserName?.Length > 1 && AuthenticateUser(domainAndUserName[0], domainAndUserName[1])))
{
return Task.FromResult(incomingPrincipal);
}
var newClaimsIdentity = new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.Role, "HasAccessToThisApp", ClaimValueTypes.String)
// copy other claims from incoming if required
}, "Windows");
_cache.Set(incomingPrincipal.Identity.Name, newClaimsIdentity,
DateTime.Now.AddHours(1));
principal.AddIdentity(newClaimsIdentity);
return Task.FromResult(principal);
}
}
In Startup#ConfigureServices
services.AddSingleton<IClaimsTransformation, YourClaimsTransformer>();

Add tenant claim to access token using IdentityServer 4 based on acr value

In my scenario a user can be linked to different tenants. A user should login in the context of a tenant. That means i would like the access token to contain a tenant claim type to restrict access to data of that tenant.
When the client application tries to login i specify an acr value to indicate for which tenant to login.
OnRedirectToIdentityProvider = redirectContext => {
if (redirectContext.ProtocolMessage.RequestType == OpenIdConnectRequestType.Authentication) {
redirectContext.ProtocolMessage.AcrValues = "tenant:" + tenantId; // the acr value tenant:{value} is treated special by id4 and is made available in IIdentityServerInteractionService
}
return Task.CompletedTask;
}
The value is received by my identity provider solution and is as well available in the IIdentityServerInteractionService.
The question is now, where can i add a claim to the access token for the requested tenant?
IProfileService
In a IProfileService implementation the only point where acr values would be available is in the IsActiveAsync method when context.Caller == AuthorizeEndpoint in the HttpContext via IHttpContextAccessor.
String acr_values = _context.HttpContext.Request.Query["acr_values"].ToString();
But in IsActiveAsync i can not issue claims.
In the GetProfileDataAsync calls the acr values are not available in the ProfileDataRequestContext nor in the HttpContext. Here i wanted to access acr values when
context.Caller = IdentityServerConstants.ProfileDataCallers.ClaimsProviderAccessToken. If i would have access i could issue the tenant claim.
Further i analyzed CustomTokenRequestValidator, IClaimsService and ITokenService without success. It seems like the root problem is, that the token endpoint does not receive/process acr values. (event though here acr is mentioned)
I have a hard time figure this one out. Any help appreciated. Is it maybe completely wrong what i am trying? After figuring this one out i will have as well to understand how this affects access token refresh.
Since you want the user to login for each tenant (bypassing sso) makes this solution possible.
When logging in, you can add a claim to the local user (IdentityServer) where you store the tenant name:
public async Task<IActionResult> Login(LoginViewModel model, string button)
{
// take returnUrl from the query
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context?.ClientId != null)
{
// acr value Tenant
if (context.Tenant == null)
await HttpContext.SignInAsync(user.Id, user.UserName);
else
await HttpContext.SignInAsync(user.Id, user.UserName, new Claim("tenant", context.Tenant));
When the ProfileService is called you can use the claim and pass it to the access token:
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
// Only add the claim to the access token
if (context.Caller == "ClaimsProviderAccessToken")
{
var tenant = context.Subject.FindFirstValue("tenant");
if (tenant != null)
claims.Add(new Claim("tenant", tenant));
}
The claim is now available in the client.
Problem is, that with single sign-on the local user is assigned to the last used tenant. So you need to make sure the user has to login again, ignoring and overwriting the cookie on IdentityServer.
This is the responsibility from the client, so you can set prompt=login to force a login. But originating from the client you may want to make this the responsibility of the server. In that case you may need to override the interaction response generator.
However, it would make sense to do something like this when you want to add tenant specific claims. But it seems you are only interested in making a distinction between tenants.
In that case I wouldn't use above implementation but move from perspective. I think there's an easier solution where you can keep the ability of SSO.
What if the tenant identifies itself at the resource? IdentityServer is a token provider, so why not create a custom token that contains the information of the tenant. Use extension grants to create an access token that combines tenant and user and restricts access to that combination only.
To provide some code for others who want to use the extension grant validator as one suggested option by the accepted answer.
Take care, the code is quick and dirty and must be properly reviewed.
Here is a similar stackoverflow answer with extension grant validator.
IExtensionGrantValidator
using IdentityServer4.Models;
using IdentityServer4.Validation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace IdentityService.Logic {
public class TenantExtensionGrantValidator : IExtensionGrantValidator {
public string GrantType => "Tenant";
private readonly ITokenValidator _validator;
private readonly MyUserManager _userManager;
public TenantExtensionGrantValidator(ITokenValidator validator, MyUserManager userManager) {
_validator = validator;
_userManager = userManager;
}
public async Task ValidateAsync(ExtensionGrantValidationContext context) {
String userToken = context.Request.Raw.Get("AccessToken");
String tenantIdRequested = context.Request.Raw.Get("TenantIdRequested");
if (String.IsNullOrEmpty(userToken)) {
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
var result = await _validator.ValidateAccessTokenAsync(userToken).ConfigureAwait(false);
if (result.IsError) {
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
return;
}
if (Guid.TryParse(tenantIdRequested, out Guid tenantId)) {
var sub = result.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
var claims = result.Claims.ToList();
claims.RemoveAll(x => x.Type == "tenantid");
IEnumerable<Guid> tenantIdsAvailable = await _userManager.GetTenantIds(Guid.Parse(sub)).ConfigureAwait(false);
if (tenantIdsAvailable.Contains(tenantId)) {
claims.Add(new Claim("tenantid", tenantId.ToString()));
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
context.Result = new GrantValidationResult(principal);
return;
}
}
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
}
}
}
Client config
new Client {
ClientId = "tenant.client",
ClientSecrets = { new Secret("xxx".Sha256()) },
AllowedGrantTypes = new [] { "Tenant" },
RequireConsent = false,
RequirePkce = true,
AccessTokenType = AccessTokenType.Jwt,
AllowOfflineAccess = true,
AllowedScopes = new List<String> {
IdentityServerConstants.StandardScopes.OpenId,
},
},
Token exchange in client
I made a razor page which receives as url parameter the requested tenant id, because my test app is a blazor server side app and i had problems to do a sign in with the new token (via _userStore.StoreTokenAsync). Note that i am using IdentityModel.AspNetCore to manage token refresh. Thats why i am using the IUserTokenStore. Otherwise you would have to do httpcontext.signinasync as Here.
public class TenantSpecificAccessTokenModel : PageModel {
private readonly IUserTokenStore _userTokenStore;
public TenantSpecificAccessTokenModel(IUserTokenStore userTokenStore) {
_userTokenStore = userTokenStore;
}
public async Task OnGetAsync() {
Guid tenantId = Guid.Parse(HttpContext.Request.Query["tenantid"]);
await DoSignInForTenant(tenantId);
}
public async Task DoSignInForTenant(Guid tenantId) {
HttpClient client = new HttpClient();
Dictionary<String, String> parameters = new Dictionary<string, string>();
parameters.Add("AccessToken", await HttpContext.GetUserAccessTokenAsync());
parameters.Add("TenantIdRequested", tenantId.ToString());
TokenRequest tokenRequest = new TokenRequest() {
Address = IdentityProviderConfiguration.Authority + "connect/token",
ClientId = "tenant.client",
ClientSecret = "xxx",
GrantType = "Tenant",
Parameters = parameters
};
TokenResponse tokenResponse = await client.RequestTokenAsync(tokenRequest).ConfigureAwait(false);
if (!tokenResponse.IsError) {
await _userTokenStore.StoreTokenAsync(HttpContext.User, tokenResponse.AccessToken, tokenResponse.ExpiresIn, tokenResponse.RefreshToken);
Response.Redirect(Url.Content("~/").ToString());
}
}
}

Cannot sign in with different account or "Use another account"

I'm trying to integrate Microsoft sso with a Xamarin.Forms app.
I'm using Microsoft.Identity.Client 4.7.1
I struggling to sign in with different accounts on the same device since it seems that the first account is always picked no matter what I do.
User A signs in
User A signs out
User B enters the app opens the webview with the Microsoft login page and prompts the "Use another account" button but even after typing his account, the webview redirects it to back to the mobile app as user A.
Here's the code that handles sign-in and sing-out:
private IPublicClientApplication _publicClientApplication;
public AuthService()
{
_publicClientApplication = PublicClientApplicationBuilder.Create(Constants.MicrosoftAuthConstants.ClientId.Value)
.WithAdfsAuthority(Constants.MicrosoftAuthConstants.Authority.Value)
.WithRedirectUri(Constants.MicrosoftAuthConstants.RedirectUri.Value)
.Build();
}
public async Task<string> SignInAsync()
{
var authScopes = Constants.MicrosoftAuthConstants.Scopes.Value;
AuthenticationResult authResult;
try
{
// call to _publicClientApplication.AcquireTokenSilent
authResult = await GetAuthResultSilentlyAsync();
}
catch (MsalUiRequiredException)
{
authResult = await _publicClientApplication.AcquireTokenInteractive(authScopes)
.WithParentActivityOrWindow(App.ParentWindow)
.ExecuteAsync();
}
return authResult.AccessToken;
}
private async Task<IAccount> GetCachedAccountAsync() => (await _publicClientApplication.GetAccountsAsync()).FirstOrDefault();
public async Task SignOutAsync()
{
var firstCachedAccount = await GetCachedAccountAsync();
await _publicClientApplication.RemoveAsync(firstCachedAccount);
}
A workaround is to use Prompt.ForceLogin but what's the point of sso if you have to type the credentials every time.
The line of code await _publicClientApplication.RemoveAsync(firstCachedAccount); can jsut remove the user from the cache, it doesn't implement a signout method. So you need to do logout manually by the api below:
https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri=https://localhost/myapp/

How to enable front-channel or back-channel logout in identityserver4

I'm looking at how to disconnect the user currently logged on the mvc client (e.g. http://localhost:5001), when that user performs logout on identity server's deployment (e.g. http://localhost:5000)
I understand there's an implementation of OAuth2 in identityserver4 that does just that (https://openid.net/specs/openid-connect-backchannel-1_0.html and https://openid.net/specs/openid-connect-frontchannel-1_0.html)
Luckily for me, Brock Allen just pushed a change in the samples less than a day ago: https://github.com/IdentityServer/IdentityServer4.Samples/issues/197
However the sample is either incomplete at this point, or I'm missing something.
on my server, I'm setting the value of FrontChannelLogoutUrl to http://localhost:5001/frontchannello, and I added that piece of code to my mvc client (basically stolen from the sample):
[HttpGet("frontChannello")]
public IActionResult FrontChannelLogout(string sid)
{
if (User.Identity.IsAuthenticated)
{
var currentSid = User.FindFirst("sid")?.Value ?? "";
if (string.Equals(currentSid, sid, StringComparison.Ordinal))
{
//await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return new SignOutResult(new[] { "Cookies", "oidc" });
}
}
return NoContent();
}
That code never gets called.
So my question is: should I use backchannel or frontchannel; and, how to implement it
The Identity server 4 documentation describes well how front-channel logout should be implemented. Look for the Quickstart 8_AspnetIdentity as it provides most of the code required for the implementation.
Some highlights of the code required in the identity server :
In the AccountController.cs, the Logout function builds a LoggedOutViewModel and returns a LoggedOut view.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout(LogoutInputModel model)
{
// build a model so the logged out page knows what to display
var vm = await BuildLoggedOutViewModelAsync(model.LogoutId);
...
return View("LoggedOut", vm);
}
The SignOutIframeUrl iframe is served in the LoggedOut.cshtml.
#model LoggedOutViewModel
<div class="page-header logged-out">
<small>You are now logged out</small>
...
#if (Model.SignOutIframeUrl != null)
{
<iframe width="0" height="0" class="signout" src="#Model.SignOutIframeUrl"></iframe>
}
</div>
What remains to be done is defining the FrontChannelLogoutUri for your each of your clients. That's normally done in the identity server's config.cs
public static IEnumerable<Client> GetClients()
{
return new List<Client>
{
// resource owner password grant client
new Client
{
ClientId = "js",
ClientName = "JavaScript Client",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false,
RedirectUris = { "http://localhost:5003/callback.html" },
PostLogoutRedirectUris = { "http://localhost:5003/index.html" },
FrontChannelLogoutUri = "http://localhost:5003/frontChannello"
Ok pretty simple. In your Logout action on the account controller (in idserver), make sure you display the LoggedOut view, which in turn shows the iFrame that calls the callback on the mvc client. Pretty much what the spec are saying.

Resources