ocano.net.

AZURE AD B2C CUSTOM POLICIES (SERIES)

EXTENSIONS FILE

Cover Image for EXTENSIONS FILE

TLDR;

Overall, the post shows how to extend custom policies with claim types, claims transformations, content definitions, localization, claims providers and user journeys.

More specifically, the post shows how to:

  1. Add and set the 'mail' claim
  2. Add an extension property
  3. Set the 'displayName' claim
  4. Add custom formatting of 'userPrincipalName'
  5. Update a content definition with a custom HTML template and to reference localization
  6. Configure localization for a content definition
  7. Configure claims providers:
  • For applications with permissions to programmatically access AADB2C data
  • To persist claims
  • To output extensions claims

If you just want to see code, go to the TrustFrameworkExtensions.xml file in the source code repository.

Introduction

In the following I give examples of elements of the 'Extension file' that I have experience with adding or updating. It is not meant as an exhaustive walkthrough of the 'Extension file'. If some aspect that is relevant to you has not been covered, check out Microsoft's documentation for more information.

Prerequisites

If you haven't done so already, go read my other post: Azure AD B2C Custom Policies (Series): Preface.

ClaimType

I'll make the distinction between two claim types.

  1. User attributes pre-defined in Azure AD B2C (AADB2C)
  2. Extension attributes, that you might add based on the requirements for an application.

You can see some of the existing attributes and add extension attributes under the 'User attributes' blade in AADB2C.

Fullscreen

If you look in the starter pack (ref. 1) 'Base file', you will notice that there are some claim types that do not match what is listed under 'User attributes'. I found a full list of default user attributes in Microsoft's documentation under User profile attributes.

Here's an example of the configuration for claim type 'mail':

<ClaimType Id="mail">
    <DisplayName>Email Address</DisplayName>
    <DataType>string</DataType>
    <UserHelpText>Email address that can be used to contact the user.</UserHelpText>
</ClaimType>

This is an example of one of the pre-defined AADB2C fields for LocalAccounts that isn't included in the starter pack 'Base file'.

Here's an example of a claim type for a user extension attribute:

<ClaimType Id="extension_CompanyCVR">
    <DisplayName>Company CVR</DisplayName>
    <DataType>string</DataType>
    <DefaultPartnerClaimTypes>
    <Protocol Name="OpenIdConnect" PartnerClaimType="company_cvr" />
    <Protocol Name="SAML2" PartnerClaimType="http://schemas.microsoft.com/identity/claims/companycvr" />
    </DefaultPartnerClaimTypes>
    <UserHelpText>Enter company CVR.</UserHelpText>
</ClaimType>

The above example is an attribute for a company identification number, which is used in Denmark. It is publicly available information.

Again, we need to define a data type.

Since it's an extension attribute, you must prefix the ClaimType element's 'Id' attribute value with 'extension_' (ref. 3).

If necessary for your application, you may also define it as a partner claim type for different protocols. In this example it has been defined for OpenIdConnect and SAML2.

I usually add a user help text so it is easier for the user to understand the purpose of the claim.

Here's a claim that I won't be using by itself, but instead will include in a claims transformation to prefix an existing attribute called 'userPrincipalName':

<ClaimType Id="upnPrefix">
    <DisplayName>User Principal Name Prefix</DisplayName>
    <DataType>string</DataType>
    <UserHelpText>Prefix used for User Principal Name.</UserHelpText>
</ClaimType>

ClaimsTransformation

A ClaimsTransformation is typically used to transforms one claim into another using a specified method.

In my case, what I first discovered was that the display name of a newly created user is 'unknown', if nothing else is specified. The starter pack does not come out-of-the-box with a ClaimsTransformation that sets a display name.

I have added a simple ClaimsTransformation that joins the 'givenName' and 'surname' claims and formats them into the 'displayName' claim:

<ClaimsTransformation Id="CreateDisplayNameFromGivenNameAndLastName" TransformationMethod="FormatStringMultipleClaims">
    <InputClaims>
        <ClaimTypeReferenceId="givenName" TransformationClaimType="inputClaim1" />
        <ClaimTypeReferenceId="surname" TransformationClaimType="inputClaim2" />
    </InputClaims>
    <InputParameters>
        <InputParameter Id="stringFormat" DataType="string" Value="{0} {1}" />
    </InputParameters>
    <OutputClaims>
        <OutputClaim ClaimTypeReferenceId="displayName" TransformationClaimType="outputClaim" />
    </OutputClaims>
</ClaimsTransformation>

I have also added a ClaimsTransformation to copy the 'email' claim to the 'mail' claim, which I will use later to perform actions on users via the Microsoft Graph API:

<ClaimsTransformation Id="CopyEmailAddress" TransformationMethod="FormatStringClaim">
    <InputClaims>
        <InputClaim ClaimTypeReferenceId="email" TransformationClaimType="inputClaim"/>
    </InputClaims>
    <InputParameters>
        <InputParameter Id="stringFormat" DataType="string" Value="{0}" />
    </InputParameters>
    <OutputClaims>
        <OutputClaim ClaimTypeReferenceId="mail" TransformationClaimType="outputClaim"/>
    </OutputClaims>
</ClaimsTransformation>

Here's another few, all working on transforming the 'userPrincipalName':

<ClaimsTransformation Id="NormalizeUpnPrefixPri" TransformationMethod="StringReplace">
    <InputClaims>
    <InputClaim ClaimTypeReferenceId="email" TransformationClaimType="inputClaim" />
    </InputClaims>
    <InputParameters>
        <InputParameter Id="oldValue" DataType="string" Value="." />
        <InputParameter Id="newValue" DataType="string" Value="-" />
    </InputParameters>
    <OutputClaims>
        <OutputClaim ClaimTypeReferenceId="upnPrefix" TransformationClaimType="outputClaim" />
    </OutputClaims>
</ClaimsTransformation>
<ClaimsTransformation Id="NormalizeUpnPrefixSec" TransformationMethod="StringReplace">
    <InputClaims>
        <InputClaim ClaimTypeReferenceId="upnPrefix" TransformationClaimType="inputClaim" />
    </InputClaims>
    <InputParameters>
        <InputParameter Id="oldValue" DataType="string" Value="@" />
        <InputParameter Id="newValue" DataType="string" Value="-" />
    </InputParameters>
    <OutputClaims>
        <OutputClaim ClaimTypeReferenceId="upnPrefix" TransformationClaimType="outputClaim" />
    </OutputClaims>
</ClaimsTransformation>
<ClaimsTransformation Id="CreateUserPrincipalName" TransformationMethod="FormatStringClaim">
    <InputClaims>
        <InputClaim ClaimTypeReferenceId="upnPrefix" TransformationClaimType="inputClaim" />
    </InputClaims>
    <InputParameters>
        <InputParameter Id="stringFormat" DataType="string" Value="{0}@__TenantId__" />
    </InputParameters>
    <OutputClaims>
        <OutputClaim ClaimTypeReferenceId="userPrincipalName" TransformationClaimType="outputClaim" />
    </OutputClaims>
</ClaimsTransformation>

I transform the 'email' attribute and store it as the claim 'upnPrefix' which I defined earlier. I perform character replacement so 'upnPrefix' only contains characters that are valid in 'userPrincipalName'. In the end I format the 'userPrincipalName' claim to contain 'upnPrefix' and tenant ID.

In order to apply a ClaimsTransformation, it must be added as an InputClaimsTransformation element under a TechnicalProfile, as shown in the ClaimsProvider section.

You can see a list of links to claims transformations for different data types in Microsoft's documentation under ClaimsTransformations.

ContentDefinition

The ContentDefinitions element contains URLs to HTML5 templates that can be used in a user journey (ref. 4).

You can add or update a content definition in your 'Extensions file' as follows:

<ContentDefinition Id="api.signuporsignin">
    <LoadUri>https://__BlobBaseUri__/templates/unified.html</LoadUri>
    <RecoveryUri>~/common/default_page_error.html</RecoveryUri>
    <DataUri>urn:com:microsoft:aad:b2c:elements:unifiedssp:1.0.0</DataUri>
    <Metadata>
        <Item Key="DisplayName">Signin and Signup</Item>
    </Metadata>
    <LocalizedResourcesReferences MergeBehavior="Prepend">
        <LocalizedResourcesReference Language="da" LocalizedResourcesReferenceId="api.signuporsignin.da" />
    </LocalizedResourcesReferences>
</ContentDefinition>

You can add your own HTML5 template by extending a content definition with your own 'LoadUri' value.

I added my template as a blob in Azure as you can see from the name of the token. I used a token to be able to replace it with varying values. I would later do so by running a script to generate custom policies for different B2C tenants (DEV, QA. PRODUCTION).

If you need to localize the resulting custom user flow, this is also where you reference your localization element (see attribute 'LocalizedResourcesReferenceId' value above, which is the same as the 'LocalizedResources' element's 'Id' attribute value in the 'Localization' section).

Localication

If your application is in another language you might want to translate parts of the user journey to that language.

In my case, the application is in Danish. I have used an example from Microsoft's documentation for Localization string IDs as a starting point.

For the 'LocalizedResources' with Id attribute value 'api.signuporsignin.da' I have looked to the Sign-up or sign-in example:

<Localization Enabled="true">
    <SupportedLanguages DefaultLanguage="da" MergeBehavior="ReplaceAll">
        <SupportedLanguage>en</SupportedLanguage>
        <SupportedLanguage>da</SupportedLanguage>
    </SupportedLanguages>
    <LocalizedResources Id="api.signuporsignin.da">
        <LocalizedStrings>
            <LocalizedString ElementType="UxElement" StringId="logonIdentifier_email">E-mail</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="requiredField_email">Udfyld venligst din e-mail</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="logonIdentifier_username">Brugernavn</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="password">Adgangskode</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="createaccount_link">Tilmeld dig nu</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="requiredField_username">Udfyld venligst dit brugernavn</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="createaccount_intro">Har du ikke en konto?</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="forgotpassword_link">Glemt din adgangskode?</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="divider_title">ELLER</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="cancel_message">Brugeren har glemt sin adgangskode</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="button_signin">Log ind</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="social_intro">Log ind med din konto til sociale medier</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="requiredField_password">Udfyld venligst din adgangskode</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="invalid_password">Den udfyldte adgangskode er ikke i det forventede format</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="local_intro_username">Log ind med dit brugernavn</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="local_intro_email">Log ind med din eksisterende konto</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="invalid_email">Udfyld venligst med en gyldig e-mail</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="unknown_error">Vi har problemer med at logge dig ind. Prøv venligst igen senere</LocalizedString>
            <LocalizedString ElementType="UxElement" StringId="email_pattern">^[a-zA-Z0-9.!#$%&amp;'^_`{}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$</LocalizedString>
            <LocalizedString ElementType="ErrorMessage" StringId="UserMessageIfInvalidPassword">Den udfyldte adgangskode er ikke gyldig</LocalizedString>
            <LocalizedString ElementType="ErrorMessage" StringId="UserMessageIfClaimsPrincipalDoesNotExist">Vi kan ikke finde din konto</LocalizedString>
            <LocalizedString ElementType="ErrorMessage" StringId="UserMessageIfOldPasswordUsed">Det ser ud til at du har brugt en gammel adgangskode</LocalizedString>
            <LocalizedString ElementType="ErrorMessage" StringId="DefaultMessage">Ugyldigt brugernavn eller adgangskode</LocalizedString>
            <LocalizedString ElementType="ErrorMessage" StringId="UserMessageIfUserAccountDisabled">Din konto er blevet låst. Kontakt vores support for at få låst den op og prøv så igen</LocalizedString>
            <LocalizedString ElementType="ErrorMessage" StringId="UserMessageIfUserAccountLocked">Din konto er midlertidigt låst for at forhindre uatoriseret brug. Prøv igen senere</LocalizedString>
            <LocalizedString ElementType="ErrorMessage" StringId="AADRequestsThrottled">Der er for mange forespørgsler i øjeblikket. Vent venligst noget tid og prøv så igen</LocalizedString>
            <LocalizedString ElementType="ClaimType" ElementId="newPassword" StringId="PatternHelpText">8-16 karakterer, der indeholder 3 ud af 4 af det følgende: små bogstaver, store bogstaver, tal og et af følgende symboler: @ # $ % ^ &amp; * - _ + = [ ] { } | \ : ' , ? / ` ~ " ( ) ; .</LocalizedString>
        </LocalizedStrings>
    </LocalizedResources>
    <LocalizedResources Id="api.localaccountpasswordreset.da">
        <LocalizedStrings>
            <LocalizedString ElementType="ClaimType" ElementId="email" StringId="DisplayName">E-mail</LocalizedString>
            <LocalizedString ElementType="ClaimType" ElementId="newPassword" StringId="DisplayName">Ny adgangskode</LocalizedString>
            <LocalizedString ElementType="ClaimType" ElementId="reenterPassword" StringId="DisplayName">Bekræft ny adgangskode</LocalizedString>
            <LocalizedString ElementType="ErrorMessage" StringId="UserMessageIfClaimsPrincipalDoesNotExist">Vi kan ikke finde din konto</LocalizedString>
            <LocalizedString ElementType="ClaimType" ElementId="newPassword" StringId="PatternHelpText">8-16 karakterer, der indeholder 3 ud af 4 af det følgende: små bogstaver, store bogstaver, tal og et af følgende symboler: @ # $ % ^ &amp; * - _ + = [ ] { } | \ : ' , ? / ` ~ " ( ) ; .</LocalizedString>
        </LocalizedStrings>
    </LocalizedResources>
</Localization>

For the 'LocalizedResources' with Id attribute value 'api.localaccountpasswordreset.da' I have looked at the corresponding ContentDefinition and seen that it references the self-asserted page template. Thus, I have used the Sign-up and self-asserted pages example as a reference and chosen a subset of the elements that I find applies to the password reset user flow after having tested it.

ClaimsProvider

As per instructions in the guide Get started with custom policies in Azure Active Directory B2C, I replaced 'IdentityExperienceFrameworkAppId' and 'ProxyIdentityExperienceFrameworkAppId' in the extension of the 'login-NonInteractive' TechnicalProfile with actual application IDs from applications I had created.

I ended up replacing the application IDs with tokens:

<ClaimsProvider>
    <DisplayName>Local Account SignIn</DisplayName> 
    <TechnicalProfiles>
        <TechnicalProfile Id="login-NonInteractive">
            <Metadata>
                <!--ProxyIdentityExperienceFramework - Application (client) ID-->
                <Item Key="client_id">__ProxyIdentityExperienceFrameworkId__</Item>
                <!--IdentityExperienceFramework - Application (client) ID-->
                <Item Key="IdTokenAudience">__IdentityExperienceFrameworkId__</Item>
            </Metadata>
            <InputClaims>
                <InputClaim ClaimTypeReferenceId="client_id" DefaultValue="__ProxyIdentityExperienceFrameworkId__" />
                <InputClaim ClaimTypeReferenceId="resource_id" PartnerClaimType="resource" DefaultValue="__IdentityExperienceFrameworkId__" />
            </InputClaims>
        </TechnicalProfile>
    </TechnicalProfiles>
</ClaimsProvider>

I have done so to be able to replace them with varying values. I would later do so by running a script to generate custom policies for different B2C tenants (DEV, QA. PRODUCTION).

I have also extended two of the technical profiles under the 'Azure Active Directory' ClaimsProvider:

<ClaimsProvider>
    <DisplayName>Azure Active Directory</DisplayName>
    <TechnicalProfiles>
        <TechnicalProfile Id="AAD-UserWriteUsingLogonEmail">
            <Metadata>
                <!--b2c-extensions-app application ID-->
                <Item Key="ClientId">__B2CExtensionApplicationId__</Item>
                <!--b2c-extensions-app application ObjectId-->
                <Item Key="ApplicationObjectId">__B2CExtensionApplicationObjectId__</Item>
            </Metadata>
            <InputClaimsTransformations>
                <InputClaimsTransformation ReferenceId="CreateDisplayNameFromGivenNameAndLastName" />
                <InputClaimsTransformation ReferenceId="CopyEmailAddress" />
                <InputClaimsTransformation ReferenceId="NormalizeUpnPrefixPri" />
                <InputClaimsTransformation ReferenceId="NormalizeUpnPrefixSec" />
                <InputClaimsTransformation ReferenceId="CreateUserPrincipalName" />
            <PersistedClaims>
                <PersistedClaim ClaimTypeReferenceId="mail" />
                <PersistedClaim ClaimTypeReferenceId="userPrincipalName" />
                <PersistedClaim ClaimTypeReferenceId="extension_CompanyCVR" />
            </PersistedClaims>
        </TechnicalProfile>

        <TechnicalProfile Id="AAD-UserReadUsingObjectId">
            <Metadata>
                <!--b2c-extensions-app application ID-->
                <Item Key="ClientId">__B2CExtensionApplicationId__</Item>
                <!--b2c-extensions-app application ObjectId-->
                <Item Key="ApplicationObjectId">__B2CExtensionApplicationObjectId__</Item>
            </Metadata>
            <OutputClaims>
                <OutputClaim ClaimTypeReferenceId="email" PartnerClaimType="signInNames.emailAddress" />
                <OutputClaim ClaimTypeReferenceId="extension_CompanyCVR" />
            </OutputClaims>
        </TechnicalProfile>
    </TechnicalProfiles>
</ClaimsProvider>

For both the 'AAD-UserWriteUsingLogonEmail' TechnicalProfile and the 'AAD-UserReadUsingObjectId' TechnicalProfile, I have added application ID and object ID for the extensions application.

Specifically for the 'AAD-UserWriteUsingLogonEmail' TechnicalProfile, I have added claims transformations and the claims I want to be persisted upon creating a user.

Specifically for the 'AAD-UserReadUsingObjectId' TechnicalProfile, have I added output claims to be included upon log-in for a user.

To find the extensions application, go to the 'All applications' tab under the 'App registrations' blade.

[TODO: IMAGE]

UserJourney

User journeys explicit paths through which the user is taken, in order to retrieve the claims that are to be presented to the relying party (ref. 5).

I have extended the 'PasswordReset' UserJourney to read the user. I have done so, to output claims when a user is logged in after resetting their password:

<UserJourney Id="PasswordReset">
    <OrchestrationSteps>
        <!-- This step reads any user attributes that we may not have received when in the token. -->
        <OrchestrationStep Order="3" Type="ClaimsExchange">
            <ClaimsExchanges>
                <ClaimsExchange Id="AADUserReadWithObjectId" TechnicalProfileReferenceId="AAD-UserReadUsingObjectId" />
            </ClaimsExchanges>
        </OrchestrationStep>

        <OrchestrationStep Order="4" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer" />
    </OrchestrationSteps>
</UserJourney>

I started by checking out the UserJourney in the 'Base file'. I then inserted the step by assigning it an order of 3 and incrementing the order of the next step.

That concludes the initial changes I have made to the 'Extensions file'. In a later post I will get into some of the additions I made in order to support SAML2 service providers.

Next steps

Go to the next post in this series Azure AD B2C Custom Policies (Series): Starter Pack Relying Party Files.

There's also my other post on Azure AD B2C Custom Policies (Series): SAML2 Sign-in Only.

For C# developers there's a post in the series called Azure AD B2C Custom Policies (Series): Basic User Operations With Microsoft Graph API

References

  1. https://github.com/Azure-Samples/active-directory-b2c-custom-policy-starterpack
  2. Get started with custom policies in Azure Active Directory B2C
  3. Azure Active Directory B2C: Enable custom attributes in a custom profile policy
  4. ContentDefinitions
  5. UserJourneys