Using Azure AD B2C custom policies with Entra External ID
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.
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”.
We enter the new user details.
We proof up for SMS using B2C.
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:
And it’s not there as we would expect.
Check for the user 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:
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:
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:
It’s a web application, and my redirect URL is:
https://jwt.io
Set “Allow public client” to “Yes”.
The second is:
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.
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!