Add Microsoft Entra sign-in to a Znode 9 storefront
Recipe 01 / Znode 9
Add Microsoft Entra sign-in to a Znode 9 storefront
Build a complete employee sign-in flow with Entra authorization code and PKCE, explicit Store-account mapping, native Znode session creation, and the exact MVC, OWIN, configuration, and login-view integration.
- IdentityEntra ID
- TrustTenant + object ID
- AccessProvisioned Znode user
- ExperienceNative Store session
The complete implementation
What you are building
An employee already has a provisioned Znode storefront account, but the business wants sign-in to follow its Microsoft Entra access policy. The integration must establish which employee signed in and then connect that identity to the right Store account, profile, and permissions.
This recipe implements both parts. Entra authenticates the employee. A typed, explicit account map selects an existing Znode user, and native Znode services finish the Store session. A successful Microsoft login alone does not grant Store access.
The implementation is for approved employees using a Znode 9 storefront at the root of an HTTPS hostname. It does not configure Znode Admin SSO, create users automatically, or migrate this Framework application to Znode 10. All account identifiers shown here are fictional.
Znode 9.7.4 / .NET Framework 4.8 / C# 6 / Katana 4.2.3
Before you start
- A working Znode 9.7.4 storefront and custom library targeting .NET Framework 4.8, with the native login, session, profile, and dependency registrations intact.
- Visual Studio with .NET Framework 4.8 targeting tools, the solution's configured Znode package feeds, and a reversible test deployment.
- Access to register an Entra application, configure enterprise-app assignment and consent, and provision its client secret to the IIS worker environment.
- A root-host HTTPS Store with a callback at /signin-gcg-entra, plus at least two ordinary test employee identities for allowed and denied scenarios.
- One existing, verified, unlocked Store user with an eligible profile for the allowed identity. The recipe preserves native Store authorization and does not add an Admin sign-in path.
- Filenames below are relative to the legacy solution's Projects directory. Use the current Store theme where the login view differs from the inspected B2B path.
Step 01
Register one confidential web application
Create a Microsoft Entra app registration for accounts in this organizational directory only. Add a Web platform redirect URI for the exact public HTTPS origin of the test Store, followed by /signin-gcg-entra. The fictional example is https://store.example.com/signin-gcg-entra. This is a server application using authorization code flow with PKCE, so use a Web registration rather than a SPA redirect.
Record the Directory (tenant) ID and Application (client) ID. Create a client secret and deliver its value through the protected deployment configuration for the IIS worker. Give each environment its own registration and credential. The browser and the source repository must never receive this secret.
In the corresponding enterprise application, set Properties > Assignment required? to Yes, grant the administrator consent required for assigned applications, and assign only the employee accounts approved for this Store. Single-tenant sign-in can also include guests invited into that tenant. Employee access therefore comes from your assignment policy and explicit account map, not from the tenant claim alone.
Step 02
Align the legacy authentication packages
Open the Znode solution in Visual Studio and use Package Manager Console. Run these commands for the WebStore host and custom library. They install the OpenID Connect middleware and align the existing Katana packages to the 4.2.3 baseline, including any existing social providers. Preserve the solution's other package versions.
The C# files target .NET Framework 4.8 and C# 6. The compiled reference set uses Znode 9.7.4 assemblies, Microsoft.AspNet.Mvc 5.2.9, Autofac 4.9.4, Katana 4.2.3, and System.IdentityModel.Tokens.Jwt 6.35.1. Keep MVC and Autofac on a compatible version already supported by your Znode solution; this recipe does not replace the native dependency container.
Review the resulting package and Web.config changes. Resolve assembly-binding conflicts in the deployed WebStore host, where redirects take effect. Do not rely on an app.config file in the custom class library. Rebuild and exercise the existing password sign-in and any enabled social provider after package alignment.
Visual Studio Package Manager Consolepowershell
$webStoreProjects = @("Znode.WebStore.Custom", "Znode.Engine.WebStore")
foreach ($projectName in $webStoreProjects) {
foreach ($packageId in @(
"Microsoft.Owin.Security.OpenIdConnect",
"Microsoft.Owin.Security.Cookies",
"Microsoft.Owin.Host.SystemWeb"
)) {
$installedPackage = Get-Package -ProjectName $projectName |
Where-Object { $_.Id -eq $packageId }
if ($installedPackage) {
Update-Package -Id $packageId -Version 4.2.3 -ProjectName $projectName
} else {
Install-Package -Id $packageId -Version 4.2.3 -ProjectName $projectName
}
}
$katanaPackageIds = Get-Package -ProjectName $projectName |
Where-Object { $_.Id -eq "Microsoft.Owin" -or $_.Id.StartsWith("Microsoft.Owin.") } |
Select-Object -ExpandProperty Id
foreach ($packageId in $katanaPackageIds) {
Update-Package -Id $packageId -Version 4.2.3 -ProjectName $projectName
}
$jwtPackage = Get-Package -ProjectName $projectName |
Where-Object { $_.Id -eq "System.IdentityModel.Tokens.Jwt" }
if ($jwtPackage) {
Update-Package -Id System.IdentityModel.Tokens.Jwt -Version 6.35.1 -ProjectName $projectName
} else {
Install-Package -Id System.IdentityModel.Tokens.Jwt -Version 6.35.1 -ProjectName $projectName
}
}
Add-BindingRedirect -ProjectName Znode.Engine.WebStore
Step 03
Load and validate named settings
Create an Entra directory in Libraries/Znode.WebStore.Custom and add this file. EntraSettings gives each setting a concrete type. Startup fails if the tenant or client identifier is missing, the callback is not the exact HTTPS path, or the worker cannot read its secret.
The implementation deliberately targets a Store hosted at the root of its public hostname. A virtual directory needs a separate callback, cookie-path, and route design. Complete the settings before enabling the new middleware, because a configuration error stops startup.
Libraries/Znode.WebStore.Custom/Entra/EntraSettings.cscsharp
using System;
using System.Configuration;
namespace Gcg.Recipes.Entra
{
public sealed class EntraSettings
{
public Guid TenantId { get; private set; }
public Guid ClientId { get; private set; }
public Uri RedirectUri { get; private set; }
public string ClientSecret { get; private set; }
public static EntraSettings Load()
{
Guid tenantId;
Guid clientId;
Uri redirectUri;
string secret = Environment.GetEnvironmentVariable("GCG_ENTRA_CLIENT_SECRET");
if (!Guid.TryParse(ConfigurationManager.AppSettings["Gcg.Entra.TenantId"], out tenantId) ||
tenantId == Guid.Empty ||
!Guid.TryParse(ConfigurationManager.AppSettings["Gcg.Entra.ClientId"], out clientId) ||
clientId == Guid.Empty ||
!Uri.TryCreate(ConfigurationManager.AppSettings["Gcg.Entra.RedirectUri"], UriKind.Absolute, out redirectUri) ||
redirectUri.Scheme != Uri.UriSchemeHttps ||
redirectUri.AbsolutePath != "/signin-gcg-entra" ||
!string.IsNullOrEmpty(redirectUri.Query) || !string.IsNullOrEmpty(redirectUri.Fragment) ||
!string.IsNullOrEmpty(redirectUri.UserInfo) || string.IsNullOrWhiteSpace(secret))
{
throw new ConfigurationErrorsException("Complete the GCG Entra settings before enabling sign-in.");
}
return new EntraSettings
{
TenantId = tenantId,
ClientId = clientId,
RedirectUri = redirectUri,
ClientSecret = secret
};
}
}
}
Step 04
Keep the Entra ticket separate from the Store session
Add this middleware registration beside EntraSettings. The first middleware creates a short-lived passive cookie for the validated external identity. Passive means it is read explicitly by the completion action and does not become the Store's normal authentication identity.
The OpenID Connect middleware sends the browser to Entra, redeems the returned authorization code on the server, and validates the signed ID token. PKCE binds the authorization request to its code exchange. The configured tenant and client ID constrain which issuer and audience are accepted.
The temporary cookie lasts five minutes, requires HTTPS, and is cleared when the completion action reads it. The middleware does not save tokens in the authentication ticket. SystemWebCookieManager coordinates cookie writes with the ASP.NET Framework host.
Libraries/Znode.WebStore.Custom/Entra/EntraAuthentication.cscsharp
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Owin;
using Microsoft.Owin.Host.SystemWeb;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OpenIdConnect;
using Owin;
namespace Gcg.Recipes.Entra
{
public static class EntraAuthentication
{
public const string Provider = "Gcg.Entra";
public const string TemporaryCookie = "Gcg.Entra.Temporary";
public static void Register(IAppBuilder app)
{
EntraSettings settings = EntraSettings.Load();
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = TemporaryCookie,
AuthenticationMode = AuthenticationMode.Passive,
CookieName = "__Host-GcgEntraTemporary",
CookiePath = "/",
CookieSecure = CookieSecureOption.Always,
CookieHttpOnly = true,
CookieSameSite = SameSiteMode.Lax,
ExpireTimeSpan = TimeSpan.FromMinutes(5),
SlidingExpiration = false,
CookieManager = new SystemWebCookieManager()
});
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions(Provider)
{
AuthenticationMode = AuthenticationMode.Passive,
Authority = "https://login.microsoftonline.com/" + settings.TenantId + "/v2.0",
ClientId = settings.ClientId.ToString(),
ClientSecret = settings.ClientSecret,
RedirectUri = settings.RedirectUri.AbsoluteUri,
CallbackPath = new PathString("/signin-gcg-entra"),
ResponseType = "code",
ResponseMode = "query",
Scope = "openid profile",
RedeemCode = true,
UsePkce = true,
SaveTokens = false,
RequireHttpsMetadata = true,
UseTokenLifetime = false,
SignInAsAuthenticationType = TemporaryCookie,
CookieManager = new SystemWebCookieManager(),
SecurityTokenValidator = new JwtSecurityTokenHandler { MapInboundClaims = false },
TokenValidationParameters = new TokenValidationParameters
{
AuthenticationType = TemporaryCookie,
ValidateIssuer = true,
ValidIssuer = "https://login.microsoftonline.com/" + settings.TenantId + "/v2.0",
ValidateAudience = true,
ValidAudience = settings.ClientId.ToString(),
ValidateLifetime = true,
RequireSignedTokens = true,
ClockSkew = TimeSpan.FromMinutes(1),
NameClaimType = "oid"
},
Notifications = new OpenIdConnectAuthenticationNotifications
{
RedirectToIdentityProvider = notification =>
{
notification.ProtocolMessage.Prompt = "select_account";
return Task.FromResult(0);
},
AuthenticationFailed = notification =>
{
notification.HandleResponse();
notification.Response.Redirect("/Entra/Failure");
return Task.FromResult(0);
}
}
});
}
}
}
Step 05
Resolve an identity to exactly one provisioned Store user
The map uses the Entra tenant ID, the user's object ID in that tenant, and the current Znode PortalId. PortalId is the Store identifier. The output is an EntraAccount with the exact Znode UserId and UserName to load.
The reader requires one tid claim, one oid claim, and exactly one matching XML entry. Missing or duplicate entries fail closed. It never links accounts by email address or domain, since those values can change or be reassigned. XML external-entity resolution is disabled and document size is limited.
An Entra user who is deleted and recreated receives a different object ID. An administrator must approve the new mapping. Do not automatically relink the new identity because its displayed email address matches an old one.
Libraries/Znode.WebStore.Custom/Entra/EntraAccountMap.cscsharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Xml;
using System.Xml.Serialization;
namespace Gcg.Recipes.Entra
{
[XmlRoot("EntraAccounts")]
public sealed class EntraAccounts
{
[XmlElement("Account")]
public List<EntraAccount> Accounts { get; set; }
}
public sealed class EntraAccount
{
[XmlAttribute] public Guid TenantId { get; set; }
[XmlAttribute] public Guid ObjectId { get; set; }
[XmlAttribute] public int PortalId { get; set; }
[XmlAttribute] public int UserId { get; set; }
[XmlAttribute] public string UserName { get; set; }
}
public static class EntraAccountMap
{
public static EntraAccount Resolve(ClaimsIdentity identity, Guid tenantId, int portalId, string path)
{
Guid tokenTenant;
Guid objectId;
if (identity == null || !identity.IsAuthenticated || portalId <= 0 ||
!Guid.TryParse(UniqueClaim(identity, "tid"), out tokenTenant) || tokenTenant != tenantId ||
!Guid.TryParse(UniqueClaim(identity, "oid"), out objectId) || objectId == Guid.Empty)
throw new UnauthorizedAccessException("The external identity is not eligible for this Store.");
EntraAccounts document;
using (var reader = XmlReader.Create(path, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
MaxCharactersInDocument = 131072
}))
{
document = (EntraAccounts)new XmlSerializer(typeof(EntraAccounts)).Deserialize(reader);
}
if (document == null || document.Accounts == null)
throw new InvalidDataException("The account map is empty.");
List<EntraAccount> matches = document.Accounts.Where(account =>
account.TenantId == tokenTenant && account.ObjectId == objectId && account.PortalId == portalId).ToList();
if (matches.Count != 1 || matches[0].UserId <= 0 || string.IsNullOrWhiteSpace(matches[0].UserName))
throw new UnauthorizedAccessException("Exactly one provisioned account mapping is required.");
return matches[0];
}
private static string UniqueClaim(ClaimsIdentity identity, string type)
{
List<Claim> claims = identity.FindAll(type).ToList();
return claims.Count == 1 ? claims[0].Value : null;
}
}
}
Step 06
Finish sign-in through Znode's existing account services
The POST SignIn action requires an antiforgery token and starts only the passive Entra provider. Complete reads and clears the temporary ticket, then asks Znode's IUserClient for the mapped UserId with Profiles expanded and the current PortalId supplied.
The returned account must still match the mapped ID, username, and Store. It must be explicitly unlocked and verified, must not be a guest or Admin user, and must have a profile. The controller uses the current Znode account state each time a new Entra sign-in completes.
After these checks, SetLoginUserProfile chooses the account's current profile. The controller stores a typed UserViewModel under the native session key, clears the cached cart count, and calls the existing IAuthenticationHelper to issue a nonpersistent Store cookie. The return URL is accepted only when MVC identifies it as local.
The existing guest session is cleared. This employee sign-in flow does not merge an anonymous cart. Existing account, pricing, catalog, and permission behavior continues to come from Znode's user and profile configuration.
Libraries/Znode.WebStore.Custom/Entra/EntraController.cscsharp
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Web;
using System.Web.Mvc;
using Microsoft.Owin.Security;
using Znode.Engine.Api.Client;
using Znode.Engine.Api.Client.Expands;
using Znode.Engine.Api.Models;
using Znode.Engine.WebStore;
using Znode.Engine.WebStore.Agents;
using Znode.Engine.WebStore.ViewModels;
using Znode.Libraries.ECommerce.Utilities;
using Znode.WebStore.Core.Extensions;
namespace Gcg.Recipes.Entra
{
[AllowAnonymous]
public sealed class EntraController : Controller
{
private readonly IUserClient users;
private readonly IUserAgent userAgent;
private readonly ICartAgent cart;
private readonly IAuthenticationHelper authentication;
public EntraController(IUserClient users, IUserAgent userAgent,
ICartAgent cart, IAuthenticationHelper authentication)
{
this.users = users;
this.userAgent = userAgent;
this.cart = cart;
this.authentication = authentication;
}
[HttpPost, ValidateAntiForgeryToken]
public ActionResult SignIn(string returnUrl)
{
if (Request.IsAuthenticated)
return RedirectToAction("Dashboard", "User");
string destination = Url.IsLocalUrl(returnUrl) ? returnUrl : "/User/Dashboard";
var properties = new AuthenticationProperties
{
RedirectUri = Url.Action("Complete", "Entra", new { returnUrl = destination }),
IsPersistent = false
};
HttpContext.GetOwinContext().Authentication.Challenge(properties, EntraAuthentication.Provider);
Response.SuppressFormsAuthenticationRedirect = true;
return new HttpUnauthorizedResult();
}
[HttpGet]
public async Task<ActionResult> Complete(string returnUrl)
{
IAuthenticationManager manager = HttpContext.GetOwinContext().Authentication;
AuthenticateResult ticket = await manager.AuthenticateAsync(EntraAuthentication.TemporaryCookie);
manager.SignOut(EntraAuthentication.TemporaryCookie);
Response.Cache.SetCacheability(HttpCacheability.NoCache);
Response.Cache.SetNoStore();
if (ticket == null || ticket.Identity == null || !ticket.Identity.IsAuthenticated ||
!ticket.Properties.ExpiresUtc.HasValue || ticket.Properties.ExpiresUtc.Value <= DateTimeOffset.UtcNow ||
Request.IsAuthenticated)
return RedirectToAction("Failure");
try
{
int portalId = PortalAgent.CurrentPortal.PortalId;
EntraAccount mapping = EntraAccountMap.Resolve(ticket.Identity, EntraSettings.Load().TenantId,
portalId, Server.MapPath("~/App_Data/Gcg.EntraAccounts.xml"));
UserModel user = users.GetUserAccountData(mapping.UserId,
new ExpandCollection { "Profiles" }, portalId);
if (user == null || user.UserId != mapping.UserId || user.PortalId != portalId ||
!string.Equals(user.UserName, mapping.UserName, StringComparison.OrdinalIgnoreCase) ||
user.IsLock != false || !user.IsVerified || user.IsGuestUser || user.IsAdminUser ||
user.Profiles == null || !user.Profiles.Any())
return RedirectToAction("Failure");
// Start with this user's current Znode profile and permissions.
Session.Clear();
userAgent.SetLoginUserProfile(user);
if (user.ProfileId <= 0)
return RedirectToAction("Failure");
SessionHelper.SaveDataInSession<UserViewModel>(WebStoreConstants.UserAccountKey,
user.ToViewModel<UserViewModel>());
cart.ClearCartCountFromSession();
authentication.SetAuthCookie(user.UserName, false);
return Redirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/User/Dashboard");
}
catch (Exception exception)
{
// Log the failure category without claims, tokens, or account details.
System.Diagnostics.Trace.TraceError("GCG Entra sign-in rejected: {0}", exception.GetType().Name);
return RedirectToAction("Failure");
}
}
[HttpGet]
public ActionResult Failure()
{
Response.StatusCode = 403;
Response.TrySkipIisCustomErrors = true;
return Content("Sign-in could not be completed. Ask your Store administrator to check your account access.", "text/plain");
}
}
}
Step 07
Register the controller and its three MVC routes
Add the controller registration to the same custom assembly. Znode's existing IDependencyRegistration discovery registers EntraController with the current request scope, so its IUserClient, IUserAgent, ICartAgent, and IAuthenticationHelper dependencies come from the native container.
EntraRoutes inserts a constrained MVC route for SignIn, Complete, and Failure. The OpenID Connect middleware owns /signin-gcg-entra, so there is no MVC action at that callback path. The two routes have different jobs: the middleware validates the protocol response, then the controller authorizes the Znode account.
Libraries/Znode.WebStore.Custom/Entra/EntraRegistration.cscsharp
using System.Web.Mvc;
using System.Web.Routing;
using Autofac;
using Znode.Libraries.Framework.Business;
namespace Gcg.Recipes.Entra
{
public sealed class EntraRegistration : IDependencyRegistration
{
public int Order { get { return 10; } }
public void Register(ContainerBuilder builder)
{
builder.RegisterType<EntraController>().InstancePerRequest();
}
}
public static class EntraRoutes
{
public static void Register(RouteCollection routes)
{
var route = new Route("Entra/{action}",
new RouteValueDictionary { { "controller", "Entra" }, { "action", "Failure" } },
new RouteValueDictionary { { "action", "SignIn|Complete|Failure" } },
new MvcRouteHandler());
route.DataTokens = new RouteValueDictionary();
route.DataTokens["Namespaces"] = new[] { "Gcg.Recipes.Entra" };
routes.Insert(0, route);
}
}
}
Step 08
Include the source files in the legacy project
Older .csproj files do not automatically compile every new .cs file. Add the five files through Visual Studio, or merge these Compile items into the existing project. Do not add a second entry for a file that Visual Studio already included.
Keep the existing reference from Znode.Engine.WebStore to Znode.WebStore.Custom, and deploy the updated custom assembly with the host. The project retains its native Znode client, model, core, utilities, and framework references.
Libraries/Znode.WebStore.Custom/Znode.WebStore.Custom.csprojxml
<ItemGroup>
<Compile Include="Entra\EntraSettings.cs" />
<Compile Include="Entra\EntraAuthentication.cs" />
<Compile Include="Entra\EntraAccountMap.cs" />
<Compile Include="Entra\EntraController.cs" />
<Compile Include="Entra\EntraRegistration.cs" />
</ItemGroup>
Step 09
Add the route and middleware to the existing startup
In the host's existing Startup.Configuration, insert these two calls before ConfigureAuth(app). Keep the current assembly-level OwinStartup attribute, class, and all other startup behavior. Do not create a second startup class or replace native cookie configuration.
The fully qualified names below avoid adding imports. The existing app parameter is the Owin.IAppBuilder already passed to Configuration. Register the Entra routes and middleware only once.
Znode.Engine.WebStore/Startup.cs, beginning of Configurationcsharp
Gcg.Recipes.Entra.EntraRoutes.Register(System.Web.Routing.RouteTable.Routes);
Gcg.Recipes.Entra.EntraAuthentication.Register(app);
Step 10
Deploy the nonsecret settings and provision the worker secret
Merge the three add entries into the existing appSettings section in Znode.Engine.WebStore/Web.config, using your environment's values. The source file below is a merge fragment; copying it beside Web.config alone does not load it. Retain all other appSettings.
Provision GCG_ENTRA_CLIENT_SECRET as an environment variable available to the IIS application pool worker. Use the deployment system's protected secret input and the server's restricted configuration permissions. Use the secret value, not the secret's identifier. A variable in your interactive PowerShell session is not automatically available to an already running IIS worker.
Start a new worker process after changing its environment, and deploy the configuration and binary changes together. Confirm the public host is HTTPS and that any reverse proxy preserves the external scheme and hostname expected by the application's existing hosting configuration.
Znode.Engine.WebStore/Web.config, existing appSettingsxml
<appSettings>
<add key="Gcg.Entra.TenantId" value="11111111-1111-1111-1111-111111111111" />
<add key="Gcg.Entra.ClientId" value="33333333-3333-3333-3333-333333333333" />
<add key="Gcg.Entra.RedirectUri" value="https://store.example.com/signin-gcg-entra" />
</appSettings>
Step 11
Deploy the approved account map under App_Data
Before mapping an identity, create or approve an ordinary storefront user in the intended Store. Record its current UserId, exact UserName, and PortalId. Verify the account is unlocked, verified, and has an eligible profile. Do not map a Znode Admin account.
Obtain the Entra user's Object ID from that user's record in the app's tenant. The value is the user's object ID, not the app registration object ID, application ID, or email address. Replace every fictional identifier below and add one entry per approved tenant, identity, and Store combination.
Keep the XML under App_Data, which is not public content. Restrict modification to the deployment process and approved server administrators, and allow the application pool identity to read it. Treat map changes as account-access changes. Removing a map blocks the next Entra sign-in; it does not independently revoke a Store cookie already issued.
Znode.Engine.WebStore/App_Data/Gcg.EntraAccounts.xmlxml
<?xml version="1.0" encoding="utf-8"?>
<EntraAccounts>
<!-- Replace each example value with an explicitly provisioned Store user. -->
<Account TenantId="11111111-1111-1111-1111-111111111111"
ObjectId="22222222-2222-2222-2222-222222222222"
PortalId="12" UserId="345"
UserName="gcg-ante@example.com" />
</EntraAccounts>
Step 12
Add a standalone Microsoft sign-in form
Create this partial in the WebStore host's root Views/Shared directory. It posts to the MVC action with a fresh antiforgery token. Razor encodes the hidden returnUrl value and the controller validates it again before redirecting.
Render this partial after the native login form's closing tag in the active theme's User/Login.cshtml. The inspected B2B theme keeps that view under Views/Themes/B2B/Views/User/Login.cshtml. If your Store uses a different theme, use its corresponding view. The partial creates its own form and must not be placed inside an existing form.
Znode.Engine.WebStore/Views/Shared/_EntraSignIn.cshtmlcshtml
@using (Html.BeginForm("SignIn", "Entra", FormMethod.Post))
{
@Html.AntiForgeryToken()
<input type="hidden" name="returnUrl" value="@Request.QueryString["returnUrl"]" />
<button type="submit" class="btn-text btn-color-primary">
Sign in with Microsoft
</button>
}
Znode.Engine.WebStore/Views/Themes/B2B/Views/User/Login.cshtml, after the native login formcshtml
@Html.Partial("~/Views/Shared/_EntraSignIn.cshtml")
Step 13
Include and build the deployed content
Merge these Content items into the host project if Visual Studio has not already included them. The map and shared partial must be present in the published WebStore, and the active theme view containing the partial call must also be deployed.
Restore and rebuild the WebStore host with the solution's configured NuGet sources and Visual Studio build tools. Use the existing release configuration for the Store. The command below uses the standard Release configuration from the Projects directory. Keep private package-feed credentials in the existing NuGet configuration.
Znode.Engine.WebStore/Znode.Engine.WebStore.csprojxml
<ItemGroup>
<Content Include="App_Data\Gcg.EntraAccounts.xml" />
<Content Include="Views\Shared\_EntraSignIn.cshtml" />
</ItemGroup>
Visual Studio Developer PowerShell at Projectspowershell
msbuild .\Znode.Engine.WebStore\Znode.Engine.WebStore.csproj /t:Restore,Rebuild /p:RestorePackagesConfig=true /p:Configuration=Release /m
Step 14
Verify the employee journey and native logout
Use a clean browser profile against the configured HTTPS test Store. Visit /User/Login?returnUrl=%2FUser%2FDashboard and select Sign in with Microsoft. Choose the approved employee account. The callback should move through /signin-gcg-entra and /Entra/Complete before opening the local dashboard.
Confirm the visible Znode account, selected profile, accessible catalog, pricing, and account pages match the provisioned test user. In browser storage, check that __Host-GcgEntraTemporary is cleared after completion and the Store uses its usual authentication cookie. Do not copy tokens or cookies into logs or screenshots.
Use the Store's existing logout action, then revisit an account page. The Store should require sign-in again. The Microsoft session remains active, so a later sign-in may only need an account choice. This recipe neither signs out of every Microsoft application nor forces a fresh password prompt.
Run the rejection cases below as part of acceptance. Complete failures show a 403 message and stop. A user must deliberately select the sign-in action again after the account or configuration issue is corrected.
Verify the complete result
| Check | Action | Expected result |
|---|---|---|
| Approved mapping | Sign in as the assigned Entra test employee whose tenant, object ID, and Store match one XML entry. | The mapped Znode user signs in with its current profile. No Entra token is stored in the Store session and the temporary cookie is cleared. |
| Wrong tenant or unassigned employee | Use an ordinary account from another tenant, or an unassigned test account. Do not use a Global Administrator for the assignment-denial test. | Entra denies access or the application rejects the identity. The Store does not issue an authenticated session. |
| No mapping or duplicate mapping | In the test map, remove the identity's entry, then repeat with two identical tenant, object ID, and PortalId entries. | Both cases end in a 403 failure. The reader never chooses the first ambiguous entry. |
| Wrong Store or stale username | Set the test entry to another PortalId, then restore it and use an incorrect mapped username. | The Store check or native account comparison rejects sign-in. The same Microsoft identity cannot silently select an account from another Store. |
| Ineligible Znode user | Independently exercise a locked user, an unverified user, a guest user, an Admin user, and a user with no eligible profile in the test environment. | Each identity is rejected before the native authentication cookie is issued. Restore the ordinary test user after each case. |
| Direct completion request | Open /Entra/Complete without a current external ticket, and repeat after the five-minute temporary ticket has expired. | The action clears any temporary cookie and redirects to the 403 failure page. It cannot establish a Store session by URL alone. |
| Cross-site POST and unsafe return URL | Submit SignIn without a valid antiforgery token. Separately start the valid login form with returnUrl=https%3A%2F%2Fexample.net or returnUrl=%2F%2Fexample.net. | The tokenless POST is rejected. External return URLs fall back to /User/Dashboard after an otherwise successful login. |
| Local destination | Start sign-in from /User/Login?returnUrl=%2FUser%2FDashboard and complete it as the allowed employee. | The browser returns to the requested local dashboard with the native Znode identity. |
| Existing authentication and logout | Exercise native password login, any enabled social provider, Entra login, then the existing Store logout and a new account-page request. | Existing sign-in paths still work. Logout removes Store access while leaving the independent Microsoft session available for a later account selection. |
| Deployment boundaries | Check the published App_Data map, loaded custom assembly, worker secret configuration, callback host, and browser cookie flags without exporting secret values. | The worker reads its settings and map, the callback uses the registered HTTPS host, and the temporary cookie is Secure, HttpOnly, host-scoped, and cleared after completion. |
Troubleshooting
The host fails during startup
Check the three Gcg.Entra appSettings, exact HTTPS /signin-gcg-entra callback, and availability of GCG_ENTRA_CLIENT_SECRET to the IIS worker. EntraSettings rejects missing or malformed configuration before enabling sign-in.
Entra reports a redirect URI mismatch
Compare the scheme, hostname, port, and callback path in the Web platform registration with Gcg.Entra.RedirectUri. Use the exact external HTTPS callback, including any nondefault port.
Entra authentication succeeds but the Store returns 403
Check the tenant-specific user Object ID, current PortalId, unique XML mapping, exact Znode UserId and UserName, lock and verification state, and available profiles. App assignment and Znode account authorization are separate checks.
The browser repeatedly reaches the failure page
Start a new login from the visible button after correcting the cause. Verify the callback and completion requests stay on the same HTTPS host, cookies are accepted, the server clock is accurate, and a proxy is not rewriting the public origin. The failure page deliberately does not retry authentication.
EntraController cannot be constructed or its route is missing
Verify the five Compile entries, the deployed Znode.WebStore.Custom assembly, native IDependencyRegistration discovery, and the single EntraRoutes.Register call. Preserve the Store's existing resolver and OwinStartup selection.
Could not load file or assembly during authentication
Compare the deployed Microsoft.Owin and IdentityModel assemblies with the resolved packages. Align Katana versions across the host and custom library and review binding redirects in the deployed host Web.config. Restore and rebuild the host after package changes.
The Microsoft button does not start the intended POST
Place the shared partial outside the native login form in the active theme. Confirm the rendered form posts to /Entra/SignIn and includes its own antiforgery token.
Logout is followed by a quick Microsoft sign-in
The existing Store logout does not end the Microsoft session. The provider asks the user to select an account; it does not force credential entry. Verify the Store itself requires authentication after logout.
A recently removed mapping has not signed out an existing shopper
The map and native account checks run when this Entra flow creates a Store session. Use the site's existing session-revocation process when immediate removal of an already issued Store session is required.
References and further reading
Original examples and independent guidance from GCG. The sample data is fictional. Znode and Microsoft are trademarks of their respective owners. Discuss your implementation with GCG.