In Azure B2C Sign Up Flow, How to warn user email is in use when clicking 'Send Verfication Code' button - azure-ad-b2c

I have a B2C sign-up only custom policy, with a custom email display control. Currently, the user is notified AFTER validating the email, that the email is already in use. (profile and password information are collected on a later step)
Here is some relevant code to accomplish this:
<TechnicalProfile Id="PartnerSignUpVerifyEmailPage">
<DisplayName>Local Email Verification</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.partners.signUpVerifyEmailPage</Item>
<Item Key="IncludeClaimResolvingInClaimsHandling">true</Item>
<Item Key="UserMessageIfClaimsTransformationStringsAreNotEqual">A user with this email address already exists.</Item>
</Metadata>
<InputClaimsTransformations>
<InputClaimsTransformation ReferenceId="GetLocalizedStringsForEmail" />
</InputClaimsTransformations>
<InputClaims>
<InputClaim ClaimTypeReferenceId="email" DefaultValue="{OIDC:LoginHint}" AlwaysUseDefaultValue="true" />
</InputClaims>
<DisplayClaims>
<DisplayClaim DisplayControlReferenceId="localizedSignUpEmailVerificationControl" />
</DisplayClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="email" Required="true" />
</OutputClaims>
<ValidationTechnicalProfiles>
<!-- this validation asserts the email provided isn't already in use -->
<ValidationTechnicalProfile ReferenceId="AAD-UserReadUsingEmailAddress-RaiseIfExists" />
</ValidationTechnicalProfiles>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
<UseTechnicalProfileForSessionManagement ReferenceId="SM-AAD" />
</TechnicalProfile>
<DisplayControl Id="localizedSignUpEmailVerificationControl" UserInterfaceControlType="VerificationControl">
<InputClaims>
<InputClaim ClaimTypeReferenceId="email" />
</InputClaims>
<DisplayClaims>
<DisplayClaim ClaimTypeReferenceId="email" Required="true" />
<DisplayClaim ClaimTypeReferenceId="verificationCode" ControlClaimType="VerificationCode" Required="true" />
</DisplayClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="email" />
</OutputClaims>
<Actions>
<Action Id="SendCode">
<ValidationClaimsExchange>
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="GenerateOtp"/>
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="SendLocalizedOtp"/>
</ValidationClaimsExchange>
</Action>
<Action Id="VerifyCode">
<ValidationClaimsExchange>
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="VerifyOtp" />
</ValidationClaimsExchange>
</Action>
</Actions>
</DisplayControl>
</DisplayControls>
<TechnicalProfile Id="AAD-UserReadUsingEmailAddress-RaiseIfExists">
<Metadata>
<Item Key="Operation">Read</Item>
<Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">false</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="email" PartnerClaimType="signInNames.emailAddress" Required="true" />
</InputClaims>
<OutputClaims>
<!-- Required claims -->
<OutputClaim ClaimTypeReferenceId="objectId" DefaultValue="NOTFOUND" />
<OutputClaim ClaimTypeReferenceId="objectIdNotFound" DefaultValue="NOTFOUND" AlwaysUseDefaultValue="true" />
</OutputClaims>
<OutputClaimsTransformations>
<!-- ensure that the object id isn't already used -->
<OutputClaimsTransformation ReferenceId="AssertObjectIdObjectIdNotFoundAreEqual" />
<!-- blank the object id, in the case that it was used, so we can let the user change the email and retest -->
<OutputClaimsTransformation ReferenceId="SetObjectIdToNull" />
</OutputClaimsTransformations>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
</TechnicalProfile>
My goal is: Upon pressing the 'Send Verification Code' button, validate that the email is not already in use. Then, if the email is in use: put up an error message, don't send an email and don't progress the control.
I have tried 3 different approaches and none seem to do the job.
Approach 1: Validate, Error and Stop
Details:
At the top of my SendCode ValidationClaimsExchanges add a ValidationClaimsExchangeTechnicalProfile for AAD-UserReadUsingEmailAddress-RaiseIfExists
On the other ValidationClaimsExchangeTechnicalProfiles add ContinueOnError="false"
Results:
Displays an error to the user when using an email already in use
The control still prompts for sending a code
However, the other validation steps still execute and an email is still sent.
In this case, it looks like failing the validation and sending an error message is not the same kind of error that the ContinueOnError looks for.
Approach 2: Evaluate New Claim, Use Precondition
Details:
Create a boolean claim emailAlreadyRegistered
At the top of my SendCode ValidationClaimsExchanges add a ValidationClaimsExchangeTechnicalProfile for AAD-UserReadUsingEmailAddress-RaiseIfExists
In AAD-UserReadUsingEmailAddress-RaiseIfExists add a CompareClaims claims transformation to set emailAlreadyRegistered = True if the emails match
Add preconditions to the other ValidationClaimsExchangeTechnicalProfiles using ClaimEquals to see if emailAlreadyRegistered is True
Results:
Displays an error to the user when using an email already in use
The control still prompts for sending a code
However, the other validation steps still execute and an email is still sent.
I think in this case the precondition is not seeing the update to the claim (if I set the claim from my PartnerSignUpVerifyEmailPage technical profile then the preconditions seem to respect it).
Approach 3: Nested Validation Steps
Details:
Create a boolean claim emailAlreadyRegistered
Create a new claims transformation technical profile
The new profile takes the email as input and uses a claims transformation to set emailAlreadyRegistered in the input claim transformation
The new technical profile ALSO has 3 validationtechincalprofiles: AAD-UserReadUsingEmailAddress-RaiseIfExists, GenerateOtp, SendLocalizedOtp
In SendCode ValidationClaimsExchanges replace all ValidationClaimsExchangeTechnicalProfiles for the one new technical profile
Results:
Does not display the error when the email is in use
The control proceeds to the code verification step
No email is sent
In this case it looks like none of the nested validation technical profiles work at all.
Solution: (Credit: Christian Le Breton)
Create a boolean claim emailAlreadyRegistered
Split AAD-UserReadUsingEmailAddress-RaiseIfExists into two separate Technical Profiles
The first is the same as described without the output transformations
The second is a proper ClaimsTransformationProtocolProvider technical profile with the two OutputClaimsTransformations described above, plus a third in between them, This transformation sets emailAlreadyRegistered using a CompareClaims transform. This technical profile also outputs the claim emailAlreadyRegistered with DefaultValue = "false"
At the top of my SendCode ValidationClaimsExchanges add the two new Technical Profiles
Add preconditions to the other ValidationClaimsExchangeTechnicalProfiles using ClaimEquals to see if emailAlreadyRegistered is 'true'
Basically, what I failed to understand was that an AAD Technical Profile can't set new claims into the bag (I suppose) and a Claims Technical Profile can't read AAD, so these two activities needed to be separated, once I did that the 2nd approach worked.
Here are some of the tricky bits from the final result.
<DisplayControl Id="localizedSignUpEmailVerificationControl" UserInterfaceControlType="VerificationControl">
<InputClaims>
<InputClaim ClaimTypeReferenceId="email" />
</InputClaims>
<DisplayClaims>
<DisplayClaim ClaimTypeReferenceId="email" Required="true" />
<DisplayClaim ClaimTypeReferenceId="verificationCode" ControlClaimType="VerificationCode" Required="true" />
</DisplayClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="email" />
</OutputClaims>
<Actions>
<Action Id="SendCode">
<ValidationClaimsExchange>
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="AAD-UserReadUsingEmailAddress-RaiseIfExists-Pt1" ContinueOnError="false" />
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="AAD-UserReadUsingEmailAddress-RaiseIfExists-Pt2" ContinueOnError="false" />
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="GenerateOtp" ContinueOnError="false" >
<Preconditions>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>emailAlreadyRegistered</Value>
<Value>true</Value>
<Action>SkipThisValidationTechnicalProfile</Action>
</Precondition>
</Preconditions>
</ValidationClaimsExchangeTechnicalProfile>
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="SendLocalizedOtp" ContinueOnError="false">
<Preconditions>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>emailAlreadyRegistered</Value>
<Value>true</Value>
<Action>SkipThisValidationTechnicalProfile</Action>
</Precondition>
</Preconditions>
</ValidationClaimsExchangeTechnicalProfile>
</ValidationClaimsExchange>
</Action>
<Action Id="VerifyCode">
<ValidationClaimsExchange>
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="VerifyOtp" />
</ValidationClaimsExchange>
</Action>
</Actions>
</DisplayControl>
<TechnicalProfile Id="AAD-UserReadUsingEmailAddress-RaiseIfExists-Pt1">
<Metadata>
<Item Key="Operation">Read</Item>
<Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">false</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="email" PartnerClaimType="signInNames.emailAddress" Required="true" />
</InputClaims>
<OutputClaims>
<!-- Required claims -->
<OutputClaim ClaimTypeReferenceId="objectId" DefaultValue="NOTFOUND" />
<OutputClaim ClaimTypeReferenceId="objectIdNotFound" DefaultValue="NOTFOUND" AlwaysUseDefaultValue="true" />
</OutputClaims>
<OutputClaimsTransformations>
<!-- ensure that the object id isn't already used -->
<OutputClaimsTransformation ReferenceId="AssertObjectIdObjectIdNotFoundAreEqual" />
</OutputClaimsTransformations>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
</TechnicalProfile>
<TechnicalProfile Id="AAD-UserReadUsingEmailAddress-RaiseIfExists-Pt2">
<DisplayName>Check If Email Is Registered</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.ClaimsTransformationProtocolProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">false</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="objectId" />
<InputClaim ClaimTypeReferenceId="objectIdNotFound" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="emailAlreadyRegistered" />
<OutputClaim ClaimTypeReferenceId="objectId" />
</OutputClaims>
<OutputClaimsTransformations>
<!-- ensure that the object id isn't already used -->
<OutputClaimsTransformation ReferenceId="SetEmailAlreadyRegistered" />
<!-- blank the object id, in the case that it was used, so we can let the user change the emamil and retest -->
<OutputClaimsTransformation ReferenceId="SetObjectIdToNull" />
</OutputClaimsTransformations>
</TechnicalProfile>

I think approach 1 is likely your best approach, with some slight modifications. The main issue is that claims transformations need to be called from a ClaimsTransformations technical profile that is a validation technical profile, not by calling the ClaimsTransformations profile from within a validation technical profile.
Instead of trying to do everything in one step, it should be broken up into a few steps.
Have your user read technical profile just output the objectId and objectIdNotFound, without the claims transformation, and have your send code step use the read technical profile, the assert technical profile and the set to null technical profile as verification profiles in that order, prior to generating or sending the OTP.

Related

Azure B2C Custom Policy - Custom technical profile doesn't work in SignUp

I'm trying to use custom technical profile for Local Account in SignUpOrSignIn user journey.
I have Created the following technical profile in my customtrustframeworkextensions.xml (base:trustframeworkextensions.xml):
<ClaimsProvider>
<DisplayName>Local Account</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="CustomLocalAccountSignUpWithLogonEmail">
<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" />
<OutputClaim ClaimTypeReferenceId="extension_XXX" />
<!-- Optional claims, to be collected from the user -->
<OutputClaim ClaimTypeReferenceId="displayName" />
<OutputClaim ClaimTypeReferenceId="givenName" />
<OutputClaim ClaimTypeReferenceId="surName" />
</OutputClaims>
<ValidationTechnicalProfiles>
<ValidationTechnicalProfile ReferenceId="REST-ValidateProfile" />
</ValidationTechnicalProfiles>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-AAD" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
<ClaimsProvider>
REST-ValidateProfile looks like the following:
<ClaimsProvider>
<DisplayName>REST APIs</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="REST-ValidateProfile">
<DisplayName>Check yyy and zzz Rest API</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.RestfulProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<!-- Set the ServiceUrl with your own REST API endpoint -->
<Item Key="ServiceUrl">https://asd</Item>
<Item Key="SendClaimsIn">Body</Item>
<!-- Set AuthenticationType to Basic or ClientCertificate in production environments -->
<Item Key="AuthenticationType">ApiKeyHeader</Item>
<!-- REMOVE the following line in production environments -->
<Item Key="AllowInsecureAuthInProduction">false</Item>
</Metadata>
<CryptographicKeys>
<Key Id="Api-key" StorageReferenceId="B2C_1A_key" />
</CryptographicKeys>
<InputClaims>
<!-- Claims sent to your REST API -->
<InputClaim ClaimTypeReferenceId="email" />
<InputClaim ClaimTypeReferenceId="extension_xxx" PartnerClaimType="xxx" />
</InputClaims>
<OutputClaims>
<!-- Claims parsed from your REST API -->
<OutputClaim ClaimTypeReferenceId="extension_yyy" PartnerClaimType="yyy" />
<OutputClaim ClaimTypeReferenceId="extension_zzz" PartnerClaimType="zzz" />
</OutputClaims>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" />
</TechnicalProfile>
</ClaimsProvider>
I have modified the OrchestrationStep to use custom technical profile in user journey:
<OrchestrationStep Order="2" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="true">
<Value>objectId</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="SignUpWithLogonEmailExchange" TechnicalProfileReferenceId="CustomLocalAccountSignUpWithLogonEmail" />
</ClaimsExchanges>
</OrchestrationStep>
When I run my Custom policy and select SignUp the browser shows error: "The page cannot be displayed because an internal server error has occurred."
There are some more spesific details in Application insights:
Exception Message:Output claim type "objectId" specified in the technical profile with id "CustomLocalAccountSignUpWithLogonEmail" in policy "B2C_1A_DEV_signup_signin" of tenant does not specify a UserInputType or a DefaultValue, and is not retrieved from a ValidationTechnicalProfile either., Exception Type:InvalidDefinitionException
Everything works ok when I use name "LocalAccountSignUpWithLogonEmail" for edited technical profile and the ClaimsExChange Looks like this:
<ClaimsExchange Id="SignUpWithLogonEmailExchange" TechnicalProfileReferenceId="CustomLocalAccountSignUpWithLogonEmail" />
But when I change name of the modified technical profile, policy doesn't work anymore. It seems to me that claims exchange doesn't work or something. I don't get why because I can't find any other places that refers to LocalAccountSignUpWithLogonEmail.
I want to use custom technical profile because want want to remove some outputclaims whitout touching the base policies.
The technicalProfile "CustomLocalAccountSignUpWithLogonEmail" has an output claim of the objectID which is common when you write something. The most common patterns is:
Technical profile
-> Validation Technical 1 profile
-> Validation Technical 2 profile
Being, validation technical profile 1 maybe calls your REST to validate the profile, then you call validation technical profile 2 to perform a write operation into the directory. When you write into the directory, it outputs an objectID which will output it into your technical profile and that error will go away.

Adding app insights logging inside AD B2C Display Controls (emailVerificationControl)

We implemented a custom email verification workflow through send grid and it works great. We want to add app insights logging to know how many are putting their email but not verifying it. Calling AppInsights-EmailVerificationSent Technical Profile in the display control below is giving me a "Unable to validate the information provided." error. How can I call this technical profile to log the action in app insights? Thanks very much!
<TechnicalProfile Id="AppInsights-Common">
<DisplayName>Application Insights</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.Insights.AzureApplicationInsightsProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="InstrumentationKey">xxxxx</Item>
<Item Key="DeveloperMode">true</Item>
<Item Key="DisableTelemetry ">false</Item>
</Metadata>
<InputClaims>
<!-- Properties of an event are added through the syntax {property:NAME}, where NAME is property being added to the event. DefaultValue can be either a static value or a value that's resolved by one of the supported DefaultClaimResolvers. -->
<InputClaim ClaimTypeReferenceId="EventTimestamp" PartnerClaimType="{property:EventTimestamp}" DefaultValue="{Context:DateTimeInUtc}" />
<InputClaim ClaimTypeReferenceId="PolicyId" PartnerClaimType="{property:Policy}" DefaultValue="{Policy:PolicyId}" />
<InputClaim ClaimTypeReferenceId="CorrelationId" PartnerClaimType="{property:CorrelationId}" DefaultValue="{Context:CorrelationId}" />
<InputClaim ClaimTypeReferenceId="Culture" PartnerClaimType="{property:Culture}" DefaultValue="{Culture:RFC5646}" />
</InputClaims>
</TechnicalProfile>
<TechnicalProfile Id="AppInsights-EmailVerificationSent">
<InputClaims>
<InputClaim ClaimTypeReferenceId="EventType" PartnerClaimType="eventName" DefaultValue="EmailVerificationSent" />
<InputClaim ClaimTypeReferenceId="email" PartnerClaimType="{property:UserId}" DefaultValue="NoValue" />
</InputClaims>
<IncludeTechnicalProfile ReferenceId="AppInsights-Common" />
</TechnicalProfile>
<DisplayControls>
<DisplayControl Id="emailVerificationControl" UserInterfaceControlType="VerificationControl">
<DisplayClaims>
<DisplayClaim ClaimTypeReferenceId="email" Required="true" />
<DisplayClaim ClaimTypeReferenceId="verificationCode" ControlClaimType="VerificationCode" Required="true" />
</DisplayClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="email" />
</OutputClaims>
<Actions>
<Action Id="SendCode">
<ValidationClaimsExchange>
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="GenerateOtp" />
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="SendOtp" />
**<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="AppInsights-EmailVerificationSent" />**
</ValidationClaimsExchange>
</Action>
<Action Id="VerifyCode">
<ValidationClaimsExchange>
<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="VerifyOtp" />
**<ValidationClaimsExchangeTechnicalProfile TechnicalProfileReferenceId="AppInsights-EmailVerified" />**
</ValidationClaimsExchange>
</Action>
</Actions>
</DisplayControl>
</DisplayControls>
Having had this exact problem recently the only way I found was to set up an API endpoint that would write custom events to App Insights, then use a REST technical profile to call that API from the DisplayControl.

Azure AD B2C - Forgot Password User Journey - Don't Allow old password?

I'm building an Azure AD B2C configuration based on custom policies. Sign in, profile edit, password change, etc. are already working as wanted.
But currently I'm struggling with the password forgot policy. I want to achieve that the new password does not equal to old one. Google and the Microsoft docs always give me examples for password changes. When I change the password, I have to enter the old one and the new one. Then I'm able to compare the old and the new one. For example like the way discribed here
But when a user has forgotten his password, then he is - of course - not able to enter the old password to compare it with the new one.
Is there any way to build a real password forgot policy without entering the old password but nevertheless ensure that the new password does not equal the old password?
Thanks in advance!
Alex
I was having the same problem and the answer from Jas Suri helped me a lot. However, I had some problems getting it to work. So, that's why I am sharing my final solution in case someone else is facing the same problem.
I have used the following as a base: https://github.com/azure-ad-b2c/samples/tree/master/policies/password-reset-not-last-password/policy but made some adjustments. Here is what I had to add to my existing policy to make it work:
ClaimSchema:
These claims will be used later.
<ClaimsSchema>
<ClaimType Id="SamePassword">
<DisplayName>samePassword</DisplayName>
<DataType>boolean</DataType>
<UserHelpText />
</ClaimType>
<ClaimType Id="resetPasswordObjectId">
<DisplayName>User's Object ID</DisplayName>
<DataType>string</DataType>
<DefaultPartnerClaimTypes>
<Protocol Name="OAuth2" PartnerClaimType="oid" />
<Protocol Name="OpenIdConnect" PartnerClaimType="oid" />
<Protocol Name="SAML2" PartnerClaimType="http://schemas.microsoft.com/identity/claims/objectidentifier" />
</DefaultPartnerClaimTypes>
<UserHelpText>Object identifier (ID) of the user object in Azure AD.</UserHelpText>
</ClaimType>
</ClaimsSchema>
ClaimsTransformation:
The first claim transformation checks whether resetPasswordObjectId is set, if not the previous attempted login with the new password apparently didn't work and thus the newPassword is not the same as the old / current password. The second claim transformation checks whether the claim SamePassword is equal to false. If not it throws an error and 'You can't use an old password' is displayed. More information on this later.
<ClaimsTransformations>
<ClaimsTransformation Id="CheckPasswordEquivalence" TransformationMethod="DoesClaimExist">
<InputClaims>
<InputClaim ClaimTypeReferenceId="resetPasswordObjectId" TransformationClaimType="inputClaim" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="SamePassword" TransformationClaimType="outputClaim" />
</OutputClaims>
</ClaimsTransformation>
<ClaimsTransformation Id="AssertSamePasswordIsFalse" TransformationMethod="AssertBooleanClaimIsEqualToValue">
<InputClaims>
<InputClaim ClaimTypeReferenceId="SamePassword" TransformationClaimType="inputClaim" />
</InputClaims>
<InputParameters>
<InputParameter Id="valueToCompareTo" DataType="boolean" Value="false" />
</InputParameters>
</ClaimsTransformation>
</ClaimsTransformations>
ClaimsProvider:
The overall idea is to use the newly entered password and attempt to login the user. In case the login is successful, the newPassword is the same as the old / current password and is not allowed. When logging in, we create an output claim and call it resetPasswordObjectId which is either unset or equal to the object id of the logged in user. We then check whether the resetPasswordObjectId exists (done in the claims transformation part), if not the newPassword can be used as it is not the same as the old / current password.
To display the correct error message in case the user entered the old password, we need to override UserMessageIfClaimsTransformationBooleanValueIsNotEqual in the TrustFrameworkLocalization.xml in the <LocalizedResources Id="api.localaccountpasswordreset.en"> like this <LocalizedString ElementType="ErrorMessage" StringId="UserMessageIfClaimsTransformationBooleanValueIsNotEqual">You must not use your old password.</LocalizedString>.
When using the claim provider below, ensure to replace all parts marked with a TODO.
<ClaimsProviders>
<ClaimsProvider>
<DisplayName>Password Reset without same password</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="login-NonInteractive-PasswordChange">
<DisplayName>Local Account SignIn</DisplayName>
<Protocol Name="OpenIdConnect" />
<Metadata>
<Item Key="UserMessageIfClaimsPrincipalDoesNotExist">We can't seem to find your account</Item>
<Item Key="UserMessageIfInvalidPassword">Your password is incorrect</Item>
<Item Key="UserMessageIfOldPasswordUsed">Looks like you used an old password</Item>
<Item Key="ProviderName">https://sts.windows.net/</Item>
<!-- TODO replace YOUR-TENANT-ID -->
<Item Key="METADATA">https://login.microsoftonline.com/YOUR-TENANT.onmicrosoft.com/.well-known/openid-configuration</Item>
<!-- TODO replace YOUR-TENANT-ID -->
<Item Key="authorization_endpoint">https://login.microsoftonline.com/YOUR-TENANT.onmicrosoft.com/oauth2/token</Item>
<Item Key="response_types">id_token</Item>
<Item Key="response_mode">query</Item>
<Item Key="scope">email openid</Item>
<!-- TODO ensure this line is commented out-->
<!-- <Item Key="grant_type">password</Item> -->
<Item Key="UsePolicyInRedirectUri">false</Item>
<Item Key="HttpBinding">POST</Item>
<!-- TODO -->
<!-- ProxyIdentityExperienceFramework application / client id -->
<Item Key="client_id">YOUR-PROXY-CLIENT-ID</Item>
<!-- Native App -->
<!-- TODO -->
<!-- IdentityExperienceFramework application / client id -->
<Item Key="IdTokenAudience">YOUR-IDENTITY-CLIENT-ID</Item>
<!-- Web Api -->
</Metadata>
<InputClaims>
<InputClaim ClaimTypeReferenceId="signInName" PartnerClaimType="username" Required="true" />
<!-- INFO: replaced oldPassword with newPassword, that way we try logging in with the new password. If the login is successful, we know the newPassword is the same as the old / current password-->
<InputClaim ClaimTypeReferenceId="newPassword" PartnerClaimType="password" Required="true" />
<InputClaim ClaimTypeReferenceId="grant_type" DefaultValue="password" />
<InputClaim ClaimTypeReferenceId="scope" DefaultValue="openid" />
<InputClaim ClaimTypeReferenceId="nca" PartnerClaimType="nca" DefaultValue="1" />
<!-- TODO -->
<!-- ProxyIdentityExperienceFramework application / client id -->
<InputClaim ClaimTypeReferenceId="client_id" DefaultValue="YOUR-PROXY-CLIENT-ID" />
<!-- TODO -->
<!-- IdentityExperienceFramework application / client id -->
<InputClaim ClaimTypeReferenceId="resource_id" PartnerClaimType="resource" DefaultValue="YOUR-IDENTITY-CLIENT-ID" />
</InputClaims>
<OutputClaims>
<!-- INFO: assign the objectId (oid) to resetPasswordObjectId, since the claim objectId might already be set. In that case there would be no way of knowing whether it was set due to the attempted login with the newPassword-->
<OutputClaim ClaimTypeReferenceId="resetPasswordObjectId" PartnerClaimType="oid" />
<OutputClaim ClaimTypeReferenceId="tenantId" PartnerClaimType="tid" />
<OutputClaim ClaimTypeReferenceId="givenName" PartnerClaimType="given_name" />
<OutputClaim ClaimTypeReferenceId="surName" PartnerClaimType="family_name" />
<OutputClaim ClaimTypeReferenceId="displayName" PartnerClaimType="name" />
<OutputClaim ClaimTypeReferenceId="userPrincipalName" PartnerClaimType="upn" />
<OutputClaim ClaimTypeReferenceId="authenticationSource" DefaultValue="localAccountAuthentication" />
</OutputClaims>
</TechnicalProfile>
<!--Logic to check new password is not the same as old password
Validates old password before writing new password-->
<TechnicalProfile Id="LocalAccountWritePasswordUsingObjectId">
<DisplayName>Reset password</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.SelfAssertedAttributeProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="ContentDefinitionReferenceId">api.localaccountpasswordreset</Item>
<!-- set in the TrustFrameworkLocalization.xml -->
<!-- <Item Key="UserMessageIfClaimsTransformationBooleanValueIsNotEqual">You must not use your old password.</Item> -->
</Metadata>
<CryptographicKeys>
<Key Id="issuer_secret" StorageReferenceId="B2C_1A_TokenSigningKeyContainer" />
</CryptographicKeys>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="newPassword" Required="true" />
<OutputClaim ClaimTypeReferenceId="reenterPassword" Required="true" />
</OutputClaims>
<ValidationTechnicalProfiles>
<ValidationTechnicalProfile ReferenceId="login-NonInteractive-PasswordChange" ContinueOnError="true" />
<ValidationTechnicalProfile ReferenceId="ComparePasswords" />
<ValidationTechnicalProfile ReferenceId="AAD-UserWritePasswordUsingObjectId">
<Preconditions>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>SamePassword</Value>
<Value>True</Value>
<Action>SkipThisValidationTechnicalProfile</Action>
</Precondition>
</Preconditions>
</ValidationTechnicalProfile>
</ValidationTechnicalProfiles>
</TechnicalProfile>
<!-- Runs claimsTransformations to make sure new and old passwords differ -->
<TechnicalProfile Id="ComparePasswords">
<DisplayName>Compare Email And Verify Email</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.ClaimsTransformationProtocolProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="SamePassword" />
</OutputClaims>
<OutputClaimsTransformations>
<OutputClaimsTransformation ReferenceId="CheckPasswordEquivalence" />
<OutputClaimsTransformation ReferenceId="AssertSamePasswordIsFalse" />
</OutputClaimsTransformations>
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
You can do it with some logic with Validation Technical profiles:
Call login-noninteractive with continueOnError=true
Call a claimTransform to generate a boolean if a claim (like objectId) is null
Use the boolean for the proceeding logic, lets call it pwdIsLastPwd
Call a claimTransform to assert pwdIsLastPwd = false
If it is true, throw an error - "you cannot use this password" using the claimTransform error handler
Continue with the rest of reset password flow
References:
https://learn.microsoft.com/en-us/azure/active-directory-b2c/validation-technical-profile#validationtechnicalprofiles
Call Claim transform from VTP, Boolean ClaimTransform check if claim exists
Assert boolean is true/false
"The UserMessageIfClaimsTransformationBooleanValueIsNotEqual self-asserted technical profile metadata controls the error message that the technical profile presents to the user. "

Validation Profiles still execute if previous profiles throw error

I have a custom sign-up policy that has 3 validation profiles.
<ValidationTechnicalProfile ReferenceId="UsernameCheck" ContinueOnError = "false"/>
<ValidationTechnicalProfile ReferenceId="API-VerifyStep1" ContinueOnError = "false"/>
<ValidationTechnicalProfile ReferenceId="AAD-UserWriteUsingLogonName" ContinueOnError="false" />
<TechnicalProfile Id="UsernameCheck">
<Metadata>
<Item Key="Operation">Read</Item>
<Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">false</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="signInName" PartnerClaimType="signInNames.emailAddress" Required="true" />
</InputClaims>
<OutputClaims>
<!-- Required claims -->
<OutputClaim ClaimTypeReferenceId="objectId" DefaultValue="NOTFOUND" />
<OutputClaim ClaimTypeReferenceId="objectIdNotFound" DefaultValue="NOTFOUND" AlwaysUseDefaultValue="true" />
</OutputClaims>
<OutputClaimsTransformations>
<OutputClaimsTransformation ReferenceId="AssertObjectIdObjectIdNotFoundAreEqual" />
</OutputClaimsTransformations>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
</TechnicalProfile>
<TechnicalProfile Id="API-VerifyStep1">
<DisplayName>Validate user's input data and return if valid to sign-up</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.RestfulProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="ServiceUrl">https://...</Item>
<Item Key="SendClaimsIn">Body</Item>
<!-- Set AuthenticationType to Basic or ClientCertificate in production environments -->
<Item Key="AuthenticationType">None</Item>
<!-- REMOVE the following line in production environments -->
<Item Key="AllowInsecureAuthInProduction">true</Item>
</Metadata>
<InputClaims>
<InputClaim ClaimTypeReferenceId="extension_accessNumber" />
<InputClaim ClaimTypeReferenceId="email" />
<InputClaim ClaimTypeReferenceId="signInName" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="extension_error" PartnerClaimType="extension_error" DefaultValue="true" />
<OutputClaim ClaimTypeReferenceId="extension_message" PartnerClaimType="extension_message" DefaultValue="Error"/>
<OutputClaim ClaimTypeReferenceId="objectId" />
<OutputClaim ClaimTypeReferenceId="newUser" PartnerClaimType="newClaimsPrincipalCreated" />
<OutputClaim ClaimTypeReferenceId="authenticationSource" DefaultValue="localAccountAuthentication" />
<OutputClaim ClaimTypeReferenceId="userPrincipalName" />
</OutputClaims>
<OutputClaimsTransformations>
<OutputClaimsTransformation ReferenceId="AssertErrorMessageFalseStep1" />
</OutputClaimsTransformations>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" />
The problem is all validation profiles are executed regarldess of throwing an error. If either of the first two throw an error I receive a message on the sign-up page. However, the user still gets added to Azure AD. Why does it do this? What is the point of ContinueOnError? How do I ensure the write only happens after the other two are done validating?
Thank you in advance
ContinueOnError is by default false, so you shouldn't need to specify it as long as you want the default behavior.
Since you are using a restful provider, I suspect you are running into the issue mentioned by following thread -
Custom policy REST API ValidationTechnicalProfile ContinueOnError not working for HTTP codes like 404 NotFound and 401 unauthorized
Basically the validation technical profile needs to return 409.
https://learn.microsoft.com/en-us/azure/active-directory-b2c/validation-technical-profile
You can also use Preconditions as mentioned on the above page to avoid executing a technical profile.

Sign-up policy - Set user attributes through code

I want to programatically set user attributes for the sign up policy. I saw a previous question (Pass parameters to Sign-up policy) asked over a year ago and it was not possible at the time. Any update on this?
Is this possible with the AuthenticationProperties.Dictionary property? Something like this?
HttpContext.GetOwinContext().Set("Policy", Startup.SignUpPolicyId);
var authenticationProperties = new AuthenticationProperties();
authenticationProperties.Dictionary.Add("myattribute", "myvalue");
HttpContext.GetOwinContext().Authentication.Challenge(authenticationProperties);
This can be implemented using a custom policy.
A working sample of passing an input claim from a relying party application to a custom policy (e.g. an invitation flow as a sign-up policy) is here.
In the WingTipGamesWebApplication project, the InvitationController controller class has two action methods, Create and Redeem.
The Create action method sends a signed redemption link to the email address for the invited user. This redemption link contains this email address.
The Redeem action method handles the redemption link. It passes the email address, as the verified_email claim in a JWT that is signed with the client secret of the Wingtip Games application (see the CreateSelfIssuedToken method in the Startup class in the WingTipGamesWebApplication project), from the redemption link to the Invitation policy.
The Invitation policy can be found at here.
The Invitation policy declares the verified_email claim as an input claim:
<RelyingParty>
<DefaultUserJourney ReferenceId="Invitation" />
<TechnicalProfile Id="Invitation">
<InputTokenFormat>JWT</InputTokenFormat>
<CryptographicKeys>
<Key Id="client_secret" StorageReferenceId="WingTipGamesClientSecret" />
</CryptographicKeys>
<InputClaims>
<InputClaim ClaimTypeReferenceId="extension_VerifiedEmail" />
</InputClaims>
</TechnicalProfile>
</RelyingParty>
The extension_verifiedEmail claim type, which is declared as a read-only field (so that it can't be modified by the end user), is mapped to the verified_email input claim:
<BuildingBlocks>
<ClaimsSchema>
<ClaimType Id="extension_VerifiedEmail">
<DisplayName>Verified Email</DisplayName>
<DataType>string</DataType>
<DefaultPartnerClaimTypes>
<Protocol Name="OAuth2" PartnerClaimType="verified_email" />
<Protocol Name="OpenIdConnect" PartnerClaimType="verified_email" />
<Protocol Name="SAML2" PartnerClaimType="http://schemas.wingtipb2c.net/identity/claims/verifiedemail" />
</DefaultPartnerClaimTypes>
<UserInputType>Readonly</UserInputType>
</ClaimType>
</ClaimsSchema>
</BuildingBlocks>
The Invitation user journey can be found in here.
The second orchestration step of the Invitation user journey executes the LocalAccount-Registration-VerifiedEmail technical profile:
<UserJourney Id="Invitation">
<OrchestrationSteps>
...
<OrchestrationStep Order="2" Type="ClaimsExchange">
<ClaimsExchanges>
...
<ClaimsExchange Id="LocalAccountRegistrationExchange" TechnicalProfileReferenceId="LocalAccount-Registration-VerifiedEmail" />
</ClaimsExchanges>
</OrchestrationStep>
</OrchestrationSteps>
</UserJourney>
The LocalAccount-Registration-VerifiedEmail technical profile copies from the extension_verifiedEmail claim to the email claim and then displays the sign-up form with the verified email address (the extension_verifiedEmail claim):
<TechnicalProfile Id="LocalAccount-Registration-VerifiedEmail">
<DisplayName>WingTip Account</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.SelfAssertedAttributeProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="ContentDefinitionReferenceId">api.localaccount.registration</Item>
<Item Key="IpAddressClaimReferenceId">IpAddress</Item>
<Item Key="language.button_continue">Create</Item>
</Metadata>
<CryptographicKeys>
<Key Id="issuer_secret" StorageReferenceId="TokenSigningKeyContainer" />
</CryptographicKeys>
<InputClaimsTransformations>
<InputClaimsTransformation ReferenceId="CreateEmailFromVerifiedEmail" />
</InputClaimsTransformations>
<InputClaims>
<InputClaim ClaimTypeReferenceId="extension_VerifiedEmail" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="extension_VerifiedEmail" Required="true" />
<OutputClaim ClaimTypeReferenceId="newPassword" Required="true" />
<OutputClaim ClaimTypeReferenceId="reenterPassword" Required="true" />
<OutputClaim ClaimTypeReferenceId="displayName" Required="true" />
<OutputClaim ClaimTypeReferenceId="authenticationSource" DefaultValue="localAccountAuthentication" />
<OutputClaim ClaimTypeReferenceId="executed-SelfAsserted-Input" DefaultValue="true" />
<OutputClaim ClaimTypeReferenceId="newUser" />
<OutputClaim ClaimTypeReferenceId="objectId" />
<OutputClaim ClaimTypeReferenceId="sub" />
<OutputClaim ClaimTypeReferenceId="userPrincipalName" />
</OutputClaims>
<ValidationTechnicalProfiles>
<ValidationTechnicalProfile ReferenceId="AzureActiveDirectoryStore-WriteUserByEmail-ThrowIfExists" />
</ValidationTechnicalProfiles>
<UseTechnicalProfileForSessionManagement ReferenceId="SSOSession-AzureActiveDirectory" />
</TechnicalProfile>
This LocalAccount-Registration-VerifiedEmail technical profile references the AzureActiveDirectoryStore-WriteUserByEmail-ThrowIfExists validation technical profile that saves the local account with the verified email address (the email claim):
<TechnicalProfile Id="AzureActiveDirectoryStore-WriteUserByEmail-ThrowIfExists">
<Metadata>
<Item Key="Operation">Write</Item>
<Item Key="RaiseErrorIfClaimsPrincipalAlreadyExists">true</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="email" PartnerClaimType="signInNames.emailAddress" Required="true" />
</InputClaims>
<PersistedClaims>
<PersistedClaim ClaimTypeReferenceId="displayName" />
<PersistedClaim ClaimTypeReferenceId="email" PartnerClaimType="signInNames.emailAddress" />
<PersistedClaim ClaimTypeReferenceId="givenName" />
<PersistedClaim ClaimTypeReferenceId="newPassword" PartnerClaimType="password" />
<PersistedClaim ClaimTypeReferenceId="passwordPolicies" DefaultValue="DisablePasswordExpiration" />
<PersistedClaim ClaimTypeReferenceId="surname" />
<PersistedClaim ClaimTypeReferenceId="verified.strongAuthenticationPhoneNumber" PartnerClaimType="strongAuthenticationPhoneNumber" />
</PersistedClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="newUser" PartnerClaimType="newClaimsPrincipalCreated" />
<OutputClaim ClaimTypeReferenceId="objectId" />
<OutputClaim ClaimTypeReferenceId="signInNames.emailAddress" />
<OutputClaim ClaimTypeReferenceId="userPrincipalName" />
</OutputClaims>
<OutputClaimsTransformations>
<OutputClaimsTransformation ReferenceId="CreateSubject" />
</OutputClaimsTransformations>
<IncludeTechnicalProfile ReferenceId="AzureActiveDirectoryStore-Common" />
<UseTechnicalProfileForSessionManagement ReferenceId="SSOSession-AzureActiveDirectory" />
</TechnicalProfile>

Resources