Azure AD B2C at its base is a username and password database that you can use to integrate in your apps and implement delegated authentication. It also allows you to add social media logins and beyond that, bring any OIDC-compatible provider to your login page. So users can choose how to sign up/sign in to your application.
Out of the box, the Azure AD B2C pages come with a default look and feel but in most cases, you'll want to optimize the user experience by customizing that look to match your application's UX.
In this blog post I'll show you 2 things:
- How to use custom HTML for your sign up/sign in page
- How to change the tab order on the page
Prerequisites
I will assume that you already have an Azure AD B2C tenant, with existing app registrations and flows. So we'll dive straight into the customization bit.
Create the new custom page
The requirement is simple. You need a canonical HTML page and somewhere on that page you need a div
tag with the following format:
<div id="api">
This is where B2C dynamically injects the login controls, including any social media buttons, if you have social media logins enabled. I have created a fully customized login form that makes use of Bootstrap. For testing my page locally, I've also added two things:
- a dependency to jQuery
- a copy of the html controls that will be injected in the actual page
NOTE: B2C injects jQuery on the rendered HTML page
Before uploading my page to Azure Storage, I'll be removing both the jQuery dependency and the dummy HTML controls since we'll be using the "real" thing :)
My full custom page (including the CSS and JS) is attached below:
<!DOCTYPE html>
<html lang="en-US">
<head>
<title>Sign up or sign in</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<meta name="locale" content="en-US">
<meta name="ROBOTS" content="NONE, NOARCHIVE">
<meta name="GOOGLEBOT" content="NOARCHIVE">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=2.0, user-scalable=yes">
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<style>
@import url(https://fonts.googleapis.com/css?family=Open+Sans);
* {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
-ms-box-sizing: border-box;
-o-box-sizing: border-box;
box-sizing: border-box;
}
html {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
width: 100%;
height: 100%;
font-family: 'Open Sans', sans-serif;
color: rgb(224, 153, 86);
background: #092756;
background: -moz-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -moz-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -moz-linear-gradient(-45deg, #670d10 0%, #092756 100%);
background: -webkit-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -webkit-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -webkit-linear-gradient(-45deg, #670d10 0%, #092756 100%);
background: -o-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -o-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -o-linear-gradient(-45deg, #670d10 0%, #092756 100%);
background: -ms-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -ms-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -ms-linear-gradient(-45deg, #670d10 0%, #092756 100%);
background: -webkit-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), linear-gradient(to bottom, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), linear-gradient(135deg, #670d10 0%, #092756 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#3E1D6D', endColorstr='#092756', GradientType=1);
}
.btn-block {
margin-bottom:10px;
}
.login {
position: absolute;
top: 30%;
left: 50%;
margin: -150px 0 0 -150px;
/*width: 300px;
height: 300px;*/
}
.login h1 {
color: #fff;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
letter-spacing: 1px;
text-align: center;
}
.login h2 {
color: rgb(224, 153, 86);
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
letter-spacing: 1px;
text-align: center;
font-size: 1.2em
}
label {
color: rgb(224, 153, 86);
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
}
input {
width: 100%;
margin-bottom: 10px;
background: rgba(0, 0, 0, 0.3);
border: none;
outline: none;
padding: 10px;
font-size: 13px;
color: #fff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 4px;
box-shadow: inset 0 -5px 45px rgba(100, 100, 100, 0.2), 0 1px 1px rgba(255, 255, 255, 0.2);
-webkit-transition: box-shadow .5s ease;
-moz-transition: box-shadow .5s ease;
-o-transition: box-shadow .5s ease;
-ms-transition: box-shadow .5s ease;
transition: box-shadow .5s ease;
}
input:focus {
box-shadow: inset 0 -5px 45px rgba(100, 100, 100, 0.4), 0 1px 1px rgba(255, 255, 255, 0.2);
}
</style>
<body>
<div class="login">
<h1>Contoso High</h1>
<h2>Login</h2>
<hr/>
<div id="api">
<div class="options">
<button class="accountButton firstButton claims-provider-selection" id="GoogleExchange">Google</button>
<button class="accountButton claims-provider-selection" id="GitHubExchange">Github</button>
<button class="accountButton claims-provider-selection" id="TwitterExchange">Twiter</button>
</div>
<div><input id="email" type="text" name="u" placeholder="Username" required="required" />
<a id="forgotPassword" href="https://bing.com">Forgot Password</a>
<input id="password" type="password" name="p" placeholder="Password" required="required" />
<button id="next" type="submit" class="btn btn-primary btn-block btn-large">Let me in.</button>
<a id="createAccount" href="https://microsoft.com">Sign up now</a><!---->
</div>
</div>
</div>
<script>"use strict"; $(document).ready(function () {
if (navigator.userAgent.match(/IEMobile\/10\.0/)) {
var t = document.createElement("style");
t.appendChild(document.createTextNode("@-ms-viewport{width:auto!important}")),
t.appendChild(document.createTextNode("@-ms-viewport{height:auto!important}")),
document.getElementsByTagName("head")[0].appendChild(t)
}
if (navigator.userAgent.match(/MSIE 10/i)) {
var e = $("#footer_links_container");
$(e).css("padding-top", "100px")
}
var o, i = $("#background_background_image"),
n = function () {
document.body.style.overflow = "hidden",
($(window).width() - 500) / $(window).height() < o ? (i.height($(window).height()), i.width("auto")) : (i.width($(window).width() - 500), i.height("auto")),
document.body.style.overflow = ""
};
$("<img>").attr("src", i.attr("src")).on("load", function () {
o = this.width / this.height, n()
}),
$(window).resize(function () { n() }),
"undefined" != typeof $("#MicrosoftAccountExchange") && $("#MicrosoftAccountExchange").text("Microsoft"),
$("*").removeAttr("placeholder")
document.getElementById("email").tabIndex = "1";
document.getElementById("password").tabIndex="2";
document.getElementById("next").tabIndex="3";
document.getElementById("createAccount").tabIndex="4";
document.getElementById("forgotPassword").tabIndex="5";
var socialTabIndex = 6;
var socialButtons = document.getElementsByClassName("accountButton");
while(socialButtons.length > 0){
var button = socialButtons[0];
button.classList.remove('accountButton','claims-provider-selection');
button.classList.add('btn','btn-secondary', 'btn-block', 'btn-large');
button.tabIndex=socialTabIndex;
tabIndex++;
}
});
</script>
</body>
</html>
The fully rendered page (locally) looks like this:
It's not the most beautiful or appeasing one, but then again, I'm not a designer!! Feel free to customize your as you see fit
You may nave noticed that this page also includes some custom JavaScript at the end. This is the code the changes the Tab order on the form so that it makes more sense. This code is obviously optional
document.getElementById("email").tabIndex = "1";
document.getElementById("password").tabIndex="2";
document.getElementById("next").tabIndex="3";
document.getElementById("createAccount").tabIndex="4";
document.getElementById("forgotPassword").tabIndex="5";
Finally, I noticed that my buttons where getting messed up by the injected CSS so I used a little bit of code to enforce the CSS classes I wanted, including the right tab index
var socialTabIndex = 6;
var socialButtons = document.getElementsByClassName("accountButton");
while(socialButtons.length > 0){
var button = socialButtons[0];
button.classList.remove('accountButton','claims-provider-selection');
button.classList.add('btn','btn-secondary', 'btn-block', 'btn-large');
button.tabIndex=socialTabIndex;
tabIndex++;
}
document.getElementById("next").classList.add('btn','btn-primary', 'btn-block', 'btn-large');
We can now save this as *.cshtml
and put it in a publicly accessible location so that B2C (and only B2C) can render it.
If you want some information about how the UX customization works, you can check the official docs here
Upload the custom page to Azure Storage
Azure Storage is the ideal place to store this static file. Sign in to the Azure Portal and navigate to the Storge Account you wish to use. Create a new Azure Storage Container and give it Public access level at Blob level, as per the instructions below:
Next, we need to configure the CORS settings to ensure that B2C and only B2C can access the blob using the right origin. Open the CORS tab on the Storage Account root and add a new setting for Blob Service as per below:
- Allowed Origins: https://.b2clogin.com
- Allowed Methods: GET, OPTIONS
- Allowed Headers: *
- Exposed Headers: *
- Max Age: 200
Make sure to hit Save to persist the CORS changes before you bail out :)
Configure the Azure AD B2C flow to use the custom UX
The last step is to configure our SignIn/Signup flow to use the custom page. Navigate to your B2C tenant and select the flow you want to apply the UX changes. In the Properties tab enable JavaScript and press Save
Next, navigate to the Page Layouts tab. In the Unified Sign up or Sign In page, select Yes for the Use custom page content and set the Custom Page URI to our blob URI
If all worked as expected, after hitting Save, you should see that the Unified sign up or sign in page is set to Yes for having a Custom page assigned to it :)
NOTE > The Blob URI can be found in your Azure Storage account by navigating to the actual Blob and selecting Properties
To quickly check if everything is in order, we can use the Run user flow functionality straight from the B2C portal. My custom page renders like this on the "live" environment:
Summary
Azure AD B2C is extremely flexible and can adjust to your needs and level of complexity. As you can see, it takes only a little bit of time to customize the look and feel of your login pages, including custom behaviors, so that your users get a consistent experience when using your apps.
Top comments (9)
Thanks a ton for this guide! This is much more helpful than the Microsoft wall of text they have as a guide.
Thanks @rcls . I appreciate the kind words.
Hey OssiDev, i had a question... i have the 'Enforce Javascript enforcing page layout' Azure setting on.
I put my document.ready right before the body tag and Azure B2C popup seems to just convert it to text instead (like literally the text of the tag shows up in the UI).
For example here is what i have at the end of the custom page template:
if i move that script tag into the head, at least it seems to run, but the template it pulls into load seems to run after the document.ready. For example, if i have any code like document.getElementById('email').focus(), it won't find the element(s). so right now I'm doing this setTimeout which works most of the time but its ugly:
just curious if the code you have at the end of body still works for the latest versions of the templates.
Note here at the latest versions I saw and am using:
Thanks!,
Ari
Hey.
Thanks for the informative article. I have a question.
How safe is it to rely on the IDs of the injected elements?
Isn't there the risk that Microsoft will change the ids, and then custom JS parsing will fail?
On a project I'm adding more logic that relies on the IDs of elements injected by AD B2C in the "api" div (example of injected elem id: "emailVerificationControl_but_verify_code"), and show/hide some buttons with JQuery, to make a more custom UI.
It all works, but I'm not sure how safe is to do this on the long run? (and can't really find this info in the official docs).
Thank you,
Vasile
Just to make things more clear, I don't only rely on IDs of injected inputs, but also on IDs of injected buttons.
I'd say if Microsoft goes and changes the ID's they break backwards compatibility. But the page layouts do have versioning, so if you lock it to a specific version I think you're safe.
Quote from the page layout version:
So I'm guessing if you lock it to a specific version and not use the latest, you're safe. I don't think the layout automatically changes. Mine's locked in 2.1.5 and the latest is 2.1.6.
That's right. If you pin it to a specific version, you should be OK. But you're right in thinking that things/IDs could potentially change. I would expect that this would be properly communicated ahead of time though, especially if there going to be breaking changes
Thanks a lot @christosmatskas for wonderful article. This article solves multiple use cases I am working on. But I have realised that azure is removing components that I added inside tag. Is there any step that I may be missing? Any help or pointers would be really appreciated.
Thanks, bro.