Using Azure AD B2C custom policies with Entra External ID

Rory Braybrook
The new control plane
6 min readOct 2, 2024
Image of migration
Mmaric1978, CC BY-SA 4.0, via Wikimedia Commons

Note that this post is around a PoC, so use it at your own risk!

Overview

The sample is here.

Note it uses the MFA sample from the starter pack.

The sample is called “migrate-to-entra-external-id-for-customers”, but that is somewhat misleading.

There is no migration from B2C to Entra External ID (EEID).

“This sample will use AAD B2C as the journey orchestrator, whilst creating and authenticating users in the Entra External Id tenant. This makes it easier in the future to move apps to Entra External Id without disruption to your users. This sample performs sign up/in with MFA using Azure AD B2C, whilst maintaining user profiles in the Entra External Id tenant.”

The sample replaces the B2C API calls with Graph API calls to EEID.

As an example:

<TechnicalProfile Id="SelfAsserted-LocalAccountSignin-Email">
<DisplayName>Local Account Signin</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.SelfAssertedAttributeProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/>
<Metadata>
<Item Key="SignUpTarget">SignUpWithLogonEmailExchange</Item>
<Item Key="setting.operatingMode">Email</Item>
<Item Key="ContentDefinitionReferenceId">api.localaccountsignin</Item>
<Item Key="IncludeClaimResolvingInClaimsHandling">true</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="signInName" DefaultValue="{OIDC:LoginHint}" AlwaysUseDefaultValue="true"/>
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="signInName" Required="true"/>
<OutputClaim ClaimTypeReferenceId="password" Required="true"/>
<OutputClaim ClaimTypeReferenceId="objectId"/>
<OutputClaim ClaimTypeReferenceId="authenticationSource"/>
</OutputClaims>
<ValidationTechnicalProfiles>
<ValidationTechnicalProfile ReferenceId="login-NonInteractive"/>
</ValidationTechnicalProfiles>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-AAD"/>
</TechnicalProfile>

becomes:

<TechnicalProfile Id="CIAM-SelfAsserted-LocalAccountSignin-Email">
<DisplayName>Local Account Signin</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.SelfAssertedAttributeProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/>
<Metadata>
<Item Key="SignUpTarget">SignUpWithLogonEmailExchange</Item>
<Item Key="setting.operatingMode">Email</Item>
<Item Key="ContentDefinitionReferenceId">api.localaccountsignin</Item>
<Item Key="IncludeClaimResolvingInClaimsHandling">true</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="signInName" DefaultValue="testaaa@pm.me" AlwaysUseDefaultValue="true"/>
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="signInName" Required="true"/>
<OutputClaim ClaimTypeReferenceId="password" Required="true"/>
<OutputClaim ClaimTypeReferenceId="objectId"/>
<OutputClaim ClaimTypeReferenceId="authenticationSource" DefaultValue="localViaCiamTenant"/>
<OutputClaim ClaimTypeReferenceId="graph_bearerToken"/>
</OutputClaims>
<ValidationTechnicalProfiles>
<ValidationTechnicalProfile ReferenceId="REST-login-NonInteractive-CIAM"/>
<ValidationTechnicalProfile ReferenceId="REST-fetchUserProfile-CIAM"/>
</ValidationTechnicalProfiles>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-AAD"/>
</TechnicalProfile>

so the B2C call to “login-NonInteractive”:

<ValidationTechnicalProfiles>
<ValidationTechnicalProfile ReferenceId="login-NonInteractive"/>
</ValidationTechnicalProfiles>

is replaced by:

<ValidationTechnicalProfiles>
<ValidationTechnicalProfile ReferenceId="REST-login-NonInteractive-CIAM"/>
<ValidationTechnicalProfile ReferenceId="REST-fetchUserProfile-CIAM"/>
</ValidationTechnicalProfiles>

These are Graph API calls to EEID, which achieve the same result.

You keep the custom policy flow, but the data resides in EEID.

This makes the migration when it happens a lot easier as you don’t have to migrate users, passwords, etc.

Create user

Let’s create a user.

Image of B2C sign up screen

Ignore the hard-coded “testaaa@pm.me” and use your own.

When we run the B2C policy, we see the usual B2C login.

Click “Sign up now”.

Image of sign up attributes e.g. email, password, given name etc

We enter the new user details.

Image of B2C SMS proof up

We proof up for SMS using B2C.

Image showing that code has been verified

Hit “Continue”.

And we get the JWT back.

{
"email": "ropc1@tenant.onmicrosoft.com",
"name": "Ropc One",
"given_name": "Ropc",
"family_name": "One",
"phoneNumberString": "+64 123456"
}

Check for the user in B2C:

Image showing user doesn’t exist in B2C

And it’s not there as we would expect.

Check for the user in EEID:

Image showing user does exist in EEID

and we can see the user has been successfully created.

I also tried a random user (not @ onmicrosoft.com) e.g.

tomjames@company.co.nz

and that also worked.

Check MFA

Now, I have MFA set up via conditional access to email and SMS in EEID.

Log in to EEID.

I can log in with the email address and password I created on the sign-up screen.

I am then asked for MFA in EEID:

Image of EEID MFA screen with both email and SMS

You can see that both my email and phone numbers have been transferred over to use in EEID MFA.

I verify via SMS and then get the JWT:

{
"Display name": "Ropc One",
"Given name": "Ropc",
"Surname": "One"
}

If we look at this user’s authentication methods in EEID:

Imafe showing authentication methods in EEID for email and SMS

we see both are correctly configured.

Azure function

An Azure function in the sample is used to call the Graph API.

The B2C custom policy calls the function, and the function does the Graph API calls.

I’ve changed the Azure function, and my version is in this repo.

I updated the NuGet packages to remove potential security issues and fixed the warnings.

Unfortunately, upgrading the NuGet packages causes this error:

 System.Private.CoreLib: Exception while executing function: ciamHelper. 
Microsoft.Graph.Beta: Method not found:
'Void Microsoft.Kiota.Abstractions.RequestInformation.
AddQueryParameters(System.Object)'.

so I had to revert the code.

You also get this warning when you run the code:

The 'FUNCTIONS_WORKER_RUNTIME' setting is required. Please specify a valid 
value. See https://go.microsoft.com/fwlink/?linkid=2257963 for more
information. The application will continue to run, but may throw an
exception in a future release.

B2C makes several API calls, which makes debugging/stepping through the function code difficult, so I’ve added a lot of logging.

The parts you need to customise are marked with:

// TODO:

As usual, the custom policy is in this gist.

You can run the function locally, but you need to use ngrok to allow B2C access to the function.

Run it in the command line, and the command is:

ngrok http 7257

This results in, e.g.:

Forwarding https://50d4-222-155-30-156.ngrok-free.app -> http://localhost:7257  

You need to change the REST API calls in the custom policy to do this e.g.

<Item Key="ServiceUrl">https://50d4-222-155-30-156.ngrok-free.app/api/ciamhelper</Item>

You could also run the function in an Azure app service that is publically available.

Note that in any production scenario, you need to enable authentication in the Azure function e.g. OAuth.

Azure function logging

When you sign up a user, the console log looks like this:


Object Id Email ropc2@tenant.onmicrosoft.com Password password
Method createUser Phone number Display name Ropc Two Given name Ropc
Surname Two

JWT

Creating user
Display name Ropc Two email ropc2@tenant.onmicrosoft.com

DoWithRetryAsync

objectId 384...302 email ropc2@tenant.onmicrosoft.com

Enrolling email address: email ropc2@tenant.onmicrosoft.com
objectId 384...302

Object Id 384...302 Email Password Method setPhone Phone number +64123456
Display name Given name Surname

JWT

Setting phone number +64123456

Object Id 384...302 Email Password Method read Phone number Display name
Given name Surname

JWT

ObjectId - Reading user

Given name Ropc Surname Two Display name Ropc Two
Email ropc2@tenant.onmicrosoft.com Phone number
384...302@tenant.onmicrosoft.com

Object Id 384...302 Email Password Method getPhone Phone number
Display name Given name Surname

JWT

Getting phone number

and the JWT in the log looks like this:

{
"aud": "https://graph.microsoft.com",
"iss": "https://sts.windows.net/7fb...3bb/",
"iat": 1727820172,
"nbf": 1727820172,
"exp": 1727824072,
"aio": "k2B...gA=",
"app_displayname": "GraphCallsFromB2CTenant",
"appid": "a8f...c8d",
"appidacr": "1",
"idp": "https://sts.windows.net/7fb...3bb/",
"idtyp": "app",
"oid": "dd7...23d",
"rh": "0.Ab...AAA.",
"roles": [
"User.ReadWrite.All",
"UserAuthenticationMethod.ReadWrite.All"
],
"sub": "dd7...23d",
"tenant_region_scope": "NA",
"tid": "7fb...3bb",
"uti": "7RG...BAA",
"ver": "1.0",
"wids": [
"099...e90"
],
"xms_idrel": "7 16",
"xms_tcdt": 170...340
}

ROPC Auth

This is how B2C does it at the moment using “login-NonInteractive”.

Now that native authentication is GA, this would be another possibility.

App. registrations

You need to create two app. registrations in EEID:

If you have issues, this post covers most of the common problems.

The first is:

Image showing RopcFromB2C app registration overview

It’s a web application, and my redirect URL is:

https://jwt.io
Image showing access token / id token / public client checked

Set “Allow public client” to “Yes”.

The second is:

Image showing GraphCallsFromB2CTenant app registration overview

Again, a web app. with the same redirect and the same tokens to be issued.

You need to generate a client secret.

Set these API Application permissions and grant admin. access.

Image showing MS Graph API Application permissions: User.ReadWrite.All and UserAuthenticationMethod.ReadWrite.All

More MFA options

This approach also allows you to add more MFA options, e.g. my post on AuthSignal.

That would allow passkeys and WhatsApp OTP to be an option.

All good!

--

--

The new control plane
The new control plane

Published in The new control plane

“Identity is the new control plane”. Articles around Microsoft Identity, Auth0 and identityserver. Click the “Archive” link at the bottom for more posts.

Rory Braybrook
Rory Braybrook

Written by Rory Braybrook

NZ Microsoft Identity dude and MVP. Azure AD/B2C/ADFS/Auth0/identityserver. StackOverflow: https://bit.ly/2XU4yvJ Presentations: http://bit.ly/334ZPt5

Responses (2)