2

I am creating a custom policy in Azure B2C. This policy currently allows a user to signup for an account, after which we validate some info from the user and write a custom claim into the user object via an API call.

At the moment the user object is created in the B2C BEFORE the info from the user is validated. This means that if the user cant validate the info the user object is created and sits there dormant without the custom claim populated.

I would like to change this so the user object is not written to the directory at all until the user can successfully validate the info for the custom claim. Step 10 is where the custom info is gathered and validated.

Below is my user journey. How could I re-order this journey to achieve my goal?

    <UserJourney Id="SignUpOrSignIn_Custom">
      <OrchestrationSteps>

        <OrchestrationStep Order="1" Type="CombinedSignInAndSignUp" ContentDefinitionReferenceId="api.signuporsignin">
          <ClaimsProviderSelections>
            <!-- <ClaimsProviderSelection TargetClaimsExchangeId="FacebookExchange" /> -->
            <ClaimsProviderSelection TargetClaimsExchangeId="GoogleExchange" />
            <ClaimsProviderSelection ValidationClaimsExchangeId="LocalAccountSigninEmailExchange" />
            <ClaimsProviderSelection TargetClaimsExchangeId="ForgotPasswordExchange" />
          </ClaimsProviderSelections>
          <ClaimsExchanges>
            <ClaimsExchange Id="LocalAccountSigninEmailExchange" TechnicalProfileReferenceId="SelfAsserted-LocalAccountSignin-Email" />
          </ClaimsExchanges>
        </OrchestrationStep>

        <!-- Check if the user has selected to sign in using one of the social providers -->
        <OrchestrationStep Order="2" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="true">
              <Value>objectId</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <!-- <ClaimsExchange Id="FacebookExchange" TechnicalProfileReferenceId="Facebook-OAUTH" /> -->
            <ClaimsExchange Id="GoogleExchange" TechnicalProfileReferenceId="Google-OAuth2" />
            <ClaimsExchange Id="SignUpWithLogonEmailExchange" TechnicalProfileReferenceId="LocalAccountSignUpWithLogonEmail" />
            <ClaimsExchange Id="ForgotPasswordExchange" TechnicalProfileReferenceId="ForgotPassword" />
          </ClaimsExchanges>
        </OrchestrationStep>

        <OrchestrationStep Order="3" Type="InvokeSubJourney">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="false">
              <Value>isForgotPassword</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <JourneyList>
            <Candidate SubJourneyReferenceId="PasswordReset" />
          </JourneyList>
        </OrchestrationStep>
        

        <!-- For social IDP authentication, attempt to find the user account in the directory. -->
        <OrchestrationStep Order="4" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimEquals" ExecuteActionsIf="true">
              <Value>authenticationSource</Value>
              <Value>localAccountAuthentication</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="AADUserReadUsingAlternativeSecurityId" TechnicalProfileReferenceId="AAD-UserReadUsingAlternativeSecurityId-NoError" />
          </ClaimsExchanges>
        </OrchestrationStep>

        <!-- Show self-asserted page only if the directory does not have the user account already (i.e. we do not have an objectId). 
          This can only happen when authentication happened using a social IDP. If local account was created or authentication done
          using ESTS in step 2, then an user account must exist in the directory by this time. -->
        <OrchestrationStep Order="5" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="true">
              <Value>objectId</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="SelfAsserted-Social" TechnicalProfileReferenceId="SelfAsserted-Social" />
          </ClaimsExchanges>
        </OrchestrationStep>

        <!-- This step reads any user attributes that we may not have received when authenticating using ESTS so they can be sent 
          in the token. -->
        <OrchestrationStep Order="6" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimEquals" ExecuteActionsIf="true">
              <Value>authenticationSource</Value>
              <Value>socialIdpAuthentication</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="AADUserReadWithObjectId" TechnicalProfileReferenceId="AAD-UserReadUsingObjectId" />
          </ClaimsExchanges>
        </OrchestrationStep>

        <!-- The previous step (SelfAsserted-Social) could have been skipped if there were no attributes to collect 
             from the user. So, in that case, create the user in the directory if one does not already exist 
             (verified using objectId which would be set from the last step if account was created in the directory. -->
        <OrchestrationStep Order="7" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="true">
              <Value>objectId</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="AADUserWrite" TechnicalProfileReferenceId="AAD-UserWriteUsingAlternativeSecurityId" />
          </ClaimsExchanges>
        </OrchestrationStep>

        <!-- Call the TOTP enrollment ub journey. If user already enrolled the sub journey will not ask the user to enroll -->
        <OrchestrationStep Order="8" Type="InvokeSubJourney">
          <JourneyList>
            <Candidate SubJourneyReferenceId="TotpFactor-Input" />
          </JourneyList>
        </OrchestrationStep>
        
        
        <!-- Call the TOTP validation sub journey-->
        <OrchestrationStep Order="9" Type="InvokeSubJourney">
          <JourneyList>
            <Candidate SubJourneyReferenceId="TotpFactor-Verify" />
          </JourneyList>
        </OrchestrationStep>

        

        <!-- This step will gather custom info from user and validate against a custom API. If the info validates a custom value returned from the API is written to the user object.
        -->
        <OrchestrationStep Order="10" Type="ClaimsExchange">
          <Preconditions>
            <Precondition Type="ClaimsExist" ExecuteActionsIf="true">
              <Value>extension_empOPRID</Value>
              <Action>SkipThisOrchestrationStep</Action>
            </Precondition>
          </Preconditions>
          <ClaimsExchanges>
            <ClaimsExchange Id="LocalAccountSignUpWithLogonEmail-ValidateData" TechnicalProfileReferenceId="Get-Validate-Personal-Data" />
          </ClaimsExchanges>
        </OrchestrationStep>


        <OrchestrationStep Order="11" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer" />

      </OrchestrationSteps>
      <ClientDefinition ReferenceId="DefaultWeb" />
    </UserJourney>
    </UserJourneys> ```
Andrew Wiebe
  • 111
  • 13

1 Answers1

2

The below response assumes that the extra fields are to be collected from the user manually entering them into UI fields displayed by a self-asserted technical profile (if they are generated by some other means the considerations below may be different). It also assumes that you're building on top of the Microsoft custom policy starterpack (from the names of some of the technical profiles in your posted user journey this appears to be the case).

Unfortunately, this is more complicated than simply reordering the orchestration steps in the user journey. You need to collect the extra fields from the user and call the API to validate them before the user object is created, which happens either in the technical profile AAD-UserWriteUsingLogonEmail, which is called as a validation technical profile of the LocalAccountSignupWithLogonEmail technical profile (in orchestration step 2); or in AAD-UserWriteUsingAlternativeSecurityId (called both as a validation technical profile of SelfAsserted-Social in step 5 and on its own in orchestration step 7), but this is complicated by the following factors:

  • The values of password claims are only available in the same technical profile that collects them from the user, so you can't collect the user password in one form and then create the user object using that password in a later orchestration step.

  • The current user journey is built around the assumption that after the existing signup profiles execute, the user has been created and exists. For example, the preconditions used to implement the control flow and different login/signup cases in the user journey depend on some claims that are populated by the AAD write profiles that create the user objects.

  • Certain validations that would currently occur (for example, preventing progress and displaying an error message if a user provides an email in signup that corresponds to an already-existing user in AAD) can't happen, or will need to be implemented manually in a more complex way, if the attempt to create the user object by writing to AAD doesn't happen within the validation technical profiles of the self-asserted profile where those user fields are collected.

Note that if your custom API for validating the extra fields needs the AAD objectId of the user as an input, you're out of luck, as that won't exist until the user object is created.

There are two main approaches you could take to implement this, depending on how you want the UX to ultimately behave.

The "easy" way (add fields to the existing default forms)

If you don't need the custom fields to be entered by the user in a separate form from the default fields, you could add them to the existing self-asserted profiles, and add your API validation call as an additional validation step before the AAD write operation itself in the validation profiles. This may depend on details of your API - if it would be a problem for it to be called for a user that ultimately fails to get created, you'll need a more complex solution pre-validating some user fields. Assuming that's not the case, you would do the following:

  1. Copy the existing self-asserted profiles collecting user data from the base file you're using to your extension file (LocalAccountSignupWithLogonEmail and SelfAsserted-Social). Give the copies new IDs as you want to replace them completely, as you need to insert new validation technical profiles in specific orders.
  2. To these copies, add extra output claims collecting your extra fields.
  3. Also add in these copies an extra ValidationTechnicalProfile, before the existing one (the existing one creates the user object in AAD). This validation profile should call your external validation API.
  4. In the technical profiles called as validation profiles to write the user to AAD, add your extra claims as persisted claims.
  5. Replace the references to LocalAccountSignupWithLogonEmail and SelfAsserted-Social in the user journey's orchestration steps with the corresponding IDs you gave your modified copies.
  6. Remove orchestration step 10, the one where you currently call Get-Validate-Personal-Data, as you've now moved its functionality into technical profiles called in earlier steps (you can also remove step 7 since the fact that you added fields that will be displayed for the user to enter in SelfAsserted-Social should make step 7 unreachable).
  7. Renumber the orchestration steps to remove the gaps in the numbering you've created by removing steps.

What you would end up with for the copy of LocalAccountSignupWithLogonEmail, for example, might look something like this (with your actual extra attributes to collect and API-call profile ID instead):

<TechnicalProfile Id="LocalAccountSignUpWithLogonEmail-Extras">
  <DisplayName>Email signup</DisplayName>
  <Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.SelfAssertedAttributeProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
  <Metadata>
    <Item Key="IpAddressClaimReferenceId">IpAddress</Item>
    <Item Key="ContentDefinitionReferenceId">api.localaccountsignup</Item>
  </Metadata>
  <CryptographicKeys>
    <Key Id="issuer_secret" StorageReferenceId="B2C_1A_TokenSigningKeyContainer" />
  </CryptographicKeys>
  <InputClaims>
    <InputClaim ClaimTypeReferenceId="email" />
  </InputClaims>
  <OutputClaims>
    <OutputClaim ClaimTypeReferenceId="objectId" />
    <OutputClaim ClaimTypeReferenceId="email" PartnerClaimType="Verified.Email" Required="true" />
    <OutputClaim ClaimTypeReferenceId="newPassword" Required="true" />
    <OutputClaim ClaimTypeReferenceId="reenterPassword" Required="true" />
    <OutputClaim ClaimTypeReferenceId="executed-SelfAsserted-Input" DefaultValue="true" />
    <OutputClaim ClaimTypeReferenceId="authenticationSource" />
    <OutputClaim ClaimTypeReferenceId="newUser" />

    <!-- Optional claims, to be collected from the user -->
    <OutputClaim ClaimTypeReferenceId="displayName" />
    <OutputClaim ClaimTypeReferenceId="givenName" />
    <OutputClaim ClaimTypeReferenceId="surname" />

    <!-- Extra claims to collect and validate from the user -->
    <OutputClaim ClaimTypeReferenceId="extension_empOPRID" />
    <OutputClaim ClaimTypeReferenceId="extension_anotherExtraAttr" />
  </OutputClaims>
  <ValidationTechnicalProfiles>
    <ValidationTechnicalProfile ReferenceId="Call-Personal-Data-Validation-API" />
    <ValidationTechnicalProfile ReferenceId="AAD-UserWriteUsingLogonEmail" />
  </ValidationTechnicalProfiles>
  <UseTechnicalProfileForSessionManagement ReferenceId="SM-AAD" />
</TechnicalProfile>

And you would include in your extension file something akin to the following, to extend AAD-UserWriteUsingLogonEmail:

<TechnicalProfile Id="AAD-UserWriteUsingLogonEmail">
  <PersistedClaims>
    <!-- Extra claims to write with the new user object. -->
    <PersistedClaim ClaimTypeReferenceId="extension_empOPRID" />
    <PersistedClaim ClaimTypeReferenceId="extension_anotherExtraAttr" />
    <PersistedClaim ClaimTypeReferenceId="extension_extraAttrReturnedByValidationAPI" />
  </PersistedClaims>
</TechnicalProfile>

You'd need to do similar for SelfAsserted-Social and AAD-UserWriteUsingAlternativeSecurityId as well.

You would also want to ensure your validation API returns a 409 error if validation fails, if you want the validation in that case to leave the user on the self-asserted profile with an error message and without creating a user object.

The hard way (separate form for collecting the extra fields)

If you must have a separate form for collecting your extra fields, and you must not create the user object until those fields are collected and validated, you would need to take an approach akin to the following (described in more general terms than the above as there are more implementation details that would depend on precisely the behavior you want):

  1. Add new orchestration steps after the ones (2 and 5) that create the user objects, with appropriate preconditions so those steps only run if the preceding technical profile was executed.
  2. Create two versions of Get-Validate-Personal-Data (because they would need to handle slightly different claims used to create the user object in AAD). In each of the new orchestration steps you would call one of the Get-Validate-Personal-Data profiles.
  3. Move the validation technical profiles that perform the AAD writes creating the user object from LocalAccountSignupWithLogonEmail and SelfAsserted-Social into the respective Get-Validate-Personal-Data profiles that follow them. Additionally, move output claims that are populated by the creation of the user object (like objectId and newUser) into the Get-Validate-Personal-Data profiles. Add DisplayClaims for the claims you want the user to enter in the Get-Validate-Personal-Data profile's form, then add the claims entered by the user in the preceding LocalAccountSignupWithLogonEmail or SelfAsserted-Social profile as InputClaims and OutputClaims but not DisplayClaims (this way only the new fields will be prompted from the user in the second form).
  4. For LocalAccountSignupWithLogonEmail, you would need to also move the password claims into the corresponding Get-Validate-Personal-Data profile, as password claim values can only be accessed within the self-asserted profile the user entered them in (along with that profile's validation profiles).
  5. Add some new, extra validation profiles to LocalAccountSignupWithLogonEmail and SelfAsserted-Social in place of the old AAD-UserWrite... ones to manually validate the user's entered data without creating the user object. You might need to do an AAD read using the user email or alternativeSecurityId to try to retrieve an objectId, configured not to throw an error if no user is found, and then assert that the objectId claim doesn't exist, or similar, for example.
  6. You would lastly remove the redundant orchestration steps again (like in the "easy way" above).