“Magic” SignIn & Invitation Mails in Azure AD B2C

This post has been republished via RSS; it originally appeared at: New blog articles in Microsoft Tech Community.

In my previous post I showed how you can generate your own "fake" Azure AD tokens, and in general create JWTs that are valid and verifiable. The nice thing about doing that is that it paves the way for other use cases as well. I've probably stated multiple times before that one of the things I like about Azure AD B2C is how flexible it is with regards to customizing the authentication experience to what you want it to be.

 

Generating our own tokens allows us to use a feature called "id token hinting". The things I thought we'd be implementing today to illustrate what it can be used for are two nifty (in my opinion) features:

 

"Magic" SignIn links

You've probably come across some sites where you don't log in in the classic sense with typing a username and password. There's just this "send me a link" button - you click the link (sent via email) and you're signed in. Which feels kind of magic :) (So, it's not something I came up with, it is a real thing already.)

 

As this should be fairly invisible to the user there's not really all that much to take screenshots of.

 

Invitation-based SignUp

I'm guessing you've been in scenarios where you're asked in a regular store (be it shopping for clothes, kitchen appliances, or whatever really) if you want to join their customer club. You give them your email address and you receive an email with instructions for completing the registration. The standard experience in AAD B2C is to go to the page and click SignUp in some way, but it would be nice to be able to invite folks as well and not just tell them to click through things on the web.

 

The bonus of this is that you don't need to pre-create the account in B2C - the token is self-contained with regards to the SignUp experience. (It’s not doing a lookup based on an id the url — the token includes the necesssary attributes as claims.)

 

So, you'll land on a page like this (you'll have to trust me when I say it's not the standard SignUp page):

Invite_01.png

 

Both of these work by generating a token that you pass along to Azure AD B2C in the url, and after verifying the token the info is used in a (custom) policy. I'll be honest - there are some nuts and bolts involved in making this work, but let's see if we can sort it out.

 

I'll happily admit I stole some parts from the official AAD B2C samples repo, followed by adapting them slightly and adding a couple things not covered there:

https://github.com/azure-ad-b2c/samples/tree/master/policies/sign-in-with-magic-link

https://github.com/azure-ad-b2c/samples/tree/master/policies/invite

 

Setting up SendGrid

Azure AD B2C has a public preview where you can customize the verification emails (which it's not unlikely that you want to do on a general level):

https://docs.microsoft.com/en-us/azure/active-directory-b2c/custom-email

 

While testing that out I configured SendGrid, and decided to use the same account for sending the necessary emails for these two features. (If you already have an Azure account it doesn't require much effort, and costs nothing as long as you're just playing around in your lab.)

 

Go through the creation process in the Azure Portal and copy off the apiKey before moving to the next step.

 

Setting up OpenID Connect metadata endpoints

Validating tokens are still a part of the OAuth game so we need to handle this. If you deployed the previous sample that also included metadata endpoints for that purpose, so you can use that if you like. But an even smoother approach is using B2C for hosting the endpoint B2C uses for validation - very meta indeed.

 

You will need to generate a new certificate (assuming Windows here):

 

$cert = New-SelfSignedCertificate -Type Custom -Subject "CN=MySelfSignedCertificate" -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3") -KeyUsage DigitalSignature -KeyAlgorithm RSA -KeyLength 2048 -NotAfter (Get-Date).AddYears(2) -CertStoreLocation "Cert:\CurrentUser\My"

 

Export the certificate as a pfx-file and hop on over to the B2C part of the Azure Portal.

 

(Copying Microsoft's instructions)

In the "Policy Keys" blade, Click Add to create a new key and select Upload in the options.

 

Give it a name, something like Id_Token_Hint_Cert and select key type to be RSA and usage to be Signature. You can optionally set the expiration to the expiration date of the cert. Save the name of the generated key.

 

Create a dummy set of new base, extension and relying party files. You can do so by downloading it from the starter pack here:
https://github.com/Azure-Samples/active-directory-b2c-custom-policy-starterpack.

 

To keep things simple we will use
https://github.com/Azure-Samples/active-directory-b2c-custom-policy-starterpack/tree/master/LocalAccounts but any starter pack can be used. (Suffix these with _DUMMY or something so you don't mix them with actual policies.)

 

Once you have successfully setup the new starter pack policies open the base file of this set and update the TechnicalProfile Id="JwtIssuer" Here we will update the token signing key container to the key we created.

 

Update B2C_1A_TokenSigningKeyContainer to B2C_1A_Id_Token_Hint_Cert like this:

<Key Id="issuer_secret" StorageReferenceId="B2C_1A_Id_Token_Hint_Cert" />

 

The RP file I built looks like this:

 

 

<TrustFrameworkPolicy xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://schemas.microsoft.com/online/cpim/schemas/2013/06" PolicySchemaVersion="0.3.0.0" TenantId="yourtenant.onmicrosoft.com" PolicyId="B2C_1A_OIDC" PublicPolicyUri="http://yourtenant.onmicrosoft.com/B2C_1A_OIDC" TenantObjectId="tenant-guid"> <BasePolicy> <TenantId>yourtenant.onmicrosoft.com</TenantId> <PolicyId>B2C_1A_TrustFrameworkExtensions_DUMMY</PolicyId> </BasePolicy> <RelyingParty> <DefaultUserJourney ReferenceId="SignUpOrSignIn" /> <UserJourneyBehaviors> <ContentDefinitionParameters> <Parameter Name="ui_locales">{Culture:RFC5646}</Parameter> </ContentDefinitionParameters> <ScriptExecution>Allow</ScriptExecution> </UserJourneyBehaviors> <TechnicalProfile Id="PolicyProfile"> <DisplayName>PolicyProfile</DisplayName> <Protocol Name="OpenIdConnect" /> <OutputClaims> <OutputClaim ClaimTypeReferenceId="displayName" /> <OutputClaim ClaimTypeReferenceId="givenName" /> <OutputClaim ClaimTypeReferenceId="surname" /> <OutputClaim ClaimTypeReferenceId="email" /> <OutputClaim ClaimTypeReferenceId="objectId" PartnerClaimType="sub" /> <OutputClaim ClaimTypeReferenceId="identityProvider" /> </OutputClaims> <SubjectNamingInfo ClaimType="sub" /> </TechnicalProfile> </RelyingParty> </TrustFrameworkPolicy>

 

 

 

Upload these files through the portal.

 

Click on the relying party file in the B2C portal and copy the url to the "OpenID Connect discovery endpoint". Et voilà - metadata for you.

B2C_OIDC_RP.png

Custom policies

This isn't exactly part of the built-in policies in Azure AD B2C at the moment, so you will need custom policies to sort this out.

 

First a policy for handling SignIn (remember that the account must exist already for this to work):

B2C_1A_Signin_With_Email.xml

 

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <TrustFrameworkPolicy xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://schemas.microsoft.com/online/cpim/schemas/2013/06" PolicySchemaVersion="0.3.0.0" TenantId="yourtenant.onmicrosoft.com" PolicyId="B2C_1A_signin_with_email" PublicPolicyUri="http://yourtenant.onmicrosoft.com/B2C_1A_signin_with_email"> <BasePolicy> <TenantId>yourtenant.onmicrosoft.com</TenantId> <PolicyId>B2C_1A_TrustFrameworkExtensions_Dev</PolicyId> </BasePolicy> <BuildingBlocks> <ClaimsSchema> <!-- Stores the error message for unsolicited request (a request without id_token_hint) and user not found--> <ClaimType Id="errorMessage"> <DisplayName>Error</DisplayName> <DataType>string</DataType> <UserHelpText>Add help text here</UserHelpText> <UserInputType>Paragraph</UserInputType> </ClaimType> </ClaimsSchema> <ClaimsTransformations> <!-- Initiates the errorMessage claims type with the error message--> <ClaimsTransformation Id="CreateUnsolicitedErrorMessage" TransformationMethod="CreateStringClaim"> <InputParameters> <InputParameter Id="value" DataType="string" Value="You cannot sign-in without invitation" /> </InputParameters> <OutputClaims> <OutputClaim ClaimTypeReferenceId="errorMessage" TransformationClaimType="createdClaim" /> </OutputClaims> </ClaimsTransformation> <!-- Initiates the errorMessage claims type with the error message user not found--> <ClaimsTransformation Id="CreateUserNotFoundErrorMessage" TransformationMethod="CreateStringClaim"> <InputParameters> <InputParameter Id="value" DataType="string" Value="You aren't registered in the system!" /> </InputParameters> <OutputClaims> <OutputClaim ClaimTypeReferenceId="errorMessage" TransformationClaimType="createdClaim" /> </OutputClaims> </ClaimsTransformation> </ClaimsTransformations> </BuildingBlocks> <ClaimsProviders> <!--Sample: This technical profile specifies how B2C should validate your token, and what claims you want B2C to extract from the token. The METADATA value in the TechnicalProfile meta-data is required. The “IdTokenAudience” and “issuer” arguments are optional (see later section)--> <ClaimsProvider> <DisplayName>My ID Token Hint ClaimsProvider</DisplayName> <TechnicalProfiles> <TechnicalProfile Id="IdTokenHint_ExtractClaims"> <DisplayName> My ID Token Hint TechnicalProfile</DisplayName> <Protocol Name="None" /> <Metadata> <!--Sample action required: replace with your endpoint location --> <Item Key="METADATA">https://yourtenant.b2clogin.com/yourtenant.onmicrosoft.com/v2.0/.well-known/openid-configuration?p=B2C_1A_OIDC</Item> <!-- <Item Key="IdTokenAudience">your_optional_audience_override</Item> --> <!-- <Item Key="issuer">your_optional_token_issuer_override</Item> --> </Metadata> <OutputClaims> <!--Sample: Read the email cliam from the id_token_hint--> <OutputClaim ClaimTypeReferenceId="email" /> </OutputClaims> </TechnicalProfile> </TechnicalProfiles> </ClaimsProvider> <ClaimsProvider> <DisplayName>Self Asserted</DisplayName> <TechnicalProfiles> <!-- Show error message--> <TechnicalProfile Id="SelfAsserted-Error"> <DisplayName>Unsolicited error message</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.selfasserted</Item> <!-- Remove the continue button--> <Item Key="setting.showContinueButton">false</Item> </Metadata> <InputClaims> <InputClaim ClaimTypeReferenceId="errorMessage"/> </InputClaims> <OutputClaims> <OutputClaim ClaimTypeReferenceId="errorMessage"/> </OutputClaims> </TechnicalProfile> <!-- Show unsolicited error message--> <TechnicalProfile Id="SelfAsserted-Unsolicited"> <InputClaimsTransformations> <InputClaimsTransformation ReferenceId="CreateUnsolicitedErrorMessage" /> </InputClaimsTransformations> <IncludeTechnicalProfile ReferenceId="SelfAsserted-Error" /> </TechnicalProfile> <!-- Show user not found error message--> <TechnicalProfile Id="SelfAsserted-UserNotFound"> <InputClaimsTransformations> <InputClaimsTransformation ReferenceId="CreateUserNotFoundErrorMessage" /> </InputClaimsTransformations> <IncludeTechnicalProfile ReferenceId="SelfAsserted-Error" /> </TechnicalProfile> </TechnicalProfiles> </ClaimsProvider> <ClaimsProvider> <DisplayName>Azure Active Directory</DisplayName> <TechnicalProfiles> <TechnicalProfile Id="AAD-UserReadUsingEmailAddress"> <Metadata> <!-- don't raise error if user not found. We have an orchestration step to handle the error message--> <Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">false</Item> </Metadata> <OutputClaims> <!-- add optional claims to read from the directory--> <OutputClaim ClaimTypeReferenceId="givenName"/> <OutputClaim ClaimTypeReferenceId="surname"/> </OutputClaims> </TechnicalProfile> </TechnicalProfiles> </ClaimsProvider> </ClaimsProviders> <UserJourneys> <UserJourney Id="SignUpOrSignInWithEmail"> <OrchestrationSteps> <!-- Read the input claims from the id_token_hint--> <OrchestrationStep Order="1" Type="GetClaims" CpimIssuerTechnicalProfileReferenceId="IdTokenHint_ExtractClaims" /> <!-- Check if user tries to run the policy without invitation --> <OrchestrationStep Order="2" Type="ClaimsExchange"> <Preconditions> <Precondition Type="ClaimsExist" ExecuteActionsIf="true"> <Value>email</Value> <Action>SkipThisOrchestrationStep</Action> </Precondition> </Preconditions> <ClaimsExchanges> <ClaimsExchange Id="SelfAsserted-Unsolicited" TechnicalProfileReferenceId="SelfAsserted-Unsolicited" /> </ClaimsExchanges> </OrchestrationStep> <!-- Read the user properties from the directory--> <OrchestrationStep Order="3" Type="ClaimsExchange"> <ClaimsExchanges> <ClaimsExchange Id="AADUserReadUsingEmailAddress" TechnicalProfileReferenceId="AAD-UserReadUsingEmailAddress"/> </ClaimsExchanges> </OrchestrationStep> <!-- Check whether the user not existed in the directory --> <OrchestrationStep Order="4" Type="ClaimsExchange"> <Preconditions> <Precondition Type="ClaimsExist" ExecuteActionsIf="true"> <Value>objectId</Value> <Action>SkipThisOrchestrationStep</Action> </Precondition> </Preconditions> <ClaimsExchanges> <ClaimsExchange Id="SelfAssertedUserNotFound" TechnicalProfileReferenceId="SelfAsserted-UserNotFound" /> </ClaimsExchanges> </OrchestrationStep> <!-- Issue an access token--> <OrchestrationStep Order="5" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer"/> </OrchestrationSteps> <ClientDefinition ReferenceId="DefaultWeb"/> </UserJourney> </UserJourneys> <RelyingParty> <DefaultUserJourney ReferenceId="SignUpOrSignInWithEmail" /> <TechnicalProfile Id="PolicyProfile"> <DisplayName>PolicyProfile</DisplayName> <Protocol Name="OpenIdConnect" /> <!-- Set the input claims to be read from the id_token_hint--> <InputClaims> <InputClaim ClaimTypeReferenceId="email" /> </InputClaims> <OutputClaims> <OutputClaim ClaimTypeReferenceId="displayName" /> <OutputClaim ClaimTypeReferenceId="givenName" /> <OutputClaim ClaimTypeReferenceId="surname" /> <OutputClaim ClaimTypeReferenceId="email" /> <OutputClaim ClaimTypeReferenceId="objectId" PartnerClaimType="sub"/> <OutputClaim ClaimTypeReferenceId="tenantId" AlwaysUseDefaultValue="true" DefaultValue="{Policy:TenantObjectId}" /> </OutputClaims> <SubjectNamingInfo ClaimType="sub" /> </TechnicalProfile> </RelyingParty> </TrustFrameworkPolicy>

 

 

B2C_1A_SignUp_Invitation.xml

 

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <TrustFrameworkPolicy xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://schemas.microsoft.com/online/cpim/schemas/2013/06" PolicySchemaVersion="0.3.0.0" TenantId="yourtenant.onmicrosoft.com" PolicyId="B2C_1A_signup_invitation" PublicPolicyUri="http://yourtenant.onmicrosoft.com/B2C_1A_signup_invitation"> <BasePolicy> <TenantId>yourtenant.onmicrosoft.com</TenantId> <PolicyId>B2C_1A_TrustFrameworkExtensions_Dev</PolicyId> </BasePolicy> <BuildingBlocks> <ClaimsSchema> <!-- Read-only email address to present to the user--> <ClaimType Id="ReadOnlyEmail"> <DisplayName>Verified Email Address</DisplayName> <DataType>string</DataType> <UserInputType>Readonly</UserInputType> </ClaimType> <!-- Stores the error message for unsolicited request (a request without id_token_hint) and user not found--> <ClaimType Id="errorMessage"> <DisplayName>Error</DisplayName> <DataType>string</DataType> <UserHelpText>Add help text here</UserHelpText> <UserInputType>Paragraph</UserInputType> </ClaimType> </ClaimsSchema> <ClaimsTransformations> <!-- Initiates the errorMessage claims type with the error message--> <ClaimsTransformation Id="CreateUnsolicitedErrorMessage" TransformationMethod="CreateStringClaim"> <InputParameters> <InputParameter Id="value" DataType="string" Value="You cannot sign-up without invitation" /> </InputParameters> <OutputClaims> <OutputClaim ClaimTypeReferenceId="errorMessage" TransformationClaimType="createdClaim" /> </OutputClaims> </ClaimsTransformation> <!-- Copy the email to ReadOnlyEamil claim type--> <ClaimsTransformation Id="CopyEmailAddress" TransformationMethod="FormatStringClaim"> <InputClaims> <InputClaim ClaimTypeReferenceId="email" TransformationClaimType="inputClaim" /> </InputClaims> <InputParameters> <InputParameter Id="stringFormat" DataType="string" Value="{0}" /> </InputParameters> <OutputClaims> <OutputClaim ClaimTypeReferenceId="ReadOnlyEmail" TransformationClaimType="outputClaim" /> </OutputClaims> </ClaimsTransformation> </ClaimsTransformations> </BuildingBlocks> <ClaimsProviders> <ClaimsProvider> <DisplayName>Local Account</DisplayName> <TechnicalProfiles> <!-- Sign-up self-asserted technical profile--> <TechnicalProfile Id="LocalAccountSignUpWithReadOnlyEmail"> <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> <Item Key="language.button_continue">Create</Item> <!-- Remove sign-up email verification --> <Item Key="EnforceEmailVerification">False</Item> </Metadata> <InputClaimsTransformations> <!-- Copy the email to ReadOnlyEamil claim type--> <InputClaimsTransformation ReferenceId="CopyEmailAddress" /> </InputClaimsTransformations> <InputClaims> <!-- Set input the ReadOnlyEmail claim type to prefilled the email address--> <InputClaim ClaimTypeReferenceId="ReadOnlyEmail" /> <InputClaim ClaimTypeReferenceId="displayName" PartnerClaimType="displayName" /> <InputClaim ClaimTypeReferenceId="givenName" PartnerClaimType="givenName"/> <InputClaim ClaimTypeReferenceId="surname" PartnerClaimType="surname" /> </InputClaims> <OutputClaims> <OutputClaim ClaimTypeReferenceId="objectId" /> <!-- Display the ReadOnlyEmail claim type (instead of email claim type)--> <OutputClaim ClaimTypeReferenceId="ReadOnlyEmail" 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" /> <!-- Pre-filled from token_hint --> <OutputClaim ClaimTypeReferenceId="displayName" PartnerClaimType="displayName" /> <OutputClaim ClaimTypeReferenceId="givenName" PartnerClaimType="givenName" /> <OutputClaim ClaimTypeReferenceId="surname" PartnerClaimType="surname" /> </OutputClaims> <ValidationTechnicalProfiles> <ValidationTechnicalProfile ReferenceId="AAD-UserWriteUsingLogonEmail" /> </ValidationTechnicalProfiles> <!-- Disable session management for sign-up page --> <UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" /> </TechnicalProfile> </TechnicalProfiles> </ClaimsProvider> <ClaimsProvider> <DisplayName>Self Asserted</DisplayName> <TechnicalProfiles> <!-- Show error message--> <TechnicalProfile Id="SelfAsserted-Unsolicited"> <DisplayName>Unsolicited error message</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.selfasserted</Item> <!-- Remove the continue button--> <Item Key="setting.showContinueButton">false</Item> </Metadata> <InputClaimsTransformations> <InputClaimsTransformation ReferenceId="CreateUnsolicitedErrorMessage" /> </InputClaimsTransformations> <InputClaims> <InputClaim ClaimTypeReferenceId="errorMessage"/> </InputClaims> <OutputClaims> <OutputClaim ClaimTypeReferenceId="errorMessage"/> </OutputClaims> </TechnicalProfile> </TechnicalProfiles> </ClaimsProvider> <!--Sample: This technical profile specifies how B2C should validate your token, and what claims you want B2C to extract from the token. The METADATA value in the TechnicalProfile meta-data is required. The “IdTokenAudience” and “issuer” arguments are optional (see later section)--> <ClaimsProvider> <DisplayName>My ID Token Hint ClaimsProvider</DisplayName> <TechnicalProfiles> <TechnicalProfile Id="IdTokenHint_ExtractClaims"> <DisplayName> My ID Token Hint TechnicalProfile</DisplayName> <Protocol Name="None" /> <Metadata> <!--Sample action required: replace with your endpoint location --> <Item Key="METADATA">https://yourtenant.b2clogin.com/yourtenant.onmicrosoft.com/v2.0/.well-known/openid-configuration?p=B2C_1A_OIDC</Item> <!-- <Item Key="IdTokenAudience">your_optional_audience_override</Item> --> <!-- <Item Key="issuer">your_optional_token_issuer_override</Item> --> </Metadata> <OutputClaims> <!-- Read the email cliam from the id_token_hint--> <OutputClaim ClaimTypeReferenceId="email" /> </OutputClaims> </TechnicalProfile> </TechnicalProfiles> </ClaimsProvider> </ClaimsProviders> <UserJourneys> <UserJourney Id="SignUpInvitation"> <OrchestrationSteps> <!-- Read the input claims from the id_token_hint--> <OrchestrationStep Order="1" Type="GetClaims" CpimIssuerTechnicalProfileReferenceId="IdTokenHint_ExtractClaims" /> <!-- Check if user tries to run the policy without invitation --> <OrchestrationStep Order="2" Type="ClaimsExchange"> <Preconditions> <Precondition Type="ClaimsExist" ExecuteActionsIf="true"> <Value>email</Value> <Action>SkipThisOrchestrationStep</Action> </Precondition> </Preconditions> <ClaimsExchanges> <ClaimsExchange Id="SelfAsserted-Unsolicited" TechnicalProfileReferenceId="SelfAsserted-Unsolicited"/> </ClaimsExchanges> </OrchestrationStep> <!-- Self-asserted sign-up page --> <OrchestrationStep Order="3" Type="ClaimsExchange"> <ClaimsExchanges> <ClaimsExchange Id="LocalAccountSignUpWithReadOnlyEmail" TechnicalProfileReferenceId="LocalAccountSignUpWithReadOnlyEmail"/> </ClaimsExchanges> </OrchestrationStep> <!-- Issue an access token--> <OrchestrationStep Order="4" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer"/> </OrchestrationSteps> <ClientDefinition ReferenceId="DefaultWeb"/> </UserJourney> </UserJourneys> <RelyingParty> <DefaultUserJourney ReferenceId="SignUpInvitation" /> <UserJourneyBehaviors> </UserJourneyBehaviors> <TechnicalProfile Id="PolicyProfile"> <DisplayName>PolicyProfile</DisplayName> <Protocol Name="OpenIdConnect" /> <InputClaims> <InputClaim ClaimTypeReferenceId="email" PartnerClaimType="email" /> <InputClaim ClaimTypeReferenceId="surname" PartnerClaimType="surname" /> <InputClaim ClaimTypeReferenceId="displayName" PartnerClaimType="displayName"/> <InputClaim ClaimTypeReferenceId="givenName" PartnerClaimType="givenName" /> </InputClaims> <OutputClaims> <OutputClaim ClaimTypeReferenceId="displayName" PartnerClaimType="displayName" /> <OutputClaim ClaimTypeReferenceId="givenName" PartnerClaimType="givenName" /> <OutputClaim ClaimTypeReferenceId="surname" PartnerClaimType="family_name" /> <OutputClaim ClaimTypeReferenceId="email" /> <OutputClaim ClaimTypeReferenceId="objectId" PartnerClaimType="sub"/> <OutputClaim ClaimTypeReferenceId="tenantId" AlwaysUseDefaultValue="true" DefaultValue="{Policy:TenantObjectId}" /> </OutputClaims> <SubjectNamingInfo ClaimType="sub" /> </TechnicalProfile> </RelyingParty> </TrustFrameworkPolicy>

 

The important, and tricky part, is mapping claims correctly so that the values you seed in the token actually appear on the SignUp page.

 

Creating B2C mailer

Sending the emails are a matter of calling into a REST API which can be done any number of ways. To simplify things SendGrid has NuGet packages for use with C#, and in this case there are a couple of additional lines of code needed for generating the token and url. If you want to do a script-based version or a web page is sort of up to you. For demo purposes I created a simple web app that will let you send one mail at a time.

Magic_Link_01.png

Magic Link

 

Invite_02.png

Invitation

 

The code for this can be found here:
https://github.com/ahelland/Identity-CodeSamples-v2/tree/master/aad-b2c-mailengine-dotnet-core

With a Docker image here:
https://hub.docker.com/r/ahelland/aad-b2c-mailengine-dotnet-core-linux

 

I secured the page with Azure AD (B2E) so it's not going to be a freely available spam generator, but feel free to do as you please with your instance :)

 

The important part of the code looks like this:

 

protected async Task SendSignInLinkAsync() { string email = mailer.to.Email; string token = BuildIdToken(email); string link = BuildUrl(token); string htmlTemplate = System.IO.File.ReadAllText("SignInTemplate.html"); var apiKey = configuration.GetSection("MailerSettings")["SendGridApiKey"]; var client = new SendGridClient(apiKey); var plainTextContent = "You should be seeing a SignIn link below."; var htmlContent = string.Format(htmlTemplate, email, link); var msg = MailHelper.CreateSingleEmail(mailer.from, mailer.to, mailer.subject, plainTextContent, htmlContent); var response = await client.SendEmailAsync(msg); } private string BuildIdToken(string Email) { string B2CClientId = configuration.GetSection("MailerSettings")["B2CClientId"]; double LinkExpiresAfterMinutes; double.TryParse(configuration.GetSection("MailerSettings")["LinkExpiresAfterMinutes"], out LinkExpiresAfterMinutes); string issuer = configuration.GetSection("MailerSettings")["issuer"]; // All parameters sent to Azure AD B2C needs to be sent as claims IList<System.Security.Claims.Claim> claims = new List<System.Security.Claims.Claim> { new System.Security.Claims.Claim("email", Email, System.Security.Claims.ClaimValueTypes.String, issuer) }; // Create the token JwtSecurityToken token = new JwtSecurityToken( issuer, B2CClientId, claims, DateTime.Now, DateTime.Now.AddMinutes(LinkExpiresAfterMinutes), SigningCredentials.Value); // Get the representation of the signed token JwtSecurityTokenHandler jwtHandler = new JwtSecurityTokenHandler(); return jwtHandler.WriteToken(token); } private string BuildUrl(string token) { string B2CSignInUrl = configuration.GetSection("MailerSettings")["B2CSignInUrl"]; return $"{B2CSignInUrl}?id_token_hint={token}"; }

 

 

If you want to just prove that the policies work you can redirect to https://jwt.ms - use the following settings (appsettings.json:(

 

"B2CTenant": "yourtenant", "B2CPolicy": "B2C_1A_SignUp_Invitation", "B2CClientId": "client-guid", "B2CRedirectUri": "https://jwt.ms", "B2CSignUpUrl": "https://{0}.b2clogin.com/{0}.onmicrosoft.com/ {1}/oauth2/v2.0/authorize? client_id={2} &nonce={4} &redirect_uri={3} &scope=openid&response_type=id_token",

 

 

And change the BuildUrl method above slightly:

 

private string BuildUrl(string token) { string nonce = Guid.NewGuid().ToString("n"); return string.Format(this.AppSettings.B2CSignUpUrl, this.AppSettings.B2CTenant, this.AppSettings.B2CPolicy, this.AppSettings.B2CClientId, Uri.EscapeDataString(this.AppSettings.B2CRedirectUri), nonce) + "&id_token_hint=" + token; }

 

 

Adapting an MVC web page

Thing is - while this would prove the setup is correct this doesn't plug into your regular templatized .NET Core web app. When you build your app based on the templates in Visual Studio and enable OpenID Connect-based authentication a couple of things is configured in the background for you to make it work more or less automatically.

 

If your app is running at https://foo.bar a SignIn action will take you to https://contoso.b2clogin.com/xyz, and once you have logged in B2C will send your browser session back to https://foo.bar/signin-oidc.

 

You might think that this means that you can just make the magic link send you directly to B2C and include the corresponding return url, but what will happen is that the app running at https://foo.bar will recognize that it didn't initiate the request and basically says "I don't trust this". (Auth endpoints are different than API endpoints so it's not just a matter of accepting a token.)

 

So the flow basically becomes something like this:

Mailer_Flow.png

 

To get around these minor snags I did two things:

 

Add endpoints as landing points for the links (HomeController.cs)

 

//Separate SignIn handler for magic links sent by email public IActionResult SignInLink(string id_token_hint) { var magic_link_auth = new AuthenticationProperties { RedirectUri = "/" }; magic_link_auth.Items.Add("id_token_hint", id_token_hint); string magic_link_policy = Configuration.GetSection("AzureAdB2C")["MagicLinkPolicyId"]; return this.Challenge(magic_link_auth, magic_link_policy); } //Separate SignUp handler for invitation links sent by email public IActionResult SignUpInvitation(string id_token_hint) { var invite_auth = new AuthenticationProperties { RedirectUri = "/" }; invite_auth.Items.Add("id_token_hint", id_token_hint); string invite_policy = Configuration.GetSection("AzureAdB2C")["InvitationPolicyId"]; return this.Challenge(invite_auth, invite_policy); }

 

 

Add new auth schemes to Startup.cs

 

//Magic link auth string magic_link_policy = Configuration.GetSection("AzureAdB2C")["MagicLinkPolicyId"]; services.AddAuthentication(options => options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme).AddOpenIdConnect(magic_link_policy, GetOpenIdSignUpOptions(magic_link_policy)); //Invitation link SignUp string invite_policy = Configuration.GetSection("AzureAdB2C")["InvitationPolicyId"]; services.AddAuthentication(options => options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme).AddOpenIdConnect(invite_policy, GetOpenIdSignUpOptions(invite_policy)); … private Action<OpenIdConnectOptions> GetOpenIdSignUpOptions(string policy) => options => { string clientId = Configuration.GetSection("AzureAdB2C")["ClientId"]; string B2CDomain = Configuration.GetSection("AzureAdB2C")["B2CDomain"]; string Domain = Configuration.GetSection("AzureAdB2C")["Domain"]; string MagicLink = Configuration.GetSection("AzureAdB2C")["MagicLinkPolicyId"]; string Invite = Configuration.GetSection("AzureAdB2C")["InvitationPolicyId"]; options.MetadataAddress = $"https://{B2CDomain}/{Domain}/{policy}/v2.0/.well-known/openid-configuration"; options.ClientId = clientId; options.ResponseType = OpenIdConnectResponseType.IdToken; options.SignedOutCallbackPath = "/signout/" + policy; if (policy == MagicLink) options.CallbackPath = "/signin-oidc-link"; if (policy == Invite) options.CallbackPath = "/signin-oidc-invite"; options.SignedOutRedirectUri = "/"; options.SignInScheme = "AzureADB2C"; options.Events = new OpenIdConnectEvents { OnRedirectToIdentityProvider = context => { if (context.Properties.Items.ContainsKey("id_token_hint")) context.ProtocolMessage.SetParameter("id_token_hint", context.Properties.Items["id_token_hint"]); return Task.FromResult(0); } }; }; }

 

 

Complete code:
https://github.com/ahelland/Identity-CodeSamples-v2/tree/master/aad-b2c-custom_policies-dotnet-core

Docker:
https://hub.docker.com/r/ahelland/aad-b2c-custom_policies-dotnet-core-linux

 

Phew, a lot of things to get right there :) I hope that you managed to get it working, and agree with me that this can be a useful trick to implement in your B2C setups.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.