“Magic” SignIn & Invitation Mails in Azure AD B2C

Andreas Helland
Contosio Labs
Published in
8 min readFeb 27, 2020

--

September 2022: The contents of this post should still be valid, but I have written code and scripts for an automated deployment making it easier to test the things here without tweaking xml and the like. Check it out here: https://contos.io/passwordless-azure-for-ci-cd-infrastructure-and-user-experiences-b94dae3fb1e4

In my previous post I showed how you can generate your own “fake” Azure AD tokens, and in general create JWTs that are valid and verifiable. The nice thing about doing that is that it paves the way for other use cases as well. I’ve probably stated multiple times before that one of the things I like about Azure AD B2C is how flexible it is with regards to customizing the authentication experience to what you want it to be.

Generating our own tokens allows us to use a feature called “id token hinting”. The things I thought we’d be implementing today to illustrate what it can be used for are two nifty (in my opinion)features:

“Magic” SignIn links
You’ve probably come across some sites where you don’t log in in the classic sense with typing a username and password. There’s just this “send me a link” button — you click the link (sent via email) and you’re signed in. Which feels kind of magic :) (So, it’s not something I came up with, it is a real thing already.)

As this should be fairly invisible to the user there’s not really all that much to take screenshots of.

Invitation-based SignUp
I’m guessing you’ve been in scenarios where you’re asked in a regular store (be it shopping for clothes, kitchen appliances, or whatever really) if you want to join their customer club. You give them your email address and you receive an email with instructions for completing the registration. The standard experience in AAD B2C is to go to the page and click SignUp in some way, but it would be nice to be able to invite folks as well and not just tell them to click through things on the web.

The bonus of this is that you don’t need to pre-create the account in B2C — the token is self-contained with regards to the SignUp experience. (It’s not doing a lookup based on an id the url — the token includes the necesssary attributes as claims.)

So, you’ll land on a page like this (you’ll have to trust me when I say it’s not the standard SignUp page):

AAD B2C Pre-filled SignUp Page

Both of these work by generating a token that you pass along to Azure AD B2C in the url, and after verifying the token the info is used in a (custom) policy. I’ll be honest — there are some nuts and bolts involved in making this work, but let’s see if we can sort it out.

I’ll happily admit I stole some parts from the official AAD B2C samples repo, followed by adapting them slightly and adding a couple things not covered there:
https://github.com/azure-ad-b2c/samples/tree/master/policies/sign-in-with-magic-link
https://github.com/azure-ad-b2c/samples/tree/master/policies/invite

Setting up SendGrid
Azure AD B2C has a public preview where you can customize the verification emails (which it’s not unlikely that you want to do on a general level):
https://docs.microsoft.com/en-us/azure/active-directory-b2c/custom-email

While testing that out I configured SendGrid, and decided to use the same account for sending the necessary emails for these two features. (If you already have an Azure account it doesn’t require much effort, and costs nothing as long as you’re just playing around in your lab.)

Go through the creation process in the Azure Portal and copy off the apiKey before moving to the next step.

Setting up OpenID Connect metadata endpoints
Validating tokens are still a part of the OAuth game so we need to handle this. If you deployed the previous sample that also included metadata endpoints for that purpose, so you can use that if you like. But an even smoother approach is using B2C for hosting the endpoint B2C uses for validation — very meta indeed.

You will need to generate a new certificate (assuming Windows here):

$cert = New-SelfSignedCertificate -Type Custom -Subject “CN=MySelfSignedCertificate” -TextExtension @(“2.5.29.37={text}1.3.6.1.5.5.7.3.3”) -KeyUsage DigitalSignature -KeyAlgorithm RSA -KeyLength 2048 -NotAfter (Get-Date).AddYears(2) -CertStoreLocation “Cert:\CurrentUser\My”

Export the certificate as a pfx-file and hop on over to the B2C part of the Azure Portal.

(Copying Microsoft’s instructions)
In the “Policy Keys” blade, Click Add to create a new key and select Upload in the options.

Give it a name, something like Id_Token_Hint_Cert and select key type to be RSA and usage to be Signature. You can optionally set the expiration to the expiration date of the cert. Save the name of the generated key.

Create a dummy set of new base, extension and relying party files. You can do so by downloading it from the starter pack here
https://github.com/Azure-Samples/active-directory-b2c-custom-policy-starterpack.

To keep things simple we will use
https://github.com/Azure-Samples/active-directory-b2c-custom-policy-starterpack/tree/master/LocalAccounts but any starter pack can be used. (Suffix these with _DUMMY or something so you don’t mix them with actual policies.)

Once you have successfully setup the new starter pack policies open the base file of this set and update the TechnicalProfile Id=”JwtIssuer” Here we will update the token signing key container to the key we created.

Update B2C_1A_TokenSigningKeyContainer to B2C_1A_Id_Token_Hint_Cert like this:

<Key Id=”issuer_secret” StorageReferenceId=”B2C_1A_Id_Token_Hint_Cert” />

The RP file I built looks like this:

<TrustFrameworkPolicy xmlns:xsi=”http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd=”http://www.w3.org/2001/XMLSchema" xmlns=”http://schemas.microsoft.com/online/cpim/schemas/2013/06" PolicySchemaVersion=”0.3.0.0" TenantId=”yourtenant.onmicrosoft.com” PolicyId=”B2C_1A_OIDC” PublicPolicyUri=”http://yourtenant.onmicrosoft.com/B2C_1A_OIDC" TenantObjectId=”tenant-guid”><BasePolicy>
<TenantId>yourtenant.onmicrosoft.com</TenantId>
<PolicyId>B2C_1A_TrustFrameworkExtensions_DUMMY</PolicyId>
</BasePolicy>
<RelyingParty>
<DefaultUserJourney ReferenceId=”SignUpOrSignIn” />
<UserJourneyBehaviors>
<ContentDefinitionParameters>
<Parameter Name=”ui_locales”>{Culture:RFC5646}</Parameter>
</ContentDefinitionParameters>
<ScriptExecution>Allow</ScriptExecution>
</UserJourneyBehaviors>
<TechnicalProfile Id=”PolicyProfile”>
<DisplayName>PolicyProfile</DisplayName>
<Protocol Name=”OpenIdConnect” />
<OutputClaims>
<OutputClaim ClaimTypeReferenceId=”displayName” />
<OutputClaim ClaimTypeReferenceId=”givenName” />
<OutputClaim ClaimTypeReferenceId=”surname” />
<OutputClaim ClaimTypeReferenceId=”email” />
<OutputClaim ClaimTypeReferenceId=”objectId” PartnerClaimType=”sub” />
<OutputClaim ClaimTypeReferenceId=”identityProvider” />
</OutputClaims>
<SubjectNamingInfo ClaimType=”sub” />
</TechnicalProfile>
</RelyingParty>
</TrustFrameworkPolicy>

Upload these files through the portal.

Click on the relying party file in the B2C portal and copy the url to the “OpenID Connect discovery endpoint”. Et voilà — metadata for you.

Custom policies
This isn’t exactly part of the built-in policies in Azure AD B2C at the moment, so you will need custom policies to sort this out.

First a policy for handling SignIn (remember that the account must exist already for this to work):

B2C_1A_Signin_With_Email.xml

And then a policy for handling SignUp (account must not exist beforehand):

B2C_1A_SignUp_Invitation.xml

The important, and tricky part, is mapping claims correctly so that the values you seed in the token actually appear on the SignUp page.

Creating B2C mailer
Sending the emails are a matter of calling into a REST API which can be done any number of ways. To simplify things SendGrid has NuGet packages for use with C#, and in this case there are a couple of additional lines of code needed for generating the token and url. If you want to do a script-based version or a web page is sort of up to you. For demo purposes I created a simple web app that will let you send one mail at a time.

Sending a “Magic Link”
Sending an invitation mail

The code for this can be found here:
https://github.com/ahelland/Identity-CodeSamples-v2/tree/master/aad-b2c-mailengine-dotnet-core

With a Docker image here:
https://hub.docker.com/r/ahelland/aad-b2c-mailengine-dotnet-core-linux

I secured the page with Azure AD (B2E) so it’s not going to be a freely available spam generator, but feel free to do as you please with your instance :)

The important part of the code looks like this:

SendSignInLinkAsync method

If you want to just prove that the policies work you can redirect to https://jwt.ms — use the following settings (appsettings.json):

“B2CTenant”: “yourtenant”,
“B2CPolicy”: “B2C_1A_SignUp_Invitation”,
“B2CClientId”: “client-guid”,
“B2CRedirectUri”: “https://jwt.ms",
“B2CSignUpUrl”:
https://{0}.b2clogin.com/{0}.onmicrosoft.com/{1}/oauth2/v2.0/authorize?client_id={2}&nonce={4}&redirect_uri={3}&scope=openid&response_type=id_token",

And change the BuildUrl method above slightly:

private string BuildUrl(string token)
{
string nonce = Guid.NewGuid().ToString(“n”);
return string.Format(this.AppSettings.B2CSignUpUrl,
this.AppSettings.B2CTenant,
this.AppSettings.B2CPolicy,
this.AppSettings.B2CClientId,
Uri.EscapeDataString(this.AppSettings.B2CRedirectUri),
nonce) + “&id_token_hint=” + token;
}

Adapting an MVC web page
Thing is — while this would prove the setup is correct this doesn’t plug into your regular templatized .NET Core web app. When you build your app based on the templates in Visual Studio and enable OpenID Connect-based authentication a couple of things is configured in the background for you to make it work more or less automatically.

If your app is running at https://foo.bar a SignIn action will take you to https://contoso.b2clogin.com/xyz, and once you have logged in B2C will send your browser session back to https://foo.bar/signin-oidc.

You might think that this means that you can just make the magic link send you directly to B2C and include the corresponding return url, but what will happen is that the app running at https://foo.bar will recognize that it didn’t initiate the request and basically says “I don’t trust this”. (Auth endpoints are different than API endpoints so it’s not just a matter of accepting a token.)

So the flow basically becomes something like this:

Flow for SignIn/SIgnUp with id_token_hint

To get around these minor snags I did two things:

Add endpoints as landing points for the links (HomeController.cs)

//Separate SignIn handler for magic links sent by email
public IActionResult SignInLink(string id_token_hint)
{
var magic_link_auth = new AuthenticationProperties { RedirectUri =
“/” };
magic_link_auth.Items.Add(“id_token_hint”, id_token_hint);
string magic_link_policy = Configuration.GetSection(“AzureAdB2C”
[“MagicLinkPolicyId”];

return this.Challenge(magic_link_auth, magic_link_policy);
}
//Separate SignUp handler for invitation links sent by email
public IActionResult SignUpInvitation(string id_token_hint)
{
var invite_auth = new AuthenticationProperties { RedirectUri = “/”
};
invite_auth.Items.Add(“id_token_hint”, id_token_hint);
string invite_policy = Configuration.GetSection(“AzureAdB2C”
[“InvitationPolicyId”];

return this.Challenge(invite_auth, invite_policy);
}

Add new auth schemes to Startup.cs

//Magic link auth
string magic_link_policy = Configuration.GetSection("AzureAdB2C")["MagicLinkPolicyId"];
services.AddAuthentication(options => options.DefaultChallengeScheme =OpenIdConnectDefaults.AuthenticationScheme).AddOpenIdConnect(magic_link_policy, GetOpenIdSignUpOptions(magic_link_policy));
//Invitation link SignUp
string invite_policy = Configuration.GetSection("AzureAdB2C")["InvitationPolicyId"];
services.AddAuthentication(options => options.DefaultChallengeScheme =OpenIdConnectDefaults.AuthenticationScheme).AddOpenIdConnect(invite_policy, GetOpenIdSignUpOptions(invite_policy));

private Action<OpenIdConnectOptions> GetOpenIdSignUpOptions(string policy)=> options =>
{
string clientId =
Configuration.GetSection(“AzureAdB2C”[“ClientId”];
string B2CDomain =
Configuration.GetSection(“AzureAdB2C”)[“B2CDomain”];
string Domain =
Configuration.GetSection(“AzureAdB2C”)[“Domain”];
string MagicLink =
Configuration.GetSection(“AzureAdB2C”)[“MagicLinkPolicyId”];
string Invite =
Configuration.GetSection(“AzureAdB2C”)[“InvitationPolicyId”];
options.MetadataAddress =
$”https://{B2CDomain}/{Domain}/{policy}/v2.0/.well-known/
openid-configuration";
options.ClientId = clientId;
options.ResponseType = OpenIdConnectResponseType.IdToken;
options.SignedOutCallbackPath = “/signout/” + policy;
if (policy == MagicLink)
options.CallbackPath = “/signin-oidc-link”;
if (policy == Invite)
options.CallbackPath = “/signin-oidc-invite”;
options.SignedOutRedirectUri = “/”;
options.SignInScheme = “AzureADB2C”;
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = context =>
{
if (context.Properties.Items.ContainsKey(“id_token_hint”))
context.ProtocolMessage.SetParameter(“id_token_hint”,
context.Properties.Items[“id_token_hint”]);
return Task.FromResult(0);
}
};
};
}

The complete code can be found here:
https://github.com/ahelland/Identity-CodeSamples-v2/tree/master/aad-b2c-custom_policies-dotnet-core

And an updated Docker image her:
https://hub.docker.com/r/ahelland/aad-b2c-custom_policies-dotnet-core-linux

Phew, a lot of things to get right there :) I hope that you managed to get it working, and agree with me that this can be a useful trick to implement in your B2C setups.

--

--